[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:
@@ -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
|
|
||||||
ones = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ones = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shiftReg === 0x7E) {
|
// decodedBit === 0
|
||||||
// Flag detected
|
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) {
|
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) {
|
||||||
if (inFrame) {
|
// Bit stuffing — flush the 5 data ones, discard the stuffed zero
|
||||||
frameBits.push(nrziBit);
|
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) {
|
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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user