From b042d679fa38cbc9da840a11ce86f877ce8eaeee Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Sun, 8 Feb 2026 14:29:35 +0100 Subject: [PATCH] [fix](trx-frontend-http): fix APRS HDLC decoder and add debug logging Fix three bugs in the Bell 202 AFSK demodulator preventing frame decoding: - Separate NRZI state from clock recovery (shared lastBit variable caused NRZI to always output 1) - Buffer 1-bits in ones counter instead of pushing to frameBits immediately, preventing flag/stuff bits from contaminating frame data and corrupting byte alignment - Detect flags via ones-count on decoded bits instead of shift register on raw bits Also add framed packet log container styling, remove redundant description text, and add debug counters logging pipeline health to the browser console. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Stanislaw Grams --- .../trx-frontend-http/assets/web/app.js | 96 +++++++++++++------ .../trx-frontend-http/assets/web/index.html | 2 +- .../trx-frontend-http/assets/web/style.css | 2 +- 3 files changed, 70 insertions(+), 30 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 0af5e43..6247c80 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 @@ -667,6 +667,15 @@ function createDemodulator(sampleRate) { const samplesPerBit = sampleRate / BAUD; const windowLen = Math.round(samplesPerBit); + // Debug counters + let dbgSamples = 0; + let dbgBits = 0; + let dbgFlags = 0; + let dbgFrameAttempts = 0; + let dbgCrcFails = 0; + let dbgFramesOk = 0; + let dbgLastLog = 0; + // Correlation buffers let markI = 0, markQ = 0, spaceI = 0, spaceQ = 0; const ringLen = windowLen; @@ -678,15 +687,16 @@ function createDemodulator(sampleRate) { // Clock recovery let sampleCount = 0; - let lastBit = 0; + let lastTone = 0; // tone-level tracking for clock recovery let bitPhase = 0; + // NRZI state (separate from clock recovery) + let prevSampledBit = 0; + // HDLC state let ones = 0; let frameBits = []; let inFrame = false; - let shiftReg = 0; - let bitCount = 0; const frames = []; @@ -714,8 +724,8 @@ function createDemodulator(sampleRate) { const bit = markEnergy > spaceEnergy ? 1 : 0; // Clock recovery via zero-crossing - if (bit !== lastBit) { - lastBit = bit; + if (bit !== lastTone) { + lastTone = bit; // Nudge phase toward center of bit bitPhase = samplesPerBit / 2; } @@ -723,49 +733,62 @@ function createDemodulator(sampleRate) { bitPhase--; if (bitPhase <= 0) { bitPhase += samplesPerBit; + dbgBits++; processBit(bit); } + dbgSamples++; sampleCount++; } function processBit(rawBit) { // NRZI decode: no transition = 1, transition = 0 - // We track previous raw bit; same = 1, different = 0 - const nrziBit = (rawBit === lastBit) ? 1 : 0; + const decodedBit = (rawBit === prevSampledBit) ? 1 : 0; + prevSampledBit = rawBit; - // Check for flag (0x7E = 01111110 in bit order) - shiftReg = ((shiftReg >> 1) | (rawBit << 7)) & 0xFF; - bitCount++; - - if (nrziBit === 1) { + if (decodedBit === 1) { + // Don't push yet — buffer in ones counter until we know + // these aren't part of a flag, stuff, or abort sequence ones++; - } else { - if (ones === 5) { - // Bit stuffing — skip this zero - ones = 0; - return; - } - ones = 0; + return; } - if (shiftReg === 0x7E) { - // Flag detected + // decodedBit === 0 + if (ones >= 7) { + // Abort sequence — reset + inFrame = false; + frameBits = []; + ones = 0; + return; + } + if (ones === 6) { + // Flag (01111110) — frame boundary; the 6 ones are flag bits, not data + dbgFlags++; if (inFrame && frameBits.length >= 136) { - // Minimum AX.25 frame: 14 addr + 1 ctrl + 1 pid + 1 info + 2 fcs = 19 bytes = 152 bits - // But we check >= 136 bits (17 bytes) to be lenient + dbgFrameAttempts++; const frame = bitsToBytes(frameBits); - if (frame) frames.push(frame); + if (frame) { dbgFramesOk++; frames.push(frame); } } frameBits = []; inFrame = true; ones = 0; return; } - - if (inFrame) { - frameBits.push(nrziBit); + if (ones === 5) { + // Bit stuffing — flush the 5 data ones, discard the stuffed zero + if (inFrame) { + for (let k = 0; k < 5; k++) frameBits.push(1); + } + ones = 0; + return; } + + // Normal data: flush buffered ones then push the zero + if (inFrame) { + for (let k = 0; k < ones; k++) frameBits.push(1); + frameBits.push(0); + } + ones = 0; } function bitsToBytes(bits) { @@ -784,7 +807,12 @@ function createDemodulator(sampleRate) { const payload = bytes.subarray(0, byteLen - 2); const fcs = bytes[byteLen - 2] | (bytes[byteLen - 1] << 8); const computed = crc16ccitt(payload); - if (computed !== fcs) return null; + if (computed !== fcs) { + dbgCrcFails++; + console.debug("[APRS-DBG] CRC fail:", byteLen, "bytes, fcs=0x" + fcs.toString(16), "computed=0x" + computed.toString(16), + "first6:", Array.from(payload.subarray(0, 6)).map(b => b.toString(16).padStart(2, "0")).join(" ")); + return null; + } return payload; } @@ -793,6 +821,13 @@ function createDemodulator(sampleRate) { for (let i = 0; i < samples.length; i++) { processSample(samples[i]); } + // Periodic debug log every 3 seconds + const now = Date.now(); + if (now - dbgLastLog >= 3000) { + console.log("[APRS-DBG] samples:", dbgSamples, "bits:", dbgBits, "flags:", dbgFlags, + "frameAttempts:", dbgFrameAttempts, "crcFails:", dbgCrcFails, "ok:", dbgFramesOk); + dbgLastLog = now; + } const result = frames.splice(0); return result; } @@ -858,6 +893,7 @@ function parseAPRS(ax25) { } function addAprsPacket(pkt) { + console.log("[APRS]", `${pkt.srcCall}>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: ${pkt.info}`, pkt); const row = document.createElement("div"); row.className = "aprs-packet"; const now = new Date(); @@ -896,8 +932,12 @@ function startAprs() { aprsAudioCtx = new AudioContext({ sampleRate: sr }); demodulator = createDemodulator(sr); + let aprsFrameCount = 0; aprsDecoder = new AudioDecoder({ output: (frame) => { + if (aprsFrameCount++ === 0) { + console.log("[APRS-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 }); // Use first channel only 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 6b7bfb7..c532fd6 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 @@ -126,7 +126,7 @@
APRS Decoder
- Decodes APRS packets from RX audio using Bell 202 AFSK (1200 baud). Switch to the APRS tab to start. + Decodes APRS packets from RX audio using Bell 202 AFSK (1200 baud).
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 83ee8ea..c2d5d1b 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 @@ -214,7 +214,7 @@ small { color: var(--text-muted); } .sub-tab.active { border-bottom-color: var(--accent-green); color: var(--accent-green); font-weight: 600; } .sub-tab:hover:not(.active) { color: var(--text); } .aprs-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; } -#aprs-packets { max-height: 360px; overflow-y: auto; } +#aprs-packets { 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; } .aprs-packet { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--border); line-height: 1.4; } .aprs-packet:last-child { border-bottom: none; } .aprs-call { color: var(--accent-green); font-weight: 600; }