Compare commits

..
1 Commits
Author SHA1 Message Date
sjgandClaude Opus 4.8 571ef17295 [fix](trx-wefax): stop fragmenting one transmission into many images
CI / lint (push) Failing after 3s
CI / test (push) Successful in 9m9s
CI / frontend (push) Successful in 5m23s
CI / reuse (push) Successful in 19s
The WEFAX decoder decoded content but chopped a single chart into many
short PNG "chunks". Two heuristics fought each other: in State::Receiving
the carrier-loss watchdog finalized the image after only 30 low-correlation
scan lines (~15 s at 120 LPM), and the Idle variance auto-start then
re-triggered on the still-present carrier ~3 s later, starting a fresh
image. Ordinary HF fading (QSB) of 5-20 s therefore split every chart into
a stream of tiny images.

Reference decoders (fldigi) keep one continuous image per APT cycle up to a
large line cap and only stop on the APT stop tone or genuine signal loss.
Align with that model:

- Raise the end-of-transmission watchdog to 120 low-correlation lines
  (~60 s at 120 LPM) so normal fades ride through within one image.
- Gate the variance-based auto-start to fire at most once per session
  (auto_start_used). After the first image a new one starts only on an APT
  start tone or an explicit reset, so trailing carrier/noise can no longer
  spawn a second image. reset() re-arms it.
- Cap a single image at 3000 lines to bound memory on an open carrier.
- Require a 2 s (was 1 s) APT tone sustain, cutting false Stop detections on
  busy image content that momentarily hits ~450 transitions/s.

Also make line slicing drift-free: 120 LPM at 11025 Hz is 5512.5 samples per
line, and slicing on the rounded integer accumulated a fractional-sample
error every line (slow horizontal slant). Boundaries are now derived from the
exact fractional line length (samples_per_line_f64) so the error never
accumulates.

Adds regression tests for the single-auto-start invariant and zero slicer
drift over 1000 lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-08-15 23:27:32 +02:00
36 changed files with 112 additions and 1891 deletions
+4 -4
View File
@@ -27,7 +27,7 @@ env:
jobs: jobs:
lint: lint:
runs-on: self-hosted runs-on: ubuntu-latest
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: self-hosted runs-on: ubuntu-latest
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: self-hosted runs-on: ubuntu-latest
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: self-hosted runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: fsfe/reuse-action@v5 - uses: fsfe/reuse-action@v5
-38
View File
@@ -116,44 +116,6 @@ 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
@@ -1,287 +0,0 @@
# 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.
+18 -60
View File
@@ -752,30 +752,9 @@ 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 — the only point in on raw IQ samples before any mixing or filtering takes place. It works by
the chain where an impulse is still short in time, since the narrow channel tracking a running RMS level of the signal and replacing any sample whose
filter downstream smears it into un-removable ringing. magnitude exceeds **threshold x RMS** with the last known clean sample.
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)
@@ -792,7 +771,6 @@ 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]`:
@@ -801,30 +779,15 @@ 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 tracked noise floor. A sample whose magnitude exceeds this multiple is blanked. Lower values blank more aggressively; higher values only catch strong impulses. | | `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. |
| `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.
@@ -850,44 +813,39 @@ the running average signal level.
### Web UI ### Web UI
When the server reports noise-blanker support, these controls appear in the When the server reports noise-blanker support, two 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 sensitivity. Press Enter or click Set to apply. detection threshold. Press Enter or click Set to apply.
- **NB profile** selector — chooses the profile (Spike / Ignition / Powerline /
Broadband). Changing it applies immediately.
The controls stay hidden until the server sends filter state containing NB Both 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&profile=spike POST /set_sdr_noise_blanker?enabled=true&threshold=10
``` ```
| 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 *before* the mixer stage in the DSP pipeline, The blanker runs on every IQ block (4096 samples) *before* the mixer stage in
one sample at a time: the DSP pipeline:
1. Emit the sample from the look-ahead delay line and ingest the fresh one. 1. For each sample, compute magnitude² (`re² + im²`).
2. Compute the fresh sample's power (`re² + im²`) and compare it against 2. Compare against `threshold² × mean_sq` (the exponentially-smoothed running
`threshold² × noise_floor`. mean of magnitude²).
3. If it exceeds the threshold, hold the gate closed for the profile's blank 3. If the sample exceeds the threshold, replace it with the previous clean
window; the fresh sample reaches the output a few samples later, by which sample.
time the gate has fully ramped to zero — so the leading edge is removed. 4. Otherwise, update the running mean with smoothing factor α = 1/128 and store
4. Otherwise, update the noise-floor estimate (skipped while blanking) and let the sample as the last clean value.
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
@@ -1,38 +0,0 @@
# 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
@@ -1,38 +0,0 @@
# 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
@@ -1,156 +0,0 @@
#!/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
@@ -1,86 +0,0 @@
#!/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,19 +3729,10 @@ 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 === "DIG") return sdrDigSidebandSupported ? digSidebandDirection() : 1; if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 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 : "") {
@@ -4766,25 +4757,6 @@ 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") {
sdrDigSidebandSupported = true;
const prevPolicy = sdrDigSidebandPolicy;
sdrDigSidebandPolicy = update.filter.sdr_dig_sideband;
if (sdrDigSidebandEl && document.activeElement !== sdrDigSidebandEl) {
sdrDigSidebandEl.value = update.filter.sdr_dig_sideband;
}
updateWfmControls();
if (prevPolicy !== sdrDigSidebandPolicy && lastSpectrumData) {
scheduleSpectrumDraw();
}
} }
} }
if (typeof update.show_sdr_gain_control === "boolean") { if (typeof update.show_sdr_gain_control === "boolean") {
@@ -6651,15 +6623,7 @@ 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 sdrDigSidebandEl = document.getElementById("sdr-dig-sideband");
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(() => {
@@ -7029,7 +6993,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)}&profile=${encodeURIComponent(sdrNbProfile)}` `/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`
).catch(() => { ).catch(() => {
}); });
} }
@@ -7038,16 +7002,6 @@ 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);
@@ -7065,28 +7019,11 @@ if (sdrNbThresholdEl) {
} }
}); });
} }
function submitSdrDigSideband() {
if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
const policy = sdrDigSidebandEl.value || "auto";
if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return;
sdrDigSidebandPolicy = policy;
if (lastSpectrumData) scheduleSpectrumDraw();
postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {
});
}
if (sdrDigSidebandEl) {
sdrDigSidebandEl.addEventListener("change", () => {
submitSdrDigSideband();
});
}
function updateWfmControls() { function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase(); const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none"; if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none"; if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
if (modeControlsRow) modeControlsRow.style.display = mode === "WFM" || mode === "SAM" ? "" : "none"; if (modeControlsRow) modeControlsRow.style.display = mode === "WFM" || mode === "SAM" ? "" : "none";
if (sdrDigSidebandWrapEl) {
sdrDigSidebandWrapEl.style.display = sdrDigSidebandSupported && mode === "DIG" ? "" : "none";
}
} }
if (!hasWebCodecs) { if (!hasWebCodecs) {
rxAudioBtn.disabled = true; rxAudioBtn.disabled = true;
@@ -214,12 +214,6 @@ function bmBuildDecoderCheckboxes() {
container.appendChild(label); container.appendChild(label);
}); });
} }
function bmSyncDigSidebandVisibility() {
const label = bmOptionalEl("bm-dig-sideband-label");
if (!label) return;
const mode = (bmEl("bm-mode").value || "").trim().toUpperCase();
label.style.display = mode === "DIG" ? "" : "none";
}
function bmOpenForm(bm) { function bmOpenForm(bm) {
const wrap = bmEl("bm-form-wrap"); const wrap = bmEl("bm-form-wrap");
if (!wrap) return; if (!wrap) return;
@@ -233,9 +227,7 @@ function bmOpenForm(bm) {
bmEl("bm-locator").value = bm ? bm.locator || "" : ""; bmEl("bm-locator").value = bm ? bm.locator || "" : "";
bmEl("bm-category-input").value = bm ? bm.category || "" : ""; bmEl("bm-category-input").value = bm ? bm.category || "" : "";
bmEl("bm-comment").value = bm ? bm.comment || "" : ""; bmEl("bm-comment").value = bm ? bm.comment || "" : "";
bmEl("bm-dig-sideband").value = bm ? bm.dig_sideband || "" : "";
bmWriteDecoders(bm?.decoders ?? []); bmWriteDecoders(bm?.decoders ?? []);
bmSyncDigSidebandVisibility();
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark"; bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex"; wrap.style.display = "flex";
bmEl("bm-name").focus(); bmEl("bm-name").focus();
@@ -271,7 +263,6 @@ async function bmSave(e) {
const category = bmEl("bm-category-input").value.trim(); const category = bmEl("bm-category-input").value.trim();
const comment = bmEl("bm-comment").value.trim(); const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders(); const decoders = bmReadDecoders();
const dig_sideband = mode.toUpperCase() === "DIG" ? bmEl("bm-dig-sideband").value || null : null;
const formError = bmEl("bm-form-error"); const formError = bmEl("bm-form-error");
if (formError) formError.textContent = ""; if (formError) formError.textContent = "";
if (!name || !Number.isFinite(freq_hz) || !mode) { if (!name || !Number.isFinite(freq_hz) || !mode) {
@@ -288,8 +279,7 @@ async function bmSave(e) {
locator: locator || null, locator: locator || null,
category, category,
comment, comment,
decoders, decoders
dig_sideband
}; };
try { try {
let resp; let resp;
@@ -354,12 +344,6 @@ function bmApply(bm) {
} }
const tunePromise = (async () => { const tunePromise = (async () => {
await bridge.trx.modules.vchan?.takeSchedulerControl(); await bridge.trx.modules.vchan?.takeSchedulerControl();
if ((bm.mode || "").toUpperCase() === "DIG" && bm.dig_sideband) {
const p = bm.dig_sideband.toLowerCase();
if (p === "auto" || p === "usb" || p === "lsb") {
await hostCore.postPath("/set_sdr_dig_sideband?policy=" + encodeURIComponent(p));
}
}
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false; const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) { if (!onVirtual) {
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
@@ -612,7 +596,6 @@ function bmPopulateScopePicker() {
bmEl("bm-form").addEventListener("submit", (event) => { bmEl("bm-form").addEventListener("submit", (event) => {
void bmSave(event); void bmSave(event);
}); });
bmEl("bm-mode").addEventListener("input", bmSyncDigSidebandVisibility);
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm); bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
const formWrap = bmEl("bm-form-wrap"); const formWrap = bmEl("bm-form-wrap");
if (formWrap) { if (formWrap) {
@@ -385,23 +385,6 @@ 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;">
<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">
<option value="auto">Auto (by band)</option>
<option value="usb">USB</option>
<option value="lsb">LSB</option>
</select>
</label>
</div> </div>
</div> </div>
<div class="full-row label-below-row" id="vchan-row"> <div class="full-row label-below-row" id="vchan-row">
@@ -738,14 +721,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<label class="bm-label">Bandwidth (Hz) <label class="bm-label">Bandwidth (Hz)
<input type="number" id="bm-bw" class="status-input" min="0" placeholder="optional" /> <input type="number" id="bm-bw" class="status-input" min="0" placeholder="optional" />
</label> </label>
<label class="bm-label" id="bm-dig-sideband-label" style="display:none;">DIG sideband
<select id="bm-dig-sideband" class="status-input">
<option value="">Global setting</option>
<option value="auto">Auto (by band)</option>
<option value="usb">USB</option>
<option value="lsb">LSB</option>
</select>
</label>
<label class="bm-label">Locator <label class="bm-label">Locator
<input type="text" id="bm-locator" class="status-input" maxlength="6" placeholder="e.g. JO93" /> <input type="text" id="bm-locator" class="status-input" maxlength="6" placeholder="e.g. JO93" />
</label> </label>
@@ -12,10 +12,7 @@ use trx_core::rig::{
RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo, RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo,
RigVfoEntry, RigVfoEntry,
}; };
use trx_core::{ use trx_core::{DecoderConfig, 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;
use trx_frontend_http::server::auth::AuthRole; use trx_frontend_http::server::auth::AuthRole;
@@ -52,8 +49,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
export!(RigStatus); export!(RigStatus);
export!(DecoderConfig); export!(DecoderConfig);
export!(WfmDenoiseLevel); export!(WfmDenoiseLevel);
export!(DigSidebandPolicy);
export!(NoiseBlankerProfile);
export!(RigFilterState); export!(RigFilterState);
export!(RdsData); export!(RdsData);
export!(SpectrumData); export!(SpectrumData);
@@ -55,21 +55,7 @@ export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabl
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high"; export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
export type DigSidebandPolicy = "auto" | "usb" | "lsb"; 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, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel,
export type NoiseBlankerProfile = "spike" | "ignition" | "powerline" | "broadband";
export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null,
/**
* Current noise-blanker tuning profile (SDR backends only). Surfaces in the
* UI as the advanced-controls "NB profile" selector.
*/
sdr_nb_profile?: NoiseBlankerProfile | null,
/**
* Current DIG sideband policy (SDR backends only). Surfaces in the UI as
* the advanced-controls "DIG sideband" selector.
*/
sdr_dig_sideband?: DigSidebandPolicy | null, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel,
/** /**
* Co-Channel Interference level (0100 scale). * Co-Channel Interference level (0100 scale).
*/ */
@@ -2450,26 +2450,10 @@ 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;
// On SDR the DIG sideband is configurable; resolve it. On other backends if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1;
// keep the historical upper-sideband overlay.
if (modeUpper === "DIG") return sdrDigSidebandSupported ? digSidebandDirection() : 1;
if (modeUpper === "USB" || modeUpper === "CW") return 1;
return 0; return 0;
} }
@@ -3696,26 +3680,6 @@ 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") {
sdrDigSidebandSupported = true;
const prevPolicy = sdrDigSidebandPolicy;
sdrDigSidebandPolicy = update.filter.sdr_dig_sideband;
if (sdrDigSidebandEl && document.activeElement !== sdrDigSidebandEl) {
sdrDigSidebandEl.value = update.filter.sdr_dig_sideband;
}
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") {
@@ -5646,20 +5610,7 @@ 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 sdrDigSidebandEl = document.getElementById("sdr-dig-sideband") as HTMLSelectElement | null;
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) => {
@@ -6086,7 +6037,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)}&profile=${encodeURIComponent(sdrNbProfile)}`, `/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`,
).catch(() => {}); ).catch(() => {});
} }
if (sdrNbEnabledEl) { if (sdrNbEnabledEl) {
@@ -6094,21 +6045,6 @@ 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);
@@ -6126,21 +6062,6 @@ if (sdrNbThresholdEl) {
} }
}); });
} }
function submitSdrDigSideband() {
if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
const policy = sdrDigSidebandEl.value || "auto";
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(() => {});
}
if (sdrDigSidebandEl) {
sdrDigSidebandEl.addEventListener("change", () => {
submitSdrDigSideband();
});
}
function updateWfmControls() { function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase(); const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none"; if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
@@ -6148,11 +6069,6 @@ function updateWfmControls() {
// The row holds only these two, so it goes with them — an empty one would // The row holds only these two, so it goes with them — an empty one would
// still take a track and a gap in the tray, and draw its divider. // still take a track and a gap in the tray, and draw its divider.
if (modeControlsRow) modeControlsRow.style.display = (mode === "WFM" || mode === "SAM") ? "" : "none"; if (modeControlsRow) modeControlsRow.style.display = (mode === "WFM" || mode === "SAM") ? "" : "none";
// DIG sideband selector is only meaningful in DIG mode on an SDR backend.
if (sdrDigSidebandWrapEl) {
sdrDigSidebandWrapEl.style.display =
sdrDigSidebandSupported && mode === "DIG" ? "" : "none";
}
} }
// Show compatibility warning for non-Chromium browsers // Show compatibility warning for non-Chromium browsers
@@ -19,8 +19,6 @@ interface Bookmark {
category?: string | null; category?: string | null;
comment?: string | null; comment?: string | null;
decoders?: string[]; decoders?: string[];
/** DIG sideband override: "auto" | "usb" | "lsb". Empty/absent = global. */
dig_sideband?: string | null;
scope?: string; scope?: string;
} }
@@ -323,14 +321,6 @@ function bmBuildDecoderCheckboxes() {
}); });
} }
/** Show the DIG-sideband override selector only when the mode field is DIG. */
function bmSyncDigSidebandVisibility(): void {
const label = bmOptionalEl("bm-dig-sideband-label");
if (!label) return;
const mode = (bmEl("bm-mode").value || "").trim().toUpperCase();
label.style.display = mode === "DIG" ? "" : "none";
}
function bmOpenForm(bm: Bookmark | null): void { function bmOpenForm(bm: Bookmark | null): void {
const wrap = bmEl("bm-form-wrap"); const wrap = bmEl("bm-form-wrap");
if (!wrap) return; if (!wrap) return;
@@ -348,9 +338,7 @@ function bmOpenForm(bm: Bookmark | null): void {
bmEl("bm-locator").value = bm ? (bm.locator || "") : ""; bmEl("bm-locator").value = bm ? (bm.locator || "") : "";
bmEl("bm-category-input").value = bm ? (bm.category || "") : ""; bmEl("bm-category-input").value = bm ? (bm.category || "") : "";
bmEl("bm-comment").value = bm ? (bm.comment || "") : ""; bmEl("bm-comment").value = bm ? (bm.comment || "") : "";
bmEl("bm-dig-sideband").value = bm ? (bm.dig_sideband || "") : "";
bmWriteDecoders(bm?.decoders ?? []); bmWriteDecoders(bm?.decoders ?? []);
bmSyncDigSidebandVisibility();
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark"; bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex"; wrap.style.display = "flex";
@@ -395,9 +383,6 @@ async function bmSave(e: Event): Promise<void> {
const category = bmEl("bm-category-input").value.trim(); const category = bmEl("bm-category-input").value.trim();
const comment = bmEl("bm-comment").value.trim(); const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders(); const decoders = bmReadDecoders();
// Only meaningful for DIG; stored empty otherwise so it never overrides.
const dig_sideband =
mode.toUpperCase() === "DIG" ? (bmEl("bm-dig-sideband").value || null) : null;
const formError = bmEl("bm-form-error"); const formError = bmEl("bm-form-error");
if (formError) formError.textContent = ""; if (formError) formError.textContent = "";
@@ -418,7 +403,6 @@ async function bmSave(e: Event): Promise<void> {
category, category,
comment, comment,
decoders, decoders,
dig_sideband,
}; };
try { try {
@@ -493,16 +477,6 @@ function bmApply(bm: Bookmark): void {
const tunePromise = (async () => { const tunePromise = (async () => {
await bridge.trx.modules.vchan?.takeSchedulerControl(); await bridge.trx.modules.vchan?.takeSchedulerControl();
// For a DIG bookmark carrying a sideband override, set the global DIG
// policy first so the backend resolves the intended sideband when DIG is
// applied. "auto" is honoured explicitly; an empty override is left alone.
if ((bm.mode || "").toUpperCase() === "DIG" && bm.dig_sideband) {
const p = bm.dig_sideband.toLowerCase();
if (p === "auto" || p === "usb" || p === "lsb") {
await hostCore.postPath("/set_sdr_dig_sideband?policy=" + encodeURIComponent(p));
}
}
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false; const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) { if (!onVirtual) {
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
@@ -796,9 +770,6 @@ function bmPopulateScopePicker() {
// Form submit // Form submit
bmEl("bm-form").addEventListener("submit", (event) => { void bmSave(event); }); bmEl("bm-form").addEventListener("submit", (event) => { void bmSave(event); });
// Reveal the DIG-sideband override only while the mode field reads DIG.
bmEl("bm-mode").addEventListener("input", bmSyncDigSidebandVisibility);
// Form cancel // Form cancel
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm); bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
@@ -37,7 +37,6 @@ pub struct BookmarkInput {
pub comment: Option<String>, pub comment: Option<String>,
pub category: Option<String>, pub category: Option<String>,
pub decoders: Option<Vec<String>>, pub decoders: Option<Vec<String>>,
pub dig_sideband: Option<String>,
} }
/// A bookmark with its owning scope tag for the list response. /// A bookmark with its owning scope tag for the list response.
@@ -89,17 +88,6 @@ fn normalize_bookmark_locator(locator: Option<String>) -> Option<String> {
}) })
} }
/// Normalize a DIG sideband override to `auto`/`usb`/`lsb`, or `None` when
/// absent or unrecognized (leaves the current policy untouched on apply).
fn normalize_dig_sideband(value: Option<String>) -> Option<String> {
value.and_then(|v| match v.trim().to_lowercase().as_str() {
"auto" => Some("auto".to_string()),
"usb" => Some("usb".to_string()),
"lsb" => Some("lsb".to_string()),
_ => None,
})
}
// ============================================================================ // ============================================================================
// Endpoints // Endpoints
// ============================================================================ // ============================================================================
@@ -194,7 +182,6 @@ pub async fn create_bookmark(
comment: body.comment.clone().unwrap_or_default(), comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(), category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(), decoders: body.decoders.clone().unwrap_or_default(),
dig_sideband: normalize_dig_sideband(body.dig_sideband.clone()),
}; };
if store.insert(&bm) { if store.insert(&bm) {
Ok(HttpResponse::Created().json(bm)) Ok(HttpResponse::Created().json(bm))
@@ -232,7 +219,6 @@ pub async fn update_bookmark(
comment: body.comment.clone().unwrap_or_default(), comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(), category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(), decoders: body.decoders.clone().unwrap_or_default(),
dig_sideband: normalize_dig_sideband(body.dig_sideband.clone()),
}; };
if store.upsert(&id, &bm) { if store.upsert(&id, &bm) {
Ok(HttpResponse::Ok().json(bm)) Ok(HttpResponse::Ok().json(bm))
@@ -595,7 +595,6 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(rig::set_sdr_agc) .service(rig::set_sdr_agc)
.service(rig::set_sdr_squelch) .service(rig::set_sdr_squelch)
.service(rig::set_sdr_noise_blanker) .service(rig::set_sdr_noise_blanker)
.service(rig::set_sdr_dig_sideband)
.service(rig::set_wfm_deemphasis) .service(rig::set_wfm_deemphasis)
.service(rig::set_wfm_stereo) .service(rig::set_wfm_stereo)
.service(rig::set_wfm_denoise) .service(rig::set_wfm_denoise)
@@ -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, NoiseBlankerProfile, RigCommand, RigRequest, RigState}; use trx_core::{RigCommand, RigRequest, RigState};
use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry}; use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry};
use trx_protocol::parse_mode; use trx_protocol::parse_mode;
@@ -279,10 +279,6 @@ 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>,
} }
@@ -297,30 +293,12 @@ 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,
) )
.await .await
} }
#[derive(serde::Deserialize)]
pub struct SdrDigSidebandQuery {
/// `auto`, `usb`, or `lsb`.
pub policy: DigSidebandPolicy,
pub remote: Option<String>,
}
/// Set how the SDR backend resolves DIG mode to a sideband.
#[post("/set_sdr_dig_sideband")]
pub async fn set_sdr_dig_sideband(
query: web::Query<SdrDigSidebandQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetSdrDigSideband(q.policy), q.remote).await
}
// ============================================================================ // ============================================================================
// WFM / SAM settings // WFM / SAM settings
// ============================================================================ // ============================================================================
@@ -21,10 +21,6 @@ pub struct Bookmark {
pub comment: String, pub comment: String,
pub category: String, pub category: String,
pub decoders: Vec<String>, pub decoders: Vec<String>,
/// For `DIG` bookmarks, the sideband policy to apply on tune: `auto`,
/// `usb`, or `lsb`. `None` (or non-DIG) leaves the current policy alone.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dig_sideband: Option<String>,
} }
pub struct BookmarkStore { pub struct BookmarkStore {
@@ -592,21 +592,6 @@ async fn apply_scheduler_target(
) )
.await?; .await?;
// Apply any DIG sideband override before switching to DIG so the SDR
// backend resolves the intended sideband for this bookmark.
if let Some(policy) = bookmark
.dig_sideband
.as_deref()
.and_then(trx_core::DigSidebandPolicy::parse)
{
scheduler_send(
rig_tx,
RigCommand::SetSdrDigSideband(policy),
remote.to_string(),
)
.await?;
}
scheduler_send( scheduler_send(
rig_tx, rig_tx,
RigCommand::SetMode(trx_protocol::parse_mode(&bookmark.mode)), RigCommand::SetMode(trx_protocol::parse_mode(&bookmark.mode)),
+2 -14
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, NoiseBlankerProfile, RigMode}; use trx_core::rig::state::RigMode;
/// Every decoder the server knows how to run, by config name. /// Every decoder the server knows how to run, by config name.
/// ///
@@ -393,10 +393,6 @@ pub struct SdrConfig {
pub squelch: SdrSquelchConfig, pub squelch: SdrSquelchConfig,
/// Noise blanker for impulse noise suppression on IQ samples. /// Noise blanker for impulse noise suppression on IQ samples.
pub noise_blanker: SdrNoiseBlankerConfig, pub noise_blanker: SdrNoiseBlankerConfig,
/// How DIG mode picks a sideband: `auto` (USB ≥ 10 MHz, LSB below),
/// `usb`, or `lsb`. Overridable at runtime and per-bookmark.
#[serde(default)]
pub dig_sideband: DigSidebandPolicy,
/// Virtual receiver channels (at least one required when SDR backend is active). /// Virtual receiver channels (at least one required when SDR backend is active).
pub channels: Vec<SdrChannelConfig>, pub channels: Vec<SdrChannelConfig>,
/// Maximum number of simultaneous virtual channels (including the primary). /// Maximum number of simultaneous virtual channels (including the primary).
@@ -437,7 +433,6 @@ impl Default for SdrConfig {
gain: SdrGainConfig::default(), gain: SdrGainConfig::default(),
squelch: SdrSquelchConfig::default(), squelch: SdrSquelchConfig::default(),
noise_blanker: SdrNoiseBlankerConfig::default(), noise_blanker: SdrNoiseBlankerConfig::default(),
dig_sideband: DigSidebandPolicy::default(),
channels: Vec::new(), channels: Vec::new(),
max_virtual_channels: default_max_virtual_channels(), max_virtual_channels: default_max_virtual_channels(),
spectrum_fft_size: default_spectrum_fft_size(), spectrum_fft_size: default_spectrum_fft_size(),
@@ -478,14 +473,8 @@ 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 × the tracked noise floor is /// A sample whose magnitude exceeds threshold × running RMS is blanked.
/// 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 {
@@ -493,7 +482,6 @@ 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, DigSidebandPolicy, NoiseBlankerProfile, RdsData, DecoderConfig, DecoderResetSeqs, RdsData, RigFilterState, RigMode, RigSnapshot, RigState,
RigFilterState, RigMode, RigSnapshot, RigState, WfmDenoiseLevel, WfmDenoiseLevel,
}; };
pub use rig::AudioSource; pub use rig::AudioSource;
+3 -12
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, NoiseBlankerProfile, WfmDenoiseLevel}; use crate::rig::state::WfmDenoiseLevel;
use crate::RigMode; use crate::RigMode;
/// Internal command handled by the rig task. /// Internal command handled by the rig task.
@@ -48,17 +48,8 @@ pub enum RigCommand {
SetSdrGain(f64), SetSdrGain(f64),
SetSdrLnaGain(f64), SetSdrLnaGain(f64),
SetSdrAgc(bool), SetSdrAgc(bool),
SetSdrSquelch { SetSdrSquelch { enabled: bool, threshold_db: f64 },
enabled: bool, SetSdrNoiseBlanker { enabled: bool, threshold: f64 },
threshold_db: f64,
},
SetSdrNoiseBlanker {
enabled: bool,
threshold: f64,
profile: NoiseBlankerProfile,
},
/// Set how the SDR backend resolves DIG mode to a sideband (SDR only).
SetSdrDigSideband(DigSidebandPolicy),
SetWfmDeemphasis(u32), SetWfmDeemphasis(u32),
SetWfmStereo(bool), SetWfmStereo(bool),
SetWfmDenoise(WfmDenoiseLevel), SetWfmDenoise(WfmDenoiseLevel),
@@ -470,7 +470,6 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
| RigCommand::SetSdrAgc(_) | RigCommand::SetSdrAgc(_)
| RigCommand::SetSdrSquelch { .. } | RigCommand::SetSdrSquelch { .. }
| RigCommand::SetSdrNoiseBlanker { .. } | RigCommand::SetSdrNoiseBlanker { .. }
| RigCommand::SetSdrDigSideband(_)
| RigCommand::SetWfmDeemphasis(_) | RigCommand::SetWfmDeemphasis(_)
| RigCommand::SetWfmStereo(_) | RigCommand::SetWfmStereo(_)
| RigCommand::SetWfmDenoise(_) | RigCommand::SetWfmDenoise(_)
-12
View File
@@ -253,7 +253,6 @@ 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"))
@@ -261,17 +260,6 @@ pub trait RigSdr: Send {
))) )))
} }
/// Set how DIG mode resolves to a sideband (SDR backends only).
fn set_sdr_dig_sideband<'a>(
&'a mut self,
_policy: crate::rig::state::DigSidebandPolicy,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sdr_dig_sideband"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_wfm_stereo<'a>( fn set_wfm_stereo<'a>(
&'a mut self, &'a mut self,
_enabled: bool, _enabled: bool,
-265
View File
@@ -338,14 +338,6 @@ 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
/// the advanced-controls "DIG sideband" selector.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_dig_sideband: Option<DigSidebandPolicy>,
#[serde(default = "default_wfm_deemphasis_us")] #[serde(default = "default_wfm_deemphasis_us")]
pub wfm_deemphasis_us: u32, pub wfm_deemphasis_us: u32,
#[serde(default = "default_wfm_stereo")] #[serde(default = "default_wfm_stereo")]
@@ -378,155 +370,6 @@ pub enum WfmDenoiseLevel {
High, High,
} }
/// How the SDR backend resolves the [`RigMode::DIG`] mode to a concrete
/// sideband when demodulating.
///
/// DIG (data / digital) has no inherent sideband; by ham-radio convention it
/// follows the SSB convention for the band. `Auto` applies that convention
/// (USB at/above 10 MHz, LSB below); `Usb`/`Lsb` force a fixed sideband. The
/// setting is a global SDR control (see advanced radio controls) and may be
/// overridden per-bookmark.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "lowercase")]
pub enum DigSidebandPolicy {
/// Pick USB or LSB from the dial frequency (USB ≥ 10 MHz, LSB below).
#[default]
Auto,
/// Always demodulate DIG as upper sideband.
Usb,
/// Always demodulate DIG as lower sideband.
Lsb,
}
impl DigSidebandPolicy {
/// Frequency boundary used by [`DigSidebandPolicy::Auto`]: at or above this
/// dial frequency DIG is upper sideband, below it is lower sideband. This is
/// the standard amateur SSB/data convention.
pub const AUTO_THRESHOLD_HZ: u64 = 10_000_000;
/// Resolve this policy to a concrete sideband mode ([`RigMode::USB`] or
/// [`RigMode::LSB`]) for the given dial frequency.
pub fn resolve(self, freq_hz: u64) -> RigMode {
match self {
DigSidebandPolicy::Usb => RigMode::USB,
DigSidebandPolicy::Lsb => RigMode::LSB,
DigSidebandPolicy::Auto => {
if freq_hz >= Self::AUTO_THRESHOLD_HZ {
RigMode::USB
} else {
RigMode::LSB
}
}
}
}
/// Compact encoding for storage in an atomic (e.g. shared runtime state).
pub fn to_u8(self) -> u8 {
match self {
DigSidebandPolicy::Auto => 0,
DigSidebandPolicy::Usb => 1,
DigSidebandPolicy::Lsb => 2,
}
}
/// Inverse of [`DigSidebandPolicy::to_u8`]; unknown values decode to `Auto`.
pub fn from_u8(v: u8) -> Self {
match v {
1 => DigSidebandPolicy::Usb,
2 => DigSidebandPolicy::Lsb,
_ => DigSidebandPolicy::Auto,
}
}
/// Parse a case-insensitive `auto`/`usb`/`lsb` string; `None` if unknown.
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Some(DigSidebandPolicy::Auto),
"usb" => Some(DigSidebandPolicy::Usb),
"lsb" => Some(DigSidebandPolicy::Lsb),
_ => None,
}
}
}
/// Resolve a channel's effective demodulation mode. For [`RigMode::DIG`] this
/// applies the sideband `policy` at the given dial frequency; every other mode
/// is returned unchanged. Callers hand the result to the DSP while keeping the
/// logical `DIG` mode for display and decoder gating.
pub fn effective_demod_mode(logical: &RigMode, policy: DigSidebandPolicy, freq_hz: u64) -> RigMode {
if *logical == RigMode::DIG {
policy.resolve(freq_hz)
} else {
logical.clone()
}
}
/// 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
} }
@@ -663,111 +506,3 @@ pub struct RigSnapshot {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub vchan_rds: Option<Vec<VchanRdsEntry>>, pub vchan_rds: Option<Vec<VchanRdsEntry>>,
} }
#[cfg(test)]
mod dig_sideband_tests {
use super::{effective_demod_mode, DigSidebandPolicy, RigMode};
#[test]
fn auto_follows_ssb_band_convention() {
// Below 10 MHz → LSB; at/above 10 MHz → USB.
assert_eq!(DigSidebandPolicy::Auto.resolve(7_074_000), RigMode::LSB);
assert_eq!(DigSidebandPolicy::Auto.resolve(3_580_000), RigMode::LSB);
assert_eq!(DigSidebandPolicy::Auto.resolve(9_999_999), RigMode::LSB);
assert_eq!(DigSidebandPolicy::Auto.resolve(10_000_000), RigMode::USB);
assert_eq!(DigSidebandPolicy::Auto.resolve(14_074_000), RigMode::USB);
}
#[test]
fn forced_policies_ignore_frequency() {
assert_eq!(DigSidebandPolicy::Usb.resolve(3_580_000), RigMode::USB);
assert_eq!(DigSidebandPolicy::Lsb.resolve(14_074_000), RigMode::LSB);
}
#[test]
fn effective_mode_only_rewrites_dig() {
// Non-DIG modes pass through untouched regardless of policy/frequency.
assert_eq!(
effective_demod_mode(&RigMode::USB, DigSidebandPolicy::Lsb, 3_580_000),
RigMode::USB
);
assert_eq!(
effective_demod_mode(&RigMode::FM, DigSidebandPolicy::Usb, 3_580_000),
RigMode::FM
);
// DIG resolves by policy + frequency.
assert_eq!(
effective_demod_mode(&RigMode::DIG, DigSidebandPolicy::Auto, 7_074_000),
RigMode::LSB
);
assert_eq!(
effective_demod_mode(&RigMode::DIG, DigSidebandPolicy::Auto, 14_074_000),
RigMode::USB
);
}
#[test]
fn u8_roundtrips_and_parses() {
for p in [
DigSidebandPolicy::Auto,
DigSidebandPolicy::Usb,
DigSidebandPolicy::Lsb,
] {
assert_eq!(DigSidebandPolicy::from_u8(p.to_u8()), p);
}
assert_eq!(DigSidebandPolicy::from_u8(200), DigSidebandPolicy::Auto);
assert_eq!(
DigSidebandPolicy::parse("USB"),
Some(DigSidebandPolicy::Usb)
);
assert_eq!(
DigSidebandPolicy::parse(" lsb "),
Some(DigSidebandPolicy::Lsb)
);
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);
}
}
-4
View File
@@ -343,8 +343,6 @@ 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,
wfm_deemphasis_us: 75, wfm_deemphasis_us: 75,
wfm_stereo: true, wfm_stereo: true,
wfm_stereo_detected: false, wfm_stereo_detected: false,
@@ -393,8 +391,6 @@ 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,
wfm_deemphasis_us: 50, wfm_deemphasis_us: 50,
wfm_stereo: true, wfm_stereo: true,
wfm_stereo_detected: true, wfm_stereo_detected: true,
+1 -2
View File
@@ -146,7 +146,6 @@ define_command_mapping! {
SetSdrGain { gain_db } <=> SetSdrGain, SetSdrGain { gain_db } <=> SetSdrGain,
SetSdrLnaGain { gain_db } <=> SetSdrLnaGain, SetSdrLnaGain { gain_db } <=> SetSdrLnaGain,
SetSdrAgc { enabled } <=> SetSdrAgc, SetSdrAgc { enabled } <=> SetSdrAgc,
SetSdrDigSideband { policy } <=> SetSdrDigSideband,
SetWfmDeemphasis { deemphasis_us } <=> SetWfmDeemphasis, SetWfmDeemphasis { deemphasis_us } <=> SetWfmDeemphasis,
SetWfmStereo { enabled } <=> SetWfmStereo, SetWfmStereo { enabled } <=> SetWfmStereo,
SetWfmDenoise { level } <=> SetWfmDenoise, SetWfmDenoise { level } <=> SetWfmDenoise,
@@ -157,7 +156,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, profile } <=> SetSdrNoiseBlanker; SetSdrNoiseBlanker { enabled, threshold } <=> SetSdrNoiseBlanker;
// ── Freq conversions (u64 <=> Freq) ────────────────────────────── // ── Freq conversions (u64 <=> Freq) ──────────────────────────────
freq: freq:
+1 -5
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, NoiseBlankerProfile, WfmDenoiseLevel}; use trx_core::WfmDenoiseLevel;
/// Command received from network clients (JSON). /// Command received from network clients (JSON).
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -105,10 +105,6 @@ pub enum ClientCommand {
SetSdrNoiseBlanker { SetSdrNoiseBlanker {
enabled: bool, enabled: bool,
threshold: f64, threshold: f64,
profile: NoiseBlankerProfile,
},
SetSdrDigSideband {
policy: DigSidebandPolicy,
}, },
SetWfmDeemphasis { SetWfmDeemphasis {
deemphasis_us: u32, deemphasis_us: u32,
+5 -40
View File
@@ -360,8 +360,6 @@ 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,
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size, spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size,
})?; })?;
@@ -922,10 +920,11 @@ 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(
cli.listen.unwrap_or(rig.audio.listen), audio_ip,
rig.audio.port, rig.audio.port,
format!("rig \"{}\" [audio]", rig.id), format!("rig \"{}\" [audio]", rig.id),
)); ));
@@ -1275,9 +1274,9 @@ async fn main() -> DynResult<()> {
} }
})); }));
// Spawn audio stack. --listen overrides every configured bind address; // Spawn audio stack.
// otherwise each rig keeps its own [rigs.audio].listen value. // listen_override priority: --listen CLI flag > global [audio].listen > per-rig default.
let audio_listen_override = cli.listen; let audio_listen_override = cli.listen.or(Some(cfg.audio.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,40 +1415,6 @@ 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);
+2 -20
View File
@@ -760,13 +760,9 @@ 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 { RigCommand::SetSdrNoiseBlanker { enabled, threshold } => {
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, profile).await { if let Err(e) = sdr.set_sdr_noise_blanker(enabled, threshold).await {
return Err(RigError::communication(format!( return Err(RigError::communication(format!(
"set_sdr_noise_blanker: {e}" "set_sdr_noise_blanker: {e}"
))); )));
@@ -778,20 +774,6 @@ 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::SetSdrDigSideband(policy) => {
if let Some(sdr) = ctx.rig.as_sdr() {
if let Err(e) = sdr.set_sdr_dig_sideband(policy).await {
return Err(RigError::communication(format!(
"set_sdr_dig_sideband: {e}"
)));
}
} else {
return Err(RigError::not_supported("set_sdr_dig_sideband"));
}
ctx.state.filter = ctx.rig.as_sdr_ref().and_then(|s| s.filter_state());
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetWfmDeemphasis(deemphasis_us) => { RigCommand::SetWfmDeemphasis(deemphasis_us) => {
if let Some(sdr) = ctx.rig.as_sdr() { if let Some(sdr) = ctx.rig.as_sdr() {
if let Err(e) = sdr.set_wfm_deemphasis(deemphasis_us).await { if let Err(e) = sdr.set_wfm_deemphasis(deemphasis_us).await {
@@ -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::{NoiseBlankerProfile, RdsData, RigMode, WfmDenoiseLevel}; use trx_core::rig::state::{RdsData, RigMode, WfmDenoiseLevel};
use crate::demod::{DcBlocker, Demodulator, SamDemod, SoftAgc, WfmStereoDecoder}; use crate::demod::{DcBlocker, Demodulator, SamDemod, SoftAgc, WfmStereoDecoder};
@@ -14,174 +14,38 @@ use super::{BlockFirFilterPair, IQ_BLOCK_SIZE};
// Noise blanker // Noise blanker
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Temporal shape of a [`NoiseBlankerProfile`], in real time so the numbers /// IQ-domain impulse noise blanker.
/// 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.
/// ///
/// This runs on the wide, undecimated IQ stream — the only place an impulse is /// Maintains a running RMS estimate of the IQ magnitude. When a sample's
/// still short in time (narrow filtering downstream smears it into un-removable /// magnitude exceeds `threshold × rms`, it is replaced by linear interpolation
/// ringing). The algorithm has four parts: /// between the last clean sample and the next clean sample (lookahead of 1).
/// ///
/// 1. **Noise-floor tracker** — an exponential mean-square estimate updated /// The RMS tracker uses exponential smoothing with a time constant of ~128
/// *only from clean samples* and frozen while blanking, so a burst cannot /// samples at the IQ sample rate, fast enough to track band-noise changes
/// drag the reference up and desensitise detection. Its time constant comes /// but slow enough not to follow individual impulses.
/// 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.
// Derived from (profile, sample_rate); recomputed when either changes. mean_sq: f32,
lookahead: usize, /// Last clean sample (used for interpolation fill).
blank_samples: usize, last_clean: Complex<f32>,
/// 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(cfg: NoiseBlankerConfig, sample_rate: u32) -> Self { pub fn new(enabled: bool, threshold: f32) -> Self {
let mut nb = Self { Self {
enabled: cfg.enabled, enabled,
profile: cfg.profile, threshold: threshold.max(1.0),
sample_rate: sample_rate as f32, mean_sq: 1e-10,
threshold: cfg.threshold.max(1.0), last_clean: Complex::new(0.0, 0.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;
} }
@@ -189,14 +53,7 @@ impl NoiseBlanker {
self.threshold = threshold.max(1.0); self.threshold = threshold.max(1.0);
} }
pub fn set_profile(&mut self, profile: NoiseBlankerProfile) { /// Process a block of IQ samples in-place, blanking impulse spikes.
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;
@@ -205,59 +62,17 @@ 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 x = *sample; let s = *sample;
let mag_sq = x.re * x.re + x.im * x.im; let mag_sq = s.re * s.re + s.im * s.im;
// Look-ahead: emit the delayed sample, ingest the fresh one. if mag_sq > thresh_sq * self.mean_sq {
let y = if self.lookahead == 0 { // Impulse detected — replace with last clean sample.
x *sample = self.last_clean;
} else { } else {
let out = self.delay[self.dpos]; // Clean sample — update RMS tracker.
self.delay[self.dpos] = x; self.mean_sq += NB_ALPHA * (mag_sq - self.mean_sq);
self.dpos += 1; self.last_clean = s;
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;
} }
} }
} }
@@ -266,7 +81,6 @@ 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 {
@@ -274,7 +88,6 @@ impl Default for NoiseBlankerConfig {
Self { Self {
enabled: false, enabled: false,
threshold: 10.0, threshold: 10.0,
profile: NoiseBlankerProfile::Spike,
} }
} }
} }
@@ -743,7 +556,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, sdr_sample_rate), noise_blanker: NoiseBlanker::new(nb_cfg.enabled, nb_cfg.threshold),
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,
@@ -764,15 +577,9 @@ impl ChannelDsp {
self.squelch.set_threshold_db(threshold_db); self.squelch.set_threshold_db(threshold_db);
} }
pub fn set_noise_blanker( pub fn set_noise_blanker(&mut self, enabled: bool, threshold: f32) {
&mut self,
enabled: bool,
threshold: f32,
profile: NoiseBlankerProfile,
) {
self.noise_blanker.set_profile(profile);
self.noise_blanker.set_threshold(threshold);
self.noise_blanker.set_enabled(enabled); self.noise_blanker.set_enabled(enabled);
self.noise_blanker.set_threshold(threshold);
} }
pub fn set_mode(&mut self, mode: &RigMode) { pub fn set_mode(&mut self, mode: &RigMode) {
@@ -1239,89 +1046,30 @@ 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 sr = 1_000_000; let mut nb = NoiseBlanker::new(true, 5.0);
let mut nb = NoiseBlanker::new(nb_cfg(true, 5.0, NoiseBlankerProfile::Spike), sr); // Feed a steady signal to establish the RMS baseline.
nb_warm(&mut nb); let mut block: Vec<Complex<f32>> = (0..256).map(|_| Complex::new(0.01, 0.01)).collect();
// 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 peak = block let mut block2: Vec<Complex<f32>> = (0..256).map(|_| Complex::new(0.01, 0.01)).collect();
.iter() block2[0] = Complex::new(10.0, 10.0);
.map(|s| s.re * s.re + s.im * s.im) nb.process(&mut block2);
.fold(0.0f32, f32::max); // The spike should have been blanked (replaced by last clean sample).
assert!(peak < 1.0, "impulse leaked through, peak power {peak}"); let mag = (block2[0].re * block2[0].re + block2[0].im * block2[0].im).sqrt();
}
#[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!(
(tail.re - 0.5).abs() < 1e-3 && (tail.im - 0.5).abs() < 1e-3, mag < 1.0,
"steady signal was gated: {tail:?}" "expected impulse to be blanked, got magnitude {}",
mag
); );
} }
#[test] #[test]
fn noise_blanker_disabled_passes_through() { fn noise_blanker_disabled_passes_through() {
let mut nb = NoiseBlanker::new(nb_cfg(false, 5.0, NoiseBlankerProfile::Spike), 1_000_000); let mut nb = NoiseBlanker::new(false, 5.0);
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"
);
}
} }
@@ -13,10 +13,7 @@ use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex}; 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::{RigFilterState, SpectrumData, VchanRdsEntry, WfmDenoiseLevel};
effective_demod_mode, DigSidebandPolicy, NoiseBlankerProfile, RigFilterState, SpectrumData,
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,10 +73,6 @@ 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).
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.
/// ///
/// Fewer bins cost less DSP and put fewer bytes on the network per frame, /// Fewer bins cost less DSP and put fewer bytes on the network per frame,
@@ -111,8 +104,6 @@ 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,
spectrum_fft_size: 1024, spectrum_fft_size: 1024,
} }
} }
@@ -163,17 +154,10 @@ 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).
/// Also owns the shared DIG-sideband policy.
channel_manager: Arc<vchan_impl::SdrVirtualChannelManager>, channel_manager: Arc<vchan_impl::SdrVirtualChannelManager>,
/// Concrete demodulation mode last pushed to the primary channel DSP. For
/// DIG this is the resolved USB/LSB; used to avoid rebuilding filters on
/// every tune step when the resolved sideband hasn't actually changed.
applied_primary_mode: RigMode,
} }
impl SoapySdrRig { impl SoapySdrRig {
@@ -216,8 +200,6 @@ 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 spectrum_fft_size = config.spectrum_fft_size; let spectrum_fft_size = config.spectrum_fft_size;
tracing::info!( tracing::info!(
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})", "initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
@@ -316,7 +298,6 @@ 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,
@@ -383,16 +364,8 @@ impl SoapySdrRig {
pipeline.clone(), pipeline.clone(),
fixed_slot_count, fixed_slot_count,
max_virtual_channels, max_virtual_channels,
dig_sideband,
)); ));
// Concrete demod mode the primary channel starts in. For DIG this
// resolves the configured sideband policy against the initial dial
// 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_is_dig = initial_mode == RigMode::DIG;
let rig = Self { let rig = Self {
info, info,
freq: initial_freq, freq: initial_freq,
@@ -417,20 +390,9 @@ 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(),
}; };
// If the primary channel starts in DIG, its DSP was created with the
// logical DIG demodulator (upper sideband); push the resolved sideband
// so an Auto/LSB start is honoured immediately.
if initial_is_dig {
let dsps = rig.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(rig.primary_channel_idx) {
dsp_arc.lock().unwrap().set_mode(&initial_primary_mode);
}
}
rig.apply_ais_channel_activity(); rig.apply_ais_channel_activity();
Ok(rig) Ok(rig)
} }
@@ -486,8 +448,6 @@ impl SoapySdrRig {
max_virtual_channels, max_virtual_channels,
nb_enabled, nb_enabled,
nb_threshold, nb_threshold,
nb_profile: NoiseBlankerProfile::default(),
dig_sideband: DigSidebandPolicy::default(),
}) })
} }
@@ -659,21 +619,6 @@ impl RigCat for SoapySdrRig {
} }
} }
} }
// In DIG/Auto, tuning across the 10 MHz boundary flips the
// sideband. Rebuild the demodulator only when it actually changes,
// so ordinary tuning stays glitch-free.
if self.mode == RigMode::DIG {
let effective = self.channel_manager.dig_policy().resolve(self.freq.hz);
if effective != self.applied_primary_mode {
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
let mut dsp = dsp_arc.lock().unwrap();
dsp.set_mode(&effective);
dsp.set_filter(self.bandwidth_hz);
}
self.applied_primary_mode = effective;
}
}
self.update_ais_channel_offsets(); self.update_ais_channel_offsets();
Ok(()) Ok(())
}) })
@@ -687,18 +632,12 @@ impl RigCat for SoapySdrRig {
tracing::debug!("SoapySdrRig: set_mode -> {:?}", mode); tracing::debug!("SoapySdrRig: set_mode -> {:?}", mode);
self.mode = mode.clone(); self.mode = mode.clone();
self.bandwidth_hz = Self::default_bandwidth_for_mode(&mode); self.bandwidth_hz = Self::default_bandwidth_for_mode(&mode);
// DIG carries no inherent sideband: resolve it to a concrete
// USB/LSB demodulator from the policy + dial frequency. The logical
// 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);
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.
{ {
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) {
let mut dsp = dsp_arc.lock().unwrap(); let mut dsp = dsp_arc.lock().unwrap();
dsp.set_mode(&effective); dsp.set_mode(&mode);
dsp.set_filter(self.bandwidth_hz); dsp.set_filter(self.bandwidth_hz);
} }
} }
@@ -994,7 +933,6 @@ 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() {
@@ -1005,38 +943,13 @@ 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, profile); .set_noise_blanker(enabled, threshold as f32);
}
}
Ok(())
})
}
fn set_sdr_dig_sideband<'a>(
&'a mut self,
policy: DigSidebandPolicy,
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(async move {
// Update the shared policy (also re-resolves DIG virtual channels).
self.channel_manager.set_dig_policy(policy);
// Re-apply to the primary channel when it is currently DIG.
if self.mode == RigMode::DIG {
let effective = policy.resolve(self.freq.hz);
if effective != self.applied_primary_mode {
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
let mut dsp = dsp_arc.lock().unwrap();
dsp.set_mode(&effective);
dsp.set_filter(self.bandwidth_hz);
}
self.applied_primary_mode = effective;
} }
} }
Ok(()) Ok(())
@@ -1136,8 +1049,6 @@ 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()),
wfm_deemphasis_us: self.wfm_deemphasis_us, wfm_deemphasis_us: self.wfm_deemphasis_us,
wfm_stereo: self.wfm_stereo, wfm_stereo: self.wfm_stereo,
wfm_stereo_detected, wfm_stereo_detected,
@@ -22,12 +22,12 @@
//! updates every `ChannelDsp` in place and pauses out-of-span channels instead //! updates every `ChannelDsp` in place and pauses out-of-span channels instead
//! of destroying them. //! of destroying them.
use std::sync::atomic::{AtomicI64, AtomicU8, Ordering}; use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use num_complex::Complex; use num_complex::Complex;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use trx_core::rig::state::{effective_demod_mode, DigSidebandPolicy, RigMode, VchanRdsEntry}; use trx_core::rig::state::{RigMode, VchanRdsEntry};
use uuid::Uuid; use uuid::Uuid;
use crate::dsp::SdrPipeline; use crate::dsp::SdrPipeline;
@@ -59,12 +59,7 @@ fn default_bandwidth_hz(mode: &RigMode) -> u32 {
struct ManagedChannel { struct ManagedChannel {
id: Uuid, id: Uuid,
freq_hz: u64, freq_hz: u64,
/// Logical mode requested by the user (e.g. `DIG`), shown in listings.
mode: RigMode, mode: RigMode,
/// Concrete demod mode last pushed to the DSP. For `DIG` this is the
/// resolved USB/LSB; tracked so a frequency change only rebuilds the
/// demodulator when the resolved sideband actually flips.
applied_mode: RigMode,
/// `broadcast::Sender` kept alive so new subscribers can join at any time. /// `broadcast::Sender` kept alive so new subscribers can join at any time.
pcm_tx: broadcast::Sender<Vec<f32>>, pcm_tx: broadcast::Sender<Vec<f32>>,
/// IQ tap sender (kept alive; external consumers may subscribe). /// IQ tap sender (kept alive; external consumers may subscribe).
@@ -96,9 +91,6 @@ pub struct SdrVirtualChannelManager {
channels: RwLock<Vec<ManagedChannel>>, channels: RwLock<Vec<ManagedChannel>>,
/// Fires whenever a channel is explicitly destroyed. /// Fires whenever a channel is explicitly destroyed.
destroyed_tx: broadcast::Sender<Uuid>, destroyed_tx: broadcast::Sender<Uuid>,
/// Shared DIG-sideband policy ([`DigSidebandPolicy`] encoded via `to_u8`).
/// Read when resolving any DIG channel's demodulator.
dig_sideband: AtomicU8,
} }
impl SdrVirtualChannelManager { impl SdrVirtualChannelManager {
@@ -108,12 +100,7 @@ impl SdrVirtualChannelManager {
/// - `fixed_slot_count`: number of fixed pipeline slots (primary + AIS), /// - `fixed_slot_count`: number of fixed pipeline slots (primary + AIS),
/// i.e. the index of the first slot available for virtual channels. /// i.e. the index of the first slot available for virtual channels.
/// - `max_total`: maximum total channels including primary (e.g. 4). /// - `max_total`: maximum total channels including primary (e.g. 4).
pub fn new( pub fn new(pipeline: Arc<SdrPipeline>, fixed_slot_count: usize, max_total: usize) -> Self {
pipeline: Arc<SdrPipeline>,
fixed_slot_count: usize,
max_total: usize,
dig_sideband: DigSidebandPolicy,
) -> Self {
// Seed the channel list with a synthetic primary-channel entry. // Seed the channel list with a synthetic primary-channel entry.
// We use the first PCM sender from the pipeline (index 0). // We use the first PCM sender from the pipeline (index 0).
let primary_pcm_tx = pipeline let primary_pcm_tx = pipeline
@@ -131,7 +118,6 @@ impl SdrVirtualChannelManager {
id: Uuid::new_v4(), id: Uuid::new_v4(),
freq_hz: 0, // actual freq kept by SoapySdrRig; manager treats ch-0 as opaque freq_hz: 0, // actual freq kept by SoapySdrRig; manager treats ch-0 as opaque
mode: RigMode::USB, mode: RigMode::USB,
applied_mode: RigMode::USB,
pcm_tx: primary_pcm_tx, pcm_tx: primary_pcm_tx,
iq_tx: primary_iq_tx, iq_tx: primary_iq_tx,
pipeline_slot: 0, pipeline_slot: 0,
@@ -148,7 +134,6 @@ impl SdrVirtualChannelManager {
max_total: max_total.max(1), max_total: max_total.max(1),
channels: RwLock::new(vec![primary]), channels: RwLock::new(vec![primary]),
destroyed_tx, destroyed_tx,
dig_sideband: AtomicU8::new(dig_sideband.to_u8()),
} }
} }
@@ -156,36 +141,6 @@ impl SdrVirtualChannelManager {
self.destroyed_tx.clone() self.destroyed_tx.clone()
} }
/// Current shared DIG-sideband policy.
pub fn dig_policy(&self) -> DigSidebandPolicy {
DigSidebandPolicy::from_u8(self.dig_sideband.load(Ordering::Relaxed))
}
/// Update the shared DIG-sideband policy and re-resolve every DIG virtual
/// channel's demodulator (the primary channel is handled by `SoapySdrRig`).
pub fn set_dig_policy(&self, policy: DigSidebandPolicy) {
self.dig_sideband.store(policy.to_u8(), Ordering::Relaxed);
let mut channels = self.channels.write().unwrap();
let dsps = self.pipeline.channel_dsps.read().unwrap();
for ch in channels
.iter_mut()
.filter(|c| !c.permanent && c.mode == RigMode::DIG)
{
let effective = policy.resolve(ch.freq_hz);
if effective != ch.applied_mode {
ch.applied_mode = effective.clone();
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
dsp_arc.lock().unwrap().set_mode(&effective);
}
}
}
}
/// Resolve a channel's effective demod mode under the current policy.
fn resolve_mode(&self, logical: &RigMode, freq_hz: u64) -> RigMode {
effective_demod_mode(logical, self.dig_policy(), freq_hz)
}
fn half_span_hz(&self) -> i64 { fn half_span_hz(&self) -> i64 {
i64::from(self.pipeline.sdr_sample_rate) / 2 i64::from(self.pipeline.sdr_sample_rate) / 2
} }
@@ -217,13 +172,10 @@ impl SdrVirtualChannelManager {
}); });
} }
// Bandwidth follows the logical mode; the DSP demodulator follows the
// resolved sideband (DIG → USB/LSB by policy + frequency).
let bandwidth_hz = default_bandwidth_hz(mode); let bandwidth_hz = default_bandwidth_hz(mode);
let applied_mode = self.resolve_mode(mode, freq_hz); let (pcm_tx, iq_tx) = self
let (pcm_tx, iq_tx) = .pipeline
self.pipeline .add_virtual_channel(if_hz as f64, mode, bandwidth_hz);
.add_virtual_channel(if_hz as f64, &applied_mode, bandwidth_hz);
let pipeline_slot = self let pipeline_slot = self
.pipeline .pipeline
@@ -238,7 +190,6 @@ impl SdrVirtualChannelManager {
id, id,
freq_hz, freq_hz,
mode: mode.clone(), mode: mode.clone(),
applied_mode,
pcm_tx, pcm_tx,
iq_tx, iq_tx,
pipeline_slot, pipeline_slot,
@@ -363,18 +314,9 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
.ok_or(VChanError::NotFound)?; .ok_or(VChanError::NotFound)?;
ch.freq_hz = freq_hz; ch.freq_hz = freq_hz;
// A DIG/Auto channel flips sideband across the 10 MHz boundary; rebuild
// the demodulator only when the resolved sideband actually changes.
let effective = self.resolve_mode(&ch.mode, freq_hz);
let mode_changed = effective != ch.applied_mode;
ch.applied_mode = effective.clone();
let dsps = self.pipeline.channel_dsps.read().unwrap(); let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) { if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
let mut dsp = dsp_arc.lock().unwrap(); dsp_arc.lock().unwrap().set_channel_if_hz(if_hz as f64);
dsp.set_channel_if_hz(if_hz as f64);
if mode_changed {
dsp.set_mode(&effective);
}
} }
Ok(()) Ok(())
} }
@@ -387,11 +329,9 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
.ok_or(VChanError::NotFound)?; .ok_or(VChanError::NotFound)?;
ch.mode = mode.clone(); ch.mode = mode.clone();
let effective = self.resolve_mode(mode, ch.freq_hz);
ch.applied_mode = effective.clone();
let dsps = self.pipeline.channel_dsps.read().unwrap(); let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) { if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
dsp_arc.lock().unwrap().set_mode(&effective); dsp_arc.lock().unwrap().set_mode(mode);
} }
Ok(()) Ok(())
} }
@@ -485,7 +425,6 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::demod::Demodulator;
use crate::dsp::{MockIqSource, NoiseBlankerConfig, SdrPipeline}; use crate::dsp::{MockIqSource, NoiseBlankerConfig, SdrPipeline};
fn make_pipeline() -> Arc<SdrPipeline> { fn make_pipeline() -> Arc<SdrPipeline> {
@@ -507,7 +446,7 @@ mod tests {
#[test] #[test]
fn add_and_list() { fn add_and_list() {
let p = make_pipeline(); let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto); let mgr = SdrVirtualChannelManager::new(p, 1, 4);
// Set center to 14.1 MHz so that 14.074 MHz is within ±960 kHz. // Set center to 14.1 MHz so that 14.074 MHz is within ±960 kHz.
mgr.update_center_hz(14_100_000); mgr.update_center_hz(14_100_000);
assert_eq!(mgr.channels().len(), 1); // primary only assert_eq!(mgr.channels().len(), 1); // primary only
@@ -523,7 +462,7 @@ mod tests {
#[test] #[test]
fn remove_virtual_channel() { fn remove_virtual_channel() {
let p = make_pipeline(); let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto); let mgr = SdrVirtualChannelManager::new(p, 1, 4);
mgr.update_center_hz(14_100_000); mgr.update_center_hz(14_100_000);
let (id, _) = mgr.add_channel(14_074_000, &RigMode::USB).unwrap(); let (id, _) = mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
mgr.remove_channel(id).unwrap(); mgr.remove_channel(id).unwrap();
@@ -533,7 +472,7 @@ mod tests {
#[test] #[test]
fn cannot_remove_primary() { fn cannot_remove_primary() {
let p = make_pipeline(); let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto); let mgr = SdrVirtualChannelManager::new(p, 1, 4);
let primary_id = mgr.channels()[0].id; let primary_id = mgr.channels()[0].id;
let err = mgr.remove_channel(primary_id).unwrap_err(); let err = mgr.remove_channel(primary_id).unwrap_err();
assert!(matches!(err, VChanError::Permanent)); assert!(matches!(err, VChanError::Permanent));
@@ -542,7 +481,7 @@ mod tests {
#[test] #[test]
fn cap_enforced() { fn cap_enforced() {
let p = make_pipeline(); let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 2, DigSidebandPolicy::Auto); // primary + 1 virtual max let mgr = SdrVirtualChannelManager::new(p, 1, 2); // primary + 1 virtual max
mgr.update_center_hz(14_100_000); mgr.update_center_hz(14_100_000);
mgr.add_channel(14_074_000, &RigMode::USB).unwrap(); mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
let err = mgr.add_channel(14_075_000, &RigMode::USB).unwrap_err(); let err = mgr.add_channel(14_075_000, &RigMode::USB).unwrap_err();
@@ -552,7 +491,7 @@ mod tests {
#[test] #[test]
fn out_of_bandwidth() { fn out_of_bandwidth() {
let p = make_pipeline(); let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto); let mgr = SdrVirtualChannelManager::new(p, 1, 4);
// center_hz = 0, half_span = 960_000 Hz — 10 MHz is way out // center_hz = 0, half_span = 960_000 Hz — 10 MHz is way out
let err = mgr.add_channel(10_000_000, &RigMode::USB).unwrap_err(); let err = mgr.add_channel(10_000_000, &RigMode::USB).unwrap_err();
assert!(matches!(err, VChanError::OutOfBandwidth { .. })); assert!(matches!(err, VChanError::OutOfBandwidth { .. }));
@@ -561,7 +500,7 @@ mod tests {
#[test] #[test]
fn hidden_background_channels_are_outside_visible_cap() { fn hidden_background_channels_are_outside_visible_cap() {
let p = make_pipeline(); let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 2, DigSidebandPolicy::Auto); // primary + 1 visible max let mgr = SdrVirtualChannelManager::new(p, 1, 2); // primary + 1 visible max
mgr.update_center_hz(14_100_000); mgr.update_center_hz(14_100_000);
mgr.add_channel(14_074_000, &RigMode::USB).unwrap(); mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
@@ -574,46 +513,10 @@ mod tests {
assert!(visible.iter().all(|channel| channel.id != hidden_id)); assert!(visible.iter().all(|channel| channel.id != hidden_id));
} }
#[test]
fn dig_channel_resolves_sideband_and_tracks_policy() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p.clone(), 1, 6, DigSidebandPolicy::Auto);
mgr.update_center_hz(7_100_000);
// The virtual channel occupies pipeline slot 1 (slot 0 is the primary).
let demod_at_slot1 = || {
p.channel_dsps.read().unwrap()[1]
.lock()
.unwrap()
.demodulator
.clone()
};
// DIG below 10 MHz under Auto → lower sideband.
let (_id, _rx) = mgr.add_channel(7_074_000, &RigMode::DIG).unwrap();
assert_eq!(demod_at_slot1(), Demodulator::Lsb);
// Forcing USB re-resolves the live channel.
mgr.set_dig_policy(DigSidebandPolicy::Usb);
assert_eq!(demod_at_slot1(), Demodulator::Usb);
// Back to Auto (still below 10 MHz) → lower sideband again.
mgr.set_dig_policy(DigSidebandPolicy::Auto);
assert_eq!(demod_at_slot1(), Demodulator::Lsb);
// The listing keeps the logical DIG mode for display.
let ch = mgr
.channels()
.into_iter()
.find(|c| !c.permanent)
.expect("virtual channel");
assert_eq!(ch.mode, "DIG");
}
#[test] #[test]
fn retune_keeps_virtual_channel_allocated() { fn retune_keeps_virtual_channel_allocated() {
let p = make_pipeline(); let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto); let mgr = SdrVirtualChannelManager::new(p, 1, 4);
mgr.update_center_hz(14_100_000); mgr.update_center_hz(14_100_000);
let mut destroyed_rx = mgr.subscribe_destroyed(); let mut destroyed_rx = mgr.subscribe_destroyed();
-2
View File
@@ -118,7 +118,6 @@ sample_rate = 1920000
bandwidth = 1500000 bandwidth = 1500000
wfm_deemphasis_us = 50 wfm_deemphasis_us = 50
center_offset_hz = 100000 center_offset_hz = 100000
dig_sideband = "auto"
channels = [] channels = []
max_virtual_channels = 4 max_virtual_channels = 4
spectrum_fft_size = 1024 spectrum_fft_size = 1024
@@ -140,7 +139,6 @@ 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]