From 03bdc10b023f49eaf75056fc6b1cc236d76fa06b Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 1 Aug 2026 12:18:51 +0200 Subject: [PATCH] refactor: convert CW plugin to TypeScript --- .../assets/web/generated/cw.js | 761 +++++++++--------- .../trx-frontend-http/frontend/build.mjs | 2 +- .../frontend/src/plugins/{cw.js => cw.ts} | 253 +++--- .../frontend/tests/cw.test.mjs | 49 ++ 4 files changed, 588 insertions(+), 477 deletions(-) rename src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/{cw.js => cw.ts} (61%) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/cw.test.mjs diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js index eee6c526..d9b8d649 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js @@ -1,386 +1,397 @@ "use strict"; -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 = 1e4; -const CW_WPM_MIN = 5; -const CW_WPM_MAX = 40; -const CW_BAR_WINDOW_MS = 15 * 60 * 1e3; -const CW_BAR_LINE_GAP_MS = 5e3; -let cwLastAppendTime = 0; -let cwTonePickerRaf = null; -let cwBarHistory = []; -let cwBarCurrentLine = null; -let cwBarDismissedAtMs = 0; -let cwAutoLocalOverride = null; -function applyCwAutoUi(enabled) { - if (cwAutoInput) cwAutoInput.checked = enabled; +(() => { + // src/plugins/cw.ts + var cwWindow = window; + var cwStatusEl = document.getElementById("cw-status"); + var cwOutputEl = document.getElementById("cw-output"); + var cwAutoInput = document.getElementById("cw-auto"); + var cwWpmInput = document.getElementById("cw-wpm"); + var cwToneInput = document.getElementById("cw-tone"); + var cwSignalIndicator = document.getElementById("cw-signal-indicator"); + var cwToneCanvas = document.getElementById("cw-tone-waterfall"); + var cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null; + var cwTonePickerEl = document.querySelector(".cw-tone-picker"); + var cwToneRangeEl = document.getElementById("cw-tone-range"); + var cwBarOverlay = document.getElementById("cw-bar-overlay"); + var CW_MAX_LINES = 200; + var CW_TONE_MIN_HZ = 100; + var CW_TONE_MAX_HZ = 1e4; + var CW_WPM_MIN = 5; + var CW_WPM_MAX = 40; + var CW_BAR_WINDOW_MS = 15 * 60 * 1e3; + var CW_BAR_LINE_GAP_MS = 5e3; + var cwLastAppendTime = 0; + var cwTonePickerRaf = null; + var cwBarHistory = []; + var cwBarCurrentLine = null; + var cwBarDismissedAtMs = 0; + var cwAutoLocalOverride = null; + function escapeCwHtml(input) { + return cwWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); + } + 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); + } + } + cwWindow.applyCwAutoUi = applyCwAutoUi; + cwWindow.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); + const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent; + const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0); + if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) { + cwBarOverlay.style.display = "none"; + cwBarOverlay.innerHTML = ""; + return; + } + let html = `
CWLiveLast 15 minutesClear
`; + for (const line of liveLines.slice(0, 8)) { + const ts = line.ts ? `${line.ts}` : ""; + const meta = [ + line.wpm ? `${String(line.wpm)} WPM` : null, + line.tone_hz ? `${String(line.tone_hz)} Hz` : null + ].filter(Boolean).join(" · "); + html += `
${ts}${escapeCwHtml(line.text)}` + (meta ? ` ${escapeCwHtml(meta)}` : "") + `
`; + } + cwBarOverlay.innerHTML = html; + cwBarOverlay.style.display = "flex"; + } + cwWindow.updateCwBar = updateCwBar; + cwWindow.clearCwBar = function() { + cwWindow.resetCwHistoryView?.(); + }; + cwWindow.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(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN; + const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN; + if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { + return null; + } + const mode = (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; + 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 (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) { + if (cwToneRangeEl) { + const mode = (document.getElementById("mode")?.value || "").toUpperCase(); + if (mode !== "CW" && mode !== "CWR") { + cwToneRangeEl.textContent = "CW/CWR mode required"; + } else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.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 ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`; + } + const bins = cwWindow.lastSpectrumData.bins; + const sampleRate = cwWindow.lastSpectrumData.sample_rate; + const centerHz = cwWindow.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] ?? -140; + count += 1; + } + smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140; + } + 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 cwWindow.trxParseCssColor === "function" ? cwWindow.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 <= 1e3 ? 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] ?? -140)); + } + 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))] ?? -140); + 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 = String(clamped); + } + try { + await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`); + } catch (e) { + console.error("CW tone set failed", e); + } + drawCwTonePicker(); + } + if (cwAutoInput) { + cwAutoInput.addEventListener("change", () => { + void (async () => { + const enabled = cwAutoInput.checked; + cwAutoLocalOverride = enabled; + applyCwAutoUi(enabled); + try { + await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); + drawCwTonePicker(); + } catch (error) { + console.error("CW auto toggle failed", error); + } finally { + cwAutoLocalOverride = null; + } + })(); + }); + } if (cwWpmInput) { - cwWpmInput.disabled = enabled; - cwWpmInput.readOnly = enabled; + cwWpmInput.addEventListener("change", () => { + void (async () => { + if (cwAutoInput?.checked) return; + const wpm = clampCwWpm(cwWpmInput.value); + cwWpmInput.value = String(wpm); + try { + await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); + } catch (error) { + console.error("CW WPM set failed", error); + } + })(); + }); } if (cwToneInput) { - cwToneInput.disabled = enabled; - cwToneInput.readOnly = enabled; + cwToneInput.addEventListener("change", () => { + if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value); + }); } - if (cwTonePickerEl) { - cwTonePickerEl.classList.toggle("is-auto", enabled); + if (cwToneCanvas) { + cwToneCanvas.addEventListener("click", (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; + void setCwTone(tone); + }); } -} -window.applyCwAutoUi = applyCwAutoUi; -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); - 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 = `
CWLiveLast 15 minutesClear
`; - 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 <= 1e3 ? 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 (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return; - try { - await postPath("/clear_cw_decode"); - window.resetCwHistoryView(); - } catch (e) { - console.error("CW history clear failed", e); - } -}); -window.onServerCw = function(evt) { - if (cwStatusEl) cwStatusEl.textContent = "Receiving"; - if (evt.text && cwOutputEl) { - const now = Date.now(); - if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || 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; - } - 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)); - } + cwWindow.resetCwHistoryView = function() { + if (cwOutputEl) cwOutputEl.innerHTML = ""; + cwLastAppendTime = 0; + cwBarHistory = []; + cwBarCurrentLine = null; 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(); + }; + document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => { + void (async () => { + if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await cwWindow.postPath?.("/clear_cw_decode"); + cwWindow.resetCwHistoryView?.(); + } catch (error) { + console.error("CW history clear failed", error); + } + })(); }); -}; -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() { + cwWindow.onServerCw = function(evt) { + if (cwStatusEl) cwStatusEl.textContent = "Receiving"; + if (evt.text && cwOutputEl) { + const now = Date.now(); + if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || 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) { + const firstChild = cwOutputEl.firstChild; + if (!firstChild) break; + cwOutputEl.removeChild(firstChild); + } + cwOutputEl.scrollTop = cwOutputEl.scrollHeight; + } + 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 = String(clampCwWpm(evt.wpm)); + } + if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) { + cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange())); + } + } + if (cwTonePickerRaf != null) return; + cwTonePickerRaf = requestAnimationFrame(() => { + cwTonePickerRaf = null; + drawCwTonePicker(); + }); + }; + cwWindow.restoreCwHistory = function(events) { + if (!Array.isArray(events) || events.length === 0) return; + if (cwStatusEl) cwStatusEl.textContent = "Receiving"; + for (const evt of events) { + cwWindow.onServerCw?.(evt); + } + }; + cwWindow.refreshCwTonePicker = function refreshCwTonePicker() { + ensureCwToneCanvasResolution(); + drawCwTonePicker(); + }; + window.addEventListener("resize", () => { + if (ensureCwToneCanvasResolution()) drawCwTonePicker(); + }); + applyCwAutoUi(!!cwAutoInput?.checked); + updateCwBar(); ensureCwToneCanvasResolution(); drawCwTonePicker(); -}; -window.addEventListener("resize", () => { - if (ensureCwToneCanvasResolution()) drawCwTonePicker(); -}); -applyCwAutoUi(!!cwAutoInput?.checked); -updateCwBar(); -ensureCwToneCanvasResolution(); -drawCwTonePicker(); +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs index c74e7bfc..d929c723 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs @@ -26,7 +26,6 @@ await build({ aprs: path.join(sourceDir, "plugins", "aprs.js"), "background-decode": path.join(sourceDir, "plugins", "background-decode.js"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"), - cw: path.join(sourceDir, "plugins", "cw.js"), ft8: path.join(sourceDir, "plugins", "ft8.js"), "hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.js"), sat: path.join(sourceDir, "plugins", "sat.js"), @@ -50,6 +49,7 @@ await build({ ft2: path.join(sourceDir, "plugins", "ft2.ts"), ft4: path.join(sourceDir, "plugins", "ft4.ts"), wspr: path.join(sourceDir, "plugins", "wspr.ts"), + cw: path.join(sourceDir, "plugins", "cw.ts"), }, outdir: outputDir, bundle: true, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.ts similarity index 61% rename from src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.js rename to src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.ts index 8ffd2ab1..404fbf73 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.ts @@ -2,16 +2,57 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +export {}; + +type Rgba = [number, number, number, number]; +interface CwRenderer { + ready: boolean; + ensureSize(width: number, height: number, dpr: number): boolean; + clear(color: Rgba): void; + fillRect(x: number, y: number, width: number, height: number, color: Rgba): void; + drawSegments(points: number[], color: Rgba, width: number): void; + drawFilledArea(points: number[], baseline: number, color: Rgba): void; + drawPolyline(points: number[], color: Rgba, width: number): void; + drawPoints(points: number[], size: number, color: Rgba): void; +} +interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number } +interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean } +interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number } +interface CwToneRange { + tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number; + toneSpanHz: number; lowerSideband: boolean; mode: string; +} +interface CwBridge { + createTrxWebGlRenderer?: (canvas: HTMLCanvasElement, options: WebGLContextAttributes) => CwRenderer; + trxParseCssColor?: (color: string) => Rgba; + lastFreqHz?: number; + currentBandwidthHz?: number; + lastSpectrumData?: CwSpectrum; + escapeMapHtml?: (input: string) => string; + postPath?: (path: string) => Promise; + trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; + applyCwAutoUi?: (enabled: boolean) => void; + applyCwAutoUiFromServer?: (enabled: boolean) => void; + updateCwBar?: () => void; + clearCwBar?: () => void; + closeCwBar?: () => void; + resetCwHistoryView?: () => void; + onServerCw?: (event: CwEvent) => void; + restoreCwHistory?: (events: CwEvent[]) => void; + refreshCwTonePicker?: () => void; +} +const cwWindow = window as unknown as CwBridge; + // --- 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 cwAutoInput = document.getElementById("cw-auto") as HTMLInputElement | null; +const cwWpmInput = document.getElementById("cw-wpm") as HTMLInputElement | null; +const cwToneInput = document.getElementById("cw-tone") as HTMLInputElement | null; const cwSignalIndicator = document.getElementById("cw-signal-indicator"); -const cwToneCanvas = document.getElementById("cw-tone-waterfall"); -const cwToneGl = typeof createTrxWebGlRenderer === "function" - ? createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) +const cwToneCanvas = document.getElementById("cw-tone-waterfall") as HTMLCanvasElement | null; +const cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer + ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null; const cwTonePickerEl = document.querySelector(".cw-tone-picker"); const cwToneRangeEl = document.getElementById("cw-tone-range"); @@ -24,17 +65,23 @@ 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 cwTonePickerRaf: number | null = null; +let cwBarHistory: CwLine[] = []; +let cwBarCurrentLine: CwLine | null = null; 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; +let cwAutoLocalOverride: boolean | null = null; -function applyCwAutoUi(enabled) { +function escapeCwHtml(input: string): string { + return cwWindow.escapeMapHtml?.(input) ?? input + .replaceAll("&", "&").replaceAll("<", "<") + .replaceAll(">", ">").replaceAll('"', """); +} + +function applyCwAutoUi(enabled: boolean): void { if (cwAutoInput) cwAutoInput.checked = enabled; if (cwWpmInput) { cwWpmInput.disabled = enabled; @@ -48,16 +95,16 @@ function applyCwAutoUi(enabled) { cwTonePickerEl.classList.toggle("is-auto", enabled); } } -window.applyCwAutoUi = applyCwAutoUi; +cwWindow.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) { +cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) { if (cwAutoLocalOverride !== null) return; applyCwAutoUi(enabled); }; -function cwBarFlushCurrentLine() { +function cwBarFlushCurrentLine(): void { if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) { cwBarHistory.unshift(cwBarCurrentLine); if (cwBarHistory.length > 50) cwBarHistory.length = 50; @@ -65,15 +112,15 @@ function cwBarFlushCurrentLine() { cwBarCurrentLine = null; } -function updateCwBar() { +function updateCwBar(): void { if (!cwBarOverlay) return; - const mode = (document.getElementById("mode")?.value || "").toUpperCase(); + const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.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); + const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0); if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) { cwBarOverlay.style.display = "none"; cwBarOverlay.innerHTML = ""; @@ -94,22 +141,22 @@ function updateCwBar() { 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, + line.wpm ? `${String(line.wpm)} WPM` : null, + line.tone_hz ? `${String(line.tone_hz)} Hz` : null, ].filter(Boolean).join(" · "); html += `
` + - `
${ts}${escapeMapHtml(line.text)}` + - (meta ? ` ${escapeMapHtml(meta)}` : "") + + `
${ts}${escapeCwHtml(line.text)}` + + (meta ? ` ${escapeCwHtml(meta)}` : "") + `
`; } cwBarOverlay.innerHTML = html; cwBarOverlay.style.display = "flex"; } -window.updateCwBar = updateCwBar; -window.clearCwBar = function() { - window.resetCwHistoryView(); +cwWindow.updateCwBar = updateCwBar; +cwWindow.clearCwBar = function() { + cwWindow.resetCwHistoryView?.(); }; -window.closeCwBar = function() { +cwWindow.closeCwBar = function() { cwBarDismissedAtMs = Date.now(); if (cwBarOverlay) { cwBarOverlay.style.display = "none"; @@ -117,34 +164,31 @@ window.closeCwBar = function() { } }; -function clampCwWpm(wpm) { +function clampCwWpm(wpm: unknown): number { 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) { +function clampCwTone(tone: unknown): number { 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; +function currentCwToneRange(): CwToneRange | null { + const tunedHz = Number.isFinite(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN; + const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN; if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { return null; } - const mode = String(document.getElementById("mode")?.value || "").toUpperCase(); + const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.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, @@ -156,20 +200,20 @@ function currentCwToneRange() { }; } -function cwToneToRfHz(range, toneHz) { +function cwToneToRfHz(range: CwToneRange | null, toneHz: number): number { if (!range) return NaN; return range.lowerSideband ? range.tunedHz - toneHz : range.tunedHz + toneHz; } -function toneClampForRange(tone, range) { +function toneClampForRange(tone: unknown, range: CwToneRange | null): number { const clamped = clampCwTone(tone); if (!range) return clamped; return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped)); } -function ensureCwToneCanvasResolution() { +function ensureCwToneCanvasResolution(): boolean { if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false; const rect = cwToneCanvas.getBoundingClientRect(); const cssWidth = Math.round(rect.width); @@ -181,7 +225,7 @@ function ensureCwToneCanvasResolution() { return cwToneGl.ensureSize(cssWidth, cssHeight, dpr); } -function drawCwTonePicker() { +function drawCwTonePicker(): void { if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return; ensureCwToneCanvasResolution(); if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return; @@ -190,12 +234,12 @@ function drawCwTonePicker() { cwToneGl.clear([0, 0, 0, 0]); const range = currentCwToneRange(); - if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) { + if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) { if (cwToneRangeEl) { - const mode = String(document.getElementById("mode")?.value || "").toUpperCase(); + const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.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) { + } else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length) { cwToneRangeEl.textContent = "Waiting for spectrum"; } } @@ -205,15 +249,15 @@ function drawCwTonePicker() { if (cwToneRangeEl) { const side = range.lowerSideband ? "Lower side" : "Upper side"; - cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`; + cwToneRangeEl.textContent = `Audio ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`; } - const bins = window.lastSpectrumData.bins; - const sampleRate = Number(window.lastSpectrumData.sample_rate); - const centerHz = Number(window.lastSpectrumData.center_hz); + const bins = cwWindow.lastSpectrumData.bins; + const sampleRate = cwWindow.lastSpectrumData.sample_rate; + const centerHz = cwWindow.lastSpectrumData.center_hz; const maxIdx = Math.max(1, bins.length - 1); const fullLoHz = centerHz - sampleRate / 2; - const tones = new Array(width).fill(-140); + const tones: number[] = 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; @@ -223,17 +267,17 @@ function drawCwTonePicker() { tones[x] = power; } - const smoothed = new Array(width).fill(-140); + const smoothed: number[] = 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]; + sum += tones[i] ?? -140; count += 1; } - smoothed[x] = count > 0 ? sum / count : tones[x]; + smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140; } const sorted = smoothed.slice().sort((a, b) => a - b); @@ -242,23 +286,23 @@ function drawCwTonePicker() { 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 yForDb = (db: number): number => { 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 + const parseColor = typeof cwWindow.trxParseCssColor === "function" + ? cwWindow.trxParseCssColor : null; - const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1]; - const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15]; + const accentRgba: Rgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1]; + const axisColor: Rgba = [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 = []; + const gridSegments: number[] = []; for (let i = 1; i <= hGridCount; i += 1) { const y = Math.round((i / (hGridCount + 1)) * (height - 1)); gridSegments.push(0, y, width, y); @@ -267,7 +311,7 @@ function drawCwTonePicker() { const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200; const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep; - const tickSegments = []; + const tickSegments: number[] = []; 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)))); @@ -275,9 +319,9 @@ function drawCwTonePicker() { } cwToneGl.drawSegments(tickSegments, axisColor, 1); - const linePoints = []; + const linePoints: number[] = []; for (let x = 0; x < width; x += 1) { - linePoints.push(x, yForDb(smoothed[x])); + linePoints.push(x, yForDb(smoothed[x] ?? -140)); } 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)); @@ -285,7 +329,7 @@ function drawCwTonePicker() { 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))]); + const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))] ?? -140); 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]); @@ -294,14 +338,14 @@ function drawCwTonePicker() { } } -async function setCwTone(tone, { syncInput = true } = {}) { +async function setCwTone(tone: unknown, { syncInput = true }: { syncInput?: boolean } = {}): Promise { const range = currentCwToneRange(); const clamped = toneClampForRange(tone, range); if (cwToneInput && syncInput) { - cwToneInput.value = clamped; + cwToneInput.value = String(clamped); } try { - await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`); + await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`); } catch (e) { console.error("CW tone set failed", e); } @@ -309,40 +353,43 @@ async function setCwTone(tone, { syncInput = true } = {}) { } 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; - } + cwAutoInput.addEventListener("change", () => { + void (async () => { + const enabled = cwAutoInput.checked; + cwAutoLocalOverride = enabled; + applyCwAutoUi(enabled); + try { + await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); + drawCwTonePicker(); + } catch (error: unknown) { + console.error("CW auto toggle failed", error); + } 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); } + cwWpmInput.addEventListener("change", () => { + void (async () => { + if (cwAutoInput?.checked) return; + const wpm = clampCwWpm(cwWpmInput.value); + cwWpmInput.value = String(wpm); + try { await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); } + catch (error: unknown) { console.error("CW WPM set failed", error); } + })(); }); } if (cwToneInput) { - cwToneInput.addEventListener("change", async () => { - if (cwAutoInput?.checked) return; - await setCwTone(cwToneInput.value); + cwToneInput.addEventListener("change", () => { + if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value); }); } if (cwToneCanvas) { - cwToneCanvas.addEventListener("click", async (event) => { + cwToneCanvas.addEventListener("click", (event) => { if (cwAutoInput?.checked) return; const rect = cwToneCanvas.getBoundingClientRect(); if (rect.width <= 0) return; @@ -350,11 +397,11 @@ if (cwToneCanvas) { 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); + void setCwTone(tone); }); } -window.resetCwHistoryView = function() { +cwWindow.resetCwHistoryView = function() { if (cwOutputEl) cwOutputEl.innerHTML = ""; cwLastAppendTime = 0; cwBarHistory = []; @@ -363,18 +410,20 @@ window.resetCwHistoryView = function() { drawCwTonePicker(); }; -document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => { - if (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return; - try { - await postPath("/clear_cw_decode"); - window.resetCwHistoryView(); - } catch (e) { - console.error("CW history clear failed", e); - } +document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => { + void (async () => { + if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await cwWindow.postPath?.("/clear_cw_decode"); + cwWindow.resetCwHistoryView?.(); + } catch (error: unknown) { + console.error("CW history clear failed", error); + } + })(); }); // --- Server-side CW decode handler --- -window.onServerCw = function(evt) { +cwWindow.onServerCw = function(evt: CwEvent) { if (cwStatusEl) cwStatusEl.textContent = "Receiving"; if (evt.text && cwOutputEl) { // Append decoded text to output @@ -390,7 +439,9 @@ window.onServerCw = function(evt) { lastLine.textContent += evt.text; } while (cwOutputEl.children.length > CW_MAX_LINES) { - cwOutputEl.removeChild(cwOutputEl.firstChild); + const firstChild = cwOutputEl.firstChild; + if (!firstChild) break; + cwOutputEl.removeChild(firstChild); } cwOutputEl.scrollTop = cwOutputEl.scrollHeight; } @@ -417,10 +468,10 @@ window.onServerCw = function(evt) { } if (!cwAutoInput || cwAutoInput.checked) { if (cwWpmInput && Number.isFinite(Number(evt.wpm))) { - cwWpmInput.value = clampCwWpm(evt.wpm); + cwWpmInput.value = String(clampCwWpm(evt.wpm)); } if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) { - cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange()); + cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange())); } } if (cwTonePickerRaf != null) return; @@ -430,15 +481,15 @@ window.onServerCw = function(evt) { }); }; -window.restoreCwHistory = function(events) { +cwWindow.restoreCwHistory = function(events: CwEvent[]) { if (!Array.isArray(events) || events.length === 0) return; if (cwStatusEl) cwStatusEl.textContent = "Receiving"; for (const evt of events) { - window.onServerCw(evt); + cwWindow.onServerCw?.(evt); } }; -window.refreshCwTonePicker = function refreshCwTonePicker() { +cwWindow.refreshCwTonePicker = function refreshCwTonePicker() { ensureCwToneCanvasResolution(); drawCwTonePicker(); }; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/cw.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/cw.test.mjs new file mode 100644 index 00000000..bf19e89d --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/cw.test.mjs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +class ElementFixture { + constructor() { this.children = []; this.textContent = ""; this.style = {}; this.classList = { toggle() {} }; } + appendChild(child) { this.children.push(child); return child; } + removeChild(child) { this.children.splice(this.children.indexOf(child), 1); } + get firstChild() { return this.children[0] ?? null; } + get lastElementChild() { return this.children.at(-1) ?? null; } + get scrollHeight() { return this.children.length; } +} + +test("CW entry appends server-decoded text and registers lifecycle callbacks", async () => { + const output = new ElementFixture(); + const status = new ElementFixture(); + const elements = new Map([["cw-output", output], ["cw-status", status]]); + const window = { trxUi: { confirm: async () => true }, addEventListener() {} }; + const context = vm.createContext({ + window, + document: { + documentElement: {}, + getElementById: (id) => elements.get(id) ?? null, + querySelector: () => null, + createElement: () => new ElementFixture(), + }, + requestAnimationFrame(callback) { callback(); return 1; }, + getComputedStyle: () => ({ getPropertyValue: () => "" }), + Date, + Number, + String, + Math, + console, + }); + const source = await readFile(new URL("../../assets/web/generated/cw.js", import.meta.url), "utf8"); + new vm.Script(source).runInContext(context); + + window.onServerCw({ text: "CQ", wpm: 18, tone_hz: 700, signal_on: true }); + assert.equal(status.textContent, "Receiving"); + assert.equal(output.children.length, 1); + assert.equal(output.children[0].textContent, "CQ"); + assert.equal(typeof window.resetCwHistoryView, "function"); + assert.equal(typeof window.restoreCwHistory, "function"); +});