diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/cw.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/cw.js deleted file mode 100644 index 2b44b13..0000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/cw.js +++ /dev/null @@ -1,287 +0,0 @@ -// --- 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 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); -}); - -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 - const windowMs = 50; // 50ms analysis window - const windowSize = Math.round(sampleRate * windowMs / 1000); - const k = Math.round(toneFreq * windowSize / sampleRate); - const omega = (2 * Math.PI * k) / windowSize; - const 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; - - // Timing: 1 unit = 1200/WPM ms - function unitMs() { return 1200 / wpm; } - - function goertzelDetect(buf) { - let s0 = 0, s1 = 0, s2 = 0; - let totalEnergy = 0; - for (let i = 0; i < buf.length; i++) { - s0 = coeff * s1 - s2 + buf[i]; - s2 = s1; - s1 = s0; - totalEnergy += buf[i] * buf[i]; - } - const toneEnergy = (s1 * s1 + s2 * s2 - coeff * s1 * s2) / (buf.length * buf.length); - const avgEnergy = totalEnergy / buf.length; - if (avgEnergy < 1e-10) return false; - return (toneEnergy / avgEnergy) > threshold; - } - - function processWindow() { - 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; - } - - // 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() { - wpm = parseInt(cwWpmInput.value, 10) || 15; - 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); - 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/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js similarity index 64% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/aprs.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js index b77ea95..2cf1563 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js @@ -30,7 +30,7 @@ function crc16ccitt(bytes) { } // AFSK Bell 202 Demodulator (1200 baud, mark=1200Hz, space=2200Hz) -// Uses delay-and-multiply frequency discriminator for robust non-coherent decoding. +// Uses mark/space correlation detector (non-coherent FSK matched filter). function createDemodulator(sampleRate) { const BAUD = 1200; const MARK = 1200; @@ -52,35 +52,27 @@ function createDemodulator(sampleRate) { const ENERGY_WINDOW = Math.round(sampleRate * 0.05); const ENERGY_THRESHOLD = 0.001; - // Bandpass pre-filter: biquad centered at 1700 Hz (AFSK midpoint), Q=1.2 - // Passes ~1000-2500 Hz, removes out-of-band noise - const bpfW0 = 2 * Math.PI * ((MARK + SPACE) / 2) / sampleRate; - const bpfAlpha = Math.sin(bpfW0) / (2 * 1.2); - const bpfA0 = 1 + bpfAlpha; - const bpfCoeffs = { - b0: bpfAlpha / bpfA0, b1: 0, b2: -bpfAlpha / bpfA0, - a1: (-2 * Math.cos(bpfW0)) / bpfA0, a2: (1 - bpfAlpha) / bpfA0, - }; - let bpfState = [0, 0, 0, 0]; // x1, x2, y1, y2 + // Mark/space correlation detector + // Mix input with cos/sin reference oscillators at mark and space frequencies, + // then integrate over one bit period to get I/Q energy at each frequency. + const markPhaseInc = 2 * Math.PI * MARK / sampleRate; + const spacePhaseInc = 2 * Math.PI * SPACE / sampleRate; + let markPhase = 0; + let spacePhase = 0; - // Delay-and-multiply discriminator - // Delay = Fs / (2 * Δf) where Δf = space - mark = 1000 Hz - // At this delay: mark tone → negative product, space tone → positive product - const DELAY = Math.round(sampleRate / (2 * (SPACE - MARK))); - const delayBuf = new Float32Array(DELAY); - let delayIdx = 0; - - // Moving-average LPF over half a bit period - // First null at 2*mark freq (2400 Hz), cleanly removing discriminator artifacts - const avgLen = Math.round(samplesPerBit / 2); - const avgBuf = new Float32Array(avgLen); - let avgIdx = 0; - let avgSum = 0; + // Sliding-window matched filter (1 bit period) + const corrLen = Math.round(samplesPerBit); + const markIBuf = new Float32Array(corrLen); + const markQBuf = new Float32Array(corrLen); + const spaceIBuf = new Float32Array(corrLen); + const spaceQBuf = new Float32Array(corrLen); + let corrIdx = 0; + let markISum = 0, markQSum = 0, spaceISum = 0, spaceQSum = 0; // Clock recovery (PLL) - let lastTone = 0; + let lastBit = 0; let bitPhase = 0; - const PLL_GAIN = 0.7; + const PLL_GAIN = 0.4; // NRZI state let prevSampledBit = 0; @@ -93,13 +85,14 @@ function createDemodulator(sampleRate) { const frames = []; function resetState() { - bpfState = [0, 0, 0, 0]; - delayBuf.fill(0); - delayIdx = 0; - avgBuf.fill(0); - avgIdx = 0; - avgSum = 0; - lastTone = 0; + markPhase = 0; + spacePhase = 0; + markIBuf.fill(0); markQBuf.fill(0); + spaceIBuf.fill(0); spaceQBuf.fill(0); + corrIdx = 0; + markISum = 0; markQSum = 0; + spaceISum = 0; spaceQSum = 0; + lastBit = 0; bitPhase = 0; prevSampledBit = 0; ones = 0; @@ -119,31 +112,35 @@ function createDemodulator(sampleRate) { energyCount = 0; } - // Bandpass pre-filter - const c = bpfCoeffs; - const filtered = c.b0 * s + c.b1 * bpfState[0] + c.b2 * bpfState[1] - - c.a1 * bpfState[2] - c.a2 * bpfState[3]; - bpfState[1] = bpfState[0]; bpfState[0] = s; - bpfState[3] = bpfState[2]; bpfState[2] = filtered; + // Mix with mark/space reference oscillators + const mI = s * Math.cos(markPhase); + const mQ = s * Math.sin(markPhase); + const sI = s * Math.cos(spacePhase); + const sQ = s * Math.sin(spacePhase); + markPhase += markPhaseInc; + spacePhase += spacePhaseInc; + if (markPhase > 6.283185307) markPhase -= 6.283185307; + if (spacePhase > 6.283185307) spacePhase -= 6.283185307; - // Delay-and-multiply frequency discriminator - const delayed = delayBuf[delayIdx]; - delayBuf[delayIdx] = filtered; - delayIdx = (delayIdx + 1) % DELAY; + // Sliding-window integration (matched filter over 1 bit period) + markISum += mI - markIBuf[corrIdx]; + markQSum += mQ - markQBuf[corrIdx]; + spaceISum += sI - spaceIBuf[corrIdx]; + spaceQSum += sQ - spaceQBuf[corrIdx]; + markIBuf[corrIdx] = mI; + markQBuf[corrIdx] = mQ; + spaceIBuf[corrIdx] = sI; + spaceQBuf[corrIdx] = sQ; + corrIdx = (corrIdx + 1) % corrLen; - const disc = filtered * delayed; - - // Moving-average LPF - avgSum += disc - avgBuf[avgIdx]; - avgBuf[avgIdx] = disc; - avgIdx = (avgIdx + 1) % avgLen; - - // mark (1200 Hz) → negative, space (2200 Hz) → positive - const bit = avgSum < 0 ? 1 : 0; + // Compare mark vs space energy (I²+Q²) + const markEnergy = markISum * markISum + markQSum * markQSum; + const spaceEnergy = spaceISum * spaceISum + spaceQSum * spaceQSum; + const bit = markEnergy > spaceEnergy ? 1 : 0; // PLL clock recovery - if (bit !== lastTone) { - lastTone = bit; + if (bit !== lastBit) { + lastBit = bit; const error = bitPhase - samplesPerBit / 2; bitPhase -= PLL_GAIN * error; } @@ -318,7 +315,117 @@ function parseAPRS(ax25) { else if (dt === "`" || dt === "'") type = "Mic-E"; } - return { srcCall, destCall, path, info: infoStr, type }; + const result = { srcCall, destCall, path, info: infoStr, type }; + + if (type === "Position") { + const pos = parseAprsPosition(infoStr); + if (pos) { + result.lat = pos.lat; + result.lon = pos.lon; + result.symbolTable = pos.symbolTable; + result.symbolCode = pos.symbolCode; + } + } + + return result; +} + +function parseAprsPosition(infoStr) { + if (infoStr.length < 1) return null; + const dt = infoStr[0]; + let posStr; + + if (dt === "!" || dt === "=") { + posStr = infoStr.substring(1); + } else if (dt === "/" || dt === "@") { + if (infoStr.length < 8) return null; + posStr = infoStr.substring(8); + } else { + return null; + } + + if (posStr.length < 1) return null; + + // Compressed format: first char is symbol table (not a digit) + // Layout: T YYYY XXXX C [cs T] — 10 chars minimum + const firstChar = posStr[0]; + if (firstChar < "0" || firstChar > "9") { + return parseAprsCompressed(posStr); + } + + // Uncompressed: DDMM.MMN/DDDMM.MMEsYYY... + // Need at least: 8 lat + 1 table + 9 lon + 1 code = 19 chars + if (posStr.length < 19) return null; + + const latStr = posStr.substring(0, 8); // DDMM.MMN + const symbolTable = posStr[8]; + const lonStr = posStr.substring(9, 18); // DDDMM.MME + const symbolCode = posStr[18]; + + const lat = parseAprsLat(latStr); + const lon = parseAprsLon(lonStr); + if (lat === null || lon === null) return null; + + return { lat, lon, symbolTable, symbolCode }; +} + +function parseAprsCompressed(posStr) { + // Compressed position: SymTable(1) Lat(4) Lon(4) SymCode(1) = 10 chars min + if (posStr.length < 10) return null; + + const symbolTable = posStr[0]; + const latChars = posStr.substring(1, 5); + const lonChars = posStr.substring(5, 9); + const symbolCode = posStr[9]; + + // Base-91 decode: each char value = (ASCII - 33) + let latVal = 0; + let lonVal = 0; + for (let i = 0; i < 4; i++) { + const lc = latChars.charCodeAt(i) - 33; + const xc = lonChars.charCodeAt(i) - 33; + if (lc < 0 || lc > 90 || xc < 0 || xc > 90) return null; + latVal = latVal * 91 + lc; + lonVal = lonVal * 91 + xc; + } + + const lat = 90 - latVal / 380926; + const lon = -180 + lonVal / 190463; + + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; + + return { + lat: Math.round(lat * 1e6) / 1e6, + lon: Math.round(lon * 1e6) / 1e6, + symbolTable, + symbolCode, + }; +} + +function parseAprsLat(s) { + // DDMM.MMN + if (s.length < 8) return null; + const deg = parseInt(s.substring(0, 2), 10); + const min = parseFloat(s.substring(2, 7)); + const ns = s[7]; + if (isNaN(deg) || isNaN(min)) return null; + let lat = deg + min / 60; + if (ns === "S" || ns === "s") lat = -lat; + else if (ns !== "N" && ns !== "n") return null; + return Math.round(lat * 1e6) / 1e6; +} + +function parseAprsLon(s) { + // DDDMM.MME + if (s.length < 9) return null; + const deg = parseInt(s.substring(0, 3), 10); + const min = parseFloat(s.substring(3, 8)); + const ew = s[8]; + if (isNaN(deg) || isNaN(min)) return null; + let lon = deg + min / 60; + if (ew === "W" || ew === "w") lon = -lon; + else if (ew !== "E" && ew !== "e") return null; + return Math.round(lon * 1e6) / 1e6; } function addAprsPacket(pkt) { @@ -330,7 +437,22 @@ function addAprsPacket(pkt) { const now = new Date(); const ts = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); const crcTag = pkt.crcOk ? "" : ' [CRC]'; - row.innerHTML = `${ts}${pkt.srcCall}>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: ${pkt.info}${crcTag}`; + let symbolHtml = ""; + if (pkt.symbolTable && pkt.symbolCode) { + const sheet = pkt.symbolTable === "/" ? 0 : 1; + const code = pkt.symbolCode.charCodeAt(0) - 33; + const col = code % 16; + const row2 = Math.floor(code / 16); + const bgX = -(col * 24); + const bgY = -(row2 * 24); + symbolHtml = ``; + } + let posHtml = ""; + if (pkt.lat != null && pkt.lon != null) { + const osmUrl = `https://www.openstreetmap.org/?mlat=${pkt.lat}&mlon=${pkt.lon}#map=15/${pkt.lat}/${pkt.lon}`; + posHtml = ` ${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}`; + } + row.innerHTML = `${ts}${symbolHtml}${pkt.srcCall}>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: ${pkt.info}${posHtml}${crcTag}`; aprsPacketsEl.prepend(row); while (aprsPacketsEl.children.length > APRS_MAX_PACKETS) { aprsPacketsEl.removeChild(aprsPacketsEl.lastChild); 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 4d694e8..479ce43 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 @@ -218,6 +218,9 @@ small { color: var(--text-muted); } .aprs-packet:last-child { border-bottom: none; } .aprs-call { color: var(--accent-green); font-weight: 600; } .aprs-time { color: var(--text-muted); margin-right: 0.5rem; } +.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; } +.aprs-pos { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; } +.aprs-pos:hover { text-decoration: underline; } .cw-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; } .cw-config { display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap; margin-bottom: 0.75rem; } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs index 31a3d22..f6e7a6f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs @@ -8,8 +8,8 @@ const PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); const INDEX_HTML: &str = include_str!("../assets/web/index.html"); pub const STYLE_CSS: &str = include_str!("../assets/web/style.css"); pub const APP_JS: &str = include_str!("../assets/web/app.js"); -pub const APRS_JS: &str = include_str!("../assets/web/aprs.js"); -pub const CW_JS: &str = include_str!("../assets/web/cw.js"); +pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js"); +pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js"); pub fn index_html() -> String { INDEX_HTML