|
|
|
@@ -6,10 +6,15 @@ const cwWpmInput = document.getElementById("cw-wpm");
|
|
|
|
|
const cwToneInput = document.getElementById("cw-tone");
|
|
|
|
|
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
|
|
|
|
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
|
|
|
|
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
|
|
|
|
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
|
|
|
|
const CW_MAX_LINES = 200;
|
|
|
|
|
const CW_TONE_MIN_HZ = 300;
|
|
|
|
|
const CW_TONE_MAX_HZ = 1200;
|
|
|
|
|
const CW_WPM_MIN = 5;
|
|
|
|
|
const CW_WPM_MAX = 40;
|
|
|
|
|
let cwLastAppendTime = 0;
|
|
|
|
|
let cwTonePickerRaf = null;
|
|
|
|
|
|
|
|
|
|
function applyCwAutoUi(enabled) {
|
|
|
|
|
if (cwAutoInput) cwAutoInput.checked = enabled;
|
|
|
|
@@ -17,6 +22,20 @@ function applyCwAutoUi(enabled) {
|
|
|
|
|
cwWpmInput.disabled = enabled;
|
|
|
|
|
cwWpmInput.readOnly = enabled;
|
|
|
|
|
}
|
|
|
|
|
if (cwToneInput) {
|
|
|
|
|
cwToneInput.disabled = enabled;
|
|
|
|
|
cwToneInput.readOnly = enabled;
|
|
|
|
|
}
|
|
|
|
|
if (cwTonePickerEl) {
|
|
|
|
|
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
window.applyCwAutoUi = applyCwAutoUi;
|
|
|
|
|
|
|
|
|
|
function clampCwWpm(wpm) {
|
|
|
|
|
const numeric = Number(wpm);
|
|
|
|
|
if (!Number.isFinite(numeric)) return 15;
|
|
|
|
|
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clampCwTone(tone) {
|
|
|
|
@@ -26,31 +45,56 @@ function clampCwTone(tone) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function currentCwToneRange() {
|
|
|
|
|
const centerHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : Number.NaN;
|
|
|
|
|
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : Number.NaN;
|
|
|
|
|
const centerHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
|
|
|
|
|
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
|
|
|
|
|
if (!Number.isFinite(centerHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
|
|
|
|
const lowerSideband = mode === "CWR" || mode === "LSB";
|
|
|
|
|
const halfBwHz = bandwidthHz / 2;
|
|
|
|
|
const lowerSideband = mode === "CWR";
|
|
|
|
|
const upperSideband = mode === "CW";
|
|
|
|
|
if (!lowerSideband && !upperSideband) return null;
|
|
|
|
|
const lowHz = lowerSideband ? centerHz - bandwidthHz : centerHz;
|
|
|
|
|
const highHz = lowerSideband ? centerHz : centerHz + bandwidthHz;
|
|
|
|
|
const toneMinHz = CW_TONE_MIN_HZ;
|
|
|
|
|
const toneMaxHz = Math.max(toneMinHz, Math.min(CW_TONE_MAX_HZ, Math.round(halfBwHz)));
|
|
|
|
|
const toneMaxHz = Math.min(CW_TONE_MAX_HZ, Math.round(bandwidthHz));
|
|
|
|
|
if (toneMaxHz < toneMinHz) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
lowHz: centerHz - halfBwHz,
|
|
|
|
|
highHz: centerHz + halfBwHz,
|
|
|
|
|
lowHz,
|
|
|
|
|
highHz,
|
|
|
|
|
centerHz,
|
|
|
|
|
bandwidthHz,
|
|
|
|
|
halfBwHz,
|
|
|
|
|
toneMinHz,
|
|
|
|
|
toneMaxHz,
|
|
|
|
|
lowerSideband,
|
|
|
|
|
mode,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toneClampForRange(tone, range) {
|
|
|
|
|
const clamped = clampCwTone(tone);
|
|
|
|
|
if (!range) return clamped;
|
|
|
|
|
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureCwToneCanvasResolution() {
|
|
|
|
|
if (!cwToneCanvas) return false;
|
|
|
|
|
const rect = cwToneCanvas.getBoundingClientRect();
|
|
|
|
|
const cssWidth = Math.max(1, Math.round(rect.width));
|
|
|
|
|
const cssHeight = Math.max(1, Math.round(rect.height));
|
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
|
|
const nextWidth = Math.max(1, Math.round(cssWidth * dpr));
|
|
|
|
|
const nextHeight = Math.max(1, Math.round(cssHeight * dpr));
|
|
|
|
|
if (cwToneCanvas.width !== nextWidth || cwToneCanvas.height !== nextHeight) {
|
|
|
|
|
cwToneCanvas.width = nextWidth;
|
|
|
|
|
cwToneCanvas.height = nextHeight;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawCwTonePicker() {
|
|
|
|
|
if (!cwToneCanvas) return;
|
|
|
|
|
const ctx = cwToneCanvas.getContext("2d");
|
|
|
|
@@ -62,14 +106,22 @@ function drawCwTonePicker() {
|
|
|
|
|
|
|
|
|
|
const range = currentCwToneRange();
|
|
|
|
|
if (!range || !window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
|
|
|
|
|
if (cwToneRangeEl) cwToneRangeEl.textContent = "Waiting for spectrum";
|
|
|
|
|
if (cwToneRangeEl) {
|
|
|
|
|
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
|
|
|
|
if (mode !== "CW" && mode !== "CWR") {
|
|
|
|
|
cwToneRangeEl.textContent = "CW/CWR mode required";
|
|
|
|
|
} else {
|
|
|
|
|
cwToneRangeEl.textContent = "Waiting for spectrum";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ctx.fillStyle = "rgba(130, 150, 165, 0.22)";
|
|
|
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (cwToneRangeEl) {
|
|
|
|
|
cwToneRangeEl.textContent = `${(range.bandwidthHz / 1000).toFixed(range.bandwidthHz >= 10_000 ? 0 : 1)} kHz span`;
|
|
|
|
|
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
|
|
|
|
cwToneRangeEl.textContent = `${side} · Tone ${range.toneMinHz}-${range.toneMaxHz} Hz`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bins = window.lastSpectrumData.bins;
|
|
|
|
@@ -99,7 +151,7 @@ function drawCwTonePicker() {
|
|
|
|
|
ctx.fillRect(x, 0, 1, height);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentTone = clampCwTone(cwToneInput ? cwToneInput.value : 700);
|
|
|
|
|
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
|
|
|
|
const markerHz = range.lowerSideband
|
|
|
|
|
? range.centerHz - currentTone
|
|
|
|
|
: range.centerHz + currentTone;
|
|
|
|
@@ -108,14 +160,27 @@ function drawCwTonePicker() {
|
|
|
|
|
ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
|
|
|
|
|
ctx.fillRect(markerX, 0, 2, height);
|
|
|
|
|
|
|
|
|
|
const centerFrac = (range.centerHz - range.lowHz) / Math.max(1, (range.highHz - range.lowHz));
|
|
|
|
|
const centerX = Math.max(0, Math.min(width - 1, Math.round(centerFrac * (width - 1))));
|
|
|
|
|
const lowLimitHz = range.lowerSideband
|
|
|
|
|
? range.centerHz - range.toneMaxHz
|
|
|
|
|
: range.centerHz + range.toneMinHz;
|
|
|
|
|
const highLimitHz = range.lowerSideband
|
|
|
|
|
? range.centerHz - range.toneMinHz
|
|
|
|
|
: range.centerHz + range.toneMaxHz;
|
|
|
|
|
const limitLowX = Math.max(0, Math.min(width - 1, Math.round(((lowLimitHz - range.lowHz) / Math.max(1, range.highHz - range.lowHz)) * (width - 1))));
|
|
|
|
|
const limitHighX = Math.max(0, Math.min(width - 1, Math.round(((highLimitHz - range.lowHz) / Math.max(1, range.highHz - range.lowHz)) * (width - 1))));
|
|
|
|
|
ctx.fillStyle = "rgba(255, 255, 255, 0.22)";
|
|
|
|
|
ctx.fillRect(centerX, 0, 1, height);
|
|
|
|
|
ctx.fillRect(limitLowX, 0, 1, height);
|
|
|
|
|
ctx.fillRect(limitHighX, 0, 1, height);
|
|
|
|
|
|
|
|
|
|
if (cwAutoInput?.checked) {
|
|
|
|
|
ctx.fillStyle = "rgba(0, 0, 0, 0.22)";
|
|
|
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function setCwTone(tone, { syncInput = true } = {}) {
|
|
|
|
|
const clamped = clampCwTone(tone);
|
|
|
|
|
const range = currentCwToneRange();
|
|
|
|
|
const clamped = toneClampForRange(tone, range);
|
|
|
|
|
if (cwToneInput && syncInput) {
|
|
|
|
|
cwToneInput.value = clamped;
|
|
|
|
|
}
|
|
|
|
@@ -131,15 +196,19 @@ if (cwAutoInput) {
|
|
|
|
|
cwAutoInput.addEventListener("change", async () => {
|
|
|
|
|
const enabled = cwAutoInput.checked;
|
|
|
|
|
applyCwAutoUi(enabled);
|
|
|
|
|
try { await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); }
|
|
|
|
|
catch (e) { console.error("CW auto toggle failed", e); }
|
|
|
|
|
try {
|
|
|
|
|
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
|
|
|
|
drawCwTonePicker();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("CW auto toggle failed", e);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (cwWpmInput) {
|
|
|
|
|
cwWpmInput.addEventListener("change", async () => {
|
|
|
|
|
if (cwAutoInput && cwAutoInput.checked) return;
|
|
|
|
|
const wpm = Math.max(5, Math.min(40, Number(cwWpmInput.value)));
|
|
|
|
|
const wpm = clampCwWpm(cwWpmInput.value);
|
|
|
|
|
cwWpmInput.value = wpm;
|
|
|
|
|
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
|
|
|
|
|
catch (e) { console.error("CW WPM set failed", e); }
|
|
|
|
@@ -148,12 +217,14 @@ if (cwWpmInput) {
|
|
|
|
|
|
|
|
|
|
if (cwToneInput) {
|
|
|
|
|
cwToneInput.addEventListener("change", async () => {
|
|
|
|
|
if (cwAutoInput?.checked) return;
|
|
|
|
|
await setCwTone(cwToneInput.value);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (cwToneCanvas) {
|
|
|
|
|
cwToneCanvas.addEventListener("click", async (event) => {
|
|
|
|
|
if (cwAutoInput?.checked) return;
|
|
|
|
|
const rect = cwToneCanvas.getBoundingClientRect();
|
|
|
|
|
if (rect.width <= 0) return;
|
|
|
|
|
const range = currentCwToneRange();
|
|
|
|
@@ -169,7 +240,7 @@ if (cwToneCanvas) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.resetCwHistoryView = function() {
|
|
|
|
|
cwOutputEl.innerHTML = "";
|
|
|
|
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
|
|
|
|
cwLastAppendTime = 0;
|
|
|
|
|
drawCwTonePicker();
|
|
|
|
|
};
|
|
|
|
@@ -184,10 +255,9 @@ document.getElementById("cw-clear-btn").addEventListener("click", async () => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- Server-side CW decode handler ---
|
|
|
|
|
let cwLastAppendTime = 0;
|
|
|
|
|
window.onServerCw = function(evt) {
|
|
|
|
|
cwStatusEl.textContent = "Receiving";
|
|
|
|
|
if (evt.text) {
|
|
|
|
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
|
|
|
|
if (evt.text && cwOutputEl) {
|
|
|
|
|
// Append decoded text to output
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
|
|
|
|
@@ -205,12 +275,28 @@ window.onServerCw = function(evt) {
|
|
|
|
|
}
|
|
|
|
|
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
|
|
|
|
}
|
|
|
|
|
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
|
|
|
|
if (!cwAutoInput || cwAutoInput.checked) {
|
|
|
|
|
cwWpmInput.value = evt.wpm;
|
|
|
|
|
if (cwSignalIndicator) {
|
|
|
|
|
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
|
|
|
|
}
|
|
|
|
|
drawCwTonePicker();
|
|
|
|
|
if (!cwAutoInput || cwAutoInput.checked) {
|
|
|
|
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
|
|
|
|
cwWpmInput.value = clampCwWpm(evt.wpm);
|
|
|
|
|
}
|
|
|
|
|
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
|
|
|
|
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (cwTonePickerRaf != null) return;
|
|
|
|
|
cwTonePickerRaf = requestAnimationFrame(() => {
|
|
|
|
|
cwTonePickerRaf = null;
|
|
|
|
|
drawCwTonePicker();
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.refreshCwTonePicker = drawCwTonePicker;
|
|
|
|
|
window.addEventListener("resize", () => {
|
|
|
|
|
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
|
|
|
|
|
});
|
|
|
|
|
applyCwAutoUi(!!cwAutoInput?.checked);
|
|
|
|
|
ensureCwToneCanvasResolution();
|
|
|
|
|
drawCwTonePicker();
|
|
|
|
|