[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:
@@ -1069,6 +1069,30 @@ pub async fn set_sdr_squelch(
|
|||||||
.await
|
.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)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct WfmDeemphasisQuery {
|
pub struct WfmDeemphasisQuery {
|
||||||
pub us: u32,
|
pub us: u32,
|
||||||
@@ -1834,6 +1858,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(set_sdr_lna_gain)
|
.service(set_sdr_lna_gain)
|
||||||
.service(set_sdr_agc)
|
.service(set_sdr_agc)
|
||||||
.service(set_sdr_squelch)
|
.service(set_sdr_squelch)
|
||||||
|
.service(set_sdr_noise_blanker)
|
||||||
.service(set_wfm_deemphasis)
|
.service(set_wfm_deemphasis)
|
||||||
.service(set_wfm_stereo)
|
.service(set_wfm_stereo)
|
||||||
.service(set_wfm_denoise)
|
.service(set_wfm_denoise)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub enum RigCommand {
|
|||||||
SetSdrLnaGain(f64),
|
SetSdrLnaGain(f64),
|
||||||
SetSdrAgc(bool),
|
SetSdrAgc(bool),
|
||||||
SetSdrSquelch { enabled: bool, threshold_db: f64 },
|
SetSdrSquelch { enabled: bool, threshold_db: f64 },
|
||||||
|
SetSdrNoiseBlanker { enabled: bool, threshold: f64 },
|
||||||
SetWfmDeemphasis(u32),
|
SetWfmDeemphasis(u32),
|
||||||
SetWfmStereo(bool),
|
SetWfmStereo(bool),
|
||||||
SetWfmDenoise(WfmDenoiseLevel),
|
SetWfmDenoise(WfmDenoiseLevel),
|
||||||
|
|||||||
@@ -525,6 +525,7 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
|
|||||||
| RigCommand::SetSdrLnaGain(_)
|
| RigCommand::SetSdrLnaGain(_)
|
||||||
| RigCommand::SetSdrAgc(_)
|
| RigCommand::SetSdrAgc(_)
|
||||||
| RigCommand::SetSdrSquelch { .. }
|
| RigCommand::SetSdrSquelch { .. }
|
||||||
|
| RigCommand::SetSdrNoiseBlanker { .. }
|
||||||
| RigCommand::SetWfmDeemphasis(_)
|
| RigCommand::SetWfmDeemphasis(_)
|
||||||
| RigCommand::SetWfmStereo(_)
|
| RigCommand::SetWfmStereo(_)
|
||||||
| RigCommand::SetWfmDenoise(_)
|
| RigCommand::SetWfmDenoise(_)
|
||||||
|
|||||||
@@ -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>(
|
fn set_wfm_stereo<'a>(
|
||||||
&'a mut self,
|
&'a mut self,
|
||||||
_enabled: bool,
|
_enabled: bool,
|
||||||
|
|||||||
@@ -326,6 +326,10 @@ pub struct RigFilterState {
|
|||||||
pub sdr_squelch_enabled: Option<bool>,
|
pub sdr_squelch_enabled: Option<bool>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub sdr_squelch_threshold_db: Option<f64>,
|
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")]
|
#[serde(default = "default_wfm_deemphasis_us")]
|
||||||
pub wfm_deemphasis_us: u32,
|
pub wfm_deemphasis_us: u32,
|
||||||
#[serde(default = "default_wfm_stereo")]
|
#[serde(default = "default_wfm_stereo")]
|
||||||
|
|||||||
@@ -316,6 +316,8 @@ mod tests {
|
|||||||
sdr_agc_enabled: None,
|
sdr_agc_enabled: None,
|
||||||
sdr_squelch_enabled: None,
|
sdr_squelch_enabled: None,
|
||||||
sdr_squelch_threshold_db: None,
|
sdr_squelch_threshold_db: None,
|
||||||
|
sdr_nb_enabled: None,
|
||||||
|
sdr_nb_threshold: None,
|
||||||
wfm_deemphasis_us: 75,
|
wfm_deemphasis_us: 75,
|
||||||
wfm_stereo: true,
|
wfm_stereo: true,
|
||||||
wfm_stereo_detected: false,
|
wfm_stereo_detected: false,
|
||||||
@@ -358,6 +360,8 @@ mod tests {
|
|||||||
sdr_agc_enabled: None,
|
sdr_agc_enabled: None,
|
||||||
sdr_squelch_enabled: None,
|
sdr_squelch_enabled: None,
|
||||||
sdr_squelch_threshold_db: None,
|
sdr_squelch_threshold_db: None,
|
||||||
|
sdr_nb_enabled: None,
|
||||||
|
sdr_nb_threshold: None,
|
||||||
wfm_deemphasis_us: 50,
|
wfm_deemphasis_us: 50,
|
||||||
wfm_stereo: true,
|
wfm_stereo: true,
|
||||||
wfm_stereo_detected: true,
|
wfm_stereo_detected: true,
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand {
|
|||||||
enabled,
|
enabled,
|
||||||
threshold_db,
|
threshold_db,
|
||||||
},
|
},
|
||||||
|
ClientCommand::SetSdrNoiseBlanker { enabled, threshold } => {
|
||||||
|
RigCommand::SetSdrNoiseBlanker { enabled, threshold }
|
||||||
|
}
|
||||||
ClientCommand::SetWfmDeemphasis { deemphasis_us } => {
|
ClientCommand::SetWfmDeemphasis { deemphasis_us } => {
|
||||||
RigCommand::SetWfmDeemphasis(deemphasis_us)
|
RigCommand::SetWfmDeemphasis(deemphasis_us)
|
||||||
}
|
}
|
||||||
@@ -128,6 +131,9 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand {
|
|||||||
enabled,
|
enabled,
|
||||||
threshold_db,
|
threshold_db,
|
||||||
},
|
},
|
||||||
|
RigCommand::SetSdrNoiseBlanker { enabled, threshold } => {
|
||||||
|
ClientCommand::SetSdrNoiseBlanker { enabled, threshold }
|
||||||
|
}
|
||||||
RigCommand::SetWfmDeemphasis(deemphasis_us) => {
|
RigCommand::SetWfmDeemphasis(deemphasis_us) => {
|
||||||
ClientCommand::SetWfmDeemphasis { deemphasis_us }
|
ClientCommand::SetWfmDeemphasis { deemphasis_us }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ pub enum ClientCommand {
|
|||||||
SetSdrLnaGain { gain_db: f64 },
|
SetSdrLnaGain { gain_db: f64 },
|
||||||
SetSdrAgc { enabled: bool },
|
SetSdrAgc { enabled: bool },
|
||||||
SetSdrSquelch { enabled: bool, threshold_db: f64 },
|
SetSdrSquelch { enabled: bool, threshold_db: f64 },
|
||||||
|
SetSdrNoiseBlanker { enabled: bool, threshold: f64 },
|
||||||
SetWfmDeemphasis { deemphasis_us: u32 },
|
SetWfmDeemphasis { deemphasis_us: u32 },
|
||||||
SetWfmStereo { enabled: bool },
|
SetWfmStereo { enabled: bool },
|
||||||
SetWfmDenoise { level: WfmDenoiseLevel },
|
SetWfmDenoise { level: WfmDenoiseLevel },
|
||||||
|
|||||||
@@ -298,6 +298,8 @@ pub struct SdrConfig {
|
|||||||
pub gain: SdrGainConfig,
|
pub gain: SdrGainConfig,
|
||||||
/// Virtual software squelch applied to demodulated audio except WFM.
|
/// Virtual software squelch applied to demodulated audio except WFM.
|
||||||
pub squelch: SdrSquelchConfig,
|
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).
|
/// Virtual receiver channels (at least one required when SDR backend is active).
|
||||||
pub channels: Vec<SdrChannelConfig>,
|
pub channels: Vec<SdrChannelConfig>,
|
||||||
/// Maximum number of simultaneous virtual channels (including the primary).
|
/// Maximum number of simultaneous virtual channels (including the primary).
|
||||||
@@ -319,6 +321,7 @@ impl Default for SdrConfig {
|
|||||||
center_offset_hz: 100_000,
|
center_offset_hz: 100_000,
|
||||||
gain: SdrGainConfig::default(),
|
gain: SdrGainConfig::default(),
|
||||||
squelch: SdrSquelchConfig::default(),
|
squelch: SdrSquelchConfig::default(),
|
||||||
|
noise_blanker: SdrNoiseBlankerConfig::default(),
|
||||||
channels: Vec::new(),
|
channels: Vec::new(),
|
||||||
max_virtual_channels: default_max_virtual_channels(),
|
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.
|
/// Gain control mode for the SDR device.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -507,6 +530,7 @@ impl ServerConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
validate_sdr_squelch_config("[sdr.squelch]", &self.sdr.squelch)?;
|
validate_sdr_squelch_config("[sdr.squelch]", &self.sdr.squelch)?;
|
||||||
|
validate_sdr_nb_config("[sdr.noise_blanker]", &self.sdr.noise_blanker)?;
|
||||||
|
|
||||||
// Multi-rig uniqueness checks.
|
// Multi-rig uniqueness checks.
|
||||||
if !self.rigs.is_empty() {
|
if !self.rigs.is_empty() {
|
||||||
@@ -546,6 +570,10 @@ impl ServerConfig {
|
|||||||
&format!("[[rigs]] [sdr.squelch] (rig id: \"{}\")", rig.id),
|
&format!("[[rigs]] [sdr.squelch] (rig id: \"{}\")", rig.id),
|
||||||
&rig.sdr.squelch,
|
&rig.sdr.squelch,
|
||||||
)?;
|
)?;
|
||||||
|
validate_sdr_nb_config(
|
||||||
|
&format!("[[rigs]] [sdr.noise_blanker] (rig id: \"{}\")", rig.id),
|
||||||
|
&rig.sdr.noise_blanker,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
if enabled_count == 0 {
|
if enabled_count == 0 {
|
||||||
return Err(
|
return Err(
|
||||||
@@ -838,6 +866,16 @@ fn validate_sdr_squelch_config(path: &str, squelch: &SdrSquelchConfig) -> Result
|
|||||||
Ok(())
|
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> {
|
fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> {
|
||||||
if tokens.iter().any(|t| t.trim().is_empty()) {
|
if tokens.iter().any(|t| t.trim().is_empty()) {
|
||||||
return Err(format!("{path} must not contain empty tokens"));
|
return Err(format!("{path} must not contain empty tokens"));
|
||||||
|
|||||||
@@ -346,6 +346,8 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
|
|||||||
rig_cfg.sdr.squelch.hysteresis_db,
|
rig_cfg.sdr.squelch.hysteresis_db,
|
||||||
rig_cfg.sdr.squelch.tail_ms,
|
rig_cfg.sdr.squelch.tail_ms,
|
||||||
rig_cfg.sdr.max_virtual_channels,
|
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();
|
let pcm_rx = sdr_rig.subscribe_pcm();
|
||||||
|
|||||||
@@ -590,6 +590,16 @@ async fn process_command(
|
|||||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
return snapshot_from(ctx.state);
|
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) => {
|
RigCommand::SetWfmDeemphasis(deemphasis_us) => {
|
||||||
if let Err(e) = ctx.rig.set_wfm_deemphasis(deemphasis_us).await {
|
if let Err(e) = ctx.rig.set_wfm_deemphasis(deemphasis_us).await {
|
||||||
return Err(RigError::communication(format!("set_wfm_deemphasis: {e}")));
|
return Err(RigError::communication(format!("set_wfm_deemphasis: {e}")));
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use tokio::sync::broadcast;
|
|||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use trx_core::rig::state::RigMode;
|
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};
|
pub use self::filter::{BlockFirFilter, BlockFirFilterPair, FirFilter};
|
||||||
use self::spectrum::SpectrumSnapshotter;
|
use self::spectrum::SpectrumSnapshotter;
|
||||||
|
|
||||||
@@ -152,6 +152,7 @@ impl SdrPipeline {
|
|||||||
wfm_deemphasis_us: u32,
|
wfm_deemphasis_us: u32,
|
||||||
wfm_stereo: bool,
|
wfm_stereo: bool,
|
||||||
squelch_cfg: VirtualSquelchConfig,
|
squelch_cfg: VirtualSquelchConfig,
|
||||||
|
nb_cfg: NoiseBlankerConfig,
|
||||||
channels: &[(f64, RigMode, u32)],
|
channels: &[(f64, RigMode, u32)],
|
||||||
) -> Self {
|
) -> Self {
|
||||||
const IQ_BROADCAST_CAPACITY: usize = 64;
|
const IQ_BROADCAST_CAPACITY: usize = 64;
|
||||||
@@ -173,6 +174,11 @@ impl SdrPipeline {
|
|||||||
} else {
|
} else {
|
||||||
VirtualSquelchConfig::default()
|
VirtualSquelchConfig::default()
|
||||||
};
|
};
|
||||||
|
let channel_nb_cfg = if channel_idx == 0 {
|
||||||
|
nb_cfg
|
||||||
|
} else {
|
||||||
|
NoiseBlankerConfig::default()
|
||||||
|
};
|
||||||
let dsp = ChannelDsp::new(
|
let dsp = ChannelDsp::new(
|
||||||
channel_if_hz,
|
channel_if_hz,
|
||||||
mode,
|
mode,
|
||||||
@@ -185,6 +191,7 @@ impl SdrPipeline {
|
|||||||
wfm_stereo,
|
wfm_stereo,
|
||||||
false,
|
false,
|
||||||
channel_squelch_cfg,
|
channel_squelch_cfg,
|
||||||
|
channel_nb_cfg,
|
||||||
pcm_tx.clone(),
|
pcm_tx.clone(),
|
||||||
iq_tx.clone(),
|
iq_tx.clone(),
|
||||||
);
|
);
|
||||||
@@ -274,6 +281,7 @@ impl SdrPipeline {
|
|||||||
self.wfm_stereo,
|
self.wfm_stereo,
|
||||||
false,
|
false,
|
||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
|
NoiseBlankerConfig::default(),
|
||||||
pcm_tx.clone(),
|
pcm_tx.clone(),
|
||||||
iq_tx.clone(),
|
iq_tx.clone(),
|
||||||
);
|
);
|
||||||
@@ -562,6 +570,7 @@ mod tests {
|
|||||||
75,
|
75,
|
||||||
true,
|
true,
|
||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
|
NoiseBlankerConfig::default(),
|
||||||
&[(200_000.0, RigMode::USB, 3000)],
|
&[(200_000.0, RigMode::USB, 3000)],
|
||||||
);
|
);
|
||||||
assert_eq!(pipeline.pcm_senders.len(), 1);
|
assert_eq!(pipeline.pcm_senders.len(), 1);
|
||||||
@@ -579,6 +588,7 @@ mod tests {
|
|||||||
75,
|
75,
|
||||||
true,
|
true,
|
||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
|
NoiseBlankerConfig::default(),
|
||||||
&[],
|
&[],
|
||||||
);
|
);
|
||||||
assert_eq!(pipeline.pcm_senders.len(), 0);
|
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};
|
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)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct VirtualSquelchConfig {
|
pub struct VirtualSquelchConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -213,6 +295,7 @@ pub struct ChannelDsp {
|
|||||||
processing_enabled: bool,
|
processing_enabled: bool,
|
||||||
force_mono_pcm: bool,
|
force_mono_pcm: bool,
|
||||||
squelch: VirtualSquelch,
|
squelch: VirtualSquelch,
|
||||||
|
noise_blanker: NoiseBlanker,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelDsp {
|
impl ChannelDsp {
|
||||||
@@ -327,6 +410,7 @@ impl ChannelDsp {
|
|||||||
wfm_stereo: bool,
|
wfm_stereo: bool,
|
||||||
force_mono_pcm: bool,
|
force_mono_pcm: bool,
|
||||||
squelch_cfg: VirtualSquelchConfig,
|
squelch_cfg: VirtualSquelchConfig,
|
||||||
|
nb_cfg: NoiseBlankerConfig,
|
||||||
pcm_tx: broadcast::Sender<Vec<f32>>,
|
pcm_tx: broadcast::Sender<Vec<f32>>,
|
||||||
iq_tx: broadcast::Sender<Vec<Complex<f32>>>,
|
iq_tx: broadcast::Sender<Vec<Complex<f32>>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -415,6 +499,7 @@ impl ChannelDsp {
|
|||||||
processing_enabled: true,
|
processing_enabled: true,
|
||||||
force_mono_pcm,
|
force_mono_pcm,
|
||||||
squelch: VirtualSquelch::new(squelch_cfg),
|
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);
|
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) {
|
pub fn set_mode(&mut self, mode: &RigMode) {
|
||||||
self.mode = mode.clone();
|
self.mode = mode.clone();
|
||||||
if *mode != RigMode::WFM {
|
if *mode != RigMode::WFM {
|
||||||
@@ -499,6 +589,16 @@ impl ChannelDsp {
|
|||||||
return;
|
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_i.resize(n, 0.0);
|
||||||
self.scratch_mixed_q.resize(n, 0.0);
|
self.scratch_mixed_q.resize(n, 0.0);
|
||||||
let mixed_i = &mut self.scratch_mixed_i;
|
let mixed_i = &mut self.scratch_mixed_i;
|
||||||
@@ -684,6 +784,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
|
NoiseBlankerConfig::default(),
|
||||||
pcm_tx,
|
pcm_tx,
|
||||||
iq_tx,
|
iq_tx,
|
||||||
);
|
);
|
||||||
@@ -707,6 +808,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
|
NoiseBlankerConfig::default(),
|
||||||
pcm_tx,
|
pcm_tx,
|
||||||
iq_tx,
|
iq_tx,
|
||||||
);
|
);
|
||||||
@@ -714,4 +816,31 @@ mod tests {
|
|||||||
dsp.set_mode(&RigMode::FM);
|
dsp.set_mode(&RigMode::FM);
|
||||||
assert_eq!(dsp.demodulator, Demodulator::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,
|
squelch_enabled: bool,
|
||||||
/// Software squelch threshold (dBFS) on primary channel.
|
/// Software squelch threshold (dBFS) on primary channel.
|
||||||
squelch_threshold_db: f32,
|
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.
|
/// Hidden AIS decoder channels (A and B) when available.
|
||||||
ais_channel_indices: Option<(usize, usize)>,
|
ais_channel_indices: Option<(usize, usize)>,
|
||||||
/// Virtual channel manager shared with external consumers (e.g. RigHandle).
|
/// Virtual channel manager shared with external consumers (e.g. RigHandle).
|
||||||
@@ -126,6 +130,8 @@ impl SoapySdrRig {
|
|||||||
squelch_hysteresis_db: f32,
|
squelch_hysteresis_db: f32,
|
||||||
squelch_tail_ms: u32,
|
squelch_tail_ms: u32,
|
||||||
max_virtual_channels: usize,
|
max_virtual_channels: usize,
|
||||||
|
nb_enabled: bool,
|
||||||
|
nb_threshold: f64,
|
||||||
) -> DynResult<Self> {
|
) -> DynResult<Self> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
|
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
|
||||||
@@ -221,6 +227,10 @@ impl SoapySdrRig {
|
|||||||
hysteresis_db: squelch_hysteresis_db,
|
hysteresis_db: squelch_hysteresis_db,
|
||||||
tail_blocks: squelch_tail_blocks,
|
tail_blocks: squelch_tail_blocks,
|
||||||
},
|
},
|
||||||
|
dsp::NoiseBlankerConfig {
|
||||||
|
enabled: nb_enabled,
|
||||||
|
threshold: nb_threshold as f32,
|
||||||
|
},
|
||||||
&all_channels,
|
&all_channels,
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -307,6 +317,8 @@ impl SoapySdrRig {
|
|||||||
agc_enabled,
|
agc_enabled,
|
||||||
squelch_enabled,
|
squelch_enabled,
|
||||||
squelch_threshold_db,
|
squelch_threshold_db,
|
||||||
|
nb_enabled,
|
||||||
|
nb_threshold,
|
||||||
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
|
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
|
||||||
channel_manager,
|
channel_manager,
|
||||||
};
|
};
|
||||||
@@ -338,6 +350,8 @@ impl SoapySdrRig {
|
|||||||
3.0, // squelch_hysteresis_db
|
3.0, // squelch_hysteresis_db
|
||||||
180, // squelch_tail_ms
|
180, // squelch_tail_ms
|
||||||
4, // max_virtual_channels
|
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>(
|
fn get_signal_strength<'a>(
|
||||||
&'a mut self,
|
&'a mut self,
|
||||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<u8>> + Send + 'a>> {
|
) -> 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_agc_enabled: Some(self.agc_enabled),
|
||||||
sdr_squelch_enabled: Some(self.squelch_enabled),
|
sdr_squelch_enabled: Some(self.squelch_enabled),
|
||||||
sdr_squelch_threshold_db: Some(self.squelch_threshold_db as f64),
|
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_deemphasis_us: self.wfm_deemphasis_us,
|
||||||
wfm_stereo: self.wfm_stereo,
|
wfm_stereo: self.wfm_stereo,
|
||||||
wfm_stereo_detected,
|
wfm_stereo_detected,
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::dsp::{MockIqSource, SdrPipeline};
|
use crate::dsp::{MockIqSource, NoiseBlankerConfig, SdrPipeline};
|
||||||
|
|
||||||
fn make_pipeline() -> Arc<SdrPipeline> {
|
fn make_pipeline() -> Arc<SdrPipeline> {
|
||||||
Arc::new(SdrPipeline::start(
|
Arc::new(SdrPipeline::start(
|
||||||
@@ -430,6 +430,7 @@ mod tests {
|
|||||||
75,
|
75,
|
||||||
true,
|
true,
|
||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
|
NoiseBlankerConfig::default(),
|
||||||
&[(0.0, RigMode::USB, 3_000)],
|
&[(0.0, RigMode::USB, 3_000)],
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user