From 953edb336a5e105afedb23609f06fd3aa32b4971 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Tue, 3 Mar 2026 02:06:28 +0100 Subject: [PATCH] [feat](trx-frontend): add CW tone waterfall picker Add a mini waterfall-based CW tone selector in the plugin tab and make CW auto mode apply only to WPM. Co-authored-by: Stan Grams Signed-off-by: Stan Grams --- .../trx-frontend-http/assets/web/app.js | 13 +- .../trx-frontend-http/assets/web/index.html | 9 +- .../assets/web/plugins/cw.js | 117 ++++++++++++++++-- .../trx-frontend-http/assets/web/style.css | 3 + 4 files changed, 129 insertions(+), 13 deletions(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index 36c5c40..350f4d9 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -1197,6 +1197,9 @@ function applyLocalTunedFrequency(hz, forceDisplay = false) { if (window.updateFt8RfDisplay) { window.updateFt8RfDisplay(); } + if (window.refreshCwTonePicker) { + window.refreshCwTonePicker(); + } if (lastSpectrumData) { scheduleSpectrumDraw(); } @@ -2083,6 +2086,9 @@ function render(update) { if (update.filter && typeof update.filter.bandwidth_hz === "number") { currentBandwidthHz = update.filter.bandwidth_hz; syncBandwidthInput(currentBandwidthHz); + if (window.refreshCwTonePicker) { + window.refreshCwTonePicker(); + } if ( sdrGainEl && typeof update.filter.sdr_gain_db === "number" @@ -2222,12 +2228,10 @@ function render(update) { if (cwToneEl && typeof update.cw_tone_hz === "number") { cwToneEl.value = update.cw_tone_hz; } - if (cwWpmEl && cwToneEl && typeof update.cw_auto === "boolean") { + if (cwWpmEl && typeof update.cw_auto === "boolean") { const disabled = update.cw_auto; cwWpmEl.disabled = disabled; cwWpmEl.readOnly = disabled; - cwToneEl.disabled = disabled; - cwToneEl.readOnly = disabled; } let activeFreqColor = "var(--accent-green)"; if (update.status && update.status.vfo && Array.isArray(update.status.vfo.entries)) { @@ -4965,6 +4969,9 @@ function startSpectrumStreaming() { pushSpectrumPeakHoldFrame(lastSpectrumRenderData); pushOverviewWaterfallFrame(lastSpectrumData); refreshCenterFreqDisplay(); + if (window.refreshCwTonePicker) { + window.refreshCwTonePicker(); + } scheduleSpectrumDraw(); if (lastModeName === "WFM") { updateRdsPsOverlay(lastSpectrumData.rds); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 0105dca..4b4b0f3 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -550,10 +550,17 @@
- +
+
+
+ CW Tone From Selected BW + -- +
+ +
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js index 7e3c7bc..2637cbf 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js @@ -5,7 +5,11 @@ 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 cwToneRangeEl = document.getElementById("cw-tone-range"); const CW_MAX_LINES = 200; +const CW_TONE_MIN_HZ = 300; +const CW_TONE_MAX_HZ = 1200; function applyCwAutoUi(enabled) { if (cwAutoInput) cwAutoInput.checked = enabled; @@ -13,10 +17,92 @@ function applyCwAutoUi(enabled) { cwWpmInput.disabled = enabled; cwWpmInput.readOnly = enabled; } - if (cwToneInput) { - cwToneInput.disabled = enabled; - cwToneInput.readOnly = enabled; +} + +function clampCwTone(tone) { + return Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, Number(tone))); +} + +function currentCwToneRange() { + const centerHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : Number.NaN; + const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : Number.NaN; + if (!Number.isFinite(centerHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { + return null; } + return { + lowHz: centerHz - bandwidthHz / 2, + highHz: centerHz + bandwidthHz / 2, + centerHz, + bandwidthHz, + }; +} + +function drawCwTonePicker() { + if (!cwToneCanvas) return; + const ctx = cwToneCanvas.getContext("2d"); + if (!ctx) return; + + const width = cwToneCanvas.width; + const height = cwToneCanvas.height; + ctx.clearRect(0, 0, width, height); + + const range = currentCwToneRange(); + if (!range || !window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) { + if (cwToneRangeEl) cwToneRangeEl.textContent = "Waiting for spectrum"; + ctx.fillStyle = "rgba(130, 150, 165, 0.22)"; + ctx.fillRect(0, 0, width, height); + return; + } + + if (cwToneRangeEl) { + const lowKHz = (range.lowHz / 1000).toFixed(range.bandwidthHz >= 10_000 ? 0 : 1); + const highKHz = (range.highHz / 1000).toFixed(range.bandwidthHz >= 10_000 ? 0 : 1); + cwToneRangeEl.textContent = `${lowKHz} - ${highKHz} kHz`; + } + + 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(0); + let maxPower = 0; + for (let x = 0; x < width; x += 1) { + const frac = width <= 1 ? 0 : x / (width - 1); + const toneHz = range.lowHz + frac * (range.highHz - range.lowHz); + const idx = Math.max(0, Math.min(maxIdx, Math.round((((toneHz - fullLoHz) / sampleRate) * maxIdx)))); + const power = Math.max(0, Number(bins[idx]) || 0); + tones[x] = power; + if (power > maxPower) maxPower = power; + } + + for (let x = 0; x < width; x += 1) { + const frac = width <= 1 ? 0 : x / (width - 1); + const level = maxPower > 0 ? tones[x] / maxPower : 0; + const hue = 200 - level * 155; + const light = 18 + level * 55; + ctx.fillStyle = `hsl(${hue} 85% ${light}%)`; + ctx.fillRect(x, 0, 1, height); + } + + const currentTone = clampCwTone(cwToneInput ? cwToneInput.value : 700); + const markerFrac = (currentTone - CW_TONE_MIN_HZ) / (CW_TONE_MAX_HZ - CW_TONE_MIN_HZ); + const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1)))); + ctx.fillStyle = "rgba(255, 255, 255, 0.9)"; + ctx.fillRect(markerX, 0, 2, height); +} + +async function setCwTone(tone, { syncInput = true } = {}) { + const clamped = clampCwTone(tone); + 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) { @@ -40,17 +126,24 @@ if (cwWpmInput) { if (cwToneInput) { cwToneInput.addEventListener("change", async () => { - if (cwAutoInput && cwAutoInput.checked) return; - const tone = Math.max(300, Math.min(1200, Number(cwToneInput.value))); - cwToneInput.value = tone; - try { await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(tone)}`); } - catch (e) { console.error("CW tone set failed", e); } + await setCwTone(cwToneInput.value); + }); +} + +if (cwToneCanvas) { + cwToneCanvas.addEventListener("click", async (event) => { + const rect = cwToneCanvas.getBoundingClientRect(); + if (rect.width <= 0) return; + const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); + const tone = CW_TONE_MIN_HZ + frac * (CW_TONE_MAX_HZ - CW_TONE_MIN_HZ); + await setCwTone(tone); }); } window.resetCwHistoryView = function() { cwOutputEl.innerHTML = ""; cwLastAppendTime = 0; + drawCwTonePicker(); }; document.getElementById("cw-clear-btn").addEventListener("click", async () => { @@ -87,6 +180,12 @@ window.onServerCw = function(evt) { cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off"; if (!cwAutoInput || cwAutoInput.checked) { cwWpmInput.value = evt.wpm; - cwToneInput.value = evt.tone_hz; } + if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) { + cwToneInput.value = clampCwTone(evt.tone_hz); + } + drawCwTonePicker(); }; + +window.refreshCwTonePicker = drawCwTonePicker; +drawCwTonePicker(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index 74457f1..a0e057d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -1536,6 +1536,9 @@ small { color: var(--text-muted); } .cw-config { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.75rem; } .cw-config label { display: flex; flex-direction: column; gap: 0.2rem; color: var(--text-muted); font-size: 0.82rem; } .cw-config input[type="number"] { width: 5rem; padding: 0.3rem 0.4rem; font-size: 0.9rem; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); color: var(--text); } +.cw-tone-picker { margin-bottom: 0.75rem; border: 1px solid var(--border-light); border-radius: 8px; background: var(--input-bg); padding: 0.5rem 0.6rem; } +.cw-tone-picker-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.6rem; margin-bottom: 0.35rem; color: var(--text-muted); font-size: 0.78rem; } +#cw-tone-waterfall { width: 100%; height: 56px; display: block; border-radius: 6px; background: linear-gradient(180deg, rgba(8, 14, 18, 0.92), rgba(18, 28, 36, 0.98)); cursor: crosshair; } #cw-output { max-height: 360px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85rem; padding: 0.4rem 0.5rem; min-height: 60px; white-space: pre-wrap; word-break: break-all; } .cw-line { line-height: 1.5; } .cw-signal-on { width: 10px; height: 10px; border-radius: 50%; background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green); flex-shrink: 0; }