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 = `
`; + for (const line of liveLines.slice(0, 8)) { + const 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 += ``; + } + 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 = ``; - for (const line of liveLines.slice(0, 8)) { - const ts = line.ts ? `` : ""; - const meta = [ - line.wpm ? `${line.wpm} WPM` : null, - line.tone_hz ? `${line.tone_hz} Hz` : null - ].filter(Boolean).join(" · "); - html += ``; - } - 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