[fix](trx-rds): tune RDS parameters for maximum sensitivity

- RRC_ALPHA 0.75→0.50: narrower noise BW, ~0.6 dB SNR gain
- COSTAS_KI 3.5e-7: maintain ζ≈0.68 (1e-6 caused loop instability)
- Soft confidence: use biphase_i.abs() instead of full vector magnitude
  so OSD confidence is aligned with bit-decision sign; suppresses
  false groups under noise with residual Costas phase error
- OSD(2) in locked mode: corrects ≤2-bit errors after block sync
- Search mode: hard decode only for Block A; OSD(1) in search yielded
  ~13% false Block A rate per bit, letting wrong clock candidates
  accumulate false groups as fast as the correct candidate
- Incumbent candidate tracking (best_candidate_idx): the winning
  candidate updates best_state at equal score; challengers need strictly
  higher score; best_score tracks incumbent even on no-state-change
  groups so challengers can't leapfrog on a single false group
- blocks_to_chips: add NRZI (NRZ-Mark) pre-encoding so the differential
  biphase decoder recovers actual data bits rather than XOR-of-pairs
- Add blocks_to_chips_round_trips_all_groups test: verifies all 16 blocks
  across 4 PS segments round-trip correctly without BPSK modulation

[fix](trx-backend-soapysdr): lower pilot lock threshold for weak-signal RDS

- PILOT_LOCK_THRESHOLD 0.25→0.20, add PILOT_LOCK_ONSET=0.30 constant
- Pilot reference engages at coherence ≥0.36 (was ≥0.45)

WIP: end_to_end_clean_signal_decodes_ps still failing (13/15 pass).
Decoder skips segment 2 due to ISI from rectangular test chips through
RRC receive filter. chips_to_rds_signal needs RRC pulse shaping.

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-27 01:46:01 +01:00
parent 8b310c184b
commit 57e88b3590
4 changed files with 700 additions and 147 deletions
+592 -23
View File
@@ -19,15 +19,17 @@ const BIPHASE_CLOCK_WINDOW: usize = 128;
/// Minimum quality score to publish RDS state to the outer decoder.
const MIN_PUBLISH_QUALITY: f32 = 0.20;
/// Tech 6: number of Block A observations before using accumulated PI.
const PI_ACC_THRESHOLD: u8 = 3;
const PI_ACC_THRESHOLD: u8 = 2;
/// Tech 5 — Costas loop proportional gain (per sample).
const COSTAS_KP: f32 = 8e-4;
/// Tech 5 — Costas loop integral gain (per sample).
/// Tuned for ζ ≈ 0.68 (ωn = √KI ≈ 5.9e-4 rad/sample → ~22 Hz loop BW).
const COSTAS_KI: f32 = 3.5e-7;
/// Tech 5 — maximum frequency correction per sample (radians).
const COSTAS_MAX_FREQ_CORR: f32 = 0.005;
/// Tech 1 — RRC roll-off factor.
const RRC_ALPHA: f32 = 0.75;
/// Tech 1 — RRC roll-off factor. 0.50 gives ~14% narrower noise bandwidth
/// than 0.75 (one-sided BW = Rs/2 × (1+α)) for ~0.6 dB sensitivity gain.
const RRC_ALPHA: f32 = 0.50;
/// Tech 1 — RRC filter span in chips.
const RRC_SPAN_CHIPS: usize = 4;
@@ -358,8 +360,13 @@ impl Candidate {
let input_bit = biphase_i >= 0.0;
let bit = (input_bit != self.prev_input_bit) as u8;
self.prev_input_bit = input_bit;
// Pass soft magnitude as confidence alongside the bit.
self.push_bit_soft(bit, magnitude)
// Soft confidence = |I| (aligned with the bit decision sign),
// not the full vector magnitude. When the Costas loop has
// residual phase error θ, |I| = |s|·|cos θ| correctly reflects
// how reliable the bit is, whereas √(I²+Q²) = |s| would
// over-state confidence. Clock history still uses full magnitude
// (phase-independent) for clock-polarity detection above.
self.push_bit_soft(bit, biphase_i.abs())
} else {
None
}
@@ -394,11 +401,11 @@ impl Candidate {
return None;
}
// Hard decode first; fall back to single-bit flip so we can acquire
// Block A on weak signals the same way locked-mode blocks are corrected.
let (data, kind) = decode_block(self.search_reg).or_else(|| {
(0..26usize).find_map(|k| decode_block(self.search_reg ^ (1 << (25 - k))))
})?;
// Hard decode only in search mode: OSD in the slide window would create
// ~13 % false Block A hits per bit, letting wrong clock candidates
// accumulate false groups as fast as the correct one accrues real ones.
// Once locked, OSD(2) in consume_locked_block handles weak blocks.
let (data, kind) = decode_block(self.search_reg)?;
if kind != BlockKind::A {
return None;
}
@@ -723,6 +730,12 @@ pub struct RdsDecoder {
pilot_ref: Option<(f32, f32)>,
candidates: Vec<Candidate>,
best_score: u32,
/// Index into `candidates` for the current winning candidate.
/// Once established, only this candidate can update `best_state` at equal
/// score; a different candidate must achieve a strictly higher score to
/// take over. This prevents N candidates decoding the same groups from
/// cycling through `best_state` with partially-accumulated ps_seen / rt_seen.
best_candidate_idx: Option<usize>,
best_state: Option<RdsData>,
}
@@ -745,6 +758,7 @@ impl RdsDecoder {
pilot_ref: None,
candidates,
best_score: 0,
best_candidate_idx: None,
best_state: None,
}
}
@@ -796,18 +810,34 @@ impl RdsDecoder {
self.carrier_phase = self.carrier_phase.rem_euclid(TAU);
}
for candidate in &mut self.candidates {
for (idx, candidate) in self.candidates.iter_mut().enumerate() {
let is_incumbent = self.best_candidate_idx == Some(idx);
if let Some(update) = candidate.process_sample(mixed_i, mixed_q) {
if candidate.score >= self.best_score {
self.best_score = candidate.score;
let same_pi = self.best_state.as_ref().and_then(|state| state.pi) == update.pi;
// The incumbent candidate can always update at equal score so its
// accumulated text state (ps_seen, rt_seen, etc.) stays coherent.
// A challenger must exceed the current best score to take over,
// which prevents N candidates decoding the same groups from cycling
// through best_state with different partial ps_seen / rt_seen arrays.
let qualifies = candidate.score > self.best_score
|| (is_incumbent && candidate.score >= self.best_score)
|| self.best_state.is_none();
if qualifies {
let same_pi =
self.best_state.as_ref().and_then(|s| s.pi) == update.pi;
if publish_quality >= MIN_PUBLISH_QUALITY
|| same_pi
|| self.best_state.is_none()
{
self.best_score = candidate.score;
self.best_candidate_idx = Some(idx);
self.best_state = Some(update);
}
}
} else if is_incumbent {
// Even when no state change occurred, keep best_score current so
// challengers must match the incumbent's actual group count to
// take over, not just its last state-emitting group count.
self.best_score = candidate.score;
}
}
self.best_state.as_ref()
@@ -850,7 +880,7 @@ fn decode_block(word: u32) -> Option<(u16, BlockKind)> {
Some((data, kind))
}
/// Tech 3/7/8: soft-decision block decoder implementing OSD(1).
/// Tech 3/7/8: soft-decision block decoder implementing OSD(2).
///
/// `word` is the 26-bit hard-decision word; `soft[k]` is the confidence
/// magnitude (|LLR|) for the k-th received bit, where bit 0 is the MSB
@@ -859,9 +889,11 @@ fn decode_block(word: u32) -> Option<(u16, BlockKind)> {
/// Search order:
/// 1. Hard decode (Hamming distance 0) — zero cost.
/// 2. All 26 single-bit flips — return the lowest-cost success.
/// 3. All C(26,2)=325 two-bit flips — return the lowest-cost success.
///
/// Limiting to distance 1 keeps false-positive rates low while still
/// correcting single-bit burst errors.
/// OSD(2) is only used in locked mode (known block boundaries), so the
/// false-positive risk is bounded by the sequential block-type gating in
/// `consume_locked_block`.
fn decode_block_soft(word: u32, soft: &[f32; 26]) -> Option<(u16, BlockKind)> {
// Distance 0.
if let Some(result) = decode_block(word) {
@@ -871,7 +903,7 @@ fn decode_block_soft(word: u32, soft: &[f32; 26]) -> Option<(u16, BlockKind)> {
let mut best_result: Option<(u16, BlockKind)> = None;
let mut best_cost = f32::INFINITY;
// Distance 1: all 26 single-bit flips.
// Distance 1: all 26 single-bit flips; pick the cheapest success.
for (k, &flip_cost) in soft.iter().enumerate() {
let trial = word ^ (1 << (25 - k));
if let Some(result) = decode_block(trial) {
@@ -882,6 +914,25 @@ fn decode_block_soft(word: u32, soft: &[f32; 26]) -> Option<(u16, BlockKind)> {
}
}
if best_result.is_some() {
return best_result;
}
// Distance 2: all C(26,2)=325 two-bit flips; pick the cheapest pair.
for k1 in 0..26usize {
for k2 in (k1 + 1)..26usize {
let pair_cost = soft[k1] + soft[k2];
if pair_cost >= best_cost {
continue;
}
let trial = word ^ (1 << (25 - k1)) ^ (1 << (25 - k2));
if let Some(result) = decode_block(trial) {
best_cost = pair_cost;
best_result = Some(result);
}
}
}
best_result
}
@@ -1010,14 +1061,30 @@ mod tests {
}
#[test]
fn decode_block_soft_rejects_two_bit_error() {
// OSD(1) does not correct 2-bit errors; verify it returns None.
fn decode_block_soft_corrects_two_bit_error_osd2() {
// OSD(2) must correct a 2-bit error at known positions.
let word = encode_block(0x1234, OFFSET_B);
let corrupted = word ^ 0b11; // flip two bits
let soft = [1.0f32; 26];
assert!(decode_block_soft(corrupted, &soft).is_none());
// Flip bits k=0 and k=1 (two most-significant positions).
let corrupted = word ^ (1 << 25) ^ (1 << 24);
// Set soft confidences very low for bits 0 and 1 so the decoder
// knows they are unreliable and picks the cheapest pair.
let mut soft = [1.0f32; 26];
soft[0] = 0.05;
soft[1] = 0.05;
let (data, kind) = decode_block_soft(corrupted, &soft).expect("OSD(2) should correct");
assert_eq!(data, 0x1234);
assert_eq!(kind, BlockKind::B);
}
// Note: OSD(2) intentionally does NOT assert None for 3-bit errors.
// When all soft values are equal (uninformative), there are ~325 two-bit
// combinations to try; some accidentally produce a valid CRC for a
// *different* codeword (~80% probability for random words). This is
// acceptable: in locked mode, sequential block-type gating (B→C→D)
// prevents any such false decode from completing a full group.
// The `pure_noise_produces_zero_pi_decodes` test is the authoritative
// guard against false PI reports.
#[test]
fn decode_block_soft_prefers_least_costly_flip() {
// Construct a word with an injected single-bit error at bit k=2 (high confidence)
@@ -1030,4 +1097,506 @@ mod tests {
assert_eq!(data, 0xBEEF);
assert_eq!(kind, BlockKind::D);
}
// -----------------------------------------------------------------------
// Signal synthesis helpers for end-to-end / sensitivity tests
// -----------------------------------------------------------------------
/// Minimal LCG pseudo-random number generator (deterministic, seedable).
fn lcg_rand(state: &mut u64) -> f32 {
*state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
(*state >> 33) as f32 / (1u64 << 31) as f32
}
/// Box-Muller Gaussian sample, zero mean, unit variance.
fn gaussian(state: &mut u64) -> f32 {
let u1 = lcg_rand(state).max(1e-9);
let u2 = lcg_rand(state);
(-2.0 * u1.ln()).sqrt() * (TAU * u2).cos()
}
/// Encode 26-bit RDS block words into a differential biphase chip stream.
///
/// The decoder uses biphase differential detection:
/// `bit = sign(biphase_I) XOR prev_sign(biphase_I)`
/// To recover the original data bits, the encoder must pre-apply NRZI
/// (NRZ-Mark: transition on 1, hold on 0) before Manchester encoding.
///
/// NRZI=1 → chips (1, +1); NRZI=0 → chips (+1, 1).
/// A 2-chip preamble (NRZI=1) is prepended so the decoder can initialise
/// `prev_psk_symbol` and establish the initial differential state.
fn blocks_to_chips(words: &[u32]) -> Vec<i8> {
let mut chips = Vec::with_capacity(words.len() * 52 + 2);
// Preamble: bit=1 encoded as NRZI=1 → chips (1, +1).
// Starting NRZI state before preamble = false; bit=1 transitions to true.
chips.push(-1i8);
chips.push(1i8);
let mut nrzi = true; // NRZI state after preamble
for &word in words {
for k in (0..26).rev() {
let bit = ((word >> k) & 1) != 0;
// NRZI mark: transition on 1, hold on 0.
if bit {
nrzi = !nrzi;
}
if nrzi {
chips.push(-1);
chips.push(1);
} else {
chips.push(1);
chips.push(-1);
}
}
}
chips
}
/// Modulate chip stream as BPSK on the 57 kHz RDS subcarrier.
/// Returns a composite FM-baseband signal for `RdsDecoder::process_sample`.
fn chips_to_rds_signal(chips: &[i8], sample_rate: f32) -> Vec<f32> {
let samples_per_chip = sample_rate / RDS_CHIP_RATE;
let n = (chips.len() as f32 * samples_per_chip).ceil() as usize;
let mut sig = vec![0.0f32; n];
for (ci, &chip) in chips.iter().enumerate() {
let t0 = (ci as f32 * samples_per_chip) as usize;
let t1 = ((ci + 1) as f32 * samples_per_chip) as usize;
for t in t0..t1.min(n) {
let phase = TAU * RDS_SUBCARRIER_HZ * t as f32 / sample_rate;
sig[t] = chip as f32 * phase.cos();
}
}
sig
}
/// Add AWGN at the given SNR (dB) relative to the signal's actual power.
fn add_awgn(sig: &mut [f32], snr_db: f32, rng: &mut u64) {
let pwr = sig.iter().map(|x| x * x).sum::<f32>() / sig.len() as f32;
let noise_sigma = (pwr / 10.0f32.powf(snr_db / 10.0)).sqrt();
for s in sig.iter_mut() {
*s += gaussian(rng) * noise_sigma;
}
}
/// Build a Group-0A block set for the given PI / PS segment.
fn group_0a(pi: u16, segment: u8, ps_chars: [u8; 2], pty: u8) -> [u32; 4] {
let block_b: u16 = (u16::from(pty) << 5) | u16::from(segment & 0x03);
[
encode_block(pi, OFFSET_A),
encode_block(block_b, OFFSET_B),
encode_block(0x0000, OFFSET_C),
encode_block(u16::from_be_bytes(ps_chars), OFFSET_D),
]
}
// -----------------------------------------------------------------------
// End-to-end sensitivity tests
// -----------------------------------------------------------------------
/// Directly decode the chip stream (no BPSK) to verify `blocks_to_chips`
/// round-trips correctly for all 16 blocks (4 groups × 4 blocks).
#[test]
fn blocks_to_chips_round_trips_all_groups() {
let pi = 0x9801u16;
let ps = b"TEST FM!";
let mut words: Vec<u32> = Vec::new();
for seg in 0..4u8 {
let g = group_0a(pi, seg, [ps[seg as usize * 2], ps[seg as usize * 2 + 1]], 10);
words.extend_from_slice(&g);
}
let chips = blocks_to_chips(&words);
// Manually decode with perfect biphase alignment.
// clock_polarity=0: emit bit from the second chip of each pair.
// The preamble chip pair is chips[0..2]; first data chip pair is chips[2..4], etc.
// We skip the preamble pair (it only sets prev_input_bit = true) and
// decode from chips[2] onward in pairs.
let mut prev_input_bit = true; // set by the preamble bit
let mut shift: u32 = 0;
let mut bit_idx = 0usize;
let mut decoded: Vec<u32> = Vec::new();
// chips[0] = preamble first (-1), chips[1] = preamble second (+1)
// The preamble pair biphase = (+1 - (-1))/2 = +1 → input_bit = true
// bit = (true != false) = 1, prev_input_bit = true (NRZI seed established)
// Preamble bit not added to decoded stream; data starts at chips[2].
let mut prev_chip = chips[1]; // last chip of preamble
let mut pair_idx = 0usize; // which chip within current bit pair (0=first/reference, 1=second/data)
for &chip in &chips[2..] {
let biphase_i = (chip as f32 - prev_chip as f32) * 0.5;
if pair_idx == 1 {
// Second chip of pair → emit bit (clock_polarity = 0, even positions)
let input_bit = biphase_i >= 0.0;
let bit = (input_bit != prev_input_bit) as u32;
prev_input_bit = input_bit;
shift = ((shift << 1) | bit) & 0x03FF_FFFF;
bit_idx += 1;
if bit_idx == 26 {
decoded.push(shift);
shift = 0;
bit_idx = 0;
}
}
prev_chip = chip;
pair_idx = 1 - pair_idx;
}
assert_eq!(decoded.len(), words.len(),
"decoded {decoded_len} blocks but expected {expected}",
decoded_len = decoded.len(), expected = words.len());
for (i, (got, want)) in decoded.iter().zip(words.iter()).enumerate() {
assert_eq!(got, want,
"block {i}: decoded 0x{got:08X} but expected 0x{want:08X}");
}
}
#[test]
fn end_to_end_clean_signal_decodes_ps() {
// Synthesise a clean RDS signal, run it through the full decoder,
// and verify that PI and the first PS segment are decoded correctly.
let sample_rate = 240_000.0f32;
let pi = 0x9801u16;
let ps = b"TEST FM!";
// Four Group-0A blocks cover all four PS segments.
let mut words: Vec<u32> = Vec::new();
for seg in 0..4u8 {
let g = group_0a(pi, seg, [ps[seg as usize * 2], ps[seg as usize * 2 + 1]], 10);
words.extend_from_slice(&g);
}
// Repeat 20× to give the decoder time to acquire.
let words: Vec<u32> = words.iter().copied().cycle().take(words.len() * 60).collect();
let chips = blocks_to_chips(&words);
let signal = chips_to_rds_signal(&chips, sample_rate);
let mut dec = RdsDecoder::new(sample_rate as u32);
let mut got_pi = false;
let mut got_ps = false;
for &s in &signal {
if let Some(state) = dec.process_sample(s, 1.0) {
if state.pi == Some(pi) {
got_pi = true;
}
if state.program_service.as_deref() == Some("TEST FM!") {
got_ps = true;
break;
}
}
}
assert!(got_pi, "PI should be decoded from clean signal");
assert!(got_ps, "PS 'TEST FM!' should be decoded from clean signal");
}
#[test]
fn end_to_end_noisy_signal_snr_10db_decodes_pi() {
// At 10 dB SNR the decoder should still recover PI reliably.
let sample_rate = 240_000.0f32;
let pi = 0x4BBC;
let mut words: Vec<u32> = Vec::new();
for seg in 0..4u8 {
let g = group_0a(pi, seg, [b'N', b'Z' + seg], 3);
words.extend_from_slice(&g);
}
let words: Vec<u32> = words.iter().copied().cycle().take(words.len() * 40).collect();
let chips = blocks_to_chips(&words);
let mut signal = chips_to_rds_signal(&chips, sample_rate);
let mut rng = 0xDEAD_BEEF_1234_5678u64;
add_awgn(&mut signal, 10.0, &mut rng);
let mut dec = RdsDecoder::new(sample_rate as u32);
let mut got_pi = false;
for &s in &signal {
if dec.process_sample(s, 1.0).and_then(|st| st.pi) == Some(pi) {
got_pi = true;
break;
}
}
assert!(got_pi, "PI should decode at SNR = 10 dB");
}
#[test]
fn end_to_end_with_pilot_reference_decodes_pi() {
// With an exact pilot reference, PI acquisition should be fast (< 20 groups).
let sample_rate = 240_000.0f32;
let pi = 0xC001u16;
let mut words: Vec<u32> = Vec::new();
for seg in 0..4u8 {
let g = group_0a(pi, seg, [b'A' + seg, b'B' + seg], 1);
words.extend_from_slice(&g);
}
let words: Vec<u32> = words.iter().copied().cycle().take(words.len() * 20).collect();
let chips = blocks_to_chips(&words);
let signal = chips_to_rds_signal(&chips, sample_rate);
let mut dec = RdsDecoder::new(sample_rate as u32);
let mut got_pi = false;
for (t, &s) in signal.iter().enumerate() {
// Provide perfect pilot reference: cos/sin of 57 kHz at each sample.
let phase57 = TAU * RDS_SUBCARRIER_HZ * t as f32 / sample_rate;
dec.set_pilot_ref(phase57.cos(), phase57.sin());
if dec.process_sample(s, 1.0).and_then(|st| st.pi) == Some(pi) {
got_pi = true;
break;
}
}
assert!(got_pi, "PI should decode quickly with pilot reference");
}
// -----------------------------------------------------------------------
// Block error rate / OSD comparison
// -----------------------------------------------------------------------
/// Inject exactly `n_errors` bit flips at random positions in a 26-bit word.
fn inject_errors(word: u32, positions: &[usize]) -> u32 {
positions
.iter()
.fold(word, |w, &k| w ^ (1 << (25 - k)))
}
#[test]
fn full_group_with_two_bit_errors_in_each_locked_block() {
// Verify OSD(2) can recover a full group where blocks B, C, D each
// have exactly 2 bit errors at known low-confidence positions.
let pi = 0xABCDu16;
let block_a = encode_block(pi, OFFSET_A);
let block_b_data: u16 = (2u16 << 12) | (1 << 11) | (10 << 5); // Group 2B, pty=10
let block_b = encode_block(block_b_data, OFFSET_B);
let block_c = encode_block(0x4865, OFFSET_CP); // C' (version B, unused)
let block_d = encode_block(u16::from_be_bytes(*b"Hi"), OFFSET_D);
// Corrupt B, C, D at known positions (k=0,1 = two MSBs).
let corrupt_b = inject_errors(block_b, &[0, 1]);
let corrupt_c = inject_errors(block_c, &[0, 1]);
let corrupt_d = inject_errors(block_d, &[0, 1]);
// Build soft confidence: bits 0 and 1 are low-confidence.
let mut soft = [1.0f32; 26];
soft[0] = 0.05;
soft[1] = 0.05;
// Verify each corrupted block individually recovers via OSD(2).
let (d_b, k_b) = decode_block_soft(corrupt_b, &soft).expect("block B should recover");
assert_eq!((d_b, k_b), (block_b_data, BlockKind::B));
// C' check
let (d_c, _k_c) = decode_block_soft(corrupt_c, &soft).expect("block C' should recover");
assert_eq!(d_c, 0x4865);
let (d_d, k_d) = decode_block_soft(corrupt_d, &soft).expect("block D should recover");
assert_eq!(k_d, BlockKind::D);
assert_eq!(d_d, u16::from_be_bytes(*b"Hi"));
// Now run a complete group through the Candidate state machine.
let mut cand = Candidate::new(240_000.0, 0.0);
let mut last: Option<RdsData> = None;
// Feed clean Block A.
for bit_idx in (0..26).rev() {
let _ = cand.push_bit_soft(((block_a >> bit_idx) & 1) as u8, 1.0);
}
// Feed corrupt B with low confidence on bits 0,1.
for bit_idx in (0..26).rev() {
let bit = ((corrupt_b >> bit_idx) & 1) as u8;
let conf = if bit_idx >= 24 { 0.05 } else { 1.0 };
let _ = cand.push_bit_soft(bit, conf);
}
// Feed corrupt C' with low confidence.
for bit_idx in (0..26).rev() {
let bit = ((corrupt_c >> bit_idx) & 1) as u8;
let conf = if bit_idx >= 24 { 0.05 } else { 1.0 };
let _ = cand.push_bit_soft(bit, conf);
}
// Feed corrupt D with low confidence.
for bit_idx in (0..26).rev() {
let bit = ((corrupt_d >> bit_idx) & 1) as u8;
let conf = if bit_idx >= 24 { 0.05 } else { 1.0 };
last = cand.push_bit_soft(bit, conf);
}
assert!(last.is_some(), "Full group should decode despite 2-bit errors in B/C/D");
}
#[test]
fn block_decode_rate_osd1_vs_osd2() {
// Measure how many blocks with exactly 2 bit errors are recovered
// by OSD(2) vs the number that would succeed at OSD(1) (none for 2-bit errors).
//
// For a valid block with 2 bit errors at the two *least* confident
// positions, OSD(2) should always recover it; OSD(1) should never.
let offsets = [OFFSET_A, OFFSET_B, OFFSET_C, OFFSET_CP, OFFSET_D];
let data_values: [u16; 5] = [0x1111, 0x2222, 0x4865, 0xBEEF, 0xCAFE];
let mut osd1_ok = 0u32;
let mut osd2_ok = 0u32;
let total = offsets.len() * data_values.len();
for &offset in &offsets {
for &data in &data_values {
let word = encode_block(data, offset);
// Flip bits k=0 and k=25 (spread across the word).
let corrupted = word ^ (1 << 25) ^ (1 << 0);
let mut soft = [1.0f32; 26];
soft[0] = 0.01; // very uncertain
soft[25] = 0.01;
// OSD(1): should fail for 2-bit errors.
let osd1_result = {
if decode_block(corrupted).is_some() {
Some(()) // d0 hit (unexpected but count it)
} else {
(0..26usize).find_map(|k| decode_block(corrupted ^ (1 << (25 - k)))).map(|_| ())
}
};
if osd1_result.is_some() {
osd1_ok += 1;
}
// OSD(2).
if decode_block_soft(corrupted, &soft).is_some() {
osd2_ok += 1;
}
}
}
// OSD(2) must recover at least 80% of cleanly 2-bit-corrupted blocks.
assert!(
osd2_ok >= (total as u32 * 8 / 10),
"OSD(2) recovery rate = {}/{total} (< 80%)",
osd2_ok
);
// OSD(1) should not recover any (all are genuine 2-bit errors).
assert_eq!(osd1_ok, 0, "OSD(1) should not recover 2-bit errors");
}
// -----------------------------------------------------------------------
// Costas loop convergence
// -----------------------------------------------------------------------
#[test]
fn costas_tracks_without_diverging_on_clean_signal() {
// Feed a clean RDS signal through the decoder (no pilot ref) and
// verify that at least some PI data is recovered, proving the Costas
// loop stays coherent rather than losing lock permanently.
let sample_rate = 240_000.0f32;
let pi = 0x7777u16;
let mut words: Vec<u32> = Vec::new();
for seg in 0..4u8 {
let g = group_0a(pi, seg, [b'C' + seg, b'D' + seg], 5);
words.extend_from_slice(&g);
}
// 60× repetitions to give Costas plenty of time to acquire.
let words: Vec<u32> = words.iter().copied().cycle().take(words.len() * 60).collect();
let chips = blocks_to_chips(&words);
let signal = chips_to_rds_signal(&chips, sample_rate);
let mut dec = RdsDecoder::new(sample_rate as u32);
let mut pi_correct = 0u32;
let mut pi_total = 0u32;
for &s in &signal {
if let Some(state) = dec.process_sample(s, 1.0) {
if state.pi.is_some() {
pi_total += 1;
if state.pi == Some(pi) {
pi_correct += 1;
}
}
}
}
assert!(
pi_correct > 0,
"Costas should converge and produce correct PI (got {pi_correct}/{pi_total})"
);
}
// -----------------------------------------------------------------------
// Noise rejection
// -----------------------------------------------------------------------
#[test]
fn pure_noise_produces_zero_pi_decodes() {
// Feed 0.5 seconds of white noise (no RDS signal) through the decoder.
// The decoder must not report any PI (false positive).
//
// Note: with OSD(2) active in locked mode, the lock gate requires
// Block A to be acquired first (hard or OSD-1 decode in search mode),
// which keeps the false-acquisition rate low even at OSD(2).
let sample_rate = 240_000.0f32;
let n_samples = (sample_rate * 0.5) as usize;
let mut rng = 0xFEED_FACE_DEAD_BEEFu64;
let mut noise: Vec<f32> = (0..n_samples).map(|_| gaussian(&mut rng)).collect();
// Scale noise to unit power.
let pwr = noise.iter().map(|x| x * x).sum::<f32>() / n_samples as f32;
let scale = pwr.sqrt().recip();
noise.iter_mut().for_each(|x| *x *= scale);
let mut dec = RdsDecoder::new(sample_rate as u32);
let mut false_pi = 0u32;
for &s in &noise {
if let Some(state) = dec.process_sample(s, 1.0) {
if state.pi.is_some() {
false_pi += 1;
}
}
}
assert_eq!(
false_pi, 0,
"Pure noise generated {false_pi} false PI reports"
);
}
// -----------------------------------------------------------------------
// PI accumulation
// -----------------------------------------------------------------------
#[test]
fn pi_accumulation_corrects_weak_pi_after_threshold() {
// The PI LLR accumulator (Tech 6) should vote out a one-bit error
// in the PI field after PI_ACC_THRESHOLD observations.
let real_pi: u16 = 0x9420;
let bad_pi: u16 = real_pi ^ 0x0001; // one bit wrong in LSB
let pty = 10u8;
let mut cand = Candidate::new(240_000.0, 0.0);
// Send PI_ACC_THRESHOLD groups; each Block A carries the correct PI
// but with a very low soft confidence on the corrupted bit position.
for i in 0..(PI_ACC_THRESHOLD + 1) {
let block_a = encode_block(real_pi, OFFSET_A);
let block_b = encode_block(u16::from(pty) << 5, OFFSET_B);
let block_c = encode_block(0, OFFSET_C);
let block_d = encode_block(u16::from_be_bytes(*b"OK"), OFFSET_D);
for bit_idx in (0..26).rev() {
let bit = ((block_a >> bit_idx) & 1) as u8;
// Bit 0 (LSB of PI) has low confidence.
let conf = if bit_idx == 0 { 0.1 } else { 1.0 };
let _ = cand.push_bit_soft(bit, conf);
}
for bit_idx in (0..26).rev() {
let _ = cand.push_bit_soft(((block_b >> bit_idx) & 1) as u8, 1.0);
}
for bit_idx in (0..26).rev() {
let _ = cand.push_bit_soft(((block_c >> bit_idx) & 1) as u8, 1.0);
}
let mut last = None;
for bit_idx in (0..26).rev() {
last = cand.push_bit_soft(((block_d >> bit_idx) & 1) as u8, 1.0);
}
let _ = (i, last, bad_pi); // silence unused warnings
}
// After threshold groups, the accumulated PI should converge to real_pi.
let pi = cand.state.pi.expect("PI should be set after accumulation");
assert_eq!(
pi, real_pi,
"Accumulated PI {pi:#06x} should converge to {real_pi:#06x}"
);
}
}
@@ -10,7 +10,13 @@ use super::{math::demod_fm_with_prev, DcBlocker};
const RDS_SUBCARRIER_HZ: f32 = 57_000.0;
/// Tech 2: pilot lock level above which the ×3 pilot reference is used.
const PILOT_LOCK_THRESHOLD: f32 = 0.25;
/// Effective pilot coherence threshold ≈ ONSET + THRESHOLD × 0.2 = 0.36.
const PILOT_LOCK_THRESHOLD: f32 = 0.20;
/// Coherence below which pilot_lock contribution is zero (linear ramp 0→1
/// over the range [ONSET, ONSET+0.2]). Lower value → pilot ref used on
/// weaker stations; risk: noisier reference. 0.30 vs original 0.40 means
/// we engage at coherence ≥ 0.36 instead of ≥ 0.45.
const PILOT_LOCK_ONSET: f32 = 0.30;
/// Tech 9: number of complex CMA equalizer taps.
const CMA_N_TAPS: usize = 8;
/// Tech 9: CMA LMS step size.
@@ -722,7 +728,7 @@ impl WfmStereoDecoder {
let avg_mag = self.detect_pilot_mag_acc * inv_n;
let avg_abs = self.detect_pilot_abs_acc * inv_n;
let pilot_coherence = (avg_mag / (avg_abs + 1e-4)).clamp(0.0, 1.0);
let pilot_lock = ((pilot_coherence - 0.4) / 0.2).clamp(0.0, 1.0);
let pilot_lock = ((pilot_coherence - PILOT_LOCK_ONSET) / 0.2).clamp(0.0, 1.0);
self.pilot_lock_level += 0.12 * (pilot_lock - self.pilot_lock_level);
let stereo_drive = (avg_mag * pilot_lock * 120.0).clamp(0.0, 1.0);
let detect_coeff = if stereo_drive > self.stereo_detect_level {