From af50c2b6ee44b0d5ff5af44be2a45fae3d60eaad Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Sun, 8 Feb 2026 15:30:42 +0100 Subject: [PATCH] [feat](trx-frontend-http): add auto tone and WPM detection to CW decoder Add Auto checkboxes (checked by default) next to WPM and Tone inputs. Auto Tone uses a multi-bin Goertzel scan across 300-1200 Hz with stability tracking. Auto WPM collects on-durations in a rolling buffer and uses k-means-style clustering to separate dits from dahs. Unchecking Auto allows manual control. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Stanislaw Grams --- .../trx-frontend-http/assets/web/index.html | 6 +- .../assets/web/plugins/cw.js | 437 ++++++++++++++++++ .../trx-frontend-http/assets/web/style.css | 3 + 3 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js 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 a27e151..42c5921 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 @@ -151,8 +151,10 @@
- - + + + +
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 new file mode 100644 index 0000000..e02ede5 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js @@ -0,0 +1,437 @@ +// --- CW (Morse) Decoder Plugin --- +const cwToggleBtn = document.getElementById("cw-toggle-btn"); +const cwStatusEl = document.getElementById("cw-status"); +const cwOutputEl = document.getElementById("cw-output"); +const cwWpmInput = document.getElementById("cw-wpm"); +const cwToneInput = document.getElementById("cw-tone"); +const cwThresholdInput = document.getElementById("cw-threshold"); +const cwThresholdVal = document.getElementById("cw-threshold-val"); +const cwSignalIndicator = document.getElementById("cw-signal-indicator"); +const cwWpmAutoCheck = document.getElementById("cw-wpm-auto"); +const cwToneAutoCheck = document.getElementById("cw-tone-auto"); +const CW_MAX_LINES = 200; + +let cwActive = false; +let cwWs = null; +let cwAudioCtx = null; +let cwDecoder = null; + +// ITU Morse code lookup +const MORSE_TABLE = { + ".-": "A", "-...": "B", "-.-.": "C", "-..": "D", ".": "E", + "..-.": "F", "--.": "G", "....": "H", "..": "I", ".---": "J", + "-.-": "K", ".-..": "L", "--": "M", "-.": "N", "---": "O", + ".--.": "P", "--.-": "Q", ".-.": "R", "...": "S", "-": "T", + "..-": "U", "...-": "V", ".--": "W", "-..-": "X", "-.--": "Y", + "--..": "Z", + "-----": "0", ".----": "1", "..---": "2", "...--": "3", "....-": "4", + ".....": "5", "-....": "6", "--...": "7", "---..": "8", "----.": "9", + ".-.-.-": ".", "--..--": ",", "..--..": "?", ".----.": "'", + "-.-.--": "!", "-..-.": "/", "-.--.": "(", "-.--.-": ")", + ".-...": "&", "---...": ":", "-.-.-.": ";", "-...-": "=", + ".-.-.": "+", "-....-": "-", "..--.-": "_", ".-..-.": "\"", + "...-..-": "$", ".--.-.": "@", +}; + +// Update threshold display +cwThresholdInput.addEventListener("input", () => { + cwThresholdVal.textContent = (cwThresholdInput.value / 100).toFixed(2); +}); + +// Toggle readonly on WPM input based on Auto checkbox +cwWpmAutoCheck.addEventListener("change", () => { + cwWpmInput.readOnly = cwWpmAutoCheck.checked; +}); + +// Toggle readonly on Tone input based on Auto checkbox +cwToneAutoCheck.addEventListener("change", () => { + cwToneInput.readOnly = cwToneAutoCheck.checked; +}); + +function createCwDecoder(sampleRate) { + let wpm = parseInt(cwWpmInput.value, 10) || 15; + let toneFreq = parseInt(cwToneInput.value, 10) || 700; + let threshold = (parseInt(cwThresholdInput.value, 10) || 5) / 100; + + // Goertzel parameters for main detector + const windowMs = 50; // 50ms analysis window + const windowSize = Math.round(sampleRate * windowMs / 1000); + let k = Math.round(toneFreq * windowSize / sampleRate); + let omega = (2 * Math.PI * k) / windowSize; + let coeff = 2 * Math.cos(omega); + + let sampleBuf = new Float32Array(windowSize); + let sampleIdx = 0; + + // Tone state tracking + let toneOn = false; + let toneOnAt = 0; + let toneOffAt = 0; + let currentSymbol = ""; // accumulates dits/dahs for current character + let decoded = ""; + let lastAppendTime = 0; + + // --- Auto Tone Detection --- + // Scan 300–1200 Hz in ~25 Hz steps + const TONE_SCAN_LOW = 300; + const TONE_SCAN_HIGH = 1200; + const TONE_SCAN_STEP = 25; + const toneScanBins = []; + for (let f = TONE_SCAN_LOW; f <= TONE_SCAN_HIGH; f += TONE_SCAN_STEP) { + const bk = Math.round(f * windowSize / sampleRate); + const bOmega = (2 * Math.PI * bk) / windowSize; + toneScanBins.push({ freq: f, coeff: 2 * Math.cos(bOmega) }); + } + let toneStableBin = -1; // index of the bin that's been stable + let toneStableCount = 0; // how many consecutive windows it's been the peak + const TONE_STABLE_NEEDED = 3; + + // --- Auto WPM Detection --- + const onDurations = []; // rolling buffer of on-durations (ms) + const MAX_ON_DURATIONS = 30; + const MIN_ON_DURATIONS = 8; + + function recomputeGoertzel(newFreq) { + toneFreq = newFreq; + k = Math.round(toneFreq * windowSize / sampleRate); + omega = (2 * Math.PI * k) / windowSize; + coeff = 2 * Math.cos(omega); + } + + // Timing: 1 unit = 1200/WPM ms + function unitMs() { return 1200 / wpm; } + + function goertzelEnergy(buf, c) { + let s0 = 0, s1 = 0, s2 = 0; + for (let i = 0; i < buf.length; i++) { + s0 = c * s1 - s2 + buf[i]; + s2 = s1; + s1 = s0; + } + return (s1 * s1 + s2 * s2 - c * s1 * s2) / (buf.length * buf.length); + } + + function goertzelDetect(buf) { + const toneEnergy = goertzelEnergy(buf, coeff); + let totalEnergy = 0; + for (let i = 0; i < buf.length; i++) { + totalEnergy += buf[i] * buf[i]; + } + const avgEnergy = totalEnergy / buf.length; + if (avgEnergy < 1e-10) return false; + return (toneEnergy / avgEnergy) > threshold; + } + + function autoDetectTone(buf) { + // Compute broadband energy + let totalEnergy = 0; + for (let i = 0; i < buf.length; i++) { + totalEnergy += buf[i] * buf[i]; + } + const avgEnergy = totalEnergy / buf.length; + if (avgEnergy < 1e-10) return; + + // Find the bin with highest energy relative to broadband + let bestIdx = -1; + let bestRatio = 0; + for (let b = 0; b < toneScanBins.length; b++) { + const e = goertzelEnergy(buf, toneScanBins[b].coeff); + const ratio = e / avgEnergy; + if (ratio > bestRatio) { + bestRatio = ratio; + bestIdx = b; + } + } + + // Require the peak to exceed threshold to be meaningful + if (bestRatio < threshold || bestIdx < 0) { + toneStableCount = 0; + toneStableBin = -1; + return; + } + + // Check stability: same bin ±1 + if (toneStableBin >= 0 && Math.abs(bestIdx - toneStableBin) <= 1) { + toneStableCount++; + } else { + toneStableBin = bestIdx; + toneStableCount = 1; + } + + if (toneStableCount >= TONE_STABLE_NEEDED) { + const detectedFreq = toneScanBins[toneStableBin].freq; + if (Math.abs(detectedFreq - toneFreq) > TONE_SCAN_STEP) { + recomputeGoertzel(detectedFreq); + cwToneInput.value = detectedFreq; + } + } + } + + function autoDetectWpm() { + if (onDurations.length < MIN_ON_DURATIONS) return; + + // Sort durations ascending + const sorted = onDurations.slice().sort((a, b) => a - b); + + // K-means-style split: find the best boundary between dit and dah clusters + let bestBoundary = 1; + let bestScore = Infinity; + for (let i = 1; i < sorted.length; i++) { + const cluster1 = sorted.slice(0, i); + const cluster2 = sorted.slice(i); + const mean1 = cluster1.reduce((a, b) => a + b, 0) / cluster1.length; + const mean2 = cluster2.reduce((a, b) => a + b, 0) / cluster2.length; + let score = 0; + for (const v of cluster1) score += (v - mean1) * (v - mean1); + for (const v of cluster2) score += (v - mean2) * (v - mean2); + if (score < bestScore) { + bestScore = score; + bestBoundary = i; + } + } + + // The shorter cluster is dits — take the median + const ditCluster = sorted.slice(0, bestBoundary); + if (ditCluster.length === 0) return; + const ditMs = ditCluster[Math.floor(ditCluster.length / 2)]; + if (ditMs < 10) return; // too short, ignore + + let newWpm = Math.round(1200 / ditMs); + newWpm = Math.max(5, Math.min(40, newWpm)); + if (newWpm !== wpm) { + wpm = newWpm; + cwWpmInput.value = wpm; + } + } + + function processWindow() { + // Run auto tone detection if enabled + if (cwToneAutoCheck.checked) { + autoDetectTone(sampleBuf); + } + + const detected = goertzelDetect(sampleBuf); + const now = performance.now(); + + // Update signal indicator + if (detected) { + cwSignalIndicator.className = "cw-signal-on"; + } else { + cwSignalIndicator.className = "cw-signal-off"; + } + + if (detected && !toneOn) { + // Tone just turned on + toneOn = true; + const offDuration = now - toneOffAt; + if (toneOffAt > 0) { + const u = unitMs(); + if (offDuration > u * 5) { + // Word gap (7 units, use 5 as threshold) + if (currentSymbol) { + const ch = MORSE_TABLE[currentSymbol] || "?"; + appendChar(ch); + currentSymbol = ""; + } + appendChar(" "); + } else if (offDuration > u * 2) { + // Character gap (3 units, use 2 as threshold) + if (currentSymbol) { + const ch = MORSE_TABLE[currentSymbol] || "?"; + appendChar(ch); + currentSymbol = ""; + } + } + // else: inter-element gap, do nothing + } + toneOnAt = now; + } else if (!detected && toneOn) { + // Tone just turned off + toneOn = false; + const onDuration = now - toneOnAt; + const u = unitMs(); + if (onDuration > u * 2) { + currentSymbol += "-"; // dah (3 units, use 2 as threshold) + } else { + currentSymbol += "."; // dit + } + toneOffAt = now; + + // Collect on-duration for auto WPM + if (cwWpmAutoCheck.checked) { + onDurations.push(onDuration); + if (onDurations.length > MAX_ON_DURATIONS) { + onDurations.shift(); + } + autoDetectWpm(); + } + } + + // Flush pending character after long silence + if (!toneOn && currentSymbol && toneOffAt > 0) { + const silenceDuration = now - toneOffAt; + if (silenceDuration > unitMs() * 5) { + const ch = MORSE_TABLE[currentSymbol] || "?"; + appendChar(ch); + currentSymbol = ""; + } + } + } + + function appendChar(ch) { + decoded += ch; + // Append to output element + const now = Date.now(); + if (!cwOutputEl.lastElementChild || now - lastAppendTime > 10000 || ch === "\n") { + const line = document.createElement("div"); + line.className = "cw-line"; + cwOutputEl.appendChild(line); + } + lastAppendTime = now; + const lastLine = cwOutputEl.lastElementChild; + if (lastLine) { + lastLine.textContent += ch; + } + // Cap lines + while (cwOutputEl.children.length > CW_MAX_LINES) { + cwOutputEl.removeChild(cwOutputEl.firstChild); + } + cwOutputEl.scrollTop = cwOutputEl.scrollHeight; + } + + function processSamples(mono) { + for (let i = 0; i < mono.length; i++) { + sampleBuf[sampleIdx++] = mono[i]; + if (sampleIdx >= windowSize) { + processWindow(); + sampleIdx = 0; + } + } + } + + function updateConfig() { + if (!cwWpmAutoCheck.checked) { + wpm = parseInt(cwWpmInput.value, 10) || 15; + } + if (!cwToneAutoCheck.checked) { + const newTone = parseInt(cwToneInput.value, 10) || 700; + if (newTone !== toneFreq) { + recomputeGoertzel(newTone); + } + } + threshold = (parseInt(cwThresholdInput.value, 10) || 5) / 100; + } + + return { processSamples, updateConfig }; +} + +function startCw() { + if (cwActive) { stopCw(); return; } + if (!hasWebCodecs) { + cwStatusEl.textContent = "Requires Chrome/Edge"; + return; + } + + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + cwWs = new WebSocket(`${proto}//${location.host}/audio`); + cwWs.binaryType = "arraybuffer"; + cwStatusEl.textContent = "Connecting…"; + + let decoderEngine = null; + + cwWs.onopen = () => { + cwStatusEl.textContent = "Waiting for stream info…"; + }; + + cwWs.onmessage = (evt) => { + if (typeof evt.data === "string") { + try { + const info = JSON.parse(evt.data); + const sr = info.sample_rate || 48000; + const ch = info.channels || 1; + cwAudioCtx = new AudioContext({ sampleRate: sr }); + decoderEngine = createCwDecoder(sr); + + let cwFrameCount = 0; + cwDecoder = new AudioDecoder({ + output: (frame) => { + if (cwFrameCount++ === 0) { + console.log("[CW-DBG] First PCM frame:", frame.numberOfFrames, "samples,", frame.numberOfChannels, "ch, format:", frame.format, "sr:", frame.sampleRate); + } + const buf = new Float32Array(frame.numberOfFrames * frame.numberOfChannels); + frame.copyTo(buf, { planeIndex: 0 }); + let mono; + if (frame.numberOfChannels === 1) { + mono = buf; + } else { + mono = new Float32Array(frame.numberOfFrames); + for (let i = 0; i < frame.numberOfFrames; i++) { + mono[i] = buf[i * frame.numberOfChannels]; + } + } + decoderEngine.processSamples(mono); + frame.close(); + }, + error: (e) => { console.error("CW AudioDecoder error", e); } + }); + cwDecoder.configure({ + codec: "opus", + sampleRate: sr, + numberOfChannels: ch, + }); + + cwActive = true; + cwToggleBtn.style.borderColor = "#00d17f"; + cwToggleBtn.style.color = "#00d17f"; + cwToggleBtn.textContent = "Stop CW"; + cwStatusEl.textContent = "Listening…"; + + // Allow live config updates + cwWpmInput.addEventListener("change", decoderEngine.updateConfig); + cwToneInput.addEventListener("change", decoderEngine.updateConfig); + cwThresholdInput.addEventListener("input", decoderEngine.updateConfig); + } catch (e) { + console.error("CW stream info error", e); + cwStatusEl.textContent = "Error"; + } + return; + } + + // Binary Opus data + if (!cwDecoder) return; + try { + cwDecoder.decode(new EncodedAudioChunk({ + type: "key", + timestamp: performance.now() * 1000, + data: new Uint8Array(evt.data), + })); + } catch (e) { + // Ignore individual decode errors + } + }; + + cwWs.onclose = () => { + stopCw(); + }; + + cwWs.onerror = () => { + cwStatusEl.textContent = "Connection error"; + }; +} + +function stopCw() { + cwActive = false; + if (cwWs) { cwWs.close(); cwWs = null; } + if (cwAudioCtx) { cwAudioCtx.close(); cwAudioCtx = null; } + if (cwDecoder) { + try { cwDecoder.close(); } catch (e) {} + cwDecoder = null; + } + cwToggleBtn.style.borderColor = ""; + cwToggleBtn.style.color = ""; + cwToggleBtn.textContent = "Start CW"; + cwStatusEl.textContent = "Stopped"; + cwSignalIndicator.className = "cw-signal-off"; +} + +cwToggleBtn.addEventListener("click", startCw); 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 a9e8404..4d694e8 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 @@ -227,6 +227,9 @@ small { color: var(--text-muted); } .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; } .cw-signal-off { width: 10px; height: 10px; border-radius: 50%; background: var(--border-light); flex-shrink: 0; } +.cw-auto-label { display: inline-flex; align-items: center; gap: 0.25rem; font-size: 0.82rem; color: var(--text-muted); cursor: pointer; } +.cw-auto-label input[type="checkbox"] { margin: 0; cursor: pointer; } +.cw-config input[type="number"][readonly] { opacity: 0.6; } button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent-green);