[fix](trx-backend-soapysdr): remove noise/pilot dependency from WFM signal strength

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 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-27 19:46:23 +00:00
committed by Stan Grams
parent 41fa9dc242
commit 0905360ba5
2 changed files with 14 additions and 107 deletions
@@ -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(
@@ -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);
}