From 25b9c31c9b1e4f6c8506ab9f7d18dd6b26676167 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Mon, 3 Aug 2026 23:03:26 +0200 Subject: [PATCH] [fix](trx-backend-soapysdr): squelch against the meter, not the post-AGC level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The threshold was compared against the block level measured after the IQ AGC. Holding that level at a setpoint is the AGC's entire purpose, so for every mode that has one — FM, PKT, AIS, AM, SAM, which is to say the modes anyone squelches — the comparison was against a near-constant. With FM's 12 dB of gain a weak signal reads some 12 dB hotter than it is, and the value never had the decimation correction the meter applies on top of that: around 20 dB adrift at 48k/8k, more as decimation grows. The threshold arrives in the other scale entirely. The slider maps its percentage onto -120..-30 dB and Auto takes the spectrum noise floor plus 6, both of which are what the meter and the spectrum display show. So a gate set just above the noise sat open on it. It now reads last_signal_db, which is already computed each block before the AGC and corrected for decimation — the same number the meter shows. The post-AGC measurement had no other consumer. The test feeds one signal twice and takes the threshold from the channel's own meter: 6 dB above must gate it, 6 dB below must pass it. Nothing there depends on the absolute scale, only on the two agreeing. Signed-off-by: Stan Grams --- .../trx-backend-soapysdr/src/dsp/channel.rs | 102 ++++++++++++++++-- 1 file changed, 95 insertions(+), 7 deletions(-) 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 fd23fedc..af41daa2 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 @@ -837,12 +837,6 @@ impl ChannelDsp { } } - let signal_power = decimated - .iter() - .map(|s| s.re * s.re + s.im * s.im) - .sum::() - / decimated.len() as f32; - let signal_db = 10.0 * signal_power.max(1e-12).log10(); 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); @@ -884,7 +878,14 @@ impl ChannelDsp { raw } }; - if !self.squelch.update(&self.mode, signal_db) { + // Against the meter reading, not the block level after the IQ AGC. + // The threshold arrives in the scale the operator sets it from — the + // S-meter and the spectrum — while the level measured here had been + // through the AGC, whose whole job is to hold it at a setpoint. For + // every mode that has one (FM, PKT, AIS, AM, SAM) the squelch was + // therefore comparing against a near-constant, and for the rest it was + // still off by the decimation correction the meter applies. + if !self.squelch.update(&self.mode, self.last_signal_db) { audio.fill(0.0); } @@ -933,6 +934,93 @@ mod tests { dsp.process_block(&block); } + /// Feeds one signal twice, with the squelch threshold set from the channel's + /// own meter reading: 6 dB above it must gate the audio, 6 dB below it must + /// pass. FM runs an IQ AGC, so a squelch measured after that stage compares + /// against a level pinned near the AGC setpoint — some 20 dB adrift of the + /// scale the operator reads the threshold off, and open on plain noise. + #[test] + fn squelch_follows_the_meter_the_threshold_is_set_from() { + const AMPLITUDE: f32 = 0.0025; + + let (pcm_tx, mut pcm_rx) = broadcast::channel::>(4096); + let (iq_tx, _iq_rx) = broadcast::channel::>>(8); + let mut dsp = ChannelDsp::new( + 0.0, + &RigMode::FM, + 48_000, + 8_000, + 1, + 20, + 12_000, + 75, + true, + false, + VirtualSquelchConfig::default(), + NoiseBlankerConfig::default(), + pcm_tx, + iq_tx, + ); + + // A 1 kHz tone on the carrier, so an open gate is audibly non-zero and + // a closed one is unambiguously silent. + let mut phase = 0.0_f32; + let mut mod_phase = 0.0_f32; + let mut feed = |dsp: &mut ChannelDsp, blocks: usize| { + for _ in 0..blocks { + let mut block = Vec::with_capacity(4096); + for _ in 0..4096 { + mod_phase += std::f32::consts::TAU * 1_000.0 / 48_000.0; + phase += std::f32::consts::TAU * (3_000.0 * mod_phase.sin()) / 48_000.0; + block.push(Complex::new( + AMPLITUDE * phase.cos(), + AMPLITUDE * phase.sin(), + )); + } + dsp.process_block(&block); + } + }; + let drain = |rx: &mut broadcast::Receiver>| { + let mut audio = Vec::new(); + while let Ok(frame) = rx.try_recv() { + audio.extend_from_slice(&frame); + } + audio + }; + let peak = |audio: &[f32]| audio.iter().fold(0.0_f32, |acc, s| acc.max(s.abs())); + + // Settle the meter on this signal, then read what the operator would. + feed(&mut dsp, 24); + let meter_db = dsp.signal_db(); + assert!( + meter_db > -120.0, + "the meter never moved off its floor ({meter_db} dB)" + ); + + dsp.set_squelch(true, meter_db + 6.0); + let _ = drain(&mut pcm_rx); + feed(&mut dsp, 24); + let gated = drain(&mut pcm_rx); + assert!(!gated.is_empty(), "no audio frames were produced at all"); + // From the second half on: the first frame out still carries the audio + // that was already buffered when the threshold changed. + assert_eq!( + peak(&gated[gated.len() / 2..]), + 0.0, + "squelch set 6 dB above the meter ({meter_db} dB) still passed audio" + ); + + dsp.set_squelch(true, meter_db - 6.0); + let _ = drain(&mut pcm_rx); + feed(&mut dsp, 24); + let passed = drain(&mut pcm_rx); + assert!(!passed.is_empty(), "no audio frames were produced at all"); + assert!( + peak(&passed[passed.len() / 2..]) > 0.0, + "squelch set 6 dB below the meter ({meter_db} dB) gated the audio" + ); + } + #[test] fn channel_dsp_set_mode() { let (pcm_tx, _) = broadcast::channel::>(8);