// SPDX-FileCopyrightText: 2026 Stan Grams // // SPDX-License-Identifier: GPL-2.0-or-later // --- CW (Morse) Decoder Plugin (server-side decode) --- const cwStatusEl = document.getElementById("cw-status"); const cwOutputEl = document.getElementById("cw-output"); const cwAutoInput = document.getElementById("cw-auto"); 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 cwToneGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null; const cwTonePickerEl = document.querySelector(".cw-tone-picker"); const cwToneRangeEl = document.getElementById("cw-tone-range"); const cwBarOverlay = document.getElementById("cw-bar-overlay"); const CW_MAX_LINES = 200; const CW_TONE_MIN_HZ = 100; const CW_TONE_MAX_HZ = 10_000; const CW_WPM_MIN = 5; const CW_WPM_MAX = 40; const CW_BAR_WINDOW_MS = 15 * 60 * 1000; const CW_BAR_LINE_GAP_MS = 5000; let cwLastAppendTime = 0; let cwTonePickerRaf = null; let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}] let cwBarCurrentLine = null; // accumulates chars until gap/newline let cwBarDismissedAtMs = 0; // Tracks a user-initiated auto toggle that is in-flight (POST not yet // acknowledged). While set, server-state updates must not override the // checkbox so that a concurrent SSE event carrying the *old* cw_auto value // does not immediately undo the user's choice. let cwAutoLocalOverride = null; function applyCwAutoUi(enabled) { if (cwAutoInput) cwAutoInput.checked = enabled; if (cwWpmInput) { 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; // Called by app.js render() when a server-state snapshot arrives. Ignores // the update while cwAutoLocalOverride is set (user change still in-flight). window.applyCwAutoUiFromServer = function(enabled) { if (cwAutoLocalOverride !== null) return; applyCwAutoUi(enabled); }; function cwBarFlushCurrentLine() { if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) { cwBarHistory.unshift(cwBarCurrentLine); if (cwBarHistory.length > 50) cwBarHistory.length = 50; } cwBarCurrentLine = null; } function updateCwBar() { if (!cwBarOverlay) return; const mode = (document.getElementById("mode")?.value || "").toUpperCase(); const isCw = mode === "CW" || mode === "CWR"; const cutoffMs = Date.now() - CW_BAR_WINDOW_MS; const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs); // Prepend the in-progress line so characters appear immediately const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent; const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0); if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) { cwBarOverlay.style.display = "none"; cwBarOverlay.innerHTML = ""; return; } let html = '
' + 'CWLive' + '' + 'Last 15 minutes' + 'Clear' + '' + '' + '
'; for (const line of liveLines.slice(0, 8)) { const ts = line.ts ? `${line.ts}` : ""; const meta = [ line.wpm ? `${line.wpm} WPM` : null, line.tone_hz ? `${line.tone_hz} Hz` : null, ].filter(Boolean).join(" · "); html += `
` + `
${ts}${escapeMapHtml(line.text)}` + (meta ? ` ${escapeMapHtml(meta)}` : "") + `
`; } cwBarOverlay.innerHTML = html; cwBarOverlay.style.display = "flex"; } window.updateCwBar = updateCwBar; window.clearCwBar = function() { window.resetCwHistoryView(); }; window.closeCwBar = function() { cwBarDismissedAtMs = Date.now(); if (cwBarOverlay) { cwBarOverlay.style.display = "none"; cwBarOverlay.innerHTML = ""; } }; 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) { const numeric = Number(tone); if (!Number.isFinite(numeric)) return 700; return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric))); } function currentCwToneRange() { const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN; const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN; if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { return null; } const mode = String(document.getElementById("mode")?.value || "").toUpperCase(); const lowerSideband = mode === "CWR"; const upperSideband = mode === "CW"; if (!lowerSideband && !upperSideband) return null; const toneMinHz = CW_TONE_MIN_HZ; const toneMaxHz = CW_TONE_MAX_HZ; if (toneMaxHz < toneMinHz) { return null; } return { tunedHz, bandwidthHz, toneMinHz, toneMaxHz, toneSpanHz: Math.max(1, toneMaxHz - toneMinHz), lowerSideband, mode, }; } function cwToneToRfHz(range, toneHz) { if (!range) return NaN; return range.lowerSideband ? range.tunedHz - toneHz : range.tunedHz + toneHz; } 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 || !cwToneGl || !cwToneGl.ready) return false; const rect = cwToneCanvas.getBoundingClientRect(); const cssWidth = Math.round(rect.width); const cssHeight = Math.round(rect.height); if (cssWidth < 8 || cssHeight < 8) { return false; } const dpr = window.devicePixelRatio || 1; return cwToneGl.ensureSize(cssWidth, cssHeight, dpr); } function drawCwTonePicker() { if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return; ensureCwToneCanvasResolution(); if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return; const width = cwToneCanvas.width; const height = cwToneCanvas.height; cwToneGl.clear([0, 0, 0, 0]); const range = currentCwToneRange(); if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) { if (cwToneRangeEl) { const mode = String(document.getElementById("mode")?.value || "").toUpperCase(); if (mode !== "CW" && mode !== "CWR") { cwToneRangeEl.textContent = "CW/CWR mode required"; } else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) { cwToneRangeEl.textContent = "Waiting for spectrum"; } } cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]); return; } if (cwToneRangeEl) { const side = range.lowerSideband ? "Lower side" : "Upper side"; cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`; } const bins = window.lastSpectrumData.bins; const sampleRate = Number(window.lastSpectrumData.sample_rate); const centerHz = Number(window.lastSpectrumData.center_hz); const maxIdx = Math.max(1, bins.length - 1); const fullLoHz = centerHz - sampleRate / 2; const tones = new Array(width).fill(-140); for (let x = 0; x < width; x += 1) { const frac = width <= 1 ? 0 : x / (width - 1); const toneHz = range.toneMinHz + frac * range.toneSpanHz; const rfHz = cwToneToRfHz(range, toneHz); const idx = Math.max(0, Math.min(maxIdx, Math.round((((rfHz - fullLoHz) / sampleRate) * maxIdx)))); const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140; tones[x] = power; } const smoothed = new Array(width).fill(-140); const smoothRadius = Math.max(1, Math.round(width / 180)); for (let x = 0; x < width; x += 1) { let sum = 0; let count = 0; for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) { if (i < 0 || i >= width) continue; sum += tones[i]; count += 1; } smoothed[x] = count > 0 ? sum / count : tones[x]; } const sorted = smoothed.slice().sort((a, b) => a - b); const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120; const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70; const floorDb = Math.min(q20 - 2, q95 - 10); const ceilDb = Math.max(floorDb + 18, q95 + 2); const dbSpan = Math.max(1, ceilDb - floorDb); const yForDb = (db) => { const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan)); return Math.round((1 - n) * (height - 1)); }; const rootStyle = getComputedStyle(document.documentElement); const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f"; const parseColor = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null; const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1]; const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15]; cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]); const hGridCount = 4; const gridSegments = []; for (let i = 1; i <= hGridCount; i += 1) { const y = Math.round((i / (hGridCount + 1)) * (height - 1)); gridSegments.push(0, y, width, y); } cwToneGl.drawSegments(gridSegments, axisColor, 1); const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200; const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep; const tickSegments = []; for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) { const frac = (tone - range.toneMinHz) / range.toneSpanHz; const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1)))); tickSegments.push(x, 0, x, height); } cwToneGl.drawSegments(tickSegments, axisColor, 1); const linePoints = []; for (let x = 0; x < width; x += 1) { linePoints.push(x, yForDb(smoothed[x])); } cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]); cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2)); const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range); const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz; const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1)))); const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]); cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5); cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]); if (cwAutoInput?.checked) { cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]); } } async function setCwTone(tone, { syncInput = true } = {}) { const range = currentCwToneRange(); const clamped = toneClampForRange(tone, range); if (cwToneInput && syncInput) { cwToneInput.value = clamped; } try { await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`); } catch (e) { console.error("CW tone set failed", e); } drawCwTonePicker(); } if (cwAutoInput) { cwAutoInput.addEventListener("change", async () => { const enabled = cwAutoInput.checked; cwAutoLocalOverride = enabled; applyCwAutoUi(enabled); try { await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); drawCwTonePicker(); } catch (e) { console.error("CW auto toggle failed", e); } finally { cwAutoLocalOverride = null; } }); } if (cwWpmInput) { cwWpmInput.addEventListener("change", async () => { if (cwAutoInput && cwAutoInput.checked) return; 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); } }); } 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(); if (!range) return; const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); const tone = range.toneMinHz + frac * range.toneSpanHz; await setCwTone(tone); }); } window.resetCwHistoryView = function() { if (cwOutputEl) cwOutputEl.innerHTML = ""; cwLastAppendTime = 0; cwBarHistory = []; cwBarCurrentLine = null; updateCwBar(); drawCwTonePicker(); }; document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => { if (!confirm("Clear all CW decode history? This cannot be undone.")) return; try { await postPath("/clear_cw_decode"); window.resetCwHistoryView(); } catch (e) { console.error("CW history clear failed", e); } }); // --- Server-side CW decode handler --- window.onServerCw = function(evt) { 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") { const line = document.createElement("div"); line.className = "cw-line"; cwOutputEl.appendChild(line); } cwLastAppendTime = now; const lastLine = cwOutputEl.lastElementChild; if (lastLine) { lastLine.textContent += evt.text; } while (cwOutputEl.children.length > CW_MAX_LINES) { cwOutputEl.removeChild(cwOutputEl.firstChild); } cwOutputEl.scrollTop = cwOutputEl.scrollHeight; } // Bar history accumulation (regardless of pause state) if (evt.text) { const now = Date.now(); if (evt.text === "\n") { cwBarFlushCurrentLine(); } else { if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) { cwBarFlushCurrentLine(); const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now }; } cwBarCurrentLine.text += evt.text; cwBarCurrentLine.lastMs = now; if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm); if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz)); } updateCwBar(); } if (cwSignalIndicator) { cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off"; } 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.restoreCwHistory = function(events) { if (!Array.isArray(events) || events.length === 0) return; if (cwStatusEl) cwStatusEl.textContent = "Receiving"; for (const evt of events) { window.onServerCw(evt); } }; window.refreshCwTonePicker = function refreshCwTonePicker() { ensureCwToneCanvasResolution(); drawCwTonePicker(); }; window.addEventListener("resize", () => { if (ensureCwToneCanvasResolution()) drawCwTonePicker(); }); applyCwAutoUi(!!cwAutoInput?.checked); updateCwBar(); ensureCwToneCanvasResolution(); drawCwTonePicker();