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 c1932e1..717c547 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 @@ -424,7 +424,8 @@ impl StereoDenoise { } WfmDenoiseLevel::Low => 1.0 - (1.0 - broadband_gain) * 0.35, WfmDenoiseLevel::Medium => 1.0 - (1.0 - broadband_gain) * 0.65, - WfmDenoiseLevel::High => broadband_gain, + // Extra attenuation profile for noisy stereo difference channels. + WfmDenoiseLevel::High => broadband_gain.powf(1.45), }; diff_i * effective_gain.clamp(0.0, 1.0) } diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs index e1339d9..c549e39 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs @@ -22,7 +22,7 @@ use num_complex::Complex; use tokio::sync::broadcast; use trx_core::rig::state::RigMode; -pub use self::channel::ChannelDsp; +pub use self::channel::{ChannelDsp, VirtualSquelchConfig}; pub use self::filter::{BlockFirFilter, BlockFirFilterPair, FirFilter}; use self::spectrum::SpectrumSnapshotter; @@ -107,6 +107,7 @@ impl SdrPipeline { frame_duration_ms: u16, wfm_deemphasis_us: u32, wfm_stereo: bool, + squelch_cfg: VirtualSquelchConfig, channels: &[(f64, RigMode, u32, usize)], ) -> Self { const IQ_BROADCAST_CAPACITY: usize = 64; @@ -118,9 +119,16 @@ impl SdrPipeline { let mut iq_senders = Vec::with_capacity(channels.len()); let mut channel_dsps: Vec>> = Vec::with_capacity(channels.len()); - for &(channel_if_hz, ref mode, audio_bandwidth_hz, fir_taps) in channels { + for (channel_idx, &(channel_if_hz, ref mode, audio_bandwidth_hz, fir_taps)) in + channels.iter().enumerate() + { let (pcm_tx, _pcm_rx) = broadcast::channel::>(PCM_BROADCAST_CAPACITY); let (iq_tx, _iq_rx) = broadcast::channel::>>(IQ_BROADCAST_CAPACITY); + let channel_squelch_cfg = if channel_idx == 0 { + squelch_cfg + } else { + VirtualSquelchConfig::default() + }; let dsp = ChannelDsp::new( channel_if_hz, mode, @@ -132,6 +140,7 @@ impl SdrPipeline { wfm_deemphasis_us, wfm_stereo, fir_taps, + channel_squelch_cfg, pcm_tx.clone(), iq_tx.clone(), ); @@ -405,6 +414,7 @@ mod tests { 20, 75, true, + VirtualSquelchConfig::default(), &[(200_000.0, RigMode::USB, 3000, 64)], ); assert_eq!(pipeline.pcm_senders.len(), 1); @@ -421,6 +431,7 @@ mod tests { 20, 75, true, + VirtualSquelchConfig::default(), &[], ); assert_eq!(pipeline.pcm_senders.len(), 0); 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 2680d27..87d696c 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 @@ -10,6 +10,88 @@ use crate::demod::{DcBlocker, Demodulator, SoftAgc, WfmStereoDecoder}; use super::{BlockFirFilterPair, IQ_BLOCK_SIZE}; +#[derive(Debug, Clone, Copy)] +pub struct VirtualSquelchConfig { + pub enabled: bool, + pub threshold_db: f32, + pub hysteresis_db: f32, + pub tail_blocks: u32, +} + +impl Default for VirtualSquelchConfig { + fn default() -> Self { + Self { + enabled: false, + threshold_db: -65.0, + hysteresis_db: 3.0, + tail_blocks: 0, + } + } +} + +#[derive(Debug, Clone)] +struct VirtualSquelch { + cfg: VirtualSquelchConfig, + open: bool, + tail_countdown: u32, +} + +impl VirtualSquelch { + fn new(cfg: VirtualSquelchConfig) -> Self { + Self { + cfg, + open: !cfg.enabled, + tail_countdown: 0, + } + } + + fn reset(&mut self) { + self.open = !self.cfg.enabled; + self.tail_countdown = 0; + } + + fn set_enabled(&mut self, enabled: bool) { + if self.cfg.enabled == enabled { + return; + } + self.cfg.enabled = enabled; + self.reset(); + } + + fn set_threshold_db(&mut self, threshold_db: f32) { + self.cfg.threshold_db = threshold_db; + self.reset(); + } + + fn supports_mode(mode: &RigMode) -> bool { + !matches!(mode, RigMode::WFM) + } + + fn update(&mut self, mode: &RigMode, level_db: f32) -> bool { + if !self.cfg.enabled || !Self::supports_mode(mode) { + self.open = true; + self.tail_countdown = 0; + return true; + } + + let close_threshold_db = self.cfg.threshold_db - self.cfg.hysteresis_db.max(0.0); + if self.open { + if level_db >= close_threshold_db { + self.tail_countdown = self.cfg.tail_blocks; + } else if self.tail_countdown > 0 { + self.tail_countdown -= 1; + } else { + self.open = false; + } + } else if level_db >= self.cfg.threshold_db { + self.open = true; + self.tail_countdown = self.cfg.tail_blocks; + } + + self.open + } +} + fn agc_for_mode(mode: &RigMode, audio_sample_rate: u32) -> SoftAgc { let sr = audio_sample_rate.max(1) as f32; match mode { @@ -87,6 +169,7 @@ pub struct ChannelDsp { audio_agc: SoftAgc, audio_dc: Option, processing_enabled: bool, + squelch: VirtualSquelch, } impl ChannelDsp { @@ -186,6 +269,7 @@ impl ChannelDsp { wfm_deemphasis_us: u32, wfm_stereo: bool, fir_taps: usize, + squelch_cfg: VirtualSquelchConfig, pcm_tx: broadcast::Sender>, iq_tx: broadcast::Sender>>, ) -> Self { @@ -264,6 +348,7 @@ impl ChannelDsp { audio_agc: agc_for_mode(mode, audio_sample_rate), audio_dc: dc_for_mode(mode), processing_enabled: true, + squelch: VirtualSquelch::new(squelch_cfg), } } @@ -271,12 +356,18 @@ impl ChannelDsp { self.processing_enabled = enabled; } + pub fn set_squelch(&mut self, enabled: bool, threshold_db: f32) { + self.squelch.set_enabled(enabled); + self.squelch.set_threshold_db(threshold_db); + } + pub fn set_mode(&mut self, mode: &RigMode) { self.mode = mode.clone(); if *mode != RigMode::WFM { self.audio_bandwidth_hz = default_bandwidth_for_mode(mode); } self.demodulator = Demodulator::for_mode(mode); + self.squelch.reset(); self.rebuild_filters(true); } @@ -427,6 +518,12 @@ 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(); if self.wfm_decoder.is_some() { for sample in decimated.iter_mut() { let mag = (sample.re * sample.re + sample.im * sample.im).sqrt(); @@ -437,7 +534,7 @@ impl ChannelDsp { } const WFM_OUTPUT_GAIN: f32 = 0.50; - let audio = if let Some(decoder) = self.wfm_decoder.as_mut() { + let mut audio = if let Some(decoder) = self.wfm_decoder.as_mut() { let mut out = decoder.process_iq(decimated); for sample in &mut out { *sample = (*sample * WFM_OUTPUT_GAIN).clamp(-1.0, 1.0); @@ -462,6 +559,9 @@ impl ChannelDsp { raw } }; + if !self.squelch.update(&self.mode, signal_db) { + audio.fill(0.0); + } self.frame_buf.extend_from_slice(&audio); while self.frame_buf.len().saturating_sub(self.frame_buf_offset) >= self.frame_size { @@ -499,6 +599,7 @@ mod tests { 75, true, 31, + VirtualSquelchConfig::default(), pcm_tx, iq_tx, ); @@ -521,6 +622,7 @@ mod tests { 75, true, 31, + VirtualSquelchConfig::default(), pcm_tx, iq_tx, ); diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs index 20638cf..4c3db44 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs @@ -49,6 +49,10 @@ pub struct SoapySdrRig { gain_db: f64, /// Optional hard ceiling for the applied hardware gain in dB. max_gain_db: Option, + /// Whether software squelch is enabled on primary channel (except WFM mode). + squelch_enabled: bool, + /// Software squelch threshold (dBFS) on primary channel. + squelch_threshold_db: f32, /// Hidden AIS decoder channels (A and B) when available. ais_channel_indices: Option<(usize, usize)>, } @@ -90,6 +94,10 @@ impl SoapySdrRig { /// - `center_offset_hz`: the hardware is tuned this many Hz *below* the /// dial frequency so the desired signal lands off-DC. The DSP mixer /// shifts it back. Pass 0 to tune exactly to the dial frequency. + /// - `squelch_enabled`: enable software squelch for all modes except WFM. + /// - `squelch_threshold_db`: squelch open threshold in dBFS. + /// - `squelch_hysteresis_db`: close hysteresis in dB. + /// - `squelch_tail_ms`: tail hold time in milliseconds. #[allow(clippy::too_many_arguments)] pub fn new_with_config( args: &str, @@ -106,6 +114,10 @@ impl SoapySdrRig { sdr_sample_rate: u32, bandwidth_hz: u32, center_offset_hz: i64, + squelch_enabled: bool, + squelch_threshold_db: f32, + squelch_hysteresis_db: f32, + squelch_tail_ms: u32, ) -> DynResult { tracing::info!( "initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})", @@ -161,6 +173,16 @@ impl SoapySdrRig { 25_000, 96, )); + let block_ms = if sdr_sample_rate == 0 { + 0.0 + } else { + dsp::IQ_BLOCK_SIZE as f64 * 1000.0 / sdr_sample_rate as f64 + }; + let squelch_tail_blocks = if block_ms <= 0.0 { + 0 + } else { + (squelch_tail_ms as f64 / block_ms).ceil().max(0.0) as u32 + }; let pipeline = dsp::SdrPipeline::start( iq_source, @@ -170,6 +192,12 @@ impl SoapySdrRig { frame_duration_ms, wfm_deemphasis_us, true, // wfm_stereo: enabled by default + dsp::VirtualSquelchConfig { + enabled: squelch_enabled, + threshold_db: squelch_threshold_db, + hysteresis_db: squelch_hysteresis_db, + tail_blocks: squelch_tail_blocks, + }, &all_channels, ); @@ -244,6 +272,8 @@ impl SoapySdrRig { wfm_denoise: WfmDenoiseLevel::Auto, gain_db, max_gain_db, + squelch_enabled, + squelch_threshold_db, ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)), }; rig.apply_ais_channel_activity(); @@ -269,6 +299,10 @@ impl SoapySdrRig { 1_920_000, 1_500_000, // bandwidth_hz 0, // center_offset_hz + false, // squelch_enabled + -65.0, // squelch_threshold_db + 3.0, // squelch_hysteresis_db + 180, // squelch_tail_ms ) } @@ -497,6 +531,30 @@ impl RigCat for SoapySdrRig { }) } + fn set_sdr_squelch<'a>( + &'a mut self, + enabled: bool, + threshold_db: f64, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + if !threshold_db.is_finite() { + return Err("squelch threshold must be finite".into()); + } + if !(-140.0..=0.0).contains(&threshold_db) { + return Err("squelch threshold must be in range -140..=0 dBFS".into()); + } + self.squelch_enabled = enabled; + self.squelch_threshold_db = threshold_db as f32; + if let Some(dsp_arc) = self.pipeline.channel_dsps.get(self.primary_channel_idx) { + dsp_arc + .lock() + .unwrap() + .set_squelch(enabled, self.squelch_threshold_db); + } + Ok(()) + }) + } + fn get_signal_strength<'a>( &'a mut self, ) -> Pin> + Send + 'a>> { @@ -667,6 +725,8 @@ impl RigCat for SoapySdrRig { .map(|max_gain| self.gain_db.min(max_gain)) .unwrap_or(self.gain_db), ), + sdr_squelch_enabled: Some(self.squelch_enabled), + sdr_squelch_threshold_db: Some(self.squelch_threshold_db as f64), wfm_deemphasis_us: self.wfm_deemphasis_us, wfm_stereo: self.wfm_stereo, wfm_stereo_detected,