Compare commits
1
Commits
main
..
571ef17295
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
571ef17295 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 (1–100) with a **Set** button — adjusts the
|
- **NB Threshold** number input (1–100) 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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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."
|
|
||||||
@@ -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."
|
|
||||||
@@ -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 ≥ 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 (0–100 scale).
|
* Co-Channel Interference level (0–100 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)),
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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(_)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user