Compare commits

...
10 Commits
Author SHA1 Message Date
sjg 0b1fb005f6 [fix](trx-rs): install binaries under user home
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Default installation and removal to /home/sjg/.local/bin while preserving explicit prefix and binary-directory overrides. Update the service examples and README to match.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 23:08:08 +02:00
sjg be9d5c301b [docs](trx-rs): add safe deployment guide
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Document deployment with a dedicated service account, restricted device access, authenticated network listeners, systemd user services, reverse proxying, verification, upgrades, and rollback.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 22:53:00 +02:00
sjg b73c97dd5b [fix](trx-server): honor per-rig audio bind addresses
Use each rig's configured audio listener unless --listen explicitly overrides all bind addresses. Keep preflight socket validation consistent with runtime behavior.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 22:45:33 +02:00
sjg 6276c3feea [fix](trx-rs): allow systemd hardware discovery
Do not restrict socket families in the generic user units because SoapySDR and libusb require netlink sockets to enumerate radio hardware.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 22:44:58 +02:00
sjg 110c0e1d49 [fix](trx-rs): correct systemd network ordering
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Let the client wait for an enabled local server without activating a disabled one, and declare the socket families required by both services for configured listeners and remote connections.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 22:35:54 +02:00
sjg 05c337b581 [feat](trx-rs): add build/install script and systemd user services
CI / lint (pull_request) Canceled after 0s
CI / test (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / reuse (pull_request) Canceled after 0s
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Provide a one-shot installer and matching systemd *user* units so the
server and client can be built, installed system-wide, and run in the
background without hand-rolled steps.

- script/install.sh: builds all three binaries (trx-server, trx-client,
  trx-configurator) in release mode and installs them to /usr/local/bin
  (configurable via --prefix/--bindir/PREFIX, sudo only when the target
  is not writable). Seeds ~/.config/trx-rs/trx-rs.toml from the example
  without ever overwriting existing config, then installs the user units
  with @BINDIR@ substituted to the real path and runs daemon-reload.
  Flags: --no-sdr, --no-build, --no-systemd, --enable-now.
- script/uninstall.sh: stops/disables the units, removes them and the
  binaries; keeps config unless --purge.
- packaging/systemd/{trx-server,trx-client}.service: user units reading
  the combined config at ~/.config/trx-rs/trx-rs.toml. The client softly
  depends on the server (Wants/After). KillSignal=SIGINT matches how the
  binaries shut down cleanly (SIGTERM is not handled).
- README: new "Install (optional, Linux + systemd)" section.

The web UI assets are embedded in the binary, so an install needs only
the binaries plus a config file — no data directory to ship.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 23:01:45 +02:00
sjg c10b5faef4 [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>
2026-08-16 21:42:14 +02:00
sjgandClaude Opus 4.8 79adc8d5c6 [chore](trx-rs): run CI on self-hosted runner
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Switch every CI job from ubuntu-latest to the self-hosted runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 13:22:44 +02:00
sjgandClaude Opus 4.8 c33e3caedb [style](trx-rs): cargo fmt
CI / frontend (push) Successful in 5m32s
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 10m20s
CI / reuse (push) Successful in 4s
Apply rustfmt to the wefax and DIG-sideband changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 12:50:12 +02:00
sjgandClaude Opus 4.8 5084c19899 [fix](trx-frontend-http): move passband overlay to resolved DIG sideband
CI / lint (push) Failing after 2s
CI / test (push) Successful in 9m8s
CI / frontend (push) Successful in 5m24s
CI / reuse (push) Successful in 4s
The spectrum passband overlay is drawn one-sided per mode, but DIG was
hardcoded to upper sideband — so switching the DIG sideband (or tuning
DIG/Auto across 10 MHz) left the overlay on the wrong side of the carrier
even though the backend had flipped the demodulator.

Resolve the DIG overlay direction from the current sideband policy and dial
frequency, mirroring the backend: usb → upper, lsb → lower, auto → upper at
or above 10 MHz and lower below. The overlay repaints immediately on a
policy change (optimistically on the selector, and on confirmed filter
state) and tracks frequency as it already did. Non-SDR backends keep the
historical upper-sideband overlay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 10:24:30 +02:00
28 changed files with 1349 additions and 100 deletions
+4 -4
View File
@@ -27,7 +27,7 @@ env:
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: self-hosted
container: git.haxx.space/sjg/trx-rs/sdk:latest container: git.haxx.space/sjg/trx-rs/sdk:latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -40,7 +40,7 @@ jobs:
run: sccache --show-stats run: sccache --show-stats
test: test:
runs-on: ubuntu-latest runs-on: self-hosted
container: git.haxx.space/sjg/trx-rs/sdk:latest container: git.haxx.space/sjg/trx-rs/sdk:latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -53,7 +53,7 @@ jobs:
run: sccache --show-stats run: sccache --show-stats
frontend: frontend:
runs-on: ubuntu-latest runs-on: self-hosted
container: git.haxx.space/sjg/trx-rs/sdk:latest container: git.haxx.space/sjg/trx-rs/sdk:latest
defaults: defaults:
run: run:
@@ -83,7 +83,7 @@ jobs:
run: npm run verify-generated run: npm run verify-generated
reuse: reuse:
runs-on: ubuntu-latest runs-on: self-hosted
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: fsfe/reuse-action@v5 - uses: fsfe/reuse-action@v5
+38
View File
@@ -116,6 +116,44 @@ section and the client reads `[trx-client]`.
Open the configured HTTP frontend address in a browser (default `http://localhost:8080`). Open the configured HTTP frontend address in a browser (default `http://localhost:8080`).
### 5. Install (optional, Linux + systemd)
To build, install the binaries system-wide, and set up systemd **user** services
that run the server and client in the background:
```bash
script/install.sh
```
This builds in release mode, installs `trx-server`, `trx-client`, and
`trx-configurator` to `~/.local/bin`, seeds
`~/.config/trx-rs/trx-rs.toml` from the example (existing config is never
overwritten), and installs `~/.config/systemd/user/{trx-server,trx-client}.service`.
```bash
script/install.sh --prefix /opt/trx-rs # choose another installation prefix
script/install.sh --no-sdr # build without SoapySDR support
script/install.sh --enable-now # also enable + start the services now
script/install.sh --help # all options
```
After editing your config, manage the services with:
```bash
systemctl --user enable --now trx-server trx-client # start now + on login
journalctl --user -u trx-server -u trx-client -f # follow logs
loginctl enable-linger "$USER" # keep running after logout
```
Serial (`/dev/ttyUSB*`) and audio access require your user to be in the
`dialout` and `audio` groups. Remove everything with `script/uninstall.sh`
(add `--purge` to also delete the config).
For an unattended or remotely accessible installation, follow the
[safe deployment guide](docs/Deployment.md). It covers a dedicated service
account, device permissions, authentication, firewall and reverse-proxy
boundaries, verification, upgrades, and rollback.
## How It Works ## How It Works
```mermaid ```mermaid
+287
View File
@@ -0,0 +1,287 @@
# Safe deployment
This guide deploys trx-rs on Linux as a dedicated, unprivileged user with
systemd user services. It keeps radio-device access, configuration, and runtime
data separate from an administrator's account.
The examples use `trx-rs` as the account name and `/opt/trx-rs/bin` for
root-owned executables. Adapt group names and firewall commands to your Linux
distribution.
## 1. Decide what must be reachable
Only expose listeners that another machine actually needs:
| Listener | Typical port | Recommended exposure |
| --- | ---: | --- |
| Server control | TCP 4530 | Loopback or trusted radio LAN only |
| Server audio | TCP 4531 and per-rig ports | Trusted radio LAN only |
| Client web UI | TCP 8080 or a chosen port | Loopback behind HTTPS proxy |
| Client rigctl | Per-rig TCP ports | Loopback or trusted LAN only |
| Client JSON | Configured TCP port | Loopback or trusted LAN only |
`127.0.0.1` accepts connections only from the same host. Use a specific LAN
address when possible, or `0.0.0.0` when the listener must accept connections
on every IPv4 interface. Binding a socket does not configure the host firewall.
Do not expose unauthenticated control, audio, rigctl, or JSON listeners to the
public Internet. Prefer a VPN for links between radio sites. Put the web UI
behind an HTTPS reverse proxy when it is remotely accessible.
## 2. Create the service account
Create a non-login service account with a home directory:
```bash
sudo useradd --create-home --shell /usr/sbin/nologin trx-rs
sudo chmod 0750 /home/trx-rs
```
Add only the hardware groups required on this host. Common group names are
`dialout` for serial devices and `audio` for sound devices:
```bash
sudo usermod -aG dialout,audio trx-rs
```
SDR USB access is distribution- and device-specific. Install the vendor's udev
rules or add a narrowly scoped rule for the device's USB vendor/product IDs.
Avoid making all USB devices world-writable. After reconnecting the device,
verify access as the service account:
```bash
sudo -u trx-rs test -r /dev/ttyUSB0
sudo -u trx-rs test -w /dev/ttyUSB0
sudo -u trx-rs SoapySDRUtil --find
```
Run only the checks relevant to the configured hardware. Group membership and
udev-rule changes normally require reconnecting the device or restarting the
service.
## 3. Build and install immutable binaries
Build from a reviewed revision as a normal development user, not as root:
```bash
git clone https://github.com/stanislawgrams/trx-rs.git
cd trx-rs
git switch --detach <reviewed-tag-or-commit>
cargo build --release -p trx-server -p trx-client -p trx-configurator
```
Install root-owned binaries into a directory the service user cannot modify:
```bash
sudo install -d -o root -g root -m 0755 /opt/trx-rs/bin
sudo install -o root -g root -m 0755 \
target/release/trx-server \
target/release/trx-client \
target/release/trx-configurator \
/opt/trx-rs/bin/
```
If SDR support is not needed, build `trx-server` with
`--no-default-features`. Keep the source revision and Rust toolchain used for
the build in deployment records.
## 4. Install and validate configuration
Create private configuration and state directories, then seed the example:
```bash
sudo install -d -o trx-rs -g trx-rs -m 0700 \
/home/trx-rs/.config/trx-rs \
/home/trx-rs/.config/systemd/user
sudo install -o trx-rs -g trx-rs -m 0600 \
trx-rs.toml.example /home/trx-rs/.config/trx-rs/trx-rs.toml
sudoedit /home/trx-rs/.config/trx-rs/trx-rs.toml
```
At minimum:
- remove unused example rigs and remotes;
- select the correct serial, TCP, or SDR device;
- give every enabled rig a unique ID and audio port;
- use `127.0.0.1` for same-host connections;
- use a LAN address or `0.0.0.0` only for deliberately remote listeners;
- enable authentication before exposing server control or the web UI;
- replace every example password and token;
- keep credential files mode `0600` and owned by `trx-rs`;
- set `cookie_secure = true` when the web UI is served through HTTPS.
For a remote server, control and each per-rig audio listener need an explicit
non-loopback address:
```toml
[trx-server.listen]
enabled = true
listen = "0.0.0.0"
port = 4530
[trx-server.listen.auth]
tokens_file = "/home/trx-rs/.config/trx-rs/server-tokens"
[[trx-server.rigs]]
id = "station-hf"
[trx-server.rigs.audio]
enabled = true
listen = "0.0.0.0"
port = 4531
```
When using several `[[trx-server.rigs]]` entries, configure audio under each
`[trx-server.rigs.audio]` section. Do not rely on the legacy flat
`[trx-server.audio]` section.
For a web UI behind a reverse proxy, keep the backend on loopback and choose an
unused port. The proxy upstream must use the same address and port:
```toml
[trx-client.frontends.http]
enabled = true
listen = "127.0.0.1"
port = 7345
[trx-client.frontends.http.auth]
enabled = true
users_file = "/home/trx-rs/.config/trx-rs/http-users.json"
cookie_secure = true
```
Validate the configuration before starting either daemon:
```bash
sudo -u trx-rs /opt/trx-rs/bin/trx-server \
--check-config --config /home/trx-rs/.config/trx-rs/trx-rs.toml
sudo -u trx-rs /opt/trx-rs/bin/trx-client \
--check-config --config /home/trx-rs/.config/trx-rs/trx-rs.toml
```
Treat unknown-key and deprecated-section warnings as deployment errors. They
often mean a setting is not applied where expected.
## 5. Install the systemd user services
Render the packaged units with the immutable binary directory:
```bash
sed 's|@BINDIR@|/opt/trx-rs/bin|g' packaging/systemd/trx-server.service \
| sudo tee /home/trx-rs/.config/systemd/user/trx-server.service >/dev/null
sed 's|@BINDIR@|/opt/trx-rs/bin|g' packaging/systemd/trx-client.service \
| sudo tee /home/trx-rs/.config/systemd/user/trx-client.service >/dev/null
sudo chown trx-rs:trx-rs \
/home/trx-rs/.config/systemd/user/trx-server.service \
/home/trx-rs/.config/systemd/user/trx-client.service
sudo chmod 0644 \
/home/trx-rs/.config/systemd/user/trx-server.service \
/home/trx-rs/.config/systemd/user/trx-client.service
```
Enable lingering so the user manager runs without an interactive login, then
start it:
```bash
sudo loginctl enable-linger trx-rs
trx_uid=$(id -u trx-rs)
sudo systemctl start "user@${trx_uid}.service"
```
Manage the user services through that user's runtime directory:
```bash
trx_uid=$(id -u trx-rs)
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user daemon-reload
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user enable --now trx-server.service trx-client.service
```
The client unit has ordering, but not activation, on `trx-server.service`. If
both are enabled locally, the client starts after the server process is
started. If the local server is disabled because all remotes are external, the
client does not start it. The client retries remote connections; systemd cannot
order startup against a service on another host.
## 6. Firewall and reverse proxy
Allow only required ports and sources. For example, permit server control and
audio only from the trusted radio subnet, not from every interface. Exact
commands differ between nftables, firewalld, and ufw.
For the web UI, terminate TLS in a maintained reverse proxy and send traffic to
the loopback backend. Preserve WebSocket upgrade headers if the proxy requires
them. Example nginx location:
```nginx
location / {
proxy_pass http://127.0.0.1:7345;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```
Test the backend directly before debugging a `502 Bad Gateway` response:
```bash
curl --fail --show-error http://127.0.0.1:7345/
```
A refused connection means nothing is listening at the proxy's configured
address and port. Check for port conflicts with `ss -ltnp` and confirm the
application log reports the same bind address as the proxy upstream.
## 7. Verify the deployment
```bash
trx_uid=$(id -u trx-rs)
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user status trx-server.service trx-client.service
sudo journalctl _UID="${trx_uid}" \
-u trx-server.service -u trx-client.service --since today
sudo ss -ltnp
```
Verify all of the following:
- both required services remain active without a restart loop;
- radio hardware opens successfully;
- logs show the intended control, audio, and frontend bind addresses;
- the client connects to every configured control and audio endpoint;
- only intended interfaces expose listeners;
- authentication works and anonymous access is rejected where configured;
- the HTTPS proxy returns the UI and supports live updates;
- the service account cannot write `/opt/trx-rs/bin`.
Do not rely only on `systemctl` reporting `active`: individual frontend tasks
can fail after the main client process starts. Always inspect startup logs and
probe each required endpoint.
## 8. Upgrade and roll back
Build and validate a new revision before replacing the installed executables.
Back up configuration and state first. Stop the services, install all binaries
from the same build, and restart:
```bash
trx_uid=$(id -u trx-rs)
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user stop trx-client.service trx-server.service
sudo install -o root -g root -m 0755 \
target/release/trx-server \
target/release/trx-client \
target/release/trx-configurator \
/opt/trx-rs/bin/
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user start trx-server.service trx-client.service
```
Keep the previous binaries or package revision available for rollback. If the
new version fails, stop both services, restore the complete previous binary
set, restore configuration only if its format changed, and start the services
again.
+62 -20
View File
@@ -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 (1100) with a **Set** button — adjusts the - **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. 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
+38
View File
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# systemd *user* unit for the trx-rs radio client and web frontend.
#
# Install with `script/install.sh` (which substitutes @BINDIR@ and copies this
# into ~/.config/systemd/user/), or by hand:
# sed "s|@BINDIR@|$HOME/.local/bin|" trx-client.service \
# > ~/.config/systemd/user/trx-client.service
# systemctl --user daemon-reload
# systemctl --user enable --now trx-client.service
#
# Reads the combined config at ~/.config/trx-rs/trx-rs.toml ([trx-client]
# section) and connects to the server (default 127.0.0.1:4530). The web UI
# defaults to http://127.0.0.1:8080.
[Unit]
Description=trx-rs radio client and web frontend
Documentation=https://github.com/stanislawgrams/trx-rs
# Wait for networking and, when it is enabled, the local server. After= only
# orders units already in the transaction: it deliberately does not start a
# disabled trx-server, since the client may connect to a remote server instead.
Wants=network-online.target
After=network-online.target trx-server.service
[Service]
Type=simple
ExecStart=@BINDIR@/trx-client --config %h/.config/trx-rs/trx-rs.toml
Restart=on-failure
RestartSec=2
# Shuts down cleanly on SIGINT (see trx-server.service for the rationale).
KillSignal=SIGINT
TimeoutStopSec=15
NoNewPrivileges=true
[Install]
WantedBy=default.target
+38
View File
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# systemd *user* unit for the trx-rs radio server.
#
# Install with `script/install.sh` (which substitutes @BINDIR@ and copies this
# into ~/.config/systemd/user/), or by hand:
# sed "s|@BINDIR@|$HOME/.local/bin|" trx-server.service \
# > ~/.config/systemd/user/trx-server.service
# systemctl --user daemon-reload
# systemctl --user enable --now trx-server.service
#
# Reads the combined config at ~/.config/trx-rs/trx-rs.toml ([trx-server]
# section). Serial (/dev/ttyUSB*) and audio access require your user to be in
# the `dialout` and `audio` groups — a user unit cannot grant them itself.
[Unit]
Description=trx-rs radio server (CAT/SDR backend)
Documentation=https://github.com/stanislawgrams/trx-rs
# The server binds its control/audio TCP listeners and may connect to networked
# rigs and reporting services, so do not start it before networking is ready.
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
ExecStart=@BINDIR@/trx-server --config %h/.config/trx-rs/trx-rs.toml
Restart=on-failure
RestartSec=2
# Both binaries shut down cleanly on SIGINT (the server drains audio and exits
# 0); systemd's default SIGTERM is not handled, so signal SIGINT instead.
KillSignal=SIGINT
TimeoutStopSec=15
NoNewPrivileges=true
[Install]
WantedBy=default.target
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Build trx-rs in release mode and install the binaries system-wide, seed a
# per-user config, and install systemd *user* services to run the server and
# client. Idempotent: existing config is never overwritten.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
BINARIES=(trx-server trx-client trx-configurator)
# --- Defaults (override via flags or environment) ---------------------------
PREFIX="${PREFIX:-$HOME/.local}"
BINDIR="${BINDIR:-}" # derived from PREFIX unless set explicitly
NO_SDR=0
DO_BUILD=1
DO_SYSTEMD=1
ENABLE_NOW=0
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/trx-rs"
CONFIG_FILE="$CONFIG_DIR/trx-rs.toml"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
usage() {
cat <<EOF
Usage: script/install.sh [options]
Build trx-rs (release) and install it.
Options:
--prefix DIR Install prefix; binaries go to DIR/bin (default: \$HOME/.local)
--bindir DIR Install binaries directly to DIR (overrides --prefix)
--no-sdr Build without SoapySDR support (--no-default-features)
--no-build Skip cargo build; install existing target/release binaries
--no-systemd Do not install the systemd user services
--enable-now Enable and start the user services immediately after install
-h, --help Show this help
Environment:
PREFIX, BINDIR Same as the matching flags.
Installs:
binaries -> ${BINDIR:-$PREFIX/bin} (uses sudo if not writable)
config -> $CONFIG_FILE (from trx-rs.toml.example, only if absent)
services -> $UNIT_DIR/{trx-server,trx-client}.service
EOF
}
# --- Parse args -------------------------------------------------------------
while [ $# -gt 0 ]; do
case "$1" in
--prefix) PREFIX="$2"; shift 2 ;;
--bindir) BINDIR="$2"; shift 2 ;;
--no-sdr) NO_SDR=1; shift ;;
--no-build) DO_BUILD=0; shift ;;
--no-systemd) DO_SYSTEMD=0; shift ;;
--enable-now) ENABLE_NOW=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown option '$1'" >&2; usage >&2; exit 2 ;;
esac
done
[ -n "$BINDIR" ] || BINDIR="$PREFIX/bin"
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; }
# Return 0 (true) if writing into $1 needs elevated privileges.
need_sudo() {
local dir="$1"
while [ ! -e "$dir" ]; do dir="$(dirname "$dir")"; done
[ -w "$dir" ] && return 1 || return 0
}
# --- Build ------------------------------------------------------------------
if [ "$DO_BUILD" -eq 1 ]; then
build_args=(build --release --manifest-path "$PROJECT_ROOT/Cargo.toml")
for b in "${BINARIES[@]}"; do build_args+=(-p "$b"); done
if [ "$NO_SDR" -eq 1 ]; then
# --no-default-features only affects trx-server; harmless elsewhere.
build_args+=(-p trx-server --no-default-features)
log "Building (release, no SDR)…"
else
log "Building (release, with SDR)…"
fi
cargo "${build_args[@]}"
fi
# --- Install binaries -------------------------------------------------------
SUDO=""
if need_sudo "$BINDIR"; then
SUDO="sudo"
log "Installing binaries to $BINDIR (using sudo)…"
else
log "Installing binaries to $BINDIR"
fi
for b in "${BINARIES[@]}"; do
src="$PROJECT_ROOT/target/release/$b"
if [ ! -x "$src" ]; then
echo "error: $src not found (build failed or --no-build with no prior build?)" >&2
exit 1
fi
$SUDO install -Dm755 "$src" "$BINDIR/$b"
log "installed $BINDIR/$b"
done
# --- Seed per-user config ---------------------------------------------------
if [ -e "$CONFIG_FILE" ]; then
log "Config already present, leaving it untouched: $CONFIG_FILE"
else
mkdir -p "$CONFIG_DIR"
install -m600 "$PROJECT_ROOT/trx-rs.toml.example" "$CONFIG_FILE"
log "Seeded config from example: $CONFIG_FILE"
warn "Edit $CONFIG_FILE for your rig before starting the services."
fi
# --- systemd user services --------------------------------------------------
if [ "$DO_SYSTEMD" -eq 1 ]; then
mkdir -p "$UNIT_DIR"
for unit in trx-server trx-client; do
sed "s|@BINDIR@|$BINDIR|g" \
"$PROJECT_ROOT/packaging/systemd/$unit.service" \
> "$UNIT_DIR/$unit.service"
log "installed $UNIT_DIR/$unit.service"
done
if command -v systemctl >/dev/null 2>&1; then
systemctl --user daemon-reload || warn "systemctl --user daemon-reload failed"
if [ "$ENABLE_NOW" -eq 1 ]; then
log "Enabling and starting user services…"
systemctl --user enable --now trx-server.service trx-client.service
warn "Run 'loginctl enable-linger $USER' to keep services running after logout."
else
cat <<EOF
Next steps:
1. Edit your config: \$EDITOR $CONFIG_FILE
2. Start the services: systemctl --user enable --now trx-server trx-client
3. Watch the logs: journalctl --user -u trx-server -u trx-client -f
4. Run after logout: loginctl enable-linger $USER
Web UI (default): http://127.0.0.1:8080
EOF
fi
else
warn "systemctl not found; units copied but not reloaded."
fi
fi
log "Done."
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Reverse script/install.sh: stop and remove the systemd user services and the
# installed binaries. User config (~/.config/trx-rs) is left in place unless
# --purge is given.
set -euo pipefail
BINARIES=(trx-server trx-client trx-configurator)
PREFIX="${PREFIX:-$HOME/.local}"
BINDIR="${BINDIR:-}"
PURGE=0
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/trx-rs"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
usage() {
cat <<EOF
Usage: script/uninstall.sh [options]
Options:
--prefix DIR Install prefix binaries were installed under (default: \$HOME/.local)
--bindir DIR Directory binaries were installed to (overrides --prefix)
--purge Also delete the user config directory ($CONFIG_DIR)
-h, --help Show this help
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--prefix) PREFIX="$2"; shift 2 ;;
--bindir) BINDIR="$2"; shift 2 ;;
--purge) PURGE=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown option '$1'" >&2; usage >&2; exit 2 ;;
esac
done
[ -n "$BINDIR" ] || BINDIR="$PREFIX/bin"
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
need_sudo() {
local dir="$1"
while [ ! -e "$dir" ]; do dir="$(dirname "$dir")"; done
[ -w "$dir" ] && return 1 || return 0
}
# --- Stop and remove user services -----------------------------------------
if command -v systemctl >/dev/null 2>&1; then
log "Stopping and disabling user services…"
systemctl --user disable --now trx-client.service trx-server.service 2>/dev/null || true
fi
for unit in trx-client trx-server; do
if [ -e "$UNIT_DIR/$unit.service" ]; then
rm -f "$UNIT_DIR/$unit.service"
log "removed $UNIT_DIR/$unit.service"
fi
done
if command -v systemctl >/dev/null 2>&1; then
systemctl --user daemon-reload || true
fi
# --- Remove binaries --------------------------------------------------------
SUDO=""
if need_sudo "$BINDIR"; then SUDO="sudo"; fi
for b in "${BINARIES[@]}"; do
if [ -e "$BINDIR/$b" ]; then
$SUDO rm -f "$BINDIR/$b"
log "removed $BINDIR/$b"
fi
done
# --- Optionally purge config ------------------------------------------------
if [ "$PURGE" -eq 1 ]; then
rm -rf "$CONFIG_DIR"
log "purged $CONFIG_DIR"
else
log "Left config in place: $CONFIG_DIR (use --purge to delete)"
fi
log "Done."
+1 -1
View File
@@ -77,11 +77,11 @@ pub struct ToneDetector {
impl ToneDetector { impl ToneDetector {
pub fn new(sample_rate: u32) -> Self { pub fn new(sample_rate: u32) -> Self {
let window_size = (sample_rate / 2) as usize; // ~0.5 s window
// APT start/stop tones are transmitted for ~5 s (WMO), so requiring a // APT start/stop tones are transmitted for ~5 s (WMO), so requiring a
// 2 s sustain costs no real detection latency while sharply cutting // 2 s sustain costs no real detection latency while sharply cutting
// false positives from busy image content that momentarily produces a // false positives from busy image content that momentarily produces a
// 300/450/675-transitions-per-second rate. // 300/450/675-transitions-per-second rate.
let window_size = (sample_rate / 2) as usize; // ~0.5 s window
let min_sustain_s = 2.0; let min_sustain_s = 2.0;
let window_duration_s = window_size as f32 / sample_rate as f32; let window_duration_s = window_size as f32 / sample_rate as f32;
let min_sustain_windows = (min_sustain_s / window_duration_s).ceil() as u32; let min_sustain_windows = (min_sustain_s / window_duration_s).ceil() as u32;
@@ -3729,10 +3729,19 @@ function visibleBandwidthSpecs(freqHz = lastFreqHz, mode = modeEl ? modeEl.value
} }
return [{ centerHz: freqHz, widthHz: currentBandwidthHz }]; return [{ centerHz: freqHz, widthHz: currentBandwidthHz }];
} }
function digSidebandDirection() {
if (sdrDigSidebandPolicy === "usb") return 1;
if (sdrDigSidebandPolicy === "lsb") return -1;
if (typeof lastFreqHz === "number" && isFiniteNumber(lastFreqHz)) {
return lastFreqHz >= DIG_AUTO_SIDEBAND_THRESHOLD_HZ ? 1 : -1;
}
return 1;
}
function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") { function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") {
const modeUpper = String(mode || "").toUpperCase(); const modeUpper = String(mode || "").toUpperCase();
if (modeUpper === "LSB" || modeUpper === "CWR") return -1; if (modeUpper === "LSB" || modeUpper === "CWR") return -1;
if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1; if (modeUpper === "DIG") return sdrDigSidebandSupported ? digSidebandDirection() : 1;
if (modeUpper === "USB" || modeUpper === "CW") return 1;
return 0; return 0;
} }
function displaySpanForBandwidthSpec(spec, mode = modeEl ? modeEl.value : "") { function displaySpanForBandwidthSpec(spec, mode = modeEl ? modeEl.value : "") {
@@ -4757,13 +4766,25 @@ 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;
const prevPolicy = sdrDigSidebandPolicy;
sdrDigSidebandPolicy = update.filter.sdr_dig_sideband;
if (sdrDigSidebandEl && document.activeElement !== sdrDigSidebandEl) { if (sdrDigSidebandEl && document.activeElement !== sdrDigSidebandEl) {
sdrDigSidebandEl.value = update.filter.sdr_dig_sideband; sdrDigSidebandEl.value = update.filter.sdr_dig_sideband;
} }
updateWfmControls(); updateWfmControls();
if (prevPolicy !== sdrDigSidebandPolicy && lastSpectrumData) {
scheduleSpectrumDraw();
}
} }
} }
if (typeof update.show_sdr_gain_control === "boolean") { if (typeof update.show_sdr_gain_control === "boolean") {
@@ -6630,10 +6651,15 @@ 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;
var sdrDigSidebandPolicy = "auto";
var DIG_AUTO_SIDEBAND_THRESHOLD_HZ = 1e7;
fetch("/audio", { method: "GET" }).then((r) => { fetch("/audio", { method: "GET" }).then((r) => {
if (r.status === 404) audioRow.style.display = "none"; if (r.status === 404) audioRow.style.display = "none";
}).catch(() => { }).catch(() => {
@@ -7003,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(() => {
}); });
} }
@@ -7012,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);
@@ -7033,6 +7069,8 @@ function submitSdrDigSideband() {
if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return; if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
const policy = sdrDigSidebandEl.value || "auto"; const policy = sdrDigSidebandEl.value || "auto";
if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return; if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return;
sdrDigSidebandPolicy = policy;
if (lastSpectrumData) scheduleSpectrumDraw();
postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => { postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {
}); });
} }
@@ -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 &ge; 10 MHz, LSB below.">DIG sideband</span> <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"> <select id="sdr-dig-sideband" class="status-input">
@@ -13,7 +13,8 @@ use trx_core::rig::{
RigVfoEntry, RigVfoEntry,
}; };
use trx_core::{ 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::rig::{RigListItem, RigListResponse};
use trx_frontend_http::server::api::FrontendMeta; use trx_frontend_http::server::api::FrontendMeta;
@@ -52,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.
@@ -2450,10 +2450,26 @@ function visibleBandwidthSpecs(freqHz: number | null = lastFreqHz, mode = modeEl
return [{ centerHz: freqHz, widthHz: currentBandwidthHz }]; return [{ centerHz: freqHz, widthHz: currentBandwidthHz }];
} }
// Resolve the DIG passband direction from the current policy and dial
// frequency, mirroring the backend's demodulator resolution so the spectrum
// overlay lands on the same sideband that is actually being demodulated.
function digSidebandDirection() {
if (sdrDigSidebandPolicy === "usb") return 1;
if (sdrDigSidebandPolicy === "lsb") return -1;
// "auto": USB at/above the threshold, LSB below.
if (typeof lastFreqHz === "number" && isFiniteNumber(lastFreqHz)) {
return lastFreqHz >= DIG_AUTO_SIDEBAND_THRESHOLD_HZ ? 1 : -1;
}
return 1;
}
function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") { function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") {
const modeUpper = String(mode || "").toUpperCase(); const modeUpper = String(mode || "").toUpperCase();
if (modeUpper === "LSB" || modeUpper === "CWR") return -1; if (modeUpper === "LSB" || modeUpper === "CWR") return -1;
if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1; // On SDR the DIG sideband is configurable; resolve it. On other backends
// keep the historical upper-sideband overlay.
if (modeUpper === "DIG") return sdrDigSidebandSupported ? digSidebandDirection() : 1;
if (modeUpper === "USB" || modeUpper === "CW") return 1;
return 0; return 0;
} }
@@ -3680,13 +3696,26 @@ 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;
const prevPolicy = sdrDigSidebandPolicy;
sdrDigSidebandPolicy = update.filter.sdr_dig_sideband;
if (sdrDigSidebandEl && document.activeElement !== sdrDigSidebandEl) { if (sdrDigSidebandEl && document.activeElement !== sdrDigSidebandEl) {
sdrDigSidebandEl.value = update.filter.sdr_dig_sideband; sdrDigSidebandEl.value = update.filter.sdr_dig_sideband;
} }
updateWfmControls(); updateWfmControls();
// Repaint the passband overlay if the DIG sideband just changed.
if (prevPolicy !== sdrDigSidebandPolicy && lastSpectrumData) {
scheduleSpectrumDraw();
}
} }
} }
if (typeof update.show_sdr_gain_control === "boolean") { if (typeof update.show_sdr_gain_control === "boolean") {
@@ -5617,10 +5646,20 @@ 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;
// Current DIG sideband policy ("auto" | "usb" | "lsb"), mirrored from server
// filter state. Drives the spectrum passband overlay direction for DIG.
let sdrDigSidebandPolicy = "auto";
// Matches DigSidebandPolicy::AUTO_THRESHOLD_HZ on the backend.
const DIG_AUTO_SIDEBAND_THRESHOLD_HZ = 10_000_000;
// Hide audio row if audio is not configured on the server // Hide audio row if audio is not configured on the server
fetch("/audio", { method: "GET" }).then((r) => { fetch("/audio", { method: "GET" }).then((r) => {
@@ -6047,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) {
@@ -6055,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);
@@ -6076,6 +6130,10 @@ function submitSdrDigSideband() {
if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return; if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
const policy = sdrDigSidebandEl.value || "auto"; const policy = sdrDigSidebandEl.value || "auto";
if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return; if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return;
// Optimistically move the passband overlay to the chosen sideband so the
// display responds instantly, before the server round-trip confirms it.
sdrDigSidebandPolicy = policy;
if (lastSpectrumData) scheduleSpectrumDraw();
postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {}); postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {});
} }
if (sdrDigSidebandEl) { if (sdrDigSidebandEl) {
@@ -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,
) )
+9 -2
View File
@@ -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,
} }
} }
} }
+2 -2
View File
@@ -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, RdsData, RigFilterState, RigMode, RigSnapshot, RigState, DecoderConfig, DecoderResetSeqs, DigSidebandPolicy, NoiseBlankerProfile, RdsData,
DigSidebandPolicy, WfmDenoiseLevel, RigFilterState, RigMode, RigSnapshot, RigState, WfmDenoiseLevel,
}; };
pub use rig::AudioSource; pub use rig::AudioSource;
+10 -3
View File
@@ -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.
@@ -48,8 +48,15 @@ pub enum RigCommand {
SetSdrGain(f64), SetSdrGain(f64),
SetSdrLnaGain(f64), SetSdrLnaGain(f64),
SetSdrAgc(bool), SetSdrAgc(bool),
SetSdrSquelch { enabled: bool, threshold_db: f64 }, SetSdrSquelch {
SetSdrNoiseBlanker { enabled: bool, threshold: f64 }, enabled: bool,
threshold_db: f64,
},
SetSdrNoiseBlanker {
enabled: bool,
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),
SetWfmDeemphasis(u32), SetWfmDeemphasis(u32),
+1
View File
@@ -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"))
+122 -2
View File
@@ -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
} }
@@ -646,8 +716,58 @@ mod dig_sideband_tests {
assert_eq!(DigSidebandPolicy::from_u8(p.to_u8()), p); assert_eq!(DigSidebandPolicy::from_u8(p.to_u8()), p);
} }
assert_eq!(DigSidebandPolicy::from_u8(200), DigSidebandPolicy::Auto); assert_eq!(DigSidebandPolicy::from_u8(200), DigSidebandPolicy::Auto);
assert_eq!(DigSidebandPolicy::parse("USB"), Some(DigSidebandPolicy::Usb)); assert_eq!(
assert_eq!(DigSidebandPolicy::parse(" lsb "), Some(DigSidebandPolicy::Lsb)); DigSidebandPolicy::parse("USB"),
Some(DigSidebandPolicy::Usb)
);
assert_eq!(
DigSidebandPolicy::parse(" lsb "),
Some(DigSidebandPolicy::Lsb)
);
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);
}
}
+2
View File
@@ -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,
+1 -1
View File
@@ -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:
+2 -1
View File
@@ -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,
+39 -5
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, 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,
})?; })?;
@@ -921,11 +922,10 @@ fn bound_sockets(cli: &Cli, cfg: &ServerConfig, rigs: &[RigInstanceConfig]) -> V
"[listen]", "[listen]",
)); ));
} }
let audio_ip = cli.listen.unwrap_or(cfg.audio.listen);
for rig in rigs { for rig in rigs {
if rig.audio.enabled { if rig.audio.enabled {
sockets.push(BoundSocket::new( sockets.push(BoundSocket::new(
audio_ip, cli.listen.unwrap_or(rig.audio.listen),
rig.audio.port, rig.audio.port,
format!("rig \"{}\" [audio]", rig.id), format!("rig \"{}\" [audio]", rig.id),
)); ));
@@ -1275,9 +1275,9 @@ async fn main() -> DynResult<()> {
} }
})); }));
// Spawn audio stack. // Spawn audio stack. --listen overrides every configured bind address;
// listen_override priority: --listen CLI flag > global [audio].listen > per-rig default. // otherwise each rig keeps its own [rigs.audio].listen value.
let audio_listen_override = cli.listen.or(Some(cfg.audio.listen)); let audio_listen_override = cli.listen;
#[cfg(feature = "soapysdr")] #[cfg(feature = "soapysdr")]
let audio_vchan_manager = sdr_vchan_manager.clone(); let audio_vchan_manager = sdr_vchan_manager.clone();
#[cfg(not(feature = "soapysdr"))] #[cfg(not(feature = "soapysdr"))]
@@ -1416,6 +1416,40 @@ mod tests {
assert!(parse_serial_addr(" ").is_err()); assert!(parse_serial_addr(" ").is_err());
} }
#[test]
fn bound_sockets_preserves_per_rig_audio_addresses() {
let cli = Cli::parse_from(["trx-server"]);
let cfg = ServerConfig::default();
let mut local = RigInstanceConfig::default();
local.id = "local".into();
local.audio.listen = "127.0.0.1".parse().unwrap();
local.audio.port = 4531;
let mut remote = RigInstanceConfig::default();
remote.id = "remote".into();
remote.audio.listen = "0.0.0.0".parse().unwrap();
remote.audio.port = 4532;
let sockets = bound_sockets(&cli, &cfg, &[local, remote]);
let local = sockets.iter().find(|s| s.port == 4531).unwrap();
let remote = sockets.iter().find(|s| s.port == 4532).unwrap();
assert_eq!(local.addr, "127.0.0.1".parse::<IpAddr>().unwrap());
assert_eq!(remote.addr, "0.0.0.0".parse::<IpAddr>().unwrap());
}
#[test]
fn bound_sockets_applies_cli_listen_to_every_audio_listener() {
let cli = Cli::parse_from(["trx-server", "--listen", "192.0.2.10"]);
let cfg = ServerConfig::default();
let mut rig = RigInstanceConfig::default();
rig.id = "remote".into();
rig.audio.listen = "127.0.0.1".parse().unwrap();
rig.audio.port = 4532;
let sockets = bound_sockets(&cli, &cfg, &[rig]);
let audio = sockets.iter().find(|s| s.port == 4532).unwrap();
assert_eq!(audio.addr, "192.0.2.10".parse::<IpAddr>().unwrap());
}
#[test] #[test]
fn default_audio_bandwidth_for_mode_table() { fn default_audio_bandwidth_for_mode_table() {
assert_eq!(default_audio_bandwidth_for_mode(&RigMode::USB), 3_000); assert_eq!(default_audio_bandwidth_for_mode(&RigMode::USB), 3_000);
+9 -3
View File
@@ -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}"
))); )));
@@ -777,7 +781,9 @@ async fn process_command(
RigCommand::SetSdrDigSideband(policy) => { RigCommand::SetSdrDigSideband(policy) => {
if let Some(sdr) = ctx.rig.as_sdr() { if let Some(sdr) = ctx.rig.as_sdr() {
if let Err(e) = sdr.set_sdr_dig_sideband(policy).await { if let Err(e) = sdr.set_sdr_dig_sideband(policy).await {
return Err(RigError::communication(format!("set_sdr_dig_sideband: {e}"))); return Err(RigError::communication(format!(
"set_sdr_dig_sideband: {e}"
)));
} }
} else { } else {
return Err(RigError::not_supported("set_sdr_dig_sideband")); return Err(RigError::not_supported("set_sdr_dig_sideband"));
@@ -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,
@@ -382,7 +389,8 @@ impl SoapySdrRig {
// Concrete demod mode the primary channel starts in. For DIG this // Concrete demod mode the primary channel starts in. For DIG this
// resolves the configured sideband policy against the initial dial // resolves the configured sideband policy against the initial dial
// frequency so the very first image/audio uses the right sideband. // frequency so the very first image/audio uses the right sideband.
let initial_primary_mode = effective_demod_mode(&initial_mode, dig_sideband, initial_freq.hz); let initial_primary_mode =
effective_demod_mode(&initial_mode, dig_sideband, initial_freq.hz);
let initial_is_dig = initial_mode == RigMode::DIG; let initial_is_dig = initial_mode == RigMode::DIG;
let rig = Self { let rig = Self {
@@ -409,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(),
@@ -477,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(),
}) })
} }
@@ -680,7 +690,8 @@ impl RigCat for SoapySdrRig {
// DIG carries no inherent sideband: resolve it to a concrete // DIG carries no inherent sideband: resolve it to a concrete
// USB/LSB demodulator from the policy + dial frequency. The logical // USB/LSB demodulator from the policy + dial frequency. The logical
// DIG mode is kept in `self.mode` (and RigState) for display. // DIG mode is kept in `self.mode` (and RigState) for display.
let effective = effective_demod_mode(&self.mode, self.channel_manager.dig_policy(), self.freq.hz); let effective =
effective_demod_mode(&self.mode, self.channel_manager.dig_policy(), self.freq.hz);
self.applied_primary_mode = effective.clone(); self.applied_primary_mode = effective.clone();
// Update the primary channel's demodulator in the live pipeline. // Update the primary channel's demodulator in the live pipeline.
{ {
@@ -983,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() {
@@ -993,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(())
@@ -1123,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,
+1
View File
@@ -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]