[feat](trx-backend-soapysdr): extend squelch and boost high denoise

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-05 22:43:19 +01:00
parent 3188c9b0ad
commit 3eb5a615b9
4 changed files with 178 additions and 4 deletions
@@ -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)
}
@@ -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<Arc<Mutex<ChannelDsp>>> = 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::<Vec<f32>>(PCM_BROADCAST_CAPACITY);
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(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);
@@ -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<DcBlocker>,
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<Vec<f32>>,
iq_tx: broadcast::Sender<Vec<Complex<f32>>>,
) -> 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::<f32>()
/ 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,
);
@@ -49,6 +49,10 @@ pub struct SoapySdrRig {
gain_db: f64,
/// Optional hard ceiling for the applied hardware gain in dB.
max_gain_db: Option<f64>,
/// 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<Self> {
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<Box<dyn std::future::Future<Output = DynResult<()>> + 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<Box<dyn std::future::Future<Output = DynResult<u8>> + 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,