[feat](trx-rs): redesign SDR noise blanker with tuning profiles
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s

The old IQ noise blanker tracked a fast running RMS and, on a
threshold crossing, replaced the sample with the last clean one. That
hard sample-and-hold is a step discontinuity: it splatters energy back
across the wideband passband, so after the narrow channel filter it
often sounded worse than the noise it removed — especially on SSB, CW
and digital. It also had no look-ahead (the impulse leading edge leaked
through before the fast RMS reacted), blanked only single samples, and
used a fixed 1/128 time constant that did not scale with capture rate.

Redesign the blanker around accepted wideband-NB practice and add
profiles matched to the interference source:

- Noise-floor tracker updated only from clean samples and frozen while
  blanking, so a burst cannot desensitise detection.
- Detection on instantaneous power vs threshold² × noise floor.
- Look-ahead delay line so the gate closes *before* the impulse reaches
  the output, removing the leading edge.
- Raised-cosine tapered gate (ramp 1→0→1) instead of a hard hold, which
  minimises blanker splatter.
- Windowed blanking that re-arms on every detected sample to cover the
  full width of a burst.
- All timings expressed in real time and converted to samples at the
  capture rate, so behaviour is consistent across SDR sample rates.

Profiles (`NoiseBlankerProfile`, default `spike`): spike, ignition,
powerline, broadband — each selects the blank window, look-ahead, taper
and floor time constant. `threshold` stays orthogonal as the
sensitivity knob.

Core/protocol:
- New `NoiseBlankerProfile` in trx-core (serde/parse/u8/TS), re-exported
  at the crate root; `RigFilterState.sdr_nb_profile` for state sync.
- `profile` added to `RigCommand`/`ClientCommand::SetSdrNoiseBlanker`,
  the trait method, and the command mapping.

Config: `[rig.sdr.noise_blanker] profile = "spike"` (regenerated
trx-rs.toml.example).

SDR backend: `NoiseBlanker` rewritten in the channel DSP; profile wired
through `SoapySdrConfig`, the runtime setter, and `filter_state()`.

Frontend: an "NB profile" selector in the SDR advanced controls
(POST /set_sdr_noise_blanker&profile=…), reflecting server state; the
profile rides along with the enable/threshold quick toggle so it is
preserved. Regenerated generated.ts and app.js.

Tests: profile u8/parse round-trips (trx-core); DSP tests for impulse
suppression with no leading-edge leak, strong-steady-signal
pass-through, and wider-profile-blanks-longer.

Docs: User-Manual NB section rewritten for the new algorithm and
profiles.

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
sjg
2026-08-16 21:42:14 +02:00
parent 79adc8d5c6
commit c10b5faef4
20 changed files with 589 additions and 82 deletions
@@ -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);
@@ -385,6 +385,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
</label>
<button id="sdr-nb-threshold-set" type="button" class="wfm-inline-btn">Set</button>
</div>
<label class="wfm-control" id="sdr-nb-profile-wrap" style="display:none;">
<span class="wfm-control-label" title="Matches the blanker to the interference: Spike = sharp bursts, Ignition = engine/PWM, Powerline = mains buzz, Broadband = dense noise.">NB profile</span>
<select id="sdr-nb-profile" class="status-input">
<option value="spike">Spike</option>
<option value="ignition">Ignition</option>
<option value="powerline">Powerline</option>
<option value="broadband">Broadband</option>
</select>
</label>
<label class="wfm-control" id="sdr-dig-sideband-wrap" style="display:none;">
<span class="wfm-control-label" title="Sideband used to demodulate DIG. Auto = USB &ge; 10 MHz, LSB below.">DIG sideband</span>
<select id="sdr-dig-sideband" class="status-input">
@@ -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<dyn std::error::Error>> {
export!(DecoderConfig);
export!(WfmDenoiseLevel);
export!(DigSidebandPolicy);
export!(NoiseBlankerProfile);
export!(RigFilterState);
export!(RdsData);
export!(SpectrumData);
@@ -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.
@@ -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);
@@ -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<String>,
}
@@ -293,6 +297,7 @@ pub async fn set_sdr_noise_blanker(
RigCommand::SetSdrNoiseBlanker {
enabled: q.enabled,
threshold: q.threshold,
profile: q.profile,
},
q.remote,
)