[fix](trx-frontend-http): improve APRS demodulator with pre-filter and moving-average LPF
Replace bandpass filter envelope detector with delay-and-multiply frequency discriminator. Add biquad bandpass pre-filter centered at 1700 Hz to remove out-of-band noise. Replace IIR low-pass with moving-average LPF over half a bit period, which places a null at 2x mark frequency for clean discriminator artifact removal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -682,7 +682,7 @@ 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.
|
// Uses delay-and-multiply frequency discriminator for robust non-coherent decoding.
|
||||||
function createDemodulator(sampleRate) {
|
function createDemodulator(sampleRate) {
|
||||||
const BAUD = 1200;
|
const BAUD = 1200;
|
||||||
const MARK = 1200;
|
const MARK = 1200;
|
||||||
@@ -704,44 +704,35 @@ function createDemodulator(sampleRate) {
|
|||||||
const ENERGY_WINDOW = Math.round(sampleRate * 0.05);
|
const ENERGY_WINDOW = Math.round(sampleRate * 0.05);
|
||||||
const ENERGY_THRESHOLD = 0.001;
|
const ENERGY_THRESHOLD = 0.001;
|
||||||
|
|
||||||
// Biquad bandpass filter coefficients
|
// Bandpass pre-filter: biquad centered at 1700 Hz (AFSK midpoint), Q=1.2
|
||||||
function biquadBP(f0, Q) {
|
// Passes ~1000-2500 Hz, removes out-of-band noise
|
||||||
const w0 = 2 * Math.PI * f0 / sampleRate;
|
const bpfW0 = 2 * Math.PI * ((MARK + SPACE) / 2) / sampleRate;
|
||||||
const alpha = Math.sin(w0) / (2 * Q);
|
const bpfAlpha = Math.sin(bpfW0) / (2 * 1.2);
|
||||||
const a0 = 1 + alpha;
|
const bpfA0 = 1 + bpfAlpha;
|
||||||
return {
|
const bpfCoeffs = {
|
||||||
b0: alpha / a0, b1: 0, b2: -alpha / a0,
|
b0: bpfAlpha / bpfA0, b1: 0, b2: -bpfAlpha / bpfA0,
|
||||||
a1: (-2 * Math.cos(w0)) / a0, a2: (1 - alpha) / a0,
|
a1: (-2 * Math.cos(bpfW0)) / bpfA0, a2: (1 - bpfAlpha) / bpfA0,
|
||||||
};
|
};
|
||||||
}
|
let bpfState = [0, 0, 0, 0]; // x1, x2, y1, y2
|
||||||
|
|
||||||
// Q chosen for adequate bandwidth at 1200 baud:
|
// Delay-and-multiply discriminator
|
||||||
// mark BPF Q≈6 → BW≈200Hz, space BPF Q≈6 → BW≈367Hz
|
// Delay = Fs / (2 * Δf) where Δf = space - mark = 1000 Hz
|
||||||
// Two cascaded stages for sharper rolloff
|
// At this delay: mark tone → negative product, space tone → positive product
|
||||||
const markCoeffs1 = biquadBP(MARK, 6);
|
const DELAY = Math.round(sampleRate / (2 * (SPACE - MARK)));
|
||||||
const markCoeffs2 = biquadBP(MARK, 6);
|
const delayBuf = new Float32Array(DELAY);
|
||||||
const spaceCoeffs1 = biquadBP(SPACE, 6);
|
let delayIdx = 0;
|
||||||
const spaceCoeffs2 = biquadBP(SPACE, 6);
|
|
||||||
|
|
||||||
// Filter states [x1, x2, y1, y2]
|
// Moving-average LPF over half a bit period
|
||||||
let mf1 = [0,0,0,0], mf2 = [0,0,0,0];
|
// First null at 2*mark freq (2400 Hz), cleanly removing discriminator artifacts
|
||||||
let sf1 = [0,0,0,0], sf2 = [0,0,0,0];
|
const avgLen = Math.round(samplesPerBit / 2);
|
||||||
|
const avgBuf = new Float32Array(avgLen);
|
||||||
function biquad(c, st, x) {
|
let avgIdx = 0;
|
||||||
const y = c.b0 * x + c.b1 * st[0] + c.b2 * st[1] - c.a1 * st[2] - c.a2 * st[3];
|
let avgSum = 0;
|
||||||
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)
|
// Clock recovery (PLL)
|
||||||
let lastTone = 0;
|
let lastTone = 0;
|
||||||
let bitPhase = 0;
|
let bitPhase = 0;
|
||||||
const PLL_GAIN = 0.7; // correction factor (0=no correction, 1=hard reset)
|
const PLL_GAIN = 0.7;
|
||||||
|
|
||||||
// NRZI state
|
// NRZI state
|
||||||
let prevSampledBit = 0;
|
let prevSampledBit = 0;
|
||||||
@@ -754,9 +745,12 @@ function createDemodulator(sampleRate) {
|
|||||||
const frames = [];
|
const frames = [];
|
||||||
|
|
||||||
function resetState() {
|
function resetState() {
|
||||||
mf1 = [0,0,0,0]; mf2 = [0,0,0,0];
|
bpfState = [0, 0, 0, 0];
|
||||||
sf1 = [0,0,0,0]; sf2 = [0,0,0,0];
|
delayBuf.fill(0);
|
||||||
markEnv = spaceEnv = 0;
|
delayIdx = 0;
|
||||||
|
avgBuf.fill(0);
|
||||||
|
avgIdx = 0;
|
||||||
|
avgSum = 0;
|
||||||
lastTone = 0;
|
lastTone = 0;
|
||||||
bitPhase = 0;
|
bitPhase = 0;
|
||||||
prevSampledBit = 0;
|
prevSampledBit = 0;
|
||||||
@@ -777,20 +771,31 @@ function createDemodulator(sampleRate) {
|
|||||||
energyCount = 0;
|
energyCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bandpass filter: two cascaded biquads per tone
|
// Bandpass pre-filter
|
||||||
const markOut = biquad(markCoeffs2, mf2, biquad(markCoeffs1, mf1, s));
|
const c = bpfCoeffs;
|
||||||
const spaceOut = biquad(spaceCoeffs2, sf2, biquad(spaceCoeffs1, sf1, s));
|
const filtered = c.b0 * s + c.b1 * bpfState[0] + c.b2 * bpfState[1]
|
||||||
|
- c.a1 * bpfState[2] - c.a2 * bpfState[3];
|
||||||
|
bpfState[1] = bpfState[0]; bpfState[0] = s;
|
||||||
|
bpfState[3] = bpfState[2]; bpfState[2] = filtered;
|
||||||
|
|
||||||
// Envelope detection: squared magnitude + IIR smoothing
|
// Delay-and-multiply frequency discriminator
|
||||||
markEnv += envAlpha * (markOut * markOut - markEnv);
|
const delayed = delayBuf[delayIdx];
|
||||||
spaceEnv += envAlpha * (spaceOut * spaceOut - spaceEnv);
|
delayBuf[delayIdx] = filtered;
|
||||||
|
delayIdx = (delayIdx + 1) % DELAY;
|
||||||
|
|
||||||
const bit = markEnv > spaceEnv ? 1 : 0;
|
const disc = filtered * delayed;
|
||||||
|
|
||||||
|
// Moving-average LPF
|
||||||
|
avgSum += disc - avgBuf[avgIdx];
|
||||||
|
avgBuf[avgIdx] = disc;
|
||||||
|
avgIdx = (avgIdx + 1) % avgLen;
|
||||||
|
|
||||||
|
// mark (1200 Hz) → negative, space (2200 Hz) → positive
|
||||||
|
const bit = avgSum < 0 ? 1 : 0;
|
||||||
|
|
||||||
// PLL clock recovery
|
// PLL clock recovery
|
||||||
if (bit !== lastTone) {
|
if (bit !== lastTone) {
|
||||||
lastTone = bit;
|
lastTone = bit;
|
||||||
// Phase error: distance from ideal center (samplesPerBit/2)
|
|
||||||
const error = bitPhase - samplesPerBit / 2;
|
const error = bitPhase - samplesPerBit / 2;
|
||||||
bitPhase -= PLL_GAIN * error;
|
bitPhase -= PLL_GAIN * error;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user