[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 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