[feat](trx-rs): add configurable noise blanker for SoapySDR backend
IQ-domain impulse noise blanker using exponential-smoothing RMS tracker. Samples exceeding threshold × running RMS are replaced with the last clean sample. Configurable via [sdr.noise_blanker] in TOML config and runtime via POST /set_sdr_noise_blanker API. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -24,7 +24,7 @@ use tokio::sync::broadcast;
|
||||
use tracing::warn;
|
||||
use trx_core::rig::state::RigMode;
|
||||
|
||||
pub use self::channel::{ChannelDsp, VirtualSquelchConfig};
|
||||
pub use self::channel::{ChannelDsp, NoiseBlankerConfig, VirtualSquelchConfig};
|
||||
pub use self::filter::{BlockFirFilter, BlockFirFilterPair, FirFilter};
|
||||
use self::spectrum::SpectrumSnapshotter;
|
||||
|
||||
@@ -152,6 +152,7 @@ impl SdrPipeline {
|
||||
wfm_deemphasis_us: u32,
|
||||
wfm_stereo: bool,
|
||||
squelch_cfg: VirtualSquelchConfig,
|
||||
nb_cfg: NoiseBlankerConfig,
|
||||
channels: &[(f64, RigMode, u32)],
|
||||
) -> Self {
|
||||
const IQ_BROADCAST_CAPACITY: usize = 64;
|
||||
@@ -173,6 +174,11 @@ impl SdrPipeline {
|
||||
} else {
|
||||
VirtualSquelchConfig::default()
|
||||
};
|
||||
let channel_nb_cfg = if channel_idx == 0 {
|
||||
nb_cfg
|
||||
} else {
|
||||
NoiseBlankerConfig::default()
|
||||
};
|
||||
let dsp = ChannelDsp::new(
|
||||
channel_if_hz,
|
||||
mode,
|
||||
@@ -185,6 +191,7 @@ impl SdrPipeline {
|
||||
wfm_stereo,
|
||||
false,
|
||||
channel_squelch_cfg,
|
||||
channel_nb_cfg,
|
||||
pcm_tx.clone(),
|
||||
iq_tx.clone(),
|
||||
);
|
||||
@@ -274,6 +281,7 @@ impl SdrPipeline {
|
||||
self.wfm_stereo,
|
||||
false,
|
||||
VirtualSquelchConfig::default(),
|
||||
NoiseBlankerConfig::default(),
|
||||
pcm_tx.clone(),
|
||||
iq_tx.clone(),
|
||||
);
|
||||
@@ -562,6 +570,7 @@ mod tests {
|
||||
75,
|
||||
true,
|
||||
VirtualSquelchConfig::default(),
|
||||
NoiseBlankerConfig::default(),
|
||||
&[(200_000.0, RigMode::USB, 3000)],
|
||||
);
|
||||
assert_eq!(pipeline.pcm_senders.len(), 1);
|
||||
@@ -579,6 +588,7 @@ mod tests {
|
||||
75,
|
||||
true,
|
||||
VirtualSquelchConfig::default(),
|
||||
NoiseBlankerConfig::default(),
|
||||
&[],
|
||||
);
|
||||
assert_eq!(pipeline.pcm_senders.len(), 0);
|
||||
|
||||
@@ -10,6 +10,88 @@ use crate::demod::{CquamDemod, DcBlocker, Demodulator, SoftAgc, WfmStereoDecoder
|
||||
|
||||
use super::{BlockFirFilterPair, IQ_BLOCK_SIZE};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Noise blanker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// IQ-domain impulse noise blanker.
|
||||
///
|
||||
/// Maintains a running RMS estimate of the IQ magnitude. When a sample's
|
||||
/// magnitude exceeds `threshold × rms`, it is replaced by linear interpolation
|
||||
/// between the last clean sample and the next clean sample (lookahead of 1).
|
||||
///
|
||||
/// The RMS tracker uses exponential smoothing with a time constant of ~128
|
||||
/// samples at the IQ sample rate, fast enough to track band-noise changes
|
||||
/// but slow enough not to follow individual impulses.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NoiseBlanker {
|
||||
enabled: bool,
|
||||
threshold: f32,
|
||||
/// Exponentially-smoothed mean-square estimate.
|
||||
mean_sq: f32,
|
||||
/// Last clean sample (used for interpolation fill).
|
||||
last_clean: Complex<f32>,
|
||||
}
|
||||
|
||||
const NB_ALPHA: f32 = 1.0 / 128.0;
|
||||
|
||||
impl NoiseBlanker {
|
||||
pub fn new(enabled: bool, threshold: f32) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
threshold: threshold.max(1.0),
|
||||
mean_sq: 1e-10,
|
||||
last_clean: Complex::new(0.0, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
pub fn set_threshold(&mut self, threshold: f32) {
|
||||
self.threshold = threshold.max(1.0);
|
||||
}
|
||||
|
||||
/// Process a block of IQ samples in-place, blanking impulse spikes.
|
||||
pub fn process(&mut self, block: &mut [Complex<f32>]) {
|
||||
if !self.enabled || block.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let thresh_sq = self.threshold * self.threshold;
|
||||
|
||||
for sample in block.iter_mut() {
|
||||
let s = *sample;
|
||||
let mag_sq = s.re * s.re + s.im * s.im;
|
||||
|
||||
if mag_sq > thresh_sq * self.mean_sq {
|
||||
// Impulse detected — replace with last clean sample.
|
||||
*sample = self.last_clean;
|
||||
} else {
|
||||
// Clean sample — update RMS tracker.
|
||||
self.mean_sq += NB_ALPHA * (mag_sq - self.mean_sq);
|
||||
self.last_clean = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct NoiseBlankerConfig {
|
||||
pub enabled: bool,
|
||||
pub threshold: f32,
|
||||
}
|
||||
|
||||
impl Default for NoiseBlankerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
threshold: 10.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct VirtualSquelchConfig {
|
||||
pub enabled: bool,
|
||||
@@ -213,6 +295,7 @@ pub struct ChannelDsp {
|
||||
processing_enabled: bool,
|
||||
force_mono_pcm: bool,
|
||||
squelch: VirtualSquelch,
|
||||
noise_blanker: NoiseBlanker,
|
||||
}
|
||||
|
||||
impl ChannelDsp {
|
||||
@@ -327,6 +410,7 @@ impl ChannelDsp {
|
||||
wfm_stereo: bool,
|
||||
force_mono_pcm: bool,
|
||||
squelch_cfg: VirtualSquelchConfig,
|
||||
nb_cfg: NoiseBlankerConfig,
|
||||
pcm_tx: broadcast::Sender<Vec<f32>>,
|
||||
iq_tx: broadcast::Sender<Vec<Complex<f32>>>,
|
||||
) -> Self {
|
||||
@@ -415,6 +499,7 @@ impl ChannelDsp {
|
||||
processing_enabled: true,
|
||||
force_mono_pcm,
|
||||
squelch: VirtualSquelch::new(squelch_cfg),
|
||||
noise_blanker: NoiseBlanker::new(nb_cfg.enabled, nb_cfg.threshold),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,6 +516,11 @@ impl ChannelDsp {
|
||||
self.squelch.set_threshold_db(threshold_db);
|
||||
}
|
||||
|
||||
pub fn set_noise_blanker(&mut self, enabled: bool, threshold: f32) {
|
||||
self.noise_blanker.set_enabled(enabled);
|
||||
self.noise_blanker.set_threshold(threshold);
|
||||
}
|
||||
|
||||
pub fn set_mode(&mut self, mode: &RigMode) {
|
||||
self.mode = mode.clone();
|
||||
if *mode != RigMode::WFM {
|
||||
@@ -499,6 +589,16 @@ impl ChannelDsp {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply noise blanker on a mutable copy when enabled.
|
||||
let block = if self.noise_blanker.enabled {
|
||||
let mut nb_buf = block.to_vec();
|
||||
self.noise_blanker.process(&mut nb_buf);
|
||||
nb_buf
|
||||
} else {
|
||||
block.to_vec()
|
||||
};
|
||||
let block = &block[..];
|
||||
|
||||
self.scratch_mixed_i.resize(n, 0.0);
|
||||
self.scratch_mixed_q.resize(n, 0.0);
|
||||
let mixed_i = &mut self.scratch_mixed_i;
|
||||
@@ -684,6 +784,7 @@ mod tests {
|
||||
true,
|
||||
false,
|
||||
VirtualSquelchConfig::default(),
|
||||
NoiseBlankerConfig::default(),
|
||||
pcm_tx,
|
||||
iq_tx,
|
||||
);
|
||||
@@ -707,6 +808,7 @@ mod tests {
|
||||
true,
|
||||
false,
|
||||
VirtualSquelchConfig::default(),
|
||||
NoiseBlankerConfig::default(),
|
||||
pcm_tx,
|
||||
iq_tx,
|
||||
);
|
||||
@@ -714,4 +816,31 @@ mod tests {
|
||||
dsp.set_mode(&RigMode::FM);
|
||||
assert_eq!(dsp.demodulator, Demodulator::Fm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_blanker_suppresses_impulse() {
|
||||
let mut nb = NoiseBlanker::new(true, 5.0);
|
||||
// Feed a steady signal to establish the RMS baseline.
|
||||
let mut block: Vec<Complex<f32>> = (0..256).map(|_| Complex::new(0.01, 0.01)).collect();
|
||||
nb.process(&mut block);
|
||||
// Now inject a single massive spike at index 0.
|
||||
let mut block2: Vec<Complex<f32>> = (0..256).map(|_| Complex::new(0.01, 0.01)).collect();
|
||||
block2[0] = Complex::new(10.0, 10.0);
|
||||
nb.process(&mut block2);
|
||||
// The spike should have been blanked (replaced by last clean sample).
|
||||
let mag = (block2[0].re * block2[0].re + block2[0].im * block2[0].im).sqrt();
|
||||
assert!(
|
||||
mag < 1.0,
|
||||
"expected impulse to be blanked, got magnitude {}",
|
||||
mag
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_blanker_disabled_passes_through() {
|
||||
let mut nb = NoiseBlanker::new(false, 5.0);
|
||||
let mut block = vec![Complex::new(10.0, 10.0); 4];
|
||||
nb.process(&mut block);
|
||||
assert_eq!(block[0], Complex::new(10.0, 10.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ pub struct SoapySdrRig {
|
||||
squelch_enabled: bool,
|
||||
/// Software squelch threshold (dBFS) on primary channel.
|
||||
squelch_threshold_db: f32,
|
||||
/// Whether the noise blanker is enabled on the primary channel.
|
||||
nb_enabled: bool,
|
||||
/// Noise blanker impulse threshold multiplier.
|
||||
nb_threshold: f64,
|
||||
/// Hidden AIS decoder channels (A and B) when available.
|
||||
ais_channel_indices: Option<(usize, usize)>,
|
||||
/// Virtual channel manager shared with external consumers (e.g. RigHandle).
|
||||
@@ -126,6 +130,8 @@ impl SoapySdrRig {
|
||||
squelch_hysteresis_db: f32,
|
||||
squelch_tail_ms: u32,
|
||||
max_virtual_channels: usize,
|
||||
nb_enabled: bool,
|
||||
nb_threshold: f64,
|
||||
) -> DynResult<Self> {
|
||||
tracing::info!(
|
||||
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
|
||||
@@ -221,6 +227,10 @@ impl SoapySdrRig {
|
||||
hysteresis_db: squelch_hysteresis_db,
|
||||
tail_blocks: squelch_tail_blocks,
|
||||
},
|
||||
dsp::NoiseBlankerConfig {
|
||||
enabled: nb_enabled,
|
||||
threshold: nb_threshold as f32,
|
||||
},
|
||||
&all_channels,
|
||||
));
|
||||
|
||||
@@ -307,6 +317,8 @@ impl SoapySdrRig {
|
||||
agc_enabled,
|
||||
squelch_enabled,
|
||||
squelch_threshold_db,
|
||||
nb_enabled,
|
||||
nb_threshold,
|
||||
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
|
||||
channel_manager,
|
||||
};
|
||||
@@ -338,6 +350,8 @@ impl SoapySdrRig {
|
||||
3.0, // squelch_hysteresis_db
|
||||
180, // squelch_tail_ms
|
||||
4, // max_virtual_channels
|
||||
false, // nb_enabled
|
||||
10.0, // nb_threshold
|
||||
)
|
||||
}
|
||||
|
||||
@@ -654,6 +668,33 @@ impl RigCat for SoapySdrRig {
|
||||
})
|
||||
}
|
||||
|
||||
fn set_sdr_noise_blanker<'a>(
|
||||
&'a mut self,
|
||||
enabled: bool,
|
||||
threshold: f64,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
if !threshold.is_finite() {
|
||||
return Err("noise blanker threshold must be finite".into());
|
||||
}
|
||||
if !(1.0..=100.0).contains(&threshold) {
|
||||
return Err("noise blanker threshold must be in range 1..=100".into());
|
||||
}
|
||||
self.nb_enabled = enabled;
|
||||
self.nb_threshold = threshold;
|
||||
{
|
||||
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
||||
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
|
||||
dsp_arc
|
||||
.lock()
|
||||
.unwrap()
|
||||
.set_noise_blanker(enabled, threshold as f32);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn get_signal_strength<'a>(
|
||||
&'a mut self,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<u8>> + Send + 'a>> {
|
||||
@@ -817,6 +858,8 @@ impl RigCat for SoapySdrRig {
|
||||
sdr_agc_enabled: Some(self.agc_enabled),
|
||||
sdr_squelch_enabled: Some(self.squelch_enabled),
|
||||
sdr_squelch_threshold_db: Some(self.squelch_threshold_db as f64),
|
||||
sdr_nb_enabled: Some(self.nb_enabled),
|
||||
sdr_nb_threshold: Some(self.nb_threshold),
|
||||
wfm_deemphasis_us: self.wfm_deemphasis_us,
|
||||
wfm_stereo: self.wfm_stereo,
|
||||
wfm_stereo_detected,
|
||||
|
||||
@@ -418,7 +418,7 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dsp::{MockIqSource, SdrPipeline};
|
||||
use crate::dsp::{MockIqSource, NoiseBlankerConfig, SdrPipeline};
|
||||
|
||||
fn make_pipeline() -> Arc<SdrPipeline> {
|
||||
Arc::new(SdrPipeline::start(
|
||||
@@ -430,6 +430,7 @@ mod tests {
|
||||
75,
|
||||
true,
|
||||
VirtualSquelchConfig::default(),
|
||||
NoiseBlankerConfig::default(),
|
||||
&[(0.0, RigMode::USB, 3_000)],
|
||||
))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user