From 0905360ba5bcde387d5b4fb083330bb115e6e053 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 19:46:23 +0000 Subject: [PATCH] [fix](trx-backend-soapysdr): remove noise/pilot dependency from WFM signal strength MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The noise floor subtraction was over-aggressive: the bandwidth ratio scaling between the 67 kHz baseband probe and the IQ domain amplified the noise estimate excessively, causing weak stations to be subtracted to nothing. The pilot-referenced correction only worked for stereo stations. Strip the signal strength path back to what actually works universally: mean IQ envelope power with asymmetric attack/decay smoothing. This always produces a reading for any FM signal — mono, stereo, with or without RDS. The baseband noise probe, CNR estimation, and pilot metrics remain in the WfmStereoDecoder for their existing uses (RDS quality weighting, CCI/ACI estimation) but no longer feed into the S-meter. https://claude.ai/code/session_017URSDqSJ8TyZpDhV2vKZUe Signed-off-by: Claude --- .../trx-backend-soapysdr/src/demod/wfm.rs | 5 - .../trx-backend-soapysdr/src/dsp/channel.rs | 116 +++--------------- 2 files changed, 14 insertions(+), 107 deletions(-) diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/wfm.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/wfm.rs index c872faa..6d4a453 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/wfm.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/wfm.rs @@ -759,7 +759,6 @@ impl WfmStereoDecoder { { const NOISE_SMOOTH_ALPHA: f32 = 0.02; const TOTAL_SMOOTH_ALPHA: f32 = 0.02; - const PILOT_POWER_ALPHA: f32 = 0.05; let mut noise_acc = 0.0_f32; let mut total_acc = 0.0_f32; for &d in &disc { @@ -775,10 +774,6 @@ impl WfmStereoDecoder { NOISE_SMOOTH_ALPHA * (noise_pwr - self.baseband_noise_power); self.baseband_total_power += TOTAL_SMOOTH_ALPHA * (total_pwr - self.baseband_total_power); - // Pilot power is updated from the PLL magnitude accumulator in - // the per-sample loop below; here we just apply smoothing from - // the previous block's pilot magnitude. - let _ = PILOT_POWER_ALPHA; // used below in detect block } let mut output = Vec::with_capacity( diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs index f851332..52be6f9 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs @@ -303,11 +303,6 @@ pub struct ChannelDsp { carrier_attack_alpha: f32, /// IIR decay coefficient (slow) for S-meter envelope tracking. carrier_decay_alpha: f32, - /// Cached noise floor estimate (dB) from the WFM demodulator's baseband - /// noise probe. Updated after each WFM decode block. - wfm_noise_floor_db: f32, - /// Previous block's estimated CNR (dB) from the WFM demodulator. - wfm_cnr_db: f32, } impl ChannelDsp { @@ -347,8 +342,6 @@ impl ChannelDsp { // from the previous frequency while the IIR catches up. self.carrier_iq_power = 0.0; self.last_signal_db = -120.0; - self.wfm_noise_floor_db = -120.0; - self.wfm_cnr_db = 0.0; } fn pipeline_rates( @@ -445,8 +438,6 @@ impl ChannelDsp { self.carrier_attack_alpha = attack; self.carrier_decay_alpha = decay; self.carrier_iq_power = 0.0; - self.wfm_noise_floor_db = -120.0; - self.wfm_cnr_db = 0.0; self.frame_buf.clear(); self.frame_buf_offset = 0; } @@ -565,8 +556,6 @@ impl ChannelDsp { carrier_iq_power: 0.0, carrier_attack_alpha: Self::smeter_alphas(channel_sample_rate).0, carrier_decay_alpha: Self::smeter_alphas(channel_sample_rate).1, - wfm_noise_floor_db: -120.0, - wfm_cnr_db: 0.0, } } @@ -787,21 +776,18 @@ impl ChannelDsp { { let decim_correction = 10.0 * (self.decim_factor as f32).max(1.0).log10(); if self.mode == RigMode::WFM { - // WFM signal strength: asymmetric attack/decay with noise - // floor correction from the demodulator's baseband noise - // probe (one-block delay, acceptable for a slow metric). + // WFM signal strength: mean IQ envelope power with asymmetric + // attack/decay smoothing. // - // 1. Compute mean IQ envelope power (channel power including - // both signal and noise). - // 2. Apply asymmetric attack/decay smoothing (fast attack - // ~2 ms, slow decay ~300 ms) per IARU R.1 S-meter spec. - // 3. Subtract the estimated noise floor (from the WFM - // demodulator's 67 kHz baseband noise probe) in the - // linear domain to isolate the carrier power. - // 4. When the stereo pilot is locked and the CNR estimate - // is available, cross-validate: the pilot has a known - // fixed amplitude at the transmitter, so its received - // level provides a quality-weighted correction. + // FM is constant-envelope, so mean(I²+Q²) is inherently + // stable regardless of modulation content. Using mean power + // (not per-sample IIR) gives a block-level estimate that is + // then tracked with fast attack / slow decay for professional + // S-meter behaviour (IARU R.1: ~2 ms attack, ~300 ms decay). + // + // This measurement is purely IQ-domain and works identically + // for mono, stereo, and any FM signal — no dependency on + // pilot tone or RDS presence. let n = decimated.len() as f32; let mean_power = decimated .iter() @@ -810,7 +796,7 @@ impl ChannelDsp { / n; // Asymmetric attack/decay: use the faster coefficient when - // the new sample is above the current estimate (attack), + // the new measurement is above the current estimate (attack), // and the slower coefficient when below (decay). let alpha = if mean_power > self.carrier_iq_power { self.carrier_attack_alpha @@ -819,57 +805,8 @@ impl ChannelDsp { }; self.carrier_iq_power += alpha * (mean_power - self.carrier_iq_power); - // Noise floor correction: subtract the estimated noise - // contribution to reveal the carrier-only power. The - // noise floor estimate comes from the WFM demodulator's - // baseband noise probe (updated after each decode block). - // - // Convert the cached noise floor dB back to linear, then - // subtract from the smoothed total power. This prevents - // the meter from reading the noise floor on empty channels. - let noise_linear = 10.0_f32.powf(self.wfm_noise_floor_db / 10.0); - let carrier_power = (self.carrier_iq_power - noise_linear).max(1e-15); - - // Pilot-referenced correction: when the 19 kHz pilot is - // locked (CNR estimate available), blend the IQ-domain - // estimate with a pilot-referenced one. The pilot tone - // has a known fixed amplitude (±7.5 kHz deviation, 10% of - // ±75 kHz), so its received power relative to the channel - // power provides an independent quality-weighted estimate. - // - // At high CNR (>20 dB), the raw IQ power is accurate and - // needs no correction. At low CNR (<10 dB, near the FM - // threshold), the pilot correction becomes more valuable - // because noise dominates the IQ reading. - let corrected_power = if self.wfm_cnr_db > 3.0 && self.wfm_cnr_db < 25.0 { - // Blend factor: pilot correction is strongest near the - // FM threshold (~10 dB CNR) and fades at high CNR. - let blend = ((25.0 - self.wfm_cnr_db) / 22.0).clamp(0.0, 0.3); - // The pilot is 10% of total deviation, so it sits ~20 dB - // below the main signal. Scale pilot power accordingly - // to estimate the equivalent carrier power. - let pilot_pwr = self - .wfm_decoder - .as_ref() - .map(|d| d.pilot_tone_power()) - .unwrap_or(0.0); - if pilot_pwr > 1e-15 { - // pilot_pwr is in the demodulated baseband domain; - // it is used only for relative correction, not as - // an absolute level. When pilot is stronger than - // expected relative to noise, bias the reading up; - // when weaker, bias it down. - let pilot_based = carrier_power - * (1.0 + blend * (pilot_pwr / (pilot_pwr + noise_linear) - 0.5)); - pilot_based.max(1e-15) - } else { - carrier_power - } - } else { - carrier_power - }; - - self.last_signal_db = 10.0 * corrected_power.max(1e-15).log10() - decim_correction; + self.last_signal_db = + 10.0 * self.carrier_iq_power.max(1e-12).log10() - decim_correction; } else { // Other modes: peak IQ magnitude with EMA smoothing. const SIGNAL_EMA_ALPHA: f32 = 0.4; @@ -897,31 +834,6 @@ impl ChannelDsp { const WFM_OUTPUT_GAIN: f32 = 0.50; let mut audio = if let Some(decoder) = self.wfm_decoder.as_mut() { let mut out = decoder.process_iq(decimated); - // Update cached noise floor and CNR estimates from the WFM - // demodulator's baseband noise probe for the next signal - // strength computation (one-block delay is acceptable for - // these slow-moving metrics). - let noise_pwr = decoder.baseband_noise_power(); - let total_pwr = decoder.baseband_total_power(); - if noise_pwr > 1e-15 && total_pwr > 1e-15 { - // Map the baseband noise power to the equivalent IQ-domain - // noise floor. The baseband probe at 67 kHz captures noise - // shaped by the f² FM demodulation curve. Scale by the - // ratio of channel bandwidth to probe bandwidth so the - // subtraction in the IQ domain is proportionally correct. - // - // The probe BPF at Q=3 around 67 kHz has a bandwidth of - // ~22 kHz. The channel bandwidth is the full decimated - // rate. The ratio gives us the scaling factor. - let probe_bw = 67_000.0_f32 / 3.0; // ~22 kHz - let channel_bw = (self.sdr_sample_rate as f32 / self.decim_factor as f32).max(1.0); - let bw_ratio = channel_bw / probe_bw; - let iq_noise_est = noise_pwr * bw_ratio; - self.wfm_noise_floor_db = 10.0 * iq_noise_est.max(1e-15).log10(); - } - if let Some(cnr) = decoder.estimated_cnr_db() { - self.wfm_cnr_db += 0.1 * (cnr - self.wfm_cnr_db); - } for sample in &mut out { *sample = (*sample * WFM_OUTPUT_GAIN).clamp(-1.0, 1.0); }