[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 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-02-08 14:29:35 +01:00
parent d44390db00
commit b042d679fa
3 changed files with 70 additions and 30 deletions
@@ -667,6 +667,15 @@ function createDemodulator(sampleRate) {
const samplesPerBit = sampleRate / BAUD; const samplesPerBit = sampleRate / BAUD;
const windowLen = Math.round(samplesPerBit); 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 // Correlation buffers
let markI = 0, markQ = 0, spaceI = 0, spaceQ = 0; let markI = 0, markQ = 0, spaceI = 0, spaceQ = 0;
const ringLen = windowLen; const ringLen = windowLen;
@@ -678,15 +687,16 @@ function createDemodulator(sampleRate) {
// Clock recovery // Clock recovery
let sampleCount = 0; let sampleCount = 0;
let lastBit = 0; let lastTone = 0; // tone-level tracking for clock recovery
let bitPhase = 0; let bitPhase = 0;
// NRZI state (separate from clock recovery)
let prevSampledBit = 0;
// HDLC state // HDLC state
let ones = 0; let ones = 0;
let frameBits = []; let frameBits = [];
let inFrame = false; let inFrame = false;
let shiftReg = 0;
let bitCount = 0;
const frames = []; const frames = [];
@@ -714,8 +724,8 @@ function createDemodulator(sampleRate) {
const bit = markEnergy > spaceEnergy ? 1 : 0; const bit = markEnergy > spaceEnergy ? 1 : 0;
// Clock recovery via zero-crossing // Clock recovery via zero-crossing
if (bit !== lastBit) { if (bit !== lastTone) {
lastBit = bit; lastTone = bit;
// Nudge phase toward center of bit // Nudge phase toward center of bit
bitPhase = samplesPerBit / 2; bitPhase = samplesPerBit / 2;
} }
@@ -723,49 +733,62 @@ function createDemodulator(sampleRate) {
bitPhase--; bitPhase--;
if (bitPhase <= 0) { if (bitPhase <= 0) {
bitPhase += samplesPerBit; bitPhase += samplesPerBit;
dbgBits++;
processBit(bit); processBit(bit);
} }
dbgSamples++;
sampleCount++; sampleCount++;
} }
function processBit(rawBit) { function processBit(rawBit) {
// NRZI decode: no transition = 1, transition = 0 // NRZI decode: no transition = 1, transition = 0
// We track previous raw bit; same = 1, different = 0 const decodedBit = (rawBit === prevSampledBit) ? 1 : 0;
const nrziBit = (rawBit === lastBit) ? 1 : 0; prevSampledBit = rawBit;
// Check for flag (0x7E = 01111110 in bit order) if (decodedBit === 1) {
shiftReg = ((shiftReg >> 1) | (rawBit << 7)) & 0xFF; // Don't push yet — buffer in ones counter until we know
bitCount++; // these aren't part of a flag, stuff, or abort sequence
if (nrziBit === 1) {
ones++; ones++;
} else { return;
if (ones === 5) { }
// Bit stuffing — skip this zero
// decodedBit === 0
if (ones >= 7) {
// Abort sequence — reset
inFrame = false;
frameBits = [];
ones = 0; ones = 0;
return; return;
} }
ones = 0; if (ones === 6) {
} // Flag (01111110) — frame boundary; the 6 ones are flag bits, not data
dbgFlags++;
if (shiftReg === 0x7E) {
// Flag detected
if (inFrame && frameBits.length >= 136) { if (inFrame && frameBits.length >= 136) {
// Minimum AX.25 frame: 14 addr + 1 ctrl + 1 pid + 1 info + 2 fcs = 19 bytes = 152 bits dbgFrameAttempts++;
// But we check >= 136 bits (17 bytes) to be lenient
const frame = bitsToBytes(frameBits); const frame = bitsToBytes(frameBits);
if (frame) frames.push(frame); if (frame) { dbgFramesOk++; frames.push(frame); }
} }
frameBits = []; frameBits = [];
inFrame = true; inFrame = true;
ones = 0; ones = 0;
return; return;
} }
if (ones === 5) {
// Bit stuffing — flush the 5 data ones, discard the stuffed zero
if (inFrame) { if (inFrame) {
frameBits.push(nrziBit); 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) { function bitsToBytes(bits) {
@@ -784,7 +807,12 @@ function createDemodulator(sampleRate) {
const payload = bytes.subarray(0, byteLen - 2); const payload = bytes.subarray(0, byteLen - 2);
const fcs = bytes[byteLen - 2] | (bytes[byteLen - 1] << 8); const fcs = bytes[byteLen - 2] | (bytes[byteLen - 1] << 8);
const computed = crc16ccitt(payload); 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; return payload;
} }
@@ -793,6 +821,13 @@ function createDemodulator(sampleRate) {
for (let i = 0; i < samples.length; i++) { for (let i = 0; i < samples.length; i++) {
processSample(samples[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); const result = frames.splice(0);
return result; return result;
} }
@@ -858,6 +893,7 @@ function parseAPRS(ax25) {
} }
function addAprsPacket(pkt) { function addAprsPacket(pkt) {
console.log("[APRS]", `${pkt.srcCall}>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: ${pkt.info}`, pkt);
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "aprs-packet"; row.className = "aprs-packet";
const now = new Date(); const now = new Date();
@@ -896,8 +932,12 @@ function startAprs() {
aprsAudioCtx = new AudioContext({ sampleRate: sr }); aprsAudioCtx = new AudioContext({ sampleRate: sr });
demodulator = createDemodulator(sr); demodulator = createDemodulator(sr);
let aprsFrameCount = 0;
aprsDecoder = new AudioDecoder({ aprsDecoder = new AudioDecoder({
output: (frame) => { 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); const buf = new Float32Array(frame.numberOfFrames * frame.numberOfChannels);
frame.copyTo(buf, { planeIndex: 0 }); frame.copyTo(buf, { planeIndex: 0 });
// Use first channel only // Use first channel only
@@ -126,7 +126,7 @@
<div class="plugin-item"> <div class="plugin-item">
<strong>APRS Decoder</strong> <strong>APRS Decoder</strong>
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;"> <div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
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).
</div> </div>
</div> </div>
</div> </div>
@@ -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.active { border-bottom-color: var(--accent-green); color: var(--accent-green); font-weight: 600; }
.sub-tab:hover:not(.active) { color: var(--text); } .sub-tab:hover:not(.active) { color: var(--text); }
.aprs-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; } .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 { 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-packet:last-child { border-bottom: none; }
.aprs-call { color: var(--accent-green); font-weight: 600; } .aprs-call { color: var(--accent-green); font-weight: 600; }