[fix](trx-frontend-http): rewrite APRS demodulator with bandpass filter approach
Replace the coherent correlation detector with a non-coherent bandpass filter + envelope detection approach for significantly better frequency discrimination between mark (1200Hz) and space (2200Hz) tones. Uses two cascaded 2nd-order IIR biquad filters per tone with IIR-smoothed envelope detection. Replace hard clock reset with PLL-style gradual correction for smoother bit timing recovery. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -682,12 +682,12 @@ function crc16ccitt(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AFSK Bell 202 Demodulator (1200 baud, mark=1200Hz, space=2200Hz)
|
// AFSK Bell 202 Demodulator (1200 baud, mark=1200Hz, space=2200Hz)
|
||||||
|
// Uses bandpass filter + envelope detection for robust non-coherent decoding.
|
||||||
function createDemodulator(sampleRate) {
|
function createDemodulator(sampleRate) {
|
||||||
const BAUD = 1200;
|
const BAUD = 1200;
|
||||||
const MARK = 1200;
|
const MARK = 1200;
|
||||||
const SPACE = 2200;
|
const SPACE = 2200;
|
||||||
const samplesPerBit = sampleRate / BAUD;
|
const samplesPerBit = sampleRate / BAUD;
|
||||||
const windowLen = Math.round(samplesPerBit);
|
|
||||||
|
|
||||||
// Debug counters
|
// Debug counters
|
||||||
let dbgSamples = 0;
|
let dbgSamples = 0;
|
||||||
@@ -701,24 +701,49 @@ function createDemodulator(sampleRate) {
|
|||||||
// Energy gate — reset demodulator when signal is absent
|
// Energy gate — reset demodulator when signal is absent
|
||||||
let energyAcc = 0;
|
let energyAcc = 0;
|
||||||
let energyCount = 0;
|
let energyCount = 0;
|
||||||
const ENERGY_WINDOW = Math.round(sampleRate * 0.05); // 50ms window
|
const ENERGY_WINDOW = Math.round(sampleRate * 0.05);
|
||||||
const ENERGY_THRESHOLD = 0.001; // silence threshold
|
const ENERGY_THRESHOLD = 0.001;
|
||||||
|
|
||||||
// Correlation buffers
|
// Biquad bandpass filter coefficients
|
||||||
let markI = 0, markQ = 0, spaceI = 0, spaceQ = 0;
|
function biquadBP(f0, Q) {
|
||||||
const ringLen = windowLen;
|
const w0 = 2 * Math.PI * f0 / sampleRate;
|
||||||
const ringMarkI = new Float32Array(ringLen);
|
const alpha = Math.sin(w0) / (2 * Q);
|
||||||
const ringMarkQ = new Float32Array(ringLen);
|
const a0 = 1 + alpha;
|
||||||
const ringSpaceI = new Float32Array(ringLen);
|
return {
|
||||||
const ringSpaceQ = new Float32Array(ringLen);
|
b0: alpha / a0, b1: 0, b2: -alpha / a0,
|
||||||
let ringIdx = 0;
|
a1: (-2 * Math.cos(w0)) / a0, a2: (1 - alpha) / a0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Clock recovery
|
// Q chosen for adequate bandwidth at 1200 baud:
|
||||||
let sampleCount = 0;
|
// mark BPF Q≈6 → BW≈200Hz, space BPF Q≈6 → BW≈367Hz
|
||||||
let lastTone = 0; // tone-level tracking for clock recovery
|
// Two cascaded stages for sharper rolloff
|
||||||
|
const markCoeffs1 = biquadBP(MARK, 6);
|
||||||
|
const markCoeffs2 = biquadBP(MARK, 6);
|
||||||
|
const spaceCoeffs1 = biquadBP(SPACE, 6);
|
||||||
|
const spaceCoeffs2 = biquadBP(SPACE, 6);
|
||||||
|
|
||||||
|
// Filter states [x1, x2, y1, y2]
|
||||||
|
let mf1 = [0,0,0,0], mf2 = [0,0,0,0];
|
||||||
|
let sf1 = [0,0,0,0], sf2 = [0,0,0,0];
|
||||||
|
|
||||||
|
function biquad(c, st, x) {
|
||||||
|
const y = c.b0 * x + c.b1 * st[0] + c.b2 * st[1] - c.a1 * st[2] - c.a2 * st[3];
|
||||||
|
st[1] = st[0]; st[0] = x;
|
||||||
|
st[3] = st[2]; st[2] = y;
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envelope smoothing: simple IIR low-pass at ~1200 Hz (one bit period)
|
||||||
|
const envAlpha = 1 - Math.exp(-2 * Math.PI * BAUD / sampleRate);
|
||||||
|
let markEnv = 0, spaceEnv = 0;
|
||||||
|
|
||||||
|
// Clock recovery (PLL)
|
||||||
|
let lastTone = 0;
|
||||||
let bitPhase = 0;
|
let bitPhase = 0;
|
||||||
|
const PLL_GAIN = 0.7; // correction factor (0=no correction, 1=hard reset)
|
||||||
|
|
||||||
// NRZI state (separate from clock recovery)
|
// NRZI state
|
||||||
let prevSampledBit = 0;
|
let prevSampledBit = 0;
|
||||||
|
|
||||||
// HDLC state
|
// HDLC state
|
||||||
@@ -729,10 +754,9 @@ function createDemodulator(sampleRate) {
|
|||||||
const frames = [];
|
const frames = [];
|
||||||
|
|
||||||
function resetState() {
|
function resetState() {
|
||||||
markI = markQ = spaceI = spaceQ = 0;
|
mf1 = [0,0,0,0]; mf2 = [0,0,0,0];
|
||||||
ringMarkI.fill(0); ringMarkQ.fill(0);
|
sf1 = [0,0,0,0]; sf2 = [0,0,0,0];
|
||||||
ringSpaceI.fill(0); ringSpaceQ.fill(0);
|
markEnv = spaceEnv = 0;
|
||||||
ringIdx = 0;
|
|
||||||
lastTone = 0;
|
lastTone = 0;
|
||||||
bitPhase = 0;
|
bitPhase = 0;
|
||||||
prevSampledBit = 0;
|
prevSampledBit = 0;
|
||||||
@@ -742,45 +766,33 @@ function createDemodulator(sampleRate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function processSample(s) {
|
function processSample(s) {
|
||||||
// Energy gate: detect silence and reset state
|
// Energy gate
|
||||||
energyAcc += s * s;
|
energyAcc += s * s;
|
||||||
energyCount++;
|
energyCount++;
|
||||||
if (energyCount >= ENERGY_WINDOW) {
|
if (energyCount >= ENERGY_WINDOW) {
|
||||||
const rms = Math.sqrt(energyAcc / energyCount);
|
if (Math.sqrt(energyAcc / energyCount) < ENERGY_THRESHOLD) {
|
||||||
if (rms < ENERGY_THRESHOLD) {
|
|
||||||
resetState();
|
resetState();
|
||||||
}
|
}
|
||||||
energyAcc = 0;
|
energyAcc = 0;
|
||||||
energyCount = 0;
|
energyCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = sampleCount / sampleRate;
|
// Bandpass filter: two cascaded biquads per tone
|
||||||
const mI = s * Math.cos(2 * Math.PI * MARK * t);
|
const markOut = biquad(markCoeffs2, mf2, biquad(markCoeffs1, mf1, s));
|
||||||
const mQ = s * Math.sin(2 * Math.PI * MARK * t);
|
const spaceOut = biquad(spaceCoeffs2, sf2, biquad(spaceCoeffs1, sf1, s));
|
||||||
const sI = s * Math.cos(2 * Math.PI * SPACE * t);
|
|
||||||
const sQ = s * Math.sin(2 * Math.PI * SPACE * t);
|
|
||||||
|
|
||||||
// Sliding window correlation
|
// Envelope detection: squared magnitude + IIR smoothing
|
||||||
markI += mI - ringMarkI[ringIdx];
|
markEnv += envAlpha * (markOut * markOut - markEnv);
|
||||||
markQ += mQ - ringMarkQ[ringIdx];
|
spaceEnv += envAlpha * (spaceOut * spaceOut - spaceEnv);
|
||||||
spaceI += sI - ringSpaceI[ringIdx];
|
|
||||||
spaceQ += sQ - ringSpaceQ[ringIdx];
|
|
||||||
|
|
||||||
ringMarkI[ringIdx] = mI;
|
const bit = markEnv > spaceEnv ? 1 : 0;
|
||||||
ringMarkQ[ringIdx] = mQ;
|
|
||||||
ringSpaceI[ringIdx] = sI;
|
|
||||||
ringSpaceQ[ringIdx] = sQ;
|
|
||||||
ringIdx = (ringIdx + 1) % ringLen;
|
|
||||||
|
|
||||||
const markEnergy = markI * markI + markQ * markQ;
|
// PLL clock recovery
|
||||||
const spaceEnergy = spaceI * spaceI + spaceQ * spaceQ;
|
|
||||||
const bit = markEnergy > spaceEnergy ? 1 : 0;
|
|
||||||
|
|
||||||
// Clock recovery via zero-crossing
|
|
||||||
if (bit !== lastTone) {
|
if (bit !== lastTone) {
|
||||||
lastTone = bit;
|
lastTone = bit;
|
||||||
// Nudge phase toward center of bit
|
// Phase error: distance from ideal center (samplesPerBit/2)
|
||||||
bitPhase = samplesPerBit / 2;
|
const error = bitPhase - samplesPerBit / 2;
|
||||||
|
bitPhase -= PLL_GAIN * error;
|
||||||
}
|
}
|
||||||
|
|
||||||
bitPhase--;
|
bitPhase--;
|
||||||
@@ -791,7 +803,6 @@ function createDemodulator(sampleRate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dbgSamples++;
|
dbgSamples++;
|
||||||
sampleCount++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function processBit(rawBit) {
|
function processBit(rawBit) {
|
||||||
|
|||||||
Reference in New Issue
Block a user