diff --git a/docs/User-Manual.md b/docs/User-Manual.md
index 3350bbc2..778047a7 100644
--- a/docs/User-Manual.md
+++ b/docs/User-Manual.md
@@ -752,9 +752,30 @@ A dedicated tab with a clock icon provides:
## SDR Noise Blanker
The noise blanker suppresses impulse noise (clicks, pops, ignition interference)
-on raw IQ samples before any mixing or filtering takes place. It works by
-tracking a running RMS level of the signal and replacing any sample whose
-magnitude exceeds **threshold x RMS** with the last known clean sample.
+on raw IQ samples before any mixing or filtering takes place — the only point in
+the chain where an impulse is still short in time, since the narrow channel
+filter downstream smears it into un-removable ringing.
+
+It combines four elements:
+
+- A **noise-floor tracker** — an exponential estimate of the background level,
+ updated only from clean samples (and frozen during a blank) so a burst cannot
+ drag the reference up and blind the detector.
+- **Detection** — a sample is flagged when its power exceeds
+ **threshold² × noise-floor**.
+- **Look-ahead** — the stream is delayed a few microseconds so the gate can
+ begin closing *before* the impulse reaches the output, catching its leading
+ edge instead of letting it leak through.
+- A **tapered gate** — instead of a hard sample-and-hold (which splatters energy
+ back across the passband and is what made the old blanker sound worse on
+ SSB/CW/data), the gain ramps smoothly down and back up, so blanking costs only
+ a short, quiet notch.
+
+The blank window, look-ahead, taper, and floor time constant are chosen by a
+**profile** matched to the interference source. The `threshold` control is
+orthogonal — it sets detection sensitivity within the chosen profile. All
+profile timings are specified in real time and converted to samples at the
+capture rate, so the blanker behaves consistently across SDR sample rates.
### Configuration (server-side)
@@ -771,6 +792,7 @@ type = "sdr"
[rigs.sdr.noise_blanker]
enabled = true
threshold = 10.0 # 1 – 100; lower = more aggressive blanking
+profile = "spike" # spike | ignition | powerline | broadband
```
For the legacy single-rig (flat) config the path is `[sdr.noise_blanker]`:
@@ -779,15 +801,30 @@ For the legacy single-rig (flat) config the path is `[sdr.noise_blanker]`:
[sdr.noise_blanker]
enabled = true
threshold = 10.0
+profile = "spike"
```
-| Field | Type | Default | Range | Description |
-|-------------|-------|---------|---------|-------------|
-| `enabled` | bool | false | — | Turn the noise blanker on or off. |
-| `threshold` | float | 10.0 | 1 – 100 | Multiplier applied to the running RMS. A sample whose magnitude exceeds this multiple is replaced. Lower values blank more aggressively; higher values only catch strong impulses. |
+| Field | Type | Default | Range | Description |
+|-------------|--------|-----------|---------|-------------|
+| `enabled` | bool | false | — | Turn the noise blanker on or off. |
+| `threshold` | float | 10.0 | 1 – 100 | Multiplier applied to the tracked noise floor. A sample whose magnitude exceeds this multiple is blanked. Lower values blank more aggressively; higher values only catch strong impulses. |
+| `profile` | string | `"spike"` | see below | Tuning profile matched to the interference source. |
The noise blanker is off by default.
+### Profiles
+
+Each profile sets the blank-window width, look-ahead, gate taper, and
+noise-floor time constant. Pick the one that matches what you are hearing, then
+fine-tune with the threshold.
+
+| Profile | Blank window | Best for |
+|--------------|--------------|----------|
+| `spike` | Narrowest | Sharp, sparse impulses — ignition sparks, static crashes, keyed relays. The safe default: minimal impact on the wanted signal, good for SSB/CW/digital. |
+| `ignition` | Medium | Automotive ignition, electric fences, PWM/LED drivers — clusters of medium-width pulses at a high repetition rate. |
+| `powerline` | Wide | Power-line and arcing noise — buzzy bursts locked to the 100/120 Hz mains cycle. Uses a slower floor tracker to ride out the burst. |
+| `broadband` | Widest | Dense, continuous impulse noise where suppression matters more than fidelity. Most aggressive gating; expect some softening of the wanted signal. |
+
### Choosing a threshold
The threshold controls how aggressively the blanker suppresses impulses.
@@ -813,39 +850,44 @@ the running average signal level.
### Web UI
-When the server reports noise-blanker support, two controls appear in the
+When the server reports noise-blanker support, these controls appear in the
**SDR Settings** row of the web interface:
- **Noise Blanker** checkbox — enables or disables the blanker in real time.
+ The **N** keyboard shortcut toggles it too.
- **NB Threshold** number input (1–100) with a **Set** button — adjusts the
- detection threshold. Press Enter or click Set to apply.
+ detection sensitivity. Press Enter or click Set to apply.
+- **NB profile** selector — chooses the profile (Spike / Ignition / Powerline /
+ Broadband). Changing it applies immediately.
-Both controls stay hidden until the server sends filter state containing NB
+The controls stay hidden until the server sends filter state containing NB
fields, so they only appear when connected to an SDR backend.
### HTTP API
```
-POST /set_sdr_noise_blanker?enabled=true&threshold=10
+POST /set_sdr_noise_blanker?enabled=true&threshold=10&profile=spike
```
| Parameter | Type | Required | Description |
|-------------|--------|----------|-------------|
| `enabled` | bool | yes | `true` or `false` |
| `threshold` | float | yes | Value between 1 and 100 |
+| `profile` | string | no | `spike` (default), `ignition`, `powerline`, or `broadband` |
### How it works
-The blanker runs on every IQ block (4096 samples) *before* the mixer stage in
-the DSP pipeline:
+The blanker runs on every IQ block *before* the mixer stage in the DSP pipeline,
+one sample at a time:
-1. For each sample, compute magnitude² (`re² + im²`).
-2. Compare against `threshold² × mean_sq` (the exponentially-smoothed running
- mean of magnitude²).
-3. If the sample exceeds the threshold, replace it with the previous clean
- sample.
-4. Otherwise, update the running mean with smoothing factor α = 1/128 and store
- the sample as the last clean value.
+1. Emit the sample from the look-ahead delay line and ingest the fresh one.
+2. Compute the fresh sample's power (`re² + im²`) and compare it against
+ `threshold² × noise_floor`.
+3. If it exceeds the threshold, hold the gate closed for the profile's blank
+ window; the fresh sample reaches the output a few samples later, by which
+ time the gate has fully ramped to zero — so the leading edge is removed.
+4. Otherwise, update the noise-floor estimate (skipped while blanking) and let
+ the gate ramp back open.
Because the blanker operates on raw IQ before frequency translation, it removes
impulse noise across the entire captured bandwidth regardless of the tuned
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js
index ce058897..2ee50206 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js
@@ -4766,6 +4766,13 @@ function render(update) {
sdrNbThresholdEl.value = String(Math.round(update.filter.sdr_nb_threshold));
}
}
+ if (typeof update.filter.sdr_nb_profile === "string") {
+ sdrNbProfile = update.filter.sdr_nb_profile;
+ if (sdrNbProfileWrapEl) sdrNbProfileWrapEl.style.display = "";
+ if (sdrNbProfileEl && document.activeElement !== sdrNbProfileEl) {
+ sdrNbProfileEl.value = update.filter.sdr_nb_profile;
+ }
+ }
}
if (typeof update.filter.sdr_dig_sideband === "string") {
sdrDigSidebandSupported = true;
@@ -6644,7 +6651,10 @@ var sdrNbEnabledEl = document.getElementById("sdr-nb-enabled");
var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
var sdrNbThresholdEl = document.getElementById("sdr-nb-threshold");
var sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set");
+var sdrNbProfileWrapEl = document.getElementById("sdr-nb-profile-wrap");
+var sdrNbProfileEl = document.getElementById("sdr-nb-profile");
var sdrNbSupported = false;
+var sdrNbProfile = "spike";
var sdrDigSidebandWrapEl = document.getElementById("sdr-dig-sideband-wrap");
var sdrDigSidebandEl = document.getElementById("sdr-dig-sideband");
var sdrDigSidebandSupported = false;
@@ -7019,7 +7029,7 @@ function submitSdrNbState() {
const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return;
postPath(
- `/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`
+ `/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}&profile=${encodeURIComponent(sdrNbProfile)}`
).catch(() => {
});
}
@@ -7028,6 +7038,16 @@ if (sdrNbEnabledEl) {
submitSdrNbState();
});
}
+if (sdrNbProfileEl) {
+ sdrNbProfileEl.addEventListener("change", () => {
+ const profile = sdrNbProfileEl.value || "spike";
+ if (profile !== "spike" && profile !== "ignition" && profile !== "powerline" && profile !== "broadband") {
+ return;
+ }
+ sdrNbProfile = profile;
+ submitSdrNbState();
+ });
+}
function submitSdrNbThreshold() {
if (!sdrNbThresholdEl) return;
const parsed = Number.parseFloat(sdrNbThresholdEl.value);
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
index 7caecf67..578fff9e 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
@@ -385,6 +385,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
Set
+
+ NB profile
+
+ Spike
+ Ignition
+ Powerline
+ Broadband
+
+
DIG sideband
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs b/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs
index 963992c2..f395ea77 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs
@@ -13,8 +13,8 @@ use trx_core::rig::{
RigVfoEntry,
};
use trx_core::{
- DecoderConfig, DigSidebandPolicy, RdsData, RigFilterState, RigMode, RigSnapshot,
- WfmDenoiseLevel,
+ DecoderConfig, DigSidebandPolicy, NoiseBlankerProfile, RdsData, RigFilterState, RigMode,
+ RigSnapshot, WfmDenoiseLevel,
};
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
use trx_frontend_http::server::api::FrontendMeta;
@@ -53,6 +53,7 @@ fn main() -> Result<(), Box> {
export!(DecoderConfig);
export!(WfmDenoiseLevel);
export!(DigSidebandPolicy);
+ export!(NoiseBlankerProfile);
export!(RigFilterState);
export!(RdsData);
export!(SpectrumData);
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts
index 243a8bd5..ed80b4c1 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts
@@ -57,7 +57,14 @@ export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
export type DigSidebandPolicy = "auto" | "usb" | "lsb";
+export type NoiseBlankerProfile = "spike" | "ignition" | "powerline" | "broadband";
+
export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null,
+/**
+ * Current noise-blanker tuning profile (SDR backends only). Surfaces in the
+ * UI as the advanced-controls "NB profile" selector.
+ */
+sdr_nb_profile?: NoiseBlankerProfile | null,
/**
* Current DIG sideband policy (SDR backends only). Surfaces in the UI as
* the advanced-controls "DIG sideband" selector.
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts
index 800a4cee..1d63d3e9 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts
@@ -3696,6 +3696,13 @@ function render(update: AppUpdate) {
sdrNbThresholdEl.value = String(Math.round(update.filter.sdr_nb_threshold));
}
}
+ if (typeof update.filter.sdr_nb_profile === "string") {
+ sdrNbProfile = update.filter.sdr_nb_profile;
+ if (sdrNbProfileWrapEl) sdrNbProfileWrapEl.style.display = "";
+ if (sdrNbProfileEl && document.activeElement !== sdrNbProfileEl) {
+ sdrNbProfileEl.value = update.filter.sdr_nb_profile;
+ }
+ }
}
if (typeof update.filter.sdr_dig_sideband === "string") {
sdrDigSidebandSupported = true;
@@ -5639,7 +5646,12 @@ const sdrNbEnabledEl = document.getElementById("sdr-nb-enabled") as HTMLInputEle
const sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
const sdrNbThresholdEl = document.getElementById("sdr-nb-threshold") as HTMLInputElement | null;
const sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set") as HTMLButtonElement | null;
+const sdrNbProfileWrapEl = document.getElementById("sdr-nb-profile-wrap");
+const sdrNbProfileEl = document.getElementById("sdr-nb-profile") as HTMLSelectElement | null;
let sdrNbSupported = false;
+// Current NB profile, mirrored from server filter state; sent alongside every
+// enable/threshold change (including the quick toggle) so it is preserved.
+let sdrNbProfile = "spike";
const sdrDigSidebandWrapEl = document.getElementById("sdr-dig-sideband-wrap");
const sdrDigSidebandEl = document.getElementById("sdr-dig-sideband") as HTMLSelectElement | null;
let sdrDigSidebandSupported = false;
@@ -6074,7 +6086,7 @@ function submitSdrNbState() {
const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return;
postPath(
- `/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`,
+ `/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}&profile=${encodeURIComponent(sdrNbProfile)}`,
).catch(() => {});
}
if (sdrNbEnabledEl) {
@@ -6082,6 +6094,21 @@ if (sdrNbEnabledEl) {
submitSdrNbState();
});
}
+if (sdrNbProfileEl) {
+ sdrNbProfileEl.addEventListener("change", () => {
+ const profile = sdrNbProfileEl.value || "spike";
+ if (
+ profile !== "spike" &&
+ profile !== "ignition" &&
+ profile !== "powerline" &&
+ profile !== "broadband"
+ ) {
+ return;
+ }
+ sdrNbProfile = profile;
+ submitSdrNbState();
+ });
+}
function submitSdrNbThreshold() {
if (!sdrNbThresholdEl) return;
const parsed = Number.parseFloat(sdrNbThresholdEl.value);
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs
index 3c6e6504..0967317d 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/rig.rs
@@ -14,7 +14,7 @@ use uuid::Uuid;
use trx_core::radio::freq::Freq;
use trx_core::rig::state::WfmDenoiseLevel;
-use trx_core::{DigSidebandPolicy, RigCommand, RigRequest, RigState};
+use trx_core::{DigSidebandPolicy, NoiseBlankerProfile, RigCommand, RigRequest, RigState};
use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry};
use trx_protocol::parse_mode;
@@ -279,6 +279,10 @@ pub async fn set_sdr_squelch(
pub struct SdrNoiseBlankerQuery {
pub enabled: bool,
pub threshold: f64,
+ /// `spike` (default), `ignition`, `powerline`, or `broadband`. Optional so
+ /// the quick toggle (which only carries enable/threshold) keeps working.
+ #[serde(default)]
+ pub profile: NoiseBlankerProfile,
pub remote: Option,
}
@@ -293,6 +297,7 @@ pub async fn set_sdr_noise_blanker(
RigCommand::SetSdrNoiseBlanker {
enabled: q.enabled,
threshold: q.threshold,
+ profile: q.profile,
},
q.remote,
)
diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs
index 1b0a805f..9a8f7623 100644
--- a/src/trx-config/src/server.rs
+++ b/src/trx-config/src/server.rs
@@ -19,7 +19,7 @@ use crate::shared::{check_socket_conflicts, validate_log_level, validate_tokens,
use serde::{Deserialize, Serialize};
pub use trx_decode_log::DecodeLogsConfig;
-use trx_core::rig::state::{DigSidebandPolicy, RigMode};
+use trx_core::rig::state::{DigSidebandPolicy, NoiseBlankerProfile, RigMode};
/// Every decoder the server knows how to run, by config name.
///
@@ -478,8 +478,14 @@ 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.
+ /// A sample whose magnitude exceeds threshold × the tracked noise floor is
+ /// blanked. Lower values blank more aggressively.
pub threshold: f64,
+ /// Tuning profile matched to the interference source: `spike` (default),
+ /// `ignition`, `powerline`, or `broadband`. Selects the blank window width,
+ /// look-ahead, gate taper, and noise-floor time constant.
+ #[serde(default)]
+ pub profile: NoiseBlankerProfile,
}
impl Default for SdrNoiseBlankerConfig {
@@ -487,6 +493,7 @@ impl Default for SdrNoiseBlankerConfig {
Self {
enabled: false,
threshold: 10.0,
+ profile: NoiseBlankerProfile::Spike,
}
}
}
diff --git a/src/trx-core/src/lib.rs b/src/trx-core/src/lib.rs
index 493aef1d..000ea492 100644
--- a/src/trx-core/src/lib.rs
+++ b/src/trx-core/src/lib.rs
@@ -16,7 +16,7 @@ pub use rig::command::RigCommand;
pub use rig::request::RigRequest;
pub use rig::response::{RigError, RigResult};
pub use rig::state::{
- DecoderConfig, DecoderResetSeqs, DigSidebandPolicy, RdsData, RigFilterState, RigMode,
- RigSnapshot, RigState, WfmDenoiseLevel,
+ DecoderConfig, DecoderResetSeqs, DigSidebandPolicy, NoiseBlankerProfile, RdsData,
+ RigFilterState, RigMode, RigSnapshot, RigState, WfmDenoiseLevel,
};
pub use rig::AudioSource;
diff --git a/src/trx-core/src/rig/command.rs b/src/trx-core/src/rig/command.rs
index 84c299d8..fa5d47a1 100644
--- a/src/trx-core/src/rig/command.rs
+++ b/src/trx-core/src/rig/command.rs
@@ -3,7 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
use crate::radio::freq::Freq;
-use crate::rig::state::{DigSidebandPolicy, WfmDenoiseLevel};
+use crate::rig::state::{DigSidebandPolicy, NoiseBlankerProfile, WfmDenoiseLevel};
use crate::RigMode;
/// Internal command handled by the rig task.
@@ -55,6 +55,7 @@ pub enum RigCommand {
SetSdrNoiseBlanker {
enabled: bool,
threshold: f64,
+ profile: NoiseBlankerProfile,
},
/// Set how the SDR backend resolves DIG mode to a sideband (SDR only).
SetSdrDigSideband(DigSidebandPolicy),
diff --git a/src/trx-core/src/rig/mod.rs b/src/trx-core/src/rig/mod.rs
index 51582fa6..e8c7bb47 100644
--- a/src/trx-core/src/rig/mod.rs
+++ b/src/trx-core/src/rig/mod.rs
@@ -253,6 +253,7 @@ pub trait RigSdr: Send {
&'a mut self,
_enabled: bool,
_threshold: f64,
+ _profile: crate::rig::state::NoiseBlankerProfile,
) -> Pin> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sdr_noise_blanker"))
diff --git a/src/trx-core/src/rig/state.rs b/src/trx-core/src/rig/state.rs
index 9a4af172..79189e70 100644
--- a/src/trx-core/src/rig/state.rs
+++ b/src/trx-core/src/rig/state.rs
@@ -338,6 +338,10 @@ pub struct RigFilterState {
pub sdr_nb_enabled: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_nb_threshold: Option,
+ /// Current noise-blanker tuning profile (SDR backends only). Surfaces in the
+ /// UI as the advanced-controls "NB profile" selector.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub sdr_nb_profile: Option,
/// Current DIG sideband policy (SDR backends only). Surfaces in the UI as
/// the advanced-controls "DIG sideband" selector.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -457,6 +461,72 @@ pub fn effective_demod_mode(logical: &RigMode, policy: DigSidebandPolicy, freq_h
}
}
+/// Tuning profile for the SDR impulse noise blanker.
+///
+/// The blanker removes short, wideband impulse noise from the IQ stream before
+/// down-conversion. Different interference sources have different pulse widths
+/// and repetition rates, so a single blank window cannot serve all of them: too
+/// narrow and it clips only the tip of a wide power-line burst, too wide and it
+/// punches audible holes in the wanted signal on sparse ignition spikes. Each
+/// profile selects a matched blank window, look-ahead, gate taper, and
+/// noise-floor time constant (the concrete values live in the SDR DSP, since
+/// they are converted to samples at the capture rate). The user-facing
+/// `threshold` control is orthogonal — it sets detection sensitivity within the
+/// chosen profile.
+#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, TS)]
+#[serde(rename_all = "lowercase")]
+pub enum NoiseBlankerProfile {
+ /// Short, sharp, sparse impulses — ignition sparks, static crashes, keyed
+ /// relays. Narrow blank window and fast recovery for minimal impact on the
+ /// wanted signal; the safe default for SSB/CW/digital.
+ #[default]
+ Spike,
+ /// Automotive ignition, electric fences, PWM/LED drivers — clusters of
+ /// medium-width pulses at a high repetition rate. Wider window than `Spike`.
+ Ignition,
+ /// Power-line and arcing noise — buzzy bursts locked to the 100/120 Hz mains
+ /// cycle. Wide blank window with a longer, slower noise-floor tracker.
+ Powerline,
+ /// Dense, continuous impulse noise where suppression matters more than
+ /// fidelity. Widest window and most aggressive gating; expect some softening
+ /// of the wanted signal.
+ Broadband,
+}
+
+impl NoiseBlankerProfile {
+ /// Compact encoding for storage in an atomic or terse wire field.
+ pub fn to_u8(self) -> u8 {
+ match self {
+ NoiseBlankerProfile::Spike => 0,
+ NoiseBlankerProfile::Ignition => 1,
+ NoiseBlankerProfile::Powerline => 2,
+ NoiseBlankerProfile::Broadband => 3,
+ }
+ }
+
+ /// Inverse of [`NoiseBlankerProfile::to_u8`]; unknown values decode to the
+ /// default `Spike`.
+ pub fn from_u8(v: u8) -> Self {
+ match v {
+ 1 => NoiseBlankerProfile::Ignition,
+ 2 => NoiseBlankerProfile::Powerline,
+ 3 => NoiseBlankerProfile::Broadband,
+ _ => NoiseBlankerProfile::Spike,
+ }
+ }
+
+ /// Parse a case-insensitive profile name; `None` if unknown.
+ pub fn parse(s: &str) -> Option {
+ match s.trim().to_ascii_lowercase().as_str() {
+ "spike" => Some(NoiseBlankerProfile::Spike),
+ "ignition" => Some(NoiseBlankerProfile::Ignition),
+ "powerline" => Some(NoiseBlankerProfile::Powerline),
+ "broadband" => Some(NoiseBlankerProfile::Broadband),
+ _ => None,
+ }
+ }
+}
+
fn default_wfm_deemphasis_us() -> u32 {
75
}
@@ -657,3 +727,47 @@ mod dig_sideband_tests {
assert_eq!(DigSidebandPolicy::parse("nonsense"), None);
}
}
+
+#[cfg(test)]
+mod noise_blanker_profile_tests {
+ use super::NoiseBlankerProfile;
+
+ #[test]
+ fn default_is_spike() {
+ assert_eq!(NoiseBlankerProfile::default(), NoiseBlankerProfile::Spike);
+ }
+
+ #[test]
+ fn u8_round_trips() {
+ for p in [
+ NoiseBlankerProfile::Spike,
+ NoiseBlankerProfile::Ignition,
+ NoiseBlankerProfile::Powerline,
+ NoiseBlankerProfile::Broadband,
+ ] {
+ assert_eq!(NoiseBlankerProfile::from_u8(p.to_u8()), p);
+ }
+ // Unknown encodings fall back to the default.
+ assert_eq!(
+ NoiseBlankerProfile::from_u8(200),
+ NoiseBlankerProfile::Spike
+ );
+ }
+
+ #[test]
+ fn parse_is_case_insensitive() {
+ assert_eq!(
+ NoiseBlankerProfile::parse("SPIKE"),
+ Some(NoiseBlankerProfile::Spike)
+ );
+ assert_eq!(
+ NoiseBlankerProfile::parse(" powerline "),
+ Some(NoiseBlankerProfile::Powerline)
+ );
+ assert_eq!(
+ NoiseBlankerProfile::parse("Broadband"),
+ Some(NoiseBlankerProfile::Broadband)
+ );
+ assert_eq!(NoiseBlankerProfile::parse("nonsense"), None);
+ }
+}
diff --git a/src/trx-protocol/src/codec.rs b/src/trx-protocol/src/codec.rs
index d029b7ba..9b0730d2 100644
--- a/src/trx-protocol/src/codec.rs
+++ b/src/trx-protocol/src/codec.rs
@@ -343,6 +343,7 @@ mod tests {
sdr_squelch_threshold_db: None,
sdr_nb_enabled: None,
sdr_nb_threshold: None,
+ sdr_nb_profile: None,
sdr_dig_sideband: None,
wfm_deemphasis_us: 75,
wfm_stereo: true,
@@ -392,6 +393,7 @@ mod tests {
sdr_squelch_threshold_db: None,
sdr_nb_enabled: None,
sdr_nb_threshold: None,
+ sdr_nb_profile: None,
sdr_dig_sideband: None,
wfm_deemphasis_us: 50,
wfm_stereo: true,
diff --git a/src/trx-protocol/src/mapping.rs b/src/trx-protocol/src/mapping.rs
index 02999489..b3aa16b4 100644
--- a/src/trx-protocol/src/mapping.rs
+++ b/src/trx-protocol/src/mapping.rs
@@ -157,7 +157,7 @@ define_command_mapping! {
// ── Multi-field struct passthrough ───────────────────────────────
multi:
SetSdrSquelch { enabled, threshold_db } <=> SetSdrSquelch,
- SetSdrNoiseBlanker { enabled, threshold } <=> SetSdrNoiseBlanker;
+ SetSdrNoiseBlanker { enabled, threshold, profile } <=> SetSdrNoiseBlanker;
// ── Freq conversions (u64 <=> Freq) ──────────────────────────────
freq:
diff --git a/src/trx-protocol/src/types.rs b/src/trx-protocol/src/types.rs
index 5465024a..42c31229 100644
--- a/src/trx-protocol/src/types.rs
+++ b/src/trx-protocol/src/types.rs
@@ -7,7 +7,7 @@
use serde::{Deserialize, Serialize};
use trx_core::rig::state::RigSnapshot;
-use trx_core::{DigSidebandPolicy, WfmDenoiseLevel};
+use trx_core::{DigSidebandPolicy, NoiseBlankerProfile, WfmDenoiseLevel};
/// Command received from network clients (JSON).
#[derive(Debug, Serialize, Deserialize)]
@@ -105,6 +105,7 @@ pub enum ClientCommand {
SetSdrNoiseBlanker {
enabled: bool,
threshold: f64,
+ profile: NoiseBlankerProfile,
},
SetSdrDigSideband {
policy: DigSidebandPolicy,
diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs
index 77cc425e..f80df92d 100644
--- a/src/trx-server/src/main.rs
+++ b/src/trx-server/src/main.rs
@@ -360,6 +360,7 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
+ nb_profile: rig_cfg.sdr.noise_blanker.profile,
dig_sideband: rig_cfg.sdr.dig_sideband,
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size,
})?;
diff --git a/src/trx-server/src/rig_task.rs b/src/trx-server/src/rig_task.rs
index c220d587..eabc9c33 100644
--- a/src/trx-server/src/rig_task.rs
+++ b/src/trx-server/src/rig_task.rs
@@ -760,9 +760,13 @@ async fn process_command(
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
- RigCommand::SetSdrNoiseBlanker { enabled, threshold } => {
+ RigCommand::SetSdrNoiseBlanker {
+ enabled,
+ threshold,
+ profile,
+ } => {
if let Some(sdr) = ctx.rig.as_sdr() {
- if let Err(e) = sdr.set_sdr_noise_blanker(enabled, threshold).await {
+ if let Err(e) = sdr.set_sdr_noise_blanker(enabled, threshold, profile).await {
return Err(RigError::communication(format!(
"set_sdr_noise_blanker: {e}"
)));
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 af41daa2..28c2772a 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
@@ -4,7 +4,7 @@
use num_complex::Complex;
use tokio::sync::broadcast;
-use trx_core::rig::state::{RdsData, RigMode, WfmDenoiseLevel};
+use trx_core::rig::state::{NoiseBlankerProfile, RdsData, RigMode, WfmDenoiseLevel};
use crate::demod::{DcBlocker, Demodulator, SamDemod, SoftAgc, WfmStereoDecoder};
@@ -14,38 +14,174 @@ use super::{BlockFirFilterPair, IQ_BLOCK_SIZE};
// Noise blanker
// ---------------------------------------------------------------------------
-/// IQ-domain impulse noise blanker.
+/// Temporal shape of a [`NoiseBlankerProfile`], in real time so the numbers
+/// scale correctly across capture rates (they are converted to samples at
+/// runtime). See [`NoiseBlanker`] for how each field is used.
+struct NbShape {
+ /// Delay applied to the stream so the gate can begin closing *before* a
+ /// detected impulse reaches the output, catching its leading edge.
+ lookahead_us: f32,
+ /// Minimum time the gate is held fully closed after each detected sample.
+ blank_us: f32,
+ /// Raised-cosine gate ramp length (attack == release).
+ ramp_us: f32,
+ /// Time constant of the exponential noise-floor tracker.
+ ref_tc_ms: f32,
+}
+
+impl NbShape {
+ const fn for_profile(p: NoiseBlankerProfile) -> Self {
+ match p {
+ // Short, sparse spikes: tight window, quick recovery, fast floor.
+ NoiseBlankerProfile::Spike => NbShape {
+ lookahead_us: 8.0,
+ blank_us: 24.0,
+ ramp_us: 5.0,
+ ref_tc_ms: 4.0,
+ },
+ // Ignition/PWM bursts: medium window at a high repetition rate.
+ NoiseBlankerProfile::Ignition => NbShape {
+ lookahead_us: 12.0,
+ blank_us: 80.0,
+ ramp_us: 8.0,
+ ref_tc_ms: 4.0,
+ },
+ // Power-line buzz: wide window, slow floor to ride out the burst.
+ NoiseBlankerProfile::Powerline => NbShape {
+ lookahead_us: 20.0,
+ blank_us: 200.0,
+ ramp_us: 12.0,
+ ref_tc_ms: 15.0,
+ },
+ // Dense impulse noise: widest, most aggressive gating.
+ NoiseBlankerProfile::Broadband => NbShape {
+ lookahead_us: 24.0,
+ blank_us: 400.0,
+ ramp_us: 16.0,
+ ref_tc_ms: 8.0,
+ },
+ }
+ }
+}
+
+/// Floor for the tracked noise-floor estimate, guarding against a zero divisor
+/// and cold-start false triggers.
+const NB_MIN_REF: f32 = 1e-12;
+
+/// IQ-domain impulse noise blanker with look-ahead and a tapered gate.
///
-/// 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).
+/// This runs on the wide, undecimated IQ stream — the only place an impulse is
+/// still short in time (narrow filtering downstream smears it into un-removable
+/// ringing). The algorithm has four parts:
///
-/// 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.
+/// 1. **Noise-floor tracker** — an exponential mean-square estimate updated
+/// *only from clean samples* and frozen while blanking, so a burst cannot
+/// drag the reference up and desensitise detection. Its time constant comes
+/// from the profile.
+/// 2. **Detection** — a sample is an impulse when its instantaneous power
+/// exceeds `threshold² × noise_floor`. `threshold` is the user sensitivity
+/// knob and is orthogonal to the profile.
+/// 3. **Look-ahead** — the signal is delayed by `lookahead` samples so the gate
+/// can start closing before the impulse reaches the output, removing the
+/// leading edge instead of letting it leak through (the old blanker's main
+/// failure). Each detection holds the gate shut for a profile-sized window.
+/// 4. **Tapered gate** — instead of the old hard sample-and-hold (a step that
+/// splattered energy right back across the passband and made the blanker
+/// audibly worse on SSB/CW/data), the gain ramps smoothly 1→0→1 over
+/// `ramp` samples, so blanking costs only a short, quiet notch.
#[derive(Debug, Clone)]
pub struct NoiseBlanker {
enabled: bool,
+ profile: NoiseBlankerProfile,
+ sample_rate: f32,
+ /// Detection sensitivity multiplier over the tracked noise floor (>= 1).
threshold: f32,
- /// Exponentially-smoothed mean-square estimate.
- mean_sq: f32,
- /// Last clean sample (used for interpolation fill).
- last_clean: Complex,
+
+ // Derived from (profile, sample_rate); recomputed when either changes.
+ lookahead: usize,
+ blank_samples: usize,
+ /// Per-sample gate gain increment; `1.0 / ramp_len`.
+ ramp_step: f32,
+ /// Noise-floor EMA coefficient.
+ ref_alpha: f32,
+ /// Samples to seed the noise floor before detection is trusted.
+ warmup: u32,
+
+ // Streaming state.
+ /// Look-ahead ring buffer (length `lookahead`; empty when `lookahead == 0`).
+ delay: Vec>,
+ dpos: usize,
+ /// Tracked noise-floor mean-square.
+ ref_sq: f32,
+ /// Samples processed since (re)configuration, capped at `warmup`.
+ seen: u32,
+ /// Remaining forced-blank samples.
+ hold: usize,
+ /// Current gate gain in `0.0..=1.0`.
+ gain: 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 new(cfg: NoiseBlankerConfig, sample_rate: u32) -> Self {
+ let mut nb = Self {
+ enabled: cfg.enabled,
+ profile: cfg.profile,
+ sample_rate: sample_rate as f32,
+ threshold: cfg.threshold.max(1.0),
+ lookahead: 0,
+ blank_samples: 0,
+ ramp_step: 1.0,
+ ref_alpha: 0.0,
+ warmup: 0,
+ delay: Vec::new(),
+ dpos: 0,
+ ref_sq: NB_MIN_REF,
+ seen: 0,
+ hold: 0,
+ gain: 1.0,
+ };
+ nb.recompute();
+ nb
+ }
+
+ /// Recompute sample-domain parameters from the current profile and sample
+ /// rate, then clear streaming state so the new geometry starts clean.
+ fn recompute(&mut self) {
+ let sr = self.sample_rate.max(1.0);
+ let shape = NbShape::for_profile(self.profile);
+ let per_us = sr / 1_000_000.0;
+
+ self.lookahead = ((shape.lookahead_us * per_us).round() as usize).max(1);
+ self.blank_samples = ((shape.blank_us * per_us).round() as usize).max(1);
+ let ramp_len = (shape.ramp_us * per_us).round().max(1.0);
+ self.ramp_step = 1.0 / ramp_len;
+ let ref_tc = (shape.ref_tc_ms * 1e-3 * sr).max(1.0);
+ self.ref_alpha = 1.0 / ref_tc;
+ // Warm up over roughly one floor time constant, bounded so a huge rate
+ // cannot stall detection for long.
+ self.warmup = ref_tc.min(96_000.0) as u32;
+
+ self.delay = vec![Complex::new(0.0, 0.0); self.lookahead];
+ self.reset_state();
+ }
+
+ /// Clear streaming state without touching the derived parameters.
+ fn reset_state(&mut self) {
+ for s in self.delay.iter_mut() {
+ *s = Complex::new(0.0, 0.0);
}
+ self.dpos = 0;
+ self.ref_sq = NB_MIN_REF;
+ self.seen = 0;
+ self.hold = 0;
+ self.gain = 1.0;
}
pub fn set_enabled(&mut self, enabled: bool) {
+ // Engaging fresh: drop any stale delayed samples and re-seed the floor.
+ if enabled && !self.enabled {
+ self.reset_state();
+ }
self.enabled = enabled;
}
@@ -53,7 +189,14 @@ impl NoiseBlanker {
self.threshold = threshold.max(1.0);
}
- /// Process a block of IQ samples in-place, blanking impulse spikes.
+ pub fn set_profile(&mut self, profile: NoiseBlankerProfile) {
+ if profile != self.profile {
+ self.profile = profile;
+ self.recompute();
+ }
+ }
+
+ /// Process a block of IQ samples in-place, blanking impulse noise.
pub fn process(&mut self, block: &mut [Complex]) {
if !self.enabled || block.is_empty() {
return;
@@ -62,17 +205,59 @@ impl NoiseBlanker {
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;
+ let x = *sample;
+ let mag_sq = x.re * x.re + x.im * x.im;
- if mag_sq > thresh_sq * self.mean_sq {
- // Impulse detected — replace with last clean sample.
- *sample = self.last_clean;
+ // Look-ahead: emit the delayed sample, ingest the fresh one.
+ let y = if self.lookahead == 0 {
+ x
} else {
- // Clean sample — update RMS tracker.
- self.mean_sq += NB_ALPHA * (mag_sq - self.mean_sq);
- self.last_clean = s;
+ let out = self.delay[self.dpos];
+ self.delay[self.dpos] = x;
+ self.dpos += 1;
+ if self.dpos >= self.lookahead {
+ self.dpos = 0;
+ }
+ out
+ };
+
+ // Seed the noise floor before trusting detection.
+ if self.seen < self.warmup {
+ self.seen += 1;
+ self.ref_sq += self.ref_alpha * (mag_sq - self.ref_sq);
+ if self.ref_sq < NB_MIN_REF {
+ self.ref_sq = NB_MIN_REF;
+ }
+ *sample = y;
+ continue;
}
+
+ // Detect on the fresh sample; it reaches the output `lookahead`
+ // samples later, by which time the gate has closed.
+ if mag_sq > thresh_sq * self.ref_sq {
+ self.hold = self.blank_samples;
+ } else if self.hold == 0 {
+ // Update the floor only from clean, passed samples.
+ self.ref_sq += self.ref_alpha * (mag_sq - self.ref_sq);
+ if self.ref_sq < NB_MIN_REF {
+ self.ref_sq = NB_MIN_REF;
+ }
+ }
+
+ // Drive the gate toward its target and apply it to the output.
+ let target = if self.hold > 0 {
+ self.hold -= 1;
+ 0.0
+ } else {
+ 1.0
+ };
+ if self.gain < target {
+ self.gain = (self.gain + self.ramp_step).min(target);
+ } else if self.gain > target {
+ self.gain = (self.gain - self.ramp_step).max(target);
+ }
+
+ *sample = y * self.gain;
}
}
}
@@ -81,6 +266,7 @@ impl NoiseBlanker {
pub struct NoiseBlankerConfig {
pub enabled: bool,
pub threshold: f32,
+ pub profile: NoiseBlankerProfile,
}
impl Default for NoiseBlankerConfig {
@@ -88,6 +274,7 @@ impl Default for NoiseBlankerConfig {
Self {
enabled: false,
threshold: 10.0,
+ profile: NoiseBlankerProfile::Spike,
}
}
}
@@ -556,7 +743,7 @@ impl ChannelDsp {
processing_enabled: true,
force_mono_pcm,
squelch: VirtualSquelch::new(squelch_cfg),
- noise_blanker: NoiseBlanker::new(nb_cfg.enabled, nb_cfg.threshold),
+ noise_blanker: NoiseBlanker::new(nb_cfg, sdr_sample_rate),
last_signal_db: -120.0,
carrier_iq_power: 0.0,
carrier_attack_alpha: Self::smeter_alphas(channel_sample_rate).0,
@@ -577,9 +764,15 @@ 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);
+ pub fn set_noise_blanker(
+ &mut self,
+ enabled: bool,
+ threshold: f32,
+ profile: NoiseBlankerProfile,
+ ) {
+ self.noise_blanker.set_profile(profile);
self.noise_blanker.set_threshold(threshold);
+ self.noise_blanker.set_enabled(enabled);
}
pub fn set_mode(&mut self, mode: &RigMode) {
@@ -1046,30 +1239,89 @@ mod tests {
assert_eq!(dsp.demodulator, Demodulator::Fm);
}
+ fn nb_cfg(enabled: bool, threshold: f32, profile: NoiseBlankerProfile) -> NoiseBlankerConfig {
+ NoiseBlankerConfig {
+ enabled,
+ threshold,
+ profile,
+ }
+ }
+
+ /// Steady low-level signal, long enough to clear the warm-up window.
+ fn nb_warm(nb: &mut NoiseBlanker) {
+ let mut warm = vec![Complex::new(0.01, 0.01); 40_000];
+ nb.process(&mut warm);
+ }
+
#[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> = (0..256).map(|_| Complex::new(0.01, 0.01)).collect();
+ let sr = 1_000_000;
+ let mut nb = NoiseBlanker::new(nb_cfg(true, 5.0, NoiseBlankerProfile::Spike), sr);
+ nb_warm(&mut nb);
+
+ // One massive spike embedded in steady signal. With look-ahead the
+ // spike emerges at the output already gated, so *no* output sample may
+ // approach the raw spike power (200) — proving the leading edge did not
+ // leak through, which the old hold-based blanker allowed.
+ let mut block = vec![Complex::new(0.01, 0.01); 512];
+ block[200] = Complex::new(10.0, 10.0);
nb.process(&mut block);
- // Now inject a single massive spike at index 0.
- let mut block2: Vec> = (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();
+
+ let peak = block
+ .iter()
+ .map(|s| s.re * s.re + s.im * s.im)
+ .fold(0.0f32, f32::max);
+ assert!(peak < 1.0, "impulse leaked through, peak power {peak}");
+ }
+
+ #[test]
+ fn noise_blanker_passes_strong_steady_signal() {
+ // A strong *continuous* tone is not impulse noise: the floor tracks it,
+ // so the blanker must leave it essentially untouched rather than gating
+ // a real signal.
+ let sr = 1_000_000;
+ let mut nb = NoiseBlanker::new(nb_cfg(true, 5.0, NoiseBlankerProfile::Spike), sr);
+ let mut warm = vec![Complex::new(0.5, 0.5); 40_000];
+ nb.process(&mut warm);
+
+ let mut block = vec![Complex::new(0.5, 0.5); 512];
+ nb.process(&mut block);
+ // Tail of the block is past all transients and should pass at unity.
+ let tail = block[511];
assert!(
- mag < 1.0,
- "expected impulse to be blanked, got magnitude {}",
- mag
+ (tail.re - 0.5).abs() < 1e-3 && (tail.im - 0.5).abs() < 1e-3,
+ "steady signal was gated: {tail:?}"
);
}
#[test]
fn noise_blanker_disabled_passes_through() {
- let mut nb = NoiseBlanker::new(false, 5.0);
+ let mut nb = NoiseBlanker::new(nb_cfg(false, 5.0, NoiseBlankerProfile::Spike), 1_000_000);
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));
}
+
+ #[test]
+ fn noise_blanker_wider_profile_blanks_longer() {
+ // A wider profile must hold the gate closed for more samples than a
+ // narrow one on the same impulse.
+ let sr = 1_000_000;
+ let count_blanked = |profile| {
+ let mut nb = NoiseBlanker::new(nb_cfg(true, 5.0, profile), sr);
+ nb_warm(&mut nb);
+ let mut block = vec![Complex::new(0.01, 0.01); 2048];
+ block[100] = Complex::new(10.0, 10.0);
+ nb.process(&mut block);
+ block
+ .iter()
+ .filter(|s| s.re * s.re + s.im * s.im < 1e-6)
+ .count()
+ };
+ assert!(
+ count_blanked(NoiseBlankerProfile::Broadband)
+ > count_blanked(NoiseBlankerProfile::Spike),
+ "broadband profile should blank a wider window than spike"
+ );
+ }
}
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 6d3b1bd0..635da3df 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
@@ -14,8 +14,8 @@ use std::sync::{Arc, Mutex};
use trx_core::radio::freq::{Band, Freq};
use trx_core::rig::response::RigError;
use trx_core::rig::state::{
- effective_demod_mode, DigSidebandPolicy, RigFilterState, SpectrumData, VchanRdsEntry,
- WfmDenoiseLevel,
+ effective_demod_mode, DigSidebandPolicy, NoiseBlankerProfile, RigFilterState, SpectrumData,
+ VchanRdsEntry, WfmDenoiseLevel,
};
use trx_core::rig::{
AudioSource, Rig, RigAccessMethod, RigCapabilities, RigCat, RigInfo, RigSdr, RigStatusFuture,
@@ -76,6 +76,8 @@ pub struct SoapySdrConfig {
pub nb_enabled: bool,
/// Noise blanker impulse threshold multiplier.
pub nb_threshold: f64,
+ /// Noise blanker tuning profile (spike/ignition/powerline/broadband).
+ pub nb_profile: NoiseBlankerProfile,
/// How DIG mode resolves to a sideband (auto/usb/lsb).
pub dig_sideband: DigSidebandPolicy,
/// FFT bin count for the spectrum display; a power of two.
@@ -109,6 +111,7 @@ impl Default for SoapySdrConfig {
max_virtual_channels: 4,
nb_enabled: false,
nb_threshold: 10.0,
+ nb_profile: NoiseBlankerProfile::Spike,
dig_sideband: DigSidebandPolicy::Auto,
spectrum_fft_size: 1024,
}
@@ -160,6 +163,8 @@ pub struct SoapySdrRig {
nb_enabled: bool,
/// Noise blanker impulse threshold multiplier.
nb_threshold: f64,
+ /// Noise blanker tuning profile on the primary channel.
+ nb_profile: NoiseBlankerProfile,
/// 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).
@@ -211,6 +216,7 @@ impl SoapySdrRig {
let max_virtual_channels = config.max_virtual_channels;
let nb_enabled = config.nb_enabled;
let nb_threshold = config.nb_threshold;
+ let nb_profile = config.nb_profile;
let dig_sideband = config.dig_sideband;
let spectrum_fft_size = config.spectrum_fft_size;
tracing::info!(
@@ -310,6 +316,7 @@ impl SoapySdrRig {
dsp::NoiseBlankerConfig {
enabled: nb_enabled,
threshold: nb_threshold as f32,
+ profile: nb_profile,
},
&all_channels,
spectrum_fft_size,
@@ -410,6 +417,7 @@ impl SoapySdrRig {
squelch_threshold_db,
nb_enabled,
nb_threshold,
+ nb_profile,
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
channel_manager,
applied_primary_mode: initial_primary_mode.clone(),
@@ -478,6 +486,7 @@ impl SoapySdrRig {
max_virtual_channels,
nb_enabled,
nb_threshold,
+ nb_profile: NoiseBlankerProfile::default(),
dig_sideband: DigSidebandPolicy::default(),
})
}
@@ -985,6 +994,7 @@ impl RigSdr for SoapySdrRig {
&'a mut self,
enabled: bool,
threshold: f64,
+ profile: NoiseBlankerProfile,
) -> Pin> + Send + 'a>> {
Box::pin(async move {
if !threshold.is_finite() {
@@ -995,13 +1005,14 @@ impl RigSdr for SoapySdrRig {
}
self.nb_enabled = enabled;
self.nb_threshold = threshold;
+ self.nb_profile = profile;
{
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);
+ .set_noise_blanker(enabled, threshold as f32, profile);
}
}
Ok(())
@@ -1125,6 +1136,7 @@ impl RigSdr for SoapySdrRig {
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),
+ sdr_nb_profile: Some(self.nb_profile),
sdr_dig_sideband: Some(self.channel_manager.dig_policy()),
wfm_deemphasis_us: self.wfm_deemphasis_us,
wfm_stereo: self.wfm_stereo,
diff --git a/trx-rs.toml.example b/trx-rs.toml.example
index 821b709e..85b5d928 100644
--- a/trx-rs.toml.example
+++ b/trx-rs.toml.example
@@ -140,6 +140,7 @@ tail_ms = 180
[trx-server.sdr.noise_blanker]
enabled = false
threshold = 10.0
+profile = "spike"
# Timeout and buffer tuning. The defaults suit most setups.
[trx-server.timeouts]