[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
+60 -18
View File
@@ -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. |
| `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 (1100) 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
@@ -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,
)
+9 -2
View File
@@ -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,
}
}
}
+2 -2
View File
@@ -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;
+2 -1
View File
@@ -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),
+1
View File
@@ -253,6 +253,7 @@ pub trait RigSdr: Send {
&'a mut self,
_enabled: bool,
_threshold: f64,
_profile: crate::rig::state::NoiseBlankerProfile,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sdr_noise_blanker"))
+114
View File
@@ -338,6 +338,10 @@ pub struct RigFilterState {
pub sdr_nb_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_nb_threshold: Option<f64>,
/// 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<NoiseBlankerProfile>,
/// 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<Self> {
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);
}
}
+2
View File
@@ -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,
+1 -1
View File
@@ -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:
+2 -1
View File
@@ -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,
+1
View File
@@ -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,
})?;
+6 -2
View File
@@ -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}"
)));
@@ -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<f32>,
// 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<Complex<f32>>,
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<f32>]) {
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<Complex<f32>> = (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<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();
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"
);
}
}
@@ -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<Box<dyn std::future::Future<Output = DynResult<()>> + 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,
+1
View File
@@ -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]