From 2ed68e4210edf51b842b15bf0d633117fd24e6d2 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 28 Feb 2026 12:19:36 +0100 Subject: [PATCH] [feat](trx-rds,trx-backend-soapysdr): improve weak-signal rds recovery Co-authored-by: Codex Signed-off-by: Stan Grams --- src/decoders/trx-rds/src/lib.rs | 138 +++++++++++++++--- .../trx-backend-soapysdr/src/demod.rs | 3 +- 2 files changed, 118 insertions(+), 23 deletions(-) diff --git a/src/decoders/trx-rds/src/lib.rs b/src/decoders/trx-rds/src/lib.rs index cc87910..3cbbe39 100644 --- a/src/decoders/trx-rds/src/lib.rs +++ b/src/decoders/trx-rds/src/lib.rs @@ -13,6 +13,10 @@ const RDS_POLY: u16 = 0x1B9; const SEARCH_REG_MASK: u32 = (1 << 26) - 1; const PHASE_CANDIDATES: usize = 8; const BIPHASE_CLOCK_WINDOW: usize = 128; +const RDS_BASEBAND_LP_HZ: f32 = 2_400.0; +const MIN_LOCK_QUALITY: f32 = 0.08; +const MIN_BIT_CONFIDENCE: f32 = 0.002; +const PS_VOTE_COMMIT_SCORE: u8 = 3; const OFFSET_A: u16 = 0x0FC; const OFFSET_B: u16 = 0x198; @@ -79,9 +83,14 @@ struct Candidate { block_a: u16, block_b: u16, score: u32, + bit_conf_avg: f32, + block_conf_sum: f32, + block_conf_count: u8, state: RdsData, ps_bytes: [u8; 8], ps_seen: [bool; 4], + ps_vote_bytes: [u8; 8], + ps_vote_score: [u8; 8], } impl Candidate { @@ -106,13 +115,18 @@ impl Candidate { block_a: 0, block_b: 0, score: 0, + bit_conf_avg: 0.0, + block_conf_sum: 0.0, + block_conf_count: 0, state: RdsData::default(), ps_bytes: [b' '; 8], ps_seen: [false; 4], + ps_vote_bytes: [b' '; 8], + ps_vote_score: [0; 8], } } - fn process_sample(&mut self, i: f32, q: f32) -> Option { + fn process_sample(&mut self, i: f32, q: f32, quality: f32) -> Option { self.sym_i_acc += i; self.sym_q_acc += q; self.sym_count = self.sym_count.saturating_add(1); @@ -135,6 +149,9 @@ impl Candidate { let emit_bit = self.clock % 2 == self.clock_polarity; self.clock_history[self.clock] = magnitude; self.clock = (self.clock + 1) % BIPHASE_CLOCK_WINDOW; + let quality = quality.clamp(0.0, 1.0); + let bit_confidence = magnitude * quality; + self.bit_conf_avg = self.bit_conf_avg * 0.995 + bit_confidence * 0.005; if self.clock == 0 { let mut even_sum = 0.0; @@ -153,10 +170,14 @@ impl Candidate { } if emit_bit { + let threshold = (self.bit_conf_avg * 0.35).max(MIN_BIT_CONFIDENCE); + if bit_confidence < threshold { + return None; + } let input_bit = biphase_i >= 0.0; let bit = (input_bit != self.prev_input_bit) as u8; self.prev_input_bit = input_bit; - self.push_bit(bit) + self.push_bit_with_confidence(bit, bit_confidence) } else { None } @@ -167,10 +188,17 @@ impl Candidate { update } + #[cfg(test)] fn push_bit(&mut self, bit: u8) -> Option { + self.push_bit_with_confidence(bit, 1.0) + } + + fn push_bit_with_confidence(&mut self, bit: u8, confidence: f32) -> Option { if self.locked { self.block_reg = ((self.block_reg << 1) | u32::from(bit)) & SEARCH_REG_MASK; self.block_bits = self.block_bits.saturating_add(1); + self.block_conf_sum += confidence; + self.block_conf_count = self.block_conf_count.saturating_add(1); if self.block_bits < 26 { return None; } @@ -195,6 +223,8 @@ impl Candidate { self.expect = ExpectBlock::B; self.block_reg = 0; self.block_bits = 0; + self.block_conf_sum = 0.0; + self.block_conf_count = 0; self.block_a = data; self.state.pi = Some(data); None @@ -211,23 +241,28 @@ impl Candidate { (ExpectBlock::B, BlockKind::B) => { self.block_b = data; self.expect = ExpectBlock::C; + self.reset_block_confidence(); None } (ExpectBlock::C, BlockKind::C | BlockKind::CPrime) => { self.expect = ExpectBlock::D; + self.reset_block_confidence(); None } (ExpectBlock::D, BlockKind::D) => { self.locked = false; self.search_bits = 0; self.search_reg = 0; - self.process_group(self.block_a, self.block_b, data) + let conf = self.take_block_confidence(); + self.process_group(self.block_a, self.block_b, data, conf) } (_, BlockKind::A) => { self.locked = true; self.expect = ExpectBlock::B; self.block_reg = 0; self.block_bits = 0; + self.block_conf_sum = 0.0; + self.block_conf_count = 0; self.block_a = data; self.state.pi = Some(data); None @@ -244,6 +279,8 @@ impl Candidate { self.expect = ExpectBlock::B; self.block_reg = 0; self.block_bits = 0; + self.block_conf_sum = 0.0; + self.block_conf_count = 0; self.search_reg = word; self.search_bits = 26; if let Some((data, kind)) = decode_block(word) { @@ -251,13 +288,55 @@ impl Candidate { self.locked = true; self.search_reg = 0; self.search_bits = 0; + self.block_conf_sum = 0.0; + self.block_conf_count = 0; self.block_a = data; self.state.pi = Some(data); } } } - fn process_group(&mut self, block_a: u16, block_b: u16, block_d: u16) -> Option { + fn reset_block_confidence(&mut self) { + self.block_conf_sum = 0.0; + self.block_conf_count = 0; + } + + fn take_block_confidence(&mut self) -> f32 { + let count = self.block_conf_count.max(1) as f32; + let confidence = self.block_conf_sum / count; + self.reset_block_confidence(); + confidence + } + + fn observe_ps_byte(&mut self, idx: usize, byte: u8, weight: u8) -> bool { + let clean = sanitize_text_byte(byte); + let weight = weight.max(1); + if self.ps_vote_score[idx] == 0 { + self.ps_vote_bytes[idx] = clean; + self.ps_vote_score[idx] = weight; + } else if self.ps_vote_bytes[idx] == clean { + self.ps_vote_score[idx] = self.ps_vote_score[idx].saturating_add(weight).min(16); + } else if weight >= self.ps_vote_score[idx] { + self.ps_vote_bytes[idx] = clean; + self.ps_vote_score[idx] = 1; + } else { + self.ps_vote_score[idx] = self.ps_vote_score[idx].saturating_sub(weight); + } + + if self.ps_vote_score[idx] >= PS_VOTE_COMMIT_SCORE && self.ps_bytes[idx] != self.ps_vote_bytes[idx] { + self.ps_bytes[idx] = self.ps_vote_bytes[idx]; + return true; + } + false + } + + fn process_group( + &mut self, + block_a: u16, + block_b: u16, + block_d: u16, + group_confidence: f32, + ) -> Option { let mut changed = false; if self.state.pi != Some(block_a) { self.state.pi = Some(block_a); @@ -275,9 +354,15 @@ impl Candidate { if group_type == 0 { let segment = usize::from((block_b & 0x0003) as u8); let [b0, b1] = block_d.to_be_bytes(); - self.ps_bytes[segment * 2] = sanitize_text_byte(b0); - self.ps_bytes[segment * 2 + 1] = sanitize_text_byte(b1); - self.ps_seen[segment] = true; + let vote_weight = ((group_confidence * 8.0).round() as u8).clamp(1, 4); + let left_idx = segment * 2; + let right_idx = left_idx + 1; + let left_committed = self.observe_ps_byte(left_idx, b0, vote_weight); + let right_committed = self.observe_ps_byte(right_idx, b1, vote_weight); + if left_committed || right_committed || self.ps_seen[segment] { + self.ps_seen[segment] = self.ps_vote_score[left_idx] >= PS_VOTE_COMMIT_SCORE + && self.ps_vote_score[right_idx] >= PS_VOTE_COMMIT_SCORE; + } if self.ps_seen.iter().all(|seen| *seen) { let ps = String::from_utf8_lossy(&self.ps_bytes).trim_end().to_string(); if !ps.is_empty() && self.state.program_service.as_deref() != Some(ps.as_str()) { @@ -287,7 +372,8 @@ impl Candidate { } } - self.score = self.score.saturating_add(1); + let score_bump = ((group_confidence * 6.0).round() as u32).max(1); + self.score = self.score.saturating_add(score_bump); changed.then(|| self.state.clone()) } } @@ -318,33 +404,41 @@ impl RdsDecoder { sample_rate_hz: sample_rate.max(1), carrier_phase: 0.0, carrier_inc: TAU * RDS_SUBCARRIER_HZ / sample_rate_f, - i_lp: OnePoleLowPass::new(sample_rate_f, 3_000.0), - q_lp: OnePoleLowPass::new(sample_rate_f, 3_000.0), + i_lp: OnePoleLowPass::new(sample_rate_f, RDS_BASEBAND_LP_HZ), + q_lp: OnePoleLowPass::new(sample_rate_f, RDS_BASEBAND_LP_HZ), candidates, best_score: 0, best_state: None, } } - pub fn process_samples(&mut self, samples: &[f32]) -> Option<&RdsData> { - for &sample in samples { - let (sin_p, cos_p) = self.carrier_phase.sin_cos(); - self.carrier_phase = (self.carrier_phase + self.carrier_inc).rem_euclid(TAU); - let mixed_i = self.i_lp.process(sample * cos_p * 2.0); - let mixed_q = self.q_lp.process(sample * -sin_p * 2.0); + pub fn process_sample(&mut self, sample: f32, quality: f32) -> Option<&RdsData> { + if quality < MIN_LOCK_QUALITY { + return self.best_state.as_ref(); + } + let (sin_p, cos_p) = self.carrier_phase.sin_cos(); + self.carrier_phase = (self.carrier_phase + self.carrier_inc).rem_euclid(TAU); + let mixed_i = self.i_lp.process(sample * cos_p * 2.0); + let mixed_q = self.q_lp.process(sample * -sin_p * 2.0); - for candidate in &mut self.candidates { - if let Some(update) = candidate.process_sample(mixed_i, mixed_q) { - if candidate.score >= self.best_score { - self.best_score = candidate.score; - self.best_state = Some(update); - } + for candidate in &mut self.candidates { + if let Some(update) = candidate.process_sample(mixed_i, mixed_q, quality) { + if candidate.score >= self.best_score { + self.best_score = candidate.score; + self.best_state = Some(update); } } } self.best_state.as_ref() } + pub fn process_samples(&mut self, samples: &[f32]) -> Option<&RdsData> { + for &sample in samples { + let _ = self.process_sample(sample, 1.0); + } + self.best_state.as_ref() + } + pub fn reset(&mut self) { *self = Self::new(self.sample_rate_hz); } diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs index a7c5c6e..e4a18d9 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs @@ -104,7 +104,6 @@ impl WfmStereoDecoder { if composite.is_empty() { return Vec::new(); } - let _ = self.rds_decoder.process_samples(&composite); let mut output = Vec::with_capacity( ((composite.len() as f64 * self.output_phase_inc).ceil() as usize + 1) @@ -122,6 +121,8 @@ impl WfmStereoDecoder { let pilot_mag = (i * i + q * q).sqrt(); let stereo_blend = (pilot_mag * 40.0).clamp(0.0, 1.0); + let rds_quality = (pilot_mag * 45.0).clamp(0.0, 1.0); + let _ = self.rds_decoder.process_sample(x, rds_quality); let sum = self.sum_lp.process(x); let stereo_carrier = (2.0 * self.pilot_phase).cos() * 2.0;