[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:
2026-03-22 13:54:17 +01:00
parent 01a6b331f6
commit 189d27bac8
15 changed files with 288 additions and 2 deletions
@@ -1069,6 +1069,30 @@ pub async fn set_sdr_squelch(
.await
}
#[derive(serde::Deserialize)]
pub struct SdrNoiseBlankerQuery {
pub enabled: bool,
pub threshold: f64,
pub rig_id: Option<String>,
}
#[post("/set_sdr_noise_blanker")]
pub async fn set_sdr_noise_blanker(
query: web::Query<SdrNoiseBlankerQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(
&rig_tx,
RigCommand::SetSdrNoiseBlanker {
enabled: q.enabled,
threshold: q.threshold,
},
q.rig_id,
)
.await
}
#[derive(serde::Deserialize)]
pub struct WfmDeemphasisQuery {
pub us: u32,
@@ -1834,6 +1858,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(set_sdr_lna_gain)
.service(set_sdr_agc)
.service(set_sdr_squelch)
.service(set_sdr_noise_blanker)
.service(set_wfm_deemphasis)
.service(set_wfm_stereo)
.service(set_wfm_denoise)
+1
View File
@@ -43,6 +43,7 @@ pub enum RigCommand {
SetSdrLnaGain(f64),
SetSdrAgc(bool),
SetSdrSquelch { enabled: bool, threshold_db: f64 },
SetSdrNoiseBlanker { enabled: bool, threshold: f64 },
SetWfmDeemphasis(u32),
SetWfmStereo(bool),
SetWfmDenoise(WfmDenoiseLevel),
@@ -525,6 +525,7 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
| RigCommand::SetSdrLnaGain(_)
| RigCommand::SetSdrAgc(_)
| RigCommand::SetSdrSquelch { .. }
| RigCommand::SetSdrNoiseBlanker { .. }
| RigCommand::SetWfmDeemphasis(_)
| RigCommand::SetWfmStereo(_)
| RigCommand::SetWfmDenoise(_)
+11
View File
@@ -211,6 +211,17 @@ pub trait RigCat: Rig + Send {
)))
}
fn set_sdr_noise_blanker<'a>(
&'a mut self,
_enabled: bool,
_threshold: f64,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sdr_noise_blanker"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_wfm_stereo<'a>(
&'a mut self,
_enabled: bool,
+4
View File
@@ -326,6 +326,10 @@ pub struct RigFilterState {
pub sdr_squelch_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_squelch_threshold_db: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_nb_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_nb_threshold: Option<f64>,
#[serde(default = "default_wfm_deemphasis_us")]
pub wfm_deemphasis_us: u32,
#[serde(default = "default_wfm_stereo")]
+4
View File
@@ -316,6 +316,8 @@ mod tests {
sdr_agc_enabled: None,
sdr_squelch_enabled: None,
sdr_squelch_threshold_db: None,
sdr_nb_enabled: None,
sdr_nb_threshold: None,
wfm_deemphasis_us: 75,
wfm_stereo: true,
wfm_stereo_detected: false,
@@ -358,6 +360,8 @@ mod tests {
sdr_agc_enabled: None,
sdr_squelch_enabled: None,
sdr_squelch_threshold_db: None,
sdr_nb_enabled: None,
sdr_nb_threshold: None,
wfm_deemphasis_us: 50,
wfm_stereo: true,
wfm_stereo_detected: true,
+6
View File
@@ -65,6 +65,9 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand {
enabled,
threshold_db,
},
ClientCommand::SetSdrNoiseBlanker { enabled, threshold } => {
RigCommand::SetSdrNoiseBlanker { enabled, threshold }
}
ClientCommand::SetWfmDeemphasis { deemphasis_us } => {
RigCommand::SetWfmDeemphasis(deemphasis_us)
}
@@ -128,6 +131,9 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand {
enabled,
threshold_db,
},
RigCommand::SetSdrNoiseBlanker { enabled, threshold } => {
ClientCommand::SetSdrNoiseBlanker { enabled, threshold }
}
RigCommand::SetWfmDeemphasis(deemphasis_us) => {
ClientCommand::SetWfmDeemphasis { deemphasis_us }
}
+1
View File
@@ -48,6 +48,7 @@ pub enum ClientCommand {
SetSdrLnaGain { gain_db: f64 },
SetSdrAgc { enabled: bool },
SetSdrSquelch { enabled: bool, threshold_db: f64 },
SetSdrNoiseBlanker { enabled: bool, threshold: f64 },
SetWfmDeemphasis { deemphasis_us: u32 },
SetWfmStereo { enabled: bool },
SetWfmDenoise { level: WfmDenoiseLevel },
+38
View File
@@ -298,6 +298,8 @@ pub struct SdrConfig {
pub gain: SdrGainConfig,
/// Virtual software squelch applied to demodulated audio except WFM.
pub squelch: SdrSquelchConfig,
/// Noise blanker for impulse noise suppression on IQ samples.
pub noise_blanker: SdrNoiseBlankerConfig,
/// Virtual receiver channels (at least one required when SDR backend is active).
pub channels: Vec<SdrChannelConfig>,
/// Maximum number of simultaneous virtual channels (including the primary).
@@ -319,6 +321,7 @@ impl Default for SdrConfig {
center_offset_hz: 100_000,
gain: SdrGainConfig::default(),
squelch: SdrSquelchConfig::default(),
noise_blanker: SdrNoiseBlankerConfig::default(),
channels: Vec::new(),
max_virtual_channels: default_max_virtual_channels(),
}
@@ -350,6 +353,26 @@ impl Default for SdrSquelchConfig {
}
}
/// Noise blanker settings for impulse noise suppression on IQ samples.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SdrNoiseBlankerConfig {
/// Enables the noise blanker.
pub enabled: bool,
/// Threshold multiplier for impulse detection (typical range: 1..100).
/// A sample whose magnitude exceeds threshold × running RMS is blanked.
pub threshold: f64,
}
impl Default for SdrNoiseBlankerConfig {
fn default() -> Self {
Self {
enabled: false,
threshold: 10.0,
}
}
}
/// Gain control mode for the SDR device.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
@@ -507,6 +530,7 @@ impl ServerConfig {
}
}
validate_sdr_squelch_config("[sdr.squelch]", &self.sdr.squelch)?;
validate_sdr_nb_config("[sdr.noise_blanker]", &self.sdr.noise_blanker)?;
// Multi-rig uniqueness checks.
if !self.rigs.is_empty() {
@@ -546,6 +570,10 @@ impl ServerConfig {
&format!("[[rigs]] [sdr.squelch] (rig id: \"{}\")", rig.id),
&rig.sdr.squelch,
)?;
validate_sdr_nb_config(
&format!("[[rigs]] [sdr.noise_blanker] (rig id: \"{}\")", rig.id),
&rig.sdr.noise_blanker,
)?;
}
if enabled_count == 0 {
return Err(
@@ -838,6 +866,16 @@ fn validate_sdr_squelch_config(path: &str, squelch: &SdrSquelchConfig) -> Result
Ok(())
}
fn validate_sdr_nb_config(path: &str, nb: &SdrNoiseBlankerConfig) -> Result<(), String> {
if !nb.threshold.is_finite() {
return Err(format!("{path}.threshold must be finite"));
}
if !(1.0..=100.0).contains(&nb.threshold) {
return Err(format!("{path}.threshold must be in range 1..=100"));
}
Ok(())
}
fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> {
if tokens.iter().any(|t| t.trim().is_empty()) {
return Err(format!("{path} must not contain empty tokens"));
+2
View File
@@ -346,6 +346,8 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
rig_cfg.sdr.squelch.hysteresis_db,
rig_cfg.sdr.squelch.tail_ms,
rig_cfg.sdr.max_virtual_channels,
rig_cfg.sdr.noise_blanker.enabled,
rig_cfg.sdr.noise_blanker.threshold,
)?;
let pcm_rx = sdr_rig.subscribe_pcm();
+10
View File
@@ -590,6 +590,16 @@ async fn process_command(
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetSdrNoiseBlanker { enabled, threshold } => {
if let Err(e) = ctx.rig.set_sdr_noise_blanker(enabled, threshold).await {
return Err(RigError::communication(format!(
"set_sdr_noise_blanker: {e}"
)));
}
ctx.state.filter = ctx.rig.filter_state();
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetWfmDeemphasis(deemphasis_us) => {
if let Err(e) = ctx.rig.set_wfm_deemphasis(deemphasis_us).await {
return Err(RigError::communication(format!("set_wfm_deemphasis: {e}")));
@@ -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)],
))
}