[feat](trx-rds,trx-backend-soapysdr): improve weak-signal rds recovery

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-02-28 12:19:36 +01:00
parent 0329be6124
commit 2ed68e4210
2 changed files with 118 additions and 23 deletions
+116 -22
View File
@@ -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<RdsData> {
fn process_sample(&mut self, i: f32, q: f32, quality: f32) -> Option<RdsData> {
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<RdsData> {
self.push_bit_with_confidence(bit, 1.0)
}
fn push_bit_with_confidence(&mut self, bit: u8, confidence: f32) -> Option<RdsData> {
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<RdsData> {
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<RdsData> {
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);
}
@@ -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;