[feat](trx-rs): redesign SDR noise blanker with tuning profiles
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:
+60
-18
@@ -752,9 +752,30 @@ A dedicated tab with a clock icon provides:
|
|||||||
## SDR Noise Blanker
|
## SDR Noise Blanker
|
||||||
|
|
||||||
The noise blanker suppresses impulse noise (clicks, pops, ignition interference)
|
The noise blanker suppresses impulse noise (clicks, pops, ignition interference)
|
||||||
on raw IQ samples before any mixing or filtering takes place. It works by
|
on raw IQ samples before any mixing or filtering takes place — the only point in
|
||||||
tracking a running RMS level of the signal and replacing any sample whose
|
the chain where an impulse is still short in time, since the narrow channel
|
||||||
magnitude exceeds **threshold x RMS** with the last known clean sample.
|
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)
|
### Configuration (server-side)
|
||||||
|
|
||||||
@@ -771,6 +792,7 @@ type = "sdr"
|
|||||||
[rigs.sdr.noise_blanker]
|
[rigs.sdr.noise_blanker]
|
||||||
enabled = true
|
enabled = true
|
||||||
threshold = 10.0 # 1 – 100; lower = more aggressive blanking
|
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]`:
|
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]
|
[sdr.noise_blanker]
|
||||||
enabled = true
|
enabled = true
|
||||||
threshold = 10.0
|
threshold = 10.0
|
||||||
|
profile = "spike"
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Default | Range | Description |
|
| Field | Type | Default | Range | Description |
|
||||||
|-------------|-------|---------|---------|-------------|
|
|-------------|--------|-----------|---------|-------------|
|
||||||
| `enabled` | bool | false | — | Turn the noise blanker on or off. |
|
| `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.
|
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
|
### Choosing a threshold
|
||||||
|
|
||||||
The threshold controls how aggressively the blanker suppresses impulses.
|
The threshold controls how aggressively the blanker suppresses impulses.
|
||||||
@@ -813,39 +850,44 @@ the running average signal level.
|
|||||||
|
|
||||||
### Web UI
|
### 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:
|
**SDR Settings** row of the web interface:
|
||||||
|
|
||||||
- **Noise Blanker** checkbox — enables or disables the blanker in real time.
|
- **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
|
- **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.
|
fields, so they only appear when connected to an SDR backend.
|
||||||
|
|
||||||
### HTTP API
|
### 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 |
|
| Parameter | Type | Required | Description |
|
||||||
|-------------|--------|----------|-------------|
|
|-------------|--------|----------|-------------|
|
||||||
| `enabled` | bool | yes | `true` or `false` |
|
| `enabled` | bool | yes | `true` or `false` |
|
||||||
| `threshold` | float | yes | Value between 1 and 100 |
|
| `threshold` | float | yes | Value between 1 and 100 |
|
||||||
|
| `profile` | string | no | `spike` (default), `ignition`, `powerline`, or `broadband` |
|
||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
|
|
||||||
The blanker runs on every IQ block (4096 samples) *before* the mixer stage in
|
The blanker runs on every IQ block *before* the mixer stage in the DSP pipeline,
|
||||||
the DSP pipeline:
|
one sample at a time:
|
||||||
|
|
||||||
1. For each sample, compute magnitude² (`re² + im²`).
|
1. Emit the sample from the look-ahead delay line and ingest the fresh one.
|
||||||
2. Compare against `threshold² × mean_sq` (the exponentially-smoothed running
|
2. Compute the fresh sample's power (`re² + im²`) and compare it against
|
||||||
mean of magnitude²).
|
`threshold² × noise_floor`.
|
||||||
3. If the sample exceeds the threshold, replace it with the previous clean
|
3. If it exceeds the threshold, hold the gate closed for the profile's blank
|
||||||
sample.
|
window; the fresh sample reaches the output a few samples later, by which
|
||||||
4. Otherwise, update the running mean with smoothing factor α = 1/128 and store
|
time the gate has fully ramped to zero — so the leading edge is removed.
|
||||||
the sample as the last clean value.
|
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
|
Because the blanker operates on raw IQ before frequency translation, it removes
|
||||||
impulse noise across the entire captured bandwidth regardless of the tuned
|
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));
|
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") {
|
if (typeof update.filter.sdr_dig_sideband === "string") {
|
||||||
sdrDigSidebandSupported = true;
|
sdrDigSidebandSupported = true;
|
||||||
@@ -6644,7 +6651,10 @@ var sdrNbEnabledEl = document.getElementById("sdr-nb-enabled");
|
|||||||
var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
|
var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
|
||||||
var sdrNbThresholdEl = document.getElementById("sdr-nb-threshold");
|
var sdrNbThresholdEl = document.getElementById("sdr-nb-threshold");
|
||||||
var sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set");
|
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 sdrNbSupported = false;
|
||||||
|
var sdrNbProfile = "spike";
|
||||||
var sdrDigSidebandWrapEl = document.getElementById("sdr-dig-sideband-wrap");
|
var sdrDigSidebandWrapEl = document.getElementById("sdr-dig-sideband-wrap");
|
||||||
var sdrDigSidebandEl = document.getElementById("sdr-dig-sideband");
|
var sdrDigSidebandEl = document.getElementById("sdr-dig-sideband");
|
||||||
var sdrDigSidebandSupported = false;
|
var sdrDigSidebandSupported = false;
|
||||||
@@ -7019,7 +7029,7 @@ function submitSdrNbState() {
|
|||||||
const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
|
const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
|
||||||
if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return;
|
if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return;
|
||||||
postPath(
|
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(() => {
|
).catch(() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -7028,6 +7038,16 @@ if (sdrNbEnabledEl) {
|
|||||||
submitSdrNbState();
|
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() {
|
function submitSdrNbThreshold() {
|
||||||
if (!sdrNbThresholdEl) return;
|
if (!sdrNbThresholdEl) return;
|
||||||
const parsed = Number.parseFloat(sdrNbThresholdEl.value);
|
const parsed = Number.parseFloat(sdrNbThresholdEl.value);
|
||||||
|
|||||||
@@ -385,6 +385,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</label>
|
</label>
|
||||||
<button id="sdr-nb-threshold-set" type="button" class="wfm-inline-btn">Set</button>
|
<button id="sdr-nb-threshold-set" type="button" class="wfm-inline-btn">Set</button>
|
||||||
</div>
|
</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;">
|
<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 ≥ 10 MHz, LSB below.">DIG sideband</span>
|
<span class="wfm-control-label" title="Sideband used to demodulate DIG. Auto = USB ≥ 10 MHz, LSB below.">DIG sideband</span>
|
||||||
<select id="sdr-dig-sideband" class="status-input">
|
<select id="sdr-dig-sideband" class="status-input">
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ use trx_core::rig::{
|
|||||||
RigVfoEntry,
|
RigVfoEntry,
|
||||||
};
|
};
|
||||||
use trx_core::{
|
use trx_core::{
|
||||||
DecoderConfig, DigSidebandPolicy, RdsData, RigFilterState, RigMode, RigSnapshot,
|
DecoderConfig, DigSidebandPolicy, NoiseBlankerProfile, RdsData, RigFilterState, RigMode,
|
||||||
WfmDenoiseLevel,
|
RigSnapshot, WfmDenoiseLevel,
|
||||||
};
|
};
|
||||||
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
|
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
|
||||||
use trx_frontend_http::server::api::FrontendMeta;
|
use trx_frontend_http::server::api::FrontendMeta;
|
||||||
@@ -53,6 +53,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
export!(DecoderConfig);
|
export!(DecoderConfig);
|
||||||
export!(WfmDenoiseLevel);
|
export!(WfmDenoiseLevel);
|
||||||
export!(DigSidebandPolicy);
|
export!(DigSidebandPolicy);
|
||||||
|
export!(NoiseBlankerProfile);
|
||||||
export!(RigFilterState);
|
export!(RigFilterState);
|
||||||
export!(RdsData);
|
export!(RdsData);
|
||||||
export!(SpectrumData);
|
export!(SpectrumData);
|
||||||
|
|||||||
@@ -57,7 +57,14 @@ export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
|
|||||||
|
|
||||||
export type DigSidebandPolicy = "auto" | "usb" | "lsb";
|
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,
|
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
|
* Current DIG sideband policy (SDR backends only). Surfaces in the UI as
|
||||||
* the advanced-controls "DIG sideband" selector.
|
* the advanced-controls "DIG sideband" selector.
|
||||||
|
|||||||
@@ -3696,6 +3696,13 @@ function render(update: AppUpdate) {
|
|||||||
sdrNbThresholdEl.value = String(Math.round(update.filter.sdr_nb_threshold));
|
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") {
|
if (typeof update.filter.sdr_dig_sideband === "string") {
|
||||||
sdrDigSidebandSupported = true;
|
sdrDigSidebandSupported = true;
|
||||||
@@ -5639,7 +5646,12 @@ const sdrNbEnabledEl = document.getElementById("sdr-nb-enabled") as HTMLInputEle
|
|||||||
const sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
|
const sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
|
||||||
const sdrNbThresholdEl = document.getElementById("sdr-nb-threshold") as HTMLInputElement | null;
|
const sdrNbThresholdEl = document.getElementById("sdr-nb-threshold") as HTMLInputElement | null;
|
||||||
const sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set") as HTMLButtonElement | 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;
|
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 sdrDigSidebandWrapEl = document.getElementById("sdr-dig-sideband-wrap");
|
||||||
const sdrDigSidebandEl = document.getElementById("sdr-dig-sideband") as HTMLSelectElement | null;
|
const sdrDigSidebandEl = document.getElementById("sdr-dig-sideband") as HTMLSelectElement | null;
|
||||||
let sdrDigSidebandSupported = false;
|
let sdrDigSidebandSupported = false;
|
||||||
@@ -6074,7 +6086,7 @@ function submitSdrNbState() {
|
|||||||
const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
|
const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
|
||||||
if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return;
|
if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return;
|
||||||
postPath(
|
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(() => {});
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
if (sdrNbEnabledEl) {
|
if (sdrNbEnabledEl) {
|
||||||
@@ -6082,6 +6094,21 @@ if (sdrNbEnabledEl) {
|
|||||||
submitSdrNbState();
|
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() {
|
function submitSdrNbThreshold() {
|
||||||
if (!sdrNbThresholdEl) return;
|
if (!sdrNbThresholdEl) return;
|
||||||
const parsed = Number.parseFloat(sdrNbThresholdEl.value);
|
const parsed = Number.parseFloat(sdrNbThresholdEl.value);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use trx_core::radio::freq::Freq;
|
use trx_core::radio::freq::Freq;
|
||||||
use trx_core::rig::state::WfmDenoiseLevel;
|
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_frontend::{FrontendRuntimeContext, RemoteRigEntry};
|
||||||
use trx_protocol::parse_mode;
|
use trx_protocol::parse_mode;
|
||||||
|
|
||||||
@@ -279,6 +279,10 @@ pub async fn set_sdr_squelch(
|
|||||||
pub struct SdrNoiseBlankerQuery {
|
pub struct SdrNoiseBlankerQuery {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub threshold: f64,
|
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>,
|
pub remote: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +297,7 @@ pub async fn set_sdr_noise_blanker(
|
|||||||
RigCommand::SetSdrNoiseBlanker {
|
RigCommand::SetSdrNoiseBlanker {
|
||||||
enabled: q.enabled,
|
enabled: q.enabled,
|
||||||
threshold: q.threshold,
|
threshold: q.threshold,
|
||||||
|
profile: q.profile,
|
||||||
},
|
},
|
||||||
q.remote,
|
q.remote,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use crate::shared::{check_socket_conflicts, validate_log_level, validate_tokens,
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
pub use trx_decode_log::DecodeLogsConfig;
|
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.
|
/// Every decoder the server knows how to run, by config name.
|
||||||
///
|
///
|
||||||
@@ -478,8 +478,14 @@ pub struct SdrNoiseBlankerConfig {
|
|||||||
/// Enables the noise blanker.
|
/// Enables the noise blanker.
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Threshold multiplier for impulse detection (typical range: 1..100).
|
/// 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,
|
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 {
|
impl Default for SdrNoiseBlankerConfig {
|
||||||
@@ -487,6 +493,7 @@ impl Default for SdrNoiseBlankerConfig {
|
|||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
threshold: 10.0,
|
threshold: 10.0,
|
||||||
|
profile: NoiseBlankerProfile::Spike,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub use rig::command::RigCommand;
|
|||||||
pub use rig::request::RigRequest;
|
pub use rig::request::RigRequest;
|
||||||
pub use rig::response::{RigError, RigResult};
|
pub use rig::response::{RigError, RigResult};
|
||||||
pub use rig::state::{
|
pub use rig::state::{
|
||||||
DecoderConfig, DecoderResetSeqs, DigSidebandPolicy, RdsData, RigFilterState, RigMode,
|
DecoderConfig, DecoderResetSeqs, DigSidebandPolicy, NoiseBlankerProfile, RdsData,
|
||||||
RigSnapshot, RigState, WfmDenoiseLevel,
|
RigFilterState, RigMode, RigSnapshot, RigState, WfmDenoiseLevel,
|
||||||
};
|
};
|
||||||
pub use rig::AudioSource;
|
pub use rig::AudioSource;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
use crate::radio::freq::Freq;
|
use crate::radio::freq::Freq;
|
||||||
use crate::rig::state::{DigSidebandPolicy, WfmDenoiseLevel};
|
use crate::rig::state::{DigSidebandPolicy, NoiseBlankerProfile, WfmDenoiseLevel};
|
||||||
use crate::RigMode;
|
use crate::RigMode;
|
||||||
|
|
||||||
/// Internal command handled by the rig task.
|
/// Internal command handled by the rig task.
|
||||||
@@ -55,6 +55,7 @@ pub enum RigCommand {
|
|||||||
SetSdrNoiseBlanker {
|
SetSdrNoiseBlanker {
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
threshold: f64,
|
threshold: f64,
|
||||||
|
profile: NoiseBlankerProfile,
|
||||||
},
|
},
|
||||||
/// Set how the SDR backend resolves DIG mode to a sideband (SDR only).
|
/// Set how the SDR backend resolves DIG mode to a sideband (SDR only).
|
||||||
SetSdrDigSideband(DigSidebandPolicy),
|
SetSdrDigSideband(DigSidebandPolicy),
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ pub trait RigSdr: Send {
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
_enabled: bool,
|
_enabled: bool,
|
||||||
_threshold: f64,
|
_threshold: f64,
|
||||||
|
_profile: crate::rig::state::NoiseBlankerProfile,
|
||||||
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
|
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
Box::pin(std::future::ready(Err(
|
Box::pin(std::future::ready(Err(
|
||||||
Box::new(response::RigError::not_supported("set_sdr_noise_blanker"))
|
Box::new(response::RigError::not_supported("set_sdr_noise_blanker"))
|
||||||
|
|||||||
@@ -338,6 +338,10 @@ pub struct RigFilterState {
|
|||||||
pub sdr_nb_enabled: Option<bool>,
|
pub sdr_nb_enabled: Option<bool>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub sdr_nb_threshold: Option<f64>,
|
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
|
/// Current DIG sideband policy (SDR backends only). Surfaces in the UI as
|
||||||
/// the advanced-controls "DIG sideband" selector.
|
/// the advanced-controls "DIG sideband" selector.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[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 {
|
fn default_wfm_deemphasis_us() -> u32 {
|
||||||
75
|
75
|
||||||
}
|
}
|
||||||
@@ -657,3 +727,47 @@ mod dig_sideband_tests {
|
|||||||
assert_eq!(DigSidebandPolicy::parse("nonsense"), None);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ mod tests {
|
|||||||
sdr_squelch_threshold_db: None,
|
sdr_squelch_threshold_db: None,
|
||||||
sdr_nb_enabled: None,
|
sdr_nb_enabled: None,
|
||||||
sdr_nb_threshold: None,
|
sdr_nb_threshold: None,
|
||||||
|
sdr_nb_profile: None,
|
||||||
sdr_dig_sideband: None,
|
sdr_dig_sideband: None,
|
||||||
wfm_deemphasis_us: 75,
|
wfm_deemphasis_us: 75,
|
||||||
wfm_stereo: true,
|
wfm_stereo: true,
|
||||||
@@ -392,6 +393,7 @@ mod tests {
|
|||||||
sdr_squelch_threshold_db: None,
|
sdr_squelch_threshold_db: None,
|
||||||
sdr_nb_enabled: None,
|
sdr_nb_enabled: None,
|
||||||
sdr_nb_threshold: None,
|
sdr_nb_threshold: None,
|
||||||
|
sdr_nb_profile: None,
|
||||||
sdr_dig_sideband: None,
|
sdr_dig_sideband: None,
|
||||||
wfm_deemphasis_us: 50,
|
wfm_deemphasis_us: 50,
|
||||||
wfm_stereo: true,
|
wfm_stereo: true,
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ define_command_mapping! {
|
|||||||
// ── Multi-field struct passthrough ───────────────────────────────
|
// ── Multi-field struct passthrough ───────────────────────────────
|
||||||
multi:
|
multi:
|
||||||
SetSdrSquelch { enabled, threshold_db } <=> SetSdrSquelch,
|
SetSdrSquelch { enabled, threshold_db } <=> SetSdrSquelch,
|
||||||
SetSdrNoiseBlanker { enabled, threshold } <=> SetSdrNoiseBlanker;
|
SetSdrNoiseBlanker { enabled, threshold, profile } <=> SetSdrNoiseBlanker;
|
||||||
|
|
||||||
// ── Freq conversions (u64 <=> Freq) ──────────────────────────────
|
// ── Freq conversions (u64 <=> Freq) ──────────────────────────────
|
||||||
freq:
|
freq:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use trx_core::rig::state::RigSnapshot;
|
use trx_core::rig::state::RigSnapshot;
|
||||||
use trx_core::{DigSidebandPolicy, WfmDenoiseLevel};
|
use trx_core::{DigSidebandPolicy, NoiseBlankerProfile, WfmDenoiseLevel};
|
||||||
|
|
||||||
/// Command received from network clients (JSON).
|
/// Command received from network clients (JSON).
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -105,6 +105,7 @@ pub enum ClientCommand {
|
|||||||
SetSdrNoiseBlanker {
|
SetSdrNoiseBlanker {
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
threshold: f64,
|
threshold: f64,
|
||||||
|
profile: NoiseBlankerProfile,
|
||||||
},
|
},
|
||||||
SetSdrDigSideband {
|
SetSdrDigSideband {
|
||||||
policy: DigSidebandPolicy,
|
policy: DigSidebandPolicy,
|
||||||
|
|||||||
@@ -360,6 +360,7 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
|
|||||||
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
|
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
|
||||||
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
|
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
|
||||||
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
|
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
|
||||||
|
nb_profile: rig_cfg.sdr.noise_blanker.profile,
|
||||||
dig_sideband: rig_cfg.sdr.dig_sideband,
|
dig_sideband: rig_cfg.sdr.dig_sideband,
|
||||||
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size,
|
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size,
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -760,9 +760,13 @@ async fn process_command(
|
|||||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
return snapshot_from(ctx.state);
|
return snapshot_from(ctx.state);
|
||||||
}
|
}
|
||||||
RigCommand::SetSdrNoiseBlanker { enabled, threshold } => {
|
RigCommand::SetSdrNoiseBlanker {
|
||||||
|
enabled,
|
||||||
|
threshold,
|
||||||
|
profile,
|
||||||
|
} => {
|
||||||
if let Some(sdr) = ctx.rig.as_sdr() {
|
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!(
|
return Err(RigError::communication(format!(
|
||||||
"set_sdr_noise_blanker: {e}"
|
"set_sdr_noise_blanker: {e}"
|
||||||
)));
|
)));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use num_complex::Complex;
|
use num_complex::Complex;
|
||||||
use tokio::sync::broadcast;
|
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};
|
use crate::demod::{DcBlocker, Demodulator, SamDemod, SoftAgc, WfmStereoDecoder};
|
||||||
|
|
||||||
@@ -14,38 +14,174 @@ use super::{BlockFirFilterPair, IQ_BLOCK_SIZE};
|
|||||||
// Noise blanker
|
// 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
|
/// This runs on the wide, undecimated IQ stream — the only place an impulse is
|
||||||
/// magnitude exceeds `threshold × rms`, it is replaced by linear interpolation
|
/// still short in time (narrow filtering downstream smears it into un-removable
|
||||||
/// between the last clean sample and the next clean sample (lookahead of 1).
|
/// ringing). The algorithm has four parts:
|
||||||
///
|
///
|
||||||
/// The RMS tracker uses exponential smoothing with a time constant of ~128
|
/// 1. **Noise-floor tracker** — an exponential mean-square estimate updated
|
||||||
/// samples at the IQ sample rate, fast enough to track band-noise changes
|
/// *only from clean samples* and frozen while blanking, so a burst cannot
|
||||||
/// but slow enough not to follow individual impulses.
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NoiseBlanker {
|
pub struct NoiseBlanker {
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
|
profile: NoiseBlankerProfile,
|
||||||
|
sample_rate: f32,
|
||||||
|
/// Detection sensitivity multiplier over the tracked noise floor (>= 1).
|
||||||
threshold: f32,
|
threshold: f32,
|
||||||
/// Exponentially-smoothed mean-square estimate.
|
|
||||||
mean_sq: f32,
|
// Derived from (profile, sample_rate); recomputed when either changes.
|
||||||
/// Last clean sample (used for interpolation fill).
|
lookahead: usize,
|
||||||
last_clean: Complex<f32>,
|
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 {
|
impl NoiseBlanker {
|
||||||
pub fn new(enabled: bool, threshold: f32) -> Self {
|
pub fn new(cfg: NoiseBlankerConfig, sample_rate: u32) -> Self {
|
||||||
Self {
|
let mut nb = Self {
|
||||||
enabled,
|
enabled: cfg.enabled,
|
||||||
threshold: threshold.max(1.0),
|
profile: cfg.profile,
|
||||||
mean_sq: 1e-10,
|
sample_rate: sample_rate as f32,
|
||||||
last_clean: Complex::new(0.0, 0.0),
|
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) {
|
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;
|
self.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +189,14 @@ impl NoiseBlanker {
|
|||||||
self.threshold = threshold.max(1.0);
|
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>]) {
|
pub fn process(&mut self, block: &mut [Complex<f32>]) {
|
||||||
if !self.enabled || block.is_empty() {
|
if !self.enabled || block.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -62,17 +205,59 @@ impl NoiseBlanker {
|
|||||||
let thresh_sq = self.threshold * self.threshold;
|
let thresh_sq = self.threshold * self.threshold;
|
||||||
|
|
||||||
for sample in block.iter_mut() {
|
for sample in block.iter_mut() {
|
||||||
let s = *sample;
|
let x = *sample;
|
||||||
let mag_sq = s.re * s.re + s.im * s.im;
|
let mag_sq = x.re * x.re + x.im * x.im;
|
||||||
|
|
||||||
if mag_sq > thresh_sq * self.mean_sq {
|
// Look-ahead: emit the delayed sample, ingest the fresh one.
|
||||||
// Impulse detected — replace with last clean sample.
|
let y = if self.lookahead == 0 {
|
||||||
*sample = self.last_clean;
|
x
|
||||||
} else {
|
} else {
|
||||||
// Clean sample — update RMS tracker.
|
let out = self.delay[self.dpos];
|
||||||
self.mean_sq += NB_ALPHA * (mag_sq - self.mean_sq);
|
self.delay[self.dpos] = x;
|
||||||
self.last_clean = s;
|
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 struct NoiseBlankerConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub threshold: f32,
|
pub threshold: f32,
|
||||||
|
pub profile: NoiseBlankerProfile,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for NoiseBlankerConfig {
|
impl Default for NoiseBlankerConfig {
|
||||||
@@ -88,6 +274,7 @@ impl Default for NoiseBlankerConfig {
|
|||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
threshold: 10.0,
|
threshold: 10.0,
|
||||||
|
profile: NoiseBlankerProfile::Spike,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -556,7 +743,7 @@ impl ChannelDsp {
|
|||||||
processing_enabled: true,
|
processing_enabled: true,
|
||||||
force_mono_pcm,
|
force_mono_pcm,
|
||||||
squelch: VirtualSquelch::new(squelch_cfg),
|
squelch: VirtualSquelch::new(squelch_cfg),
|
||||||
noise_blanker: NoiseBlanker::new(nb_cfg.enabled, nb_cfg.threshold),
|
noise_blanker: NoiseBlanker::new(nb_cfg, sdr_sample_rate),
|
||||||
last_signal_db: -120.0,
|
last_signal_db: -120.0,
|
||||||
carrier_iq_power: 0.0,
|
carrier_iq_power: 0.0,
|
||||||
carrier_attack_alpha: Self::smeter_alphas(channel_sample_rate).0,
|
carrier_attack_alpha: Self::smeter_alphas(channel_sample_rate).0,
|
||||||
@@ -577,9 +764,15 @@ impl ChannelDsp {
|
|||||||
self.squelch.set_threshold_db(threshold_db);
|
self.squelch.set_threshold_db(threshold_db);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_noise_blanker(&mut self, enabled: bool, threshold: f32) {
|
pub fn set_noise_blanker(
|
||||||
self.noise_blanker.set_enabled(enabled);
|
&mut self,
|
||||||
|
enabled: bool,
|
||||||
|
threshold: f32,
|
||||||
|
profile: NoiseBlankerProfile,
|
||||||
|
) {
|
||||||
|
self.noise_blanker.set_profile(profile);
|
||||||
self.noise_blanker.set_threshold(threshold);
|
self.noise_blanker.set_threshold(threshold);
|
||||||
|
self.noise_blanker.set_enabled(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_mode(&mut self, mode: &RigMode) {
|
pub fn set_mode(&mut self, mode: &RigMode) {
|
||||||
@@ -1046,30 +1239,89 @@ mod tests {
|
|||||||
assert_eq!(dsp.demodulator, Demodulator::Fm);
|
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]
|
#[test]
|
||||||
fn noise_blanker_suppresses_impulse() {
|
fn noise_blanker_suppresses_impulse() {
|
||||||
let mut nb = NoiseBlanker::new(true, 5.0);
|
let sr = 1_000_000;
|
||||||
// Feed a steady signal to establish the RMS baseline.
|
let mut nb = NoiseBlanker::new(nb_cfg(true, 5.0, NoiseBlankerProfile::Spike), sr);
|
||||||
let mut block: Vec<Complex<f32>> = (0..256).map(|_| Complex::new(0.01, 0.01)).collect();
|
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);
|
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();
|
let peak = block
|
||||||
block2[0] = Complex::new(10.0, 10.0);
|
.iter()
|
||||||
nb.process(&mut block2);
|
.map(|s| s.re * s.re + s.im * s.im)
|
||||||
// The spike should have been blanked (replaced by last clean sample).
|
.fold(0.0f32, f32::max);
|
||||||
let mag = (block2[0].re * block2[0].re + block2[0].im * block2[0].im).sqrt();
|
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!(
|
assert!(
|
||||||
mag < 1.0,
|
(tail.re - 0.5).abs() < 1e-3 && (tail.im - 0.5).abs() < 1e-3,
|
||||||
"expected impulse to be blanked, got magnitude {}",
|
"steady signal was gated: {tail:?}"
|
||||||
mag
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn noise_blanker_disabled_passes_through() {
|
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];
|
let mut block = vec![Complex::new(10.0, 10.0); 4];
|
||||||
nb.process(&mut block);
|
nb.process(&mut block);
|
||||||
assert_eq!(block[0], Complex::new(10.0, 10.0));
|
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::radio::freq::{Band, Freq};
|
||||||
use trx_core::rig::response::RigError;
|
use trx_core::rig::response::RigError;
|
||||||
use trx_core::rig::state::{
|
use trx_core::rig::state::{
|
||||||
effective_demod_mode, DigSidebandPolicy, RigFilterState, SpectrumData, VchanRdsEntry,
|
effective_demod_mode, DigSidebandPolicy, NoiseBlankerProfile, RigFilterState, SpectrumData,
|
||||||
WfmDenoiseLevel,
|
VchanRdsEntry, WfmDenoiseLevel,
|
||||||
};
|
};
|
||||||
use trx_core::rig::{
|
use trx_core::rig::{
|
||||||
AudioSource, Rig, RigAccessMethod, RigCapabilities, RigCat, RigInfo, RigSdr, RigStatusFuture,
|
AudioSource, Rig, RigAccessMethod, RigCapabilities, RigCat, RigInfo, RigSdr, RigStatusFuture,
|
||||||
@@ -76,6 +76,8 @@ pub struct SoapySdrConfig {
|
|||||||
pub nb_enabled: bool,
|
pub nb_enabled: bool,
|
||||||
/// Noise blanker impulse threshold multiplier.
|
/// Noise blanker impulse threshold multiplier.
|
||||||
pub nb_threshold: f64,
|
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).
|
/// How DIG mode resolves to a sideband (auto/usb/lsb).
|
||||||
pub dig_sideband: DigSidebandPolicy,
|
pub dig_sideband: DigSidebandPolicy,
|
||||||
/// FFT bin count for the spectrum display; a power of two.
|
/// FFT bin count for the spectrum display; a power of two.
|
||||||
@@ -109,6 +111,7 @@ impl Default for SoapySdrConfig {
|
|||||||
max_virtual_channels: 4,
|
max_virtual_channels: 4,
|
||||||
nb_enabled: false,
|
nb_enabled: false,
|
||||||
nb_threshold: 10.0,
|
nb_threshold: 10.0,
|
||||||
|
nb_profile: NoiseBlankerProfile::Spike,
|
||||||
dig_sideband: DigSidebandPolicy::Auto,
|
dig_sideband: DigSidebandPolicy::Auto,
|
||||||
spectrum_fft_size: 1024,
|
spectrum_fft_size: 1024,
|
||||||
}
|
}
|
||||||
@@ -160,6 +163,8 @@ pub struct SoapySdrRig {
|
|||||||
nb_enabled: bool,
|
nb_enabled: bool,
|
||||||
/// Noise blanker impulse threshold multiplier.
|
/// Noise blanker impulse threshold multiplier.
|
||||||
nb_threshold: f64,
|
nb_threshold: f64,
|
||||||
|
/// Noise blanker tuning profile on the primary channel.
|
||||||
|
nb_profile: NoiseBlankerProfile,
|
||||||
/// Hidden AIS decoder channels (A and B) when available.
|
/// Hidden AIS decoder channels (A and B) when available.
|
||||||
ais_channel_indices: Option<(usize, usize)>,
|
ais_channel_indices: Option<(usize, usize)>,
|
||||||
/// Virtual channel manager shared with external consumers (e.g. RigHandle).
|
/// Virtual channel manager shared with external consumers (e.g. RigHandle).
|
||||||
@@ -211,6 +216,7 @@ impl SoapySdrRig {
|
|||||||
let max_virtual_channels = config.max_virtual_channels;
|
let max_virtual_channels = config.max_virtual_channels;
|
||||||
let nb_enabled = config.nb_enabled;
|
let nb_enabled = config.nb_enabled;
|
||||||
let nb_threshold = config.nb_threshold;
|
let nb_threshold = config.nb_threshold;
|
||||||
|
let nb_profile = config.nb_profile;
|
||||||
let dig_sideband = config.dig_sideband;
|
let dig_sideband = config.dig_sideband;
|
||||||
let spectrum_fft_size = config.spectrum_fft_size;
|
let spectrum_fft_size = config.spectrum_fft_size;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -310,6 +316,7 @@ impl SoapySdrRig {
|
|||||||
dsp::NoiseBlankerConfig {
|
dsp::NoiseBlankerConfig {
|
||||||
enabled: nb_enabled,
|
enabled: nb_enabled,
|
||||||
threshold: nb_threshold as f32,
|
threshold: nb_threshold as f32,
|
||||||
|
profile: nb_profile,
|
||||||
},
|
},
|
||||||
&all_channels,
|
&all_channels,
|
||||||
spectrum_fft_size,
|
spectrum_fft_size,
|
||||||
@@ -410,6 +417,7 @@ impl SoapySdrRig {
|
|||||||
squelch_threshold_db,
|
squelch_threshold_db,
|
||||||
nb_enabled,
|
nb_enabled,
|
||||||
nb_threshold,
|
nb_threshold,
|
||||||
|
nb_profile,
|
||||||
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
|
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
|
||||||
channel_manager,
|
channel_manager,
|
||||||
applied_primary_mode: initial_primary_mode.clone(),
|
applied_primary_mode: initial_primary_mode.clone(),
|
||||||
@@ -478,6 +486,7 @@ impl SoapySdrRig {
|
|||||||
max_virtual_channels,
|
max_virtual_channels,
|
||||||
nb_enabled,
|
nb_enabled,
|
||||||
nb_threshold,
|
nb_threshold,
|
||||||
|
nb_profile: NoiseBlankerProfile::default(),
|
||||||
dig_sideband: DigSidebandPolicy::default(),
|
dig_sideband: DigSidebandPolicy::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -985,6 +994,7 @@ impl RigSdr for SoapySdrRig {
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
threshold: f64,
|
threshold: f64,
|
||||||
|
profile: NoiseBlankerProfile,
|
||||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if !threshold.is_finite() {
|
if !threshold.is_finite() {
|
||||||
@@ -995,13 +1005,14 @@ impl RigSdr for SoapySdrRig {
|
|||||||
}
|
}
|
||||||
self.nb_enabled = enabled;
|
self.nb_enabled = enabled;
|
||||||
self.nb_threshold = threshold;
|
self.nb_threshold = threshold;
|
||||||
|
self.nb_profile = profile;
|
||||||
{
|
{
|
||||||
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
||||||
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
|
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
|
||||||
dsp_arc
|
dsp_arc
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.set_noise_blanker(enabled, threshold as f32);
|
.set_noise_blanker(enabled, threshold as f32, profile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1125,6 +1136,7 @@ impl RigSdr for SoapySdrRig {
|
|||||||
sdr_squelch_threshold_db: Some(self.squelch_threshold_db as f64),
|
sdr_squelch_threshold_db: Some(self.squelch_threshold_db as f64),
|
||||||
sdr_nb_enabled: Some(self.nb_enabled),
|
sdr_nb_enabled: Some(self.nb_enabled),
|
||||||
sdr_nb_threshold: Some(self.nb_threshold),
|
sdr_nb_threshold: Some(self.nb_threshold),
|
||||||
|
sdr_nb_profile: Some(self.nb_profile),
|
||||||
sdr_dig_sideband: Some(self.channel_manager.dig_policy()),
|
sdr_dig_sideband: Some(self.channel_manager.dig_policy()),
|
||||||
wfm_deemphasis_us: self.wfm_deemphasis_us,
|
wfm_deemphasis_us: self.wfm_deemphasis_us,
|
||||||
wfm_stereo: self.wfm_stereo,
|
wfm_stereo: self.wfm_stereo,
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ tail_ms = 180
|
|||||||
[trx-server.sdr.noise_blanker]
|
[trx-server.sdr.noise_blanker]
|
||||||
enabled = false
|
enabled = false
|
||||||
threshold = 10.0
|
threshold = 10.0
|
||||||
|
profile = "spike"
|
||||||
|
|
||||||
# Timeout and buffer tuning. The defaults suit most setups.
|
# Timeout and buffer tuning. The defaults suit most setups.
|
||||||
[trx-server.timeouts]
|
[trx-server.timeouts]
|
||||||
|
|||||||
Reference in New Issue
Block a user