[feat](trx-rds): improve RDS robustness with 9 DSP techniques

Tech 1: replace one-pole baseband LPF with FIR RRC matched filter
(alpha=0.75, 4-chip span) — largest single measured improvement per
empirical comparison (gr-rds RRC vs plain FIR: 32/38 vs 18/38 stations).
Tech 2: 19 kHz pilot x3 -> 57 kHz coherent carrier reference via the
triple-angle formula; fed from the WFM pilot Costas PLL when
pilot_lock_level > 0.5, clearing to NCO fallback otherwise.
Tech 3/7/8: OSD(2) soft-decision block decoder replaces hard CRC check.
Per-bit soft magnitudes accumulated in Candidate::block_soft[26].
decode_block_soft() searches Hamming distance 0/1/2 (352 trials total)
and returns the minimum Euclidean-cost valid codeword; ~2-3 dB gain.
Tech 4: 8th-order 57 kHz BPF (4 cascaded biquads at Q=5) in wfm.rs
replaces the previous single Q=10 biquad; ~6x steeper ACI stopband.
Tech 5: Costas loop with tanh soft phase detector drives the RDS carrier
NCO when no pilot reference is available (P+I, B_L ~20 Hz).
Tech 6: Block A PI field LLR accumulation — signed per-bit LLR summed
over 3 independent Block A observations before committing the PI value,
correcting weak-signal false locks without delaying strong-signal lock.
Tech 9: 8-tap complex CMA blind equalizer applied to IQ samples before
FM discrimination; constant-modulus error (|y|^2 - R^2) drives tap
adaptation without a training sequence, suppressing adjacent-channel
interference at the source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-26 23:10:42 +01:00
parent 27489c3745
commit ba2fbed7c3
3 changed files with 610 additions and 52 deletions
@@ -9,7 +9,14 @@ use trx_rds::RdsDecoder;
use super::{math::demod_fm_with_prev, DcBlocker};
const RDS_SUBCARRIER_HZ: f32 = 57_000.0;
const RDS_BPF_Q: f32 = 10.0;
/// Tech 2: pilot lock level above which the ×3 pilot reference is used.
const PILOT_LOCK_THRESHOLD: f32 = 0.5;
/// Tech 9: number of complex CMA equalizer taps.
const CMA_N_TAPS: usize = 8;
/// Tech 9: CMA LMS step size.
const CMA_STEP_SIZE: f32 = 1e-5;
/// Tech 9: slow adaptation rate for the CMA radius estimate.
const CMA_RADIUS_ALPHA: f32 = 1e-3;
/// Pilot tone frequency (Hz).
const PILOT_HZ: f32 = 19_000.0;
/// Audio bandwidth for WFM (Hz).
@@ -280,6 +287,109 @@ impl BiquadNotch {
}
}
// ---------------------------------------------------------------------------
// Tech 4: 8th-order 57 kHz bandpass filter (4 cascaded biquads)
// ---------------------------------------------------------------------------
/// Four cascaded biquad bandpass sections forming an effective 8th-order BPF.
/// Q=5 per section gives ≈ ±2480 Hz passband at 57 kHz — wide enough to pass
/// the full RDS DSB signal while providing much steeper adjacent-channel
/// rejection than the previous single-stage (2nd-order) filter.
#[derive(Debug, Clone)]
struct Iir8BandPass {
stages: [BiquadBandPass; 4],
}
impl Iir8BandPass {
fn new(sample_rate: f32, center_hz: f32) -> Self {
const Q: f32 = 5.0;
Self {
stages: std::array::from_fn(|_| BiquadBandPass::new(sample_rate, center_hz, Q)),
}
}
#[inline]
fn process(&mut self, x: f32) -> f32 {
let mut y = x;
for stage in &mut self.stages {
y = stage.process(y);
}
y
}
fn reset(&mut self) {
for stage in &mut self.stages {
stage.reset();
}
}
}
// ---------------------------------------------------------------------------
// Tech 9: CMA blind equalizer (pre-FM-demodulation, constant-modulus)
// ---------------------------------------------------------------------------
/// Fractionally-spaced complex LMS equalizer driven by the constant-modulus
/// cost function. FM is constant-envelope, so E[|y|²] = R² drives tap
/// adaptation without requiring a training sequence. Applied to the IQ
/// stream before FM discrimination to suppress adjacent-channel interference.
#[derive(Debug, Clone)]
struct CmaEqualizer {
taps: [Complex<f32>; CMA_N_TAPS],
buf: [Complex<f32>; CMA_N_TAPS],
pos: usize,
/// Adaptive radius estimate (tracks long-term input power).
radius_sq: f32,
}
impl CmaEqualizer {
fn new() -> Self {
let mut taps = [Complex::new(0.0_f32, 0.0_f32); CMA_N_TAPS];
// Initialise as identity: tap at the centre = 1+0j.
taps[CMA_N_TAPS / 2] = Complex::new(1.0, 0.0);
Self {
taps,
buf: [Complex::new(0.0_f32, 0.0_f32); CMA_N_TAPS],
pos: 0,
radius_sq: 1.0,
}
}
#[inline]
fn process(&mut self, x: Complex<f32>) -> Complex<f32> {
// Update power estimate (very slow, tracks long-term signal level).
self.radius_sq =
self.radius_sq * (1.0 - CMA_RADIUS_ALPHA) + x.norm_sqr() * CMA_RADIUS_ALPHA;
self.buf[self.pos] = x;
self.pos = (self.pos + 1) % CMA_N_TAPS;
// Compute filter output y = Σ w[k] * x[n-k].
let mut y = Complex::new(0.0_f32, 0.0_f32);
for k in 0..CMA_N_TAPS {
y += self.taps[k] * self.buf[(self.pos + k) % CMA_N_TAPS];
}
// CMA gradient: e = |y|² R²; update w[k] -= μ·e·y·conj(x[n-k]).
let err = y.norm_sqr() - self.radius_sq;
let scale = CMA_STEP_SIZE * err;
for k in 0..CMA_N_TAPS {
let x_k = self.buf[(self.pos + k) % CMA_N_TAPS];
self.taps[k] -= Complex::new(scale, 0.0) * y * x_k.conj();
}
y
}
fn reset(&mut self) {
let mut taps = [Complex::new(0.0_f32, 0.0_f32); CMA_N_TAPS];
taps[CMA_N_TAPS / 2] = Complex::new(1.0, 0.0);
self.taps = taps;
self.buf = [Complex::new(0.0_f32, 0.0_f32); CMA_N_TAPS];
self.pos = 0;
self.radius_sq = 1.0;
}
}
impl OnePoleLowPass {
fn new(sample_rate: f32, cutoff_hz: f32) -> Self {
let sr = sample_rate.max(1.0);
@@ -442,8 +552,11 @@ pub struct WfmStereoDecoder {
output_channels: usize,
stereo_enabled: bool,
rds_decoder: RdsDecoder,
rds_bpf: BiquadBandPass,
/// Tech 4: 8th-order 57 kHz bandpass filter (4 cascaded biquads).
rds_bpf: Iir8BandPass,
rds_dc: DcBlocker,
/// Tech 9: CMA blind equalizer applied before FM demodulation.
cma: CmaEqualizer,
prev_iq: Option<Complex<f32>>,
nco_cos: f32,
nco_sin: f32,
@@ -505,8 +618,9 @@ impl WfmStereoDecoder {
output_channels: output_channels.max(1),
stereo_enabled,
rds_decoder: RdsDecoder::new(composite_rate),
rds_bpf: BiquadBandPass::new(composite_rate_f, RDS_SUBCARRIER_HZ, RDS_BPF_Q),
rds_bpf: Iir8BandPass::new(composite_rate_f, RDS_SUBCARRIER_HZ),
rds_dc: DcBlocker::new(0.995),
cma: CmaEqualizer::new(),
prev_iq: None,
nco_cos: 1.0,
nco_sin: 0.0,
@@ -562,7 +676,11 @@ impl WfmStereoDecoder {
return Vec::new();
}
let disc = demod_fm_with_prev(samples, &mut self.prev_iq);
// Tech 9: apply CMA blind equalizer to IQ samples before FM demodulation.
// The constant-modulus property of FM drives tap adaptation without a
// training sequence, suppressing adjacent-channel interference.
let equalized: Vec<Complex<f32>> = samples.iter().map(|&s| self.cma.process(s)).collect();
let disc = demod_fm_with_prev(&equalized, &mut self.prev_iq);
let mut output = Vec::with_capacity(
((samples.len() as f64 * self.output_phase_inc).ceil() as usize + 1)
* self.output_channels.max(1),
@@ -627,16 +745,30 @@ impl WfmStereoDecoder {
}
let stereo_blend_target = if self.stereo_detected { 1.0 } else { 0.0 };
// Phase-corrected pilot estimates (exact real pilot phase).
let sin_est = sin_p * err_cos + cos_p * err_sin;
let cos_est = cos_p * err_cos - sin_p * err_sin;
// Double-angle (38 kHz stereo carrier).
let sin_2p = 2.0 * sin_est * cos_est;
let cos_2p = 2.0 * cos_est * cos_est - 1.0;
// Tech 2: derive the 57 kHz RDS carrier reference from the 19 kHz
// pilot via the triple-angle formula: cos(3θ) = cos(2θ+θ), etc.
// This gives a phase-coherent reference that is far cleaner than
// the RDS decoder's autonomous free-running NCO.
let cos_3p = cos_2p * cos_est - sin_2p * sin_est;
let sin_3p = sin_2p * cos_est + cos_2p * sin_est;
if self.pilot_lock_level > PILOT_LOCK_THRESHOLD {
self.rds_decoder.set_pilot_ref(cos_3p, sin_3p);
} else {
self.rds_decoder.clear_pilot_ref();
}
let rds_quality = (0.35 + pilot_mag * 20.0).clamp(0.35, 1.0);
let rds_clean = self.rds_dc.process(self.rds_bpf.process(x));
let _ = self.rds_decoder.process_sample(rds_clean, rds_quality);
let sum = self.sum_lpf2.process(self.sum_lpf1.process(x));
let sin_est = sin_p * err_cos + cos_p * err_sin;
let cos_est = cos_p * err_cos - sin_p * err_sin;
let sin_2p = 2.0 * sin_est * cos_est;
let cos_2p = 2.0 * cos_est * cos_est - 1.0;
let x_notched = self.diff_pilot_notch.process(x);
let diff_i = self.diff_dc.process(
self.diff_lpf2
@@ -739,6 +871,7 @@ impl WfmStereoDecoder {
self.rds_decoder.reset();
self.rds_bpf.reset();
self.rds_dc.reset();
self.cma.reset();
self.prev_iq = None;
self.nco_cos = 1.0;
self.nco_sin = 0.0;