Initial commit
Sync docs to Wiki / wiki (push) Has been cancelled

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-05-17 23:25:14 +02:00
commit ba48de2d30
237 changed files with 105505 additions and 0 deletions
+1087
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
# trx-rs
`trx-rs` is a modular amateur radio control stack written in Rust. It splits
hardware access, DSP, transport, and user-facing interfaces into separate
components so a radio or SDR can be controlled locally while audio, decoding,
and remote control are exposed elsewhere on the network.
## Documentation
- [User Manual](User-Manual) — configuration, features, and usage
- [Architecture](Architecture) — system design, crate layout, data flow, and internals
- [Optimization Guidelines](Optimization-Guidelines) — performance guidelines for the real-time DSP pipeline
- [Planned Features](Planned-Features) — planned features and design notes
- [Improvement Areas](Improvement-Areas) — codebase audit: quality, architecture, security, performance, and improvement plan
+211
View File
@@ -0,0 +1,211 @@
# Improvement Areas
A comprehensive audit of the trx-rs codebase covering code quality, architecture,
security, testing, and performance. Each item includes the affected location and
a suggested fix.
*Last updated: 2026-03-29*
---
## Resolved Items
<details>
<summary>Click to expand resolved items from previous audits</summary>
### Plugin signing and cross-platform validation — DROPPED
Plugin system has been removed from the codebase. No longer applicable.
### Session store mutex poisoning (auth.rs) — RESOLVED
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs`
All 6 `.write().unwrap()` / `.lock().unwrap()` calls replaced with
`.unwrap_or_else(|e| { warn!(...); e.into_inner() })` pattern. Lock poisoning now
logs a warning and recovers the inner data instead of crashing.
### No rate limiting on TCP listener — RESOLVED
**Location:** `src/trx-server/src/listener.rs`
Added `ConnectionTracker` with per-IP connection limiting (default: 10 concurrent
connections per IP). Connections exceeding the limit are rejected with a log warning.
Slots are released when clients disconnect.
### RigState is a 33-field flat struct — RESOLVED
**Location:** `src/trx-core/src/rig/state.rs`
Decoder fields grouped into `DecoderConfig` (8 bools) and `DecoderResetSeqs`
(8 u64 counters). Both use `#[serde(flatten)]` for backward-compatible JSON wire
format. Updated across all consumers.
### No `spawn_blocking` timeout — RESOLVED
**Location:** `src/trx-server/src/listener.rs`
Satellite pass computation wrapped in `tokio::time::timeout(30s, ...)` with
graceful fallback to empty results on timeout or panic.
### Command handler boilerplate — RESOLVED
**Location:** `src/trx-core/src/rig/controller/handlers.rs`
Created `rig_command!` declarative macro. 7 unit commands use the macro; 4 commands
with custom fields/validation remain as explicit impls.
### No command execution timeouts at CommandExecutor level — RESOLVED
**Location:** `src/trx-server/src/rig_task.rs`
`tokio::time::timeout(command_exec_timeout, process_command(...))` wraps all
command execution. Default timeout: 10s, configurable via `RigTaskConfig`.
### No forward compatibility in protocol — RESOLVED
**Location:** `src/trx-protocol/src/types.rs`, `src/trx-protocol/src/codec.rs`
Added optional `protocol_version: Option<u32>` to `ClientEnvelope` and
`ClientResponse`. `parse_envelope()` distinguishes malformed JSON from
unrecognised `cmd` values.
### `unsafe` string construction in spectrum encoding — RESOLVED
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/api.rs`
Replaced `unsafe { String::from_utf8_unchecked(out) }` with safe
`String::from_utf8(out).expect(...)`.
### `#[allow(dead_code)]` cleanup — RESOLVED
Reduced from 6 to 4 annotations, all in trx-backend-soapysdr where fields serve
as lifetime anchors (`device`, `iq_tx`) or document reserved capacity
(`fixed_slot_count`, `process_pair`).
### VDES decoder incomplete FEC — RESOLVED
Turbo FEC decoder, CRC-16-CCITT validation, and M.2092-1 link-layer frame parsing
implemented.
### Plugin system lacks versioning — DROPPED
Plugin system removed from the codebase.
### Configurator serial detection stubbed — RESOLVED
Implemented using `tokio_serial::available_ports()` with USB, Bluetooth, PCI, and
Unknown port type descriptions.
### Inconsistent frequency/rig naming — DOCUMENTED AS INTENTIONAL
Field names reflect distinct semantic contexts: `freq_hz` (dial), `center_hz`
(SDR capture center), `cw_center_hz` (CW tone); `rig_id` (config key), `id`
(runtime UUID); `model` (hardware string), `rig_model` (config parameter).
### Decoder task duplication in audio.rs — RESOLVED
**Location:** `src/trx-server/src/audio.rs`
APRS and HF APRS decoders merged into a single parameterised
`run_aprs_decoder_inner()` function. FT8 and FT4 decoders merged into
`run_ftx_decoder_inner()`. All decoder tasks now include `tracing::info_span!`
around `block_in_place()` calls for opt-in latency measurement.
### Missing tests for critical modules — RESOLVED
**Location:** `src/trx-server/src/listener.rs`, `src/trx-client/trx-frontend/trx-frontend-http/`
Added multi-rig state isolation and command routing tests in `listener.rs`.
Added background decode `evaluate_bookmark` pure-function tests.
### Missing integration tests for multi-rig scenarios — RESOLVED
**Location:** `src/trx-server/src/listener.rs`
Added integration tests covering simultaneous state management across two rigs
with a dummy backend, verifying state isolation and command routing.
### Decode log silent failures — RESOLVED
**Location:** `src/decoders/trx-decode-log/src/lib.rs`
`flush()` errors are now logged via `warn!`. On file rotation failure, the old
writer is kept rather than silently dropping writes; a degradation warning is
emitted.
### `api.rs` file size and organization — RESOLVED
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/api/`
Split 2,831-LOC monolith into 7 logically grouped modules: `mod.rs` (shared
types and route configuration), `decoder.rs`, `rig.rs`, `vchan.rs`, `sse.rs`,
`bookmarks.rs`, `assets.rs`.
### Background decode state complexity — RESOLVED
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/background_decode.rs`
Extracted the 8-guard decision cascade into a pure `evaluate_bookmark()` function
returning `ChannelAction` enum (`Active` or `Skip { reason }`). Added unit tests
for all decision paths.
### Actix-web pinned to exact version — RESOLVED
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml`
Relaxed from `actix-web = "=4.4.1"` to `actix-web = "4.4"` to allow patch-level
security updates.
### Magic numbers in VDES plausibility scoring — RESOLVED
**Location:** `src/decoders/trx-vdes/src/lib.rs`
Inline magic numbers replaced with documented named constants:
`PLAUSIBILITY_UNSYNCED_THRESHOLD` (35) and
`PLAUSIBILITY_LOW_CONFIDENCE_THRESHOLD` (15).
### FT-817 VFO inference fragile with same frequency — DOCUMENTED
**Location:** `src/trx-server/trx-backend/trx-backend-ft817/src/lib.rs`
When both VFOs share the same frequency, inference defaults to VFO A. Resolved
after VFO toggle primes both sides. Well-documented in code comments; remains
a known limitation.
### Excessive string cloning in remote client — RESOLVED
**Location:** `src/trx-client/src/remote_client.rs`
Hot-path spectrum polling loop now caches the token to avoid per-poll cloning.
State update path restructured to send to the main watch channel last (taking
ownership) and avoid one redundant `RigState::clone()`.
### Missing doc comments on public decoder structs — RESOLVED
**Location:** `src/decoders/trx-ais/src/lib.rs`, `src/decoders/trx-vdes/src/lib.rs`,
`src/decoders/trx-rds/src/lib.rs`
Added comprehensive doc comments to `AisDecoder`, `VdesDecoder`, and `RdsDecoder`
describing valid sample rates, usage examples, and reset semantics.
### Turbo decoder precondition not asserted — RESOLVED
**Location:** `src/decoders/trx-vdes/src/turbo.rs`
Added `debug_assert_eq!` on interleaver and deinterleaver lengths in
`turbo_decode_soft()`.
### No tracing spans for decoder performance — RESOLVED
**Location:** `src/trx-server/src/audio.rs`
Added `tracing::info_span!` around `block_in_place()` calls in all 10 decoder
tasks (APRS, HF APRS, AIS A/B, VDES, CW, FT8, FT4, FT2, WSPR, LRPT) for
opt-in per-decoder latency measurement.
</details>
---
All previous improvement items have been resolved. No outstanding issues.
+175
View File
@@ -0,0 +1,175 @@
# DSP Optimization Guidelines
This document captures lessons learned and best practices for optimizing
the real-time DSP pipelines in trx-rs, particularly the WFM stereo decoder
and audio encoding paths.
## General Principles
1. **Measure first.** Profile with real workloads before optimizing.
Synthetic benchmarks miss cache effects, branch prediction patterns,
and real signal statistics.
2. **Eliminate transcendentals from inner loops.** A single `sin_cos` or
`atan2` per sample at 200 kHz composite rate costs millions of calls
per second. Replace with:
- **Quadrature NCO** for oscillators: maintain `(cos, sin)` state and
rotate by a precomputed `(cos_inc, sin_inc)` each sample. Cost:
4 muls + 2 adds. Renormalize every ~1024 samples to prevent drift.
- **Double-angle identities** to derive `sin(2θ), cos(2θ)` from
`sin(θ), cos(θ)`: `sin2 = 2·sin·cos`, `cos2 = 2·cos²−1`.
- **I/Q arm extraction** for PLL phase error: if you have
`i = lp(signal * cos)` and `q = lp(signal * -sin)`, then
`sin(err) = q/mag`, `cos(err) = i/mag` — no `atan2` or `sin_cos`
needed for the rotation.
3. **Batch operations for SIMD.** Separate data-parallel work (e.g. FM
discriminator: conjugate-multiply + atan2) from sequential-state work
(PLL, biquads). Process the parallel part in batches of 8 using AVX2,
then feed scalar results into the sequential pipeline.
4. **Power-of-2 sizes for circular buffers.** Use `& (N-1)` bitmask
instead of `% N` modulo. Ensure buffer lengths (e.g. `WFM_RESAMP_TAPS`)
are powers of two.
5. **Circular buffers over shift registers.** Writing one sample at a
ring-buffer position is O(1); `rotate_left(1)` is O(N). For a 32-tap
FIR called 3× per composite sample, this eliminates ~200 byte-moves
per sample.
6. **Decimate slow-changing metrics.** Stereo detection (pilot coherence,
lock, drive) changes over tens of milliseconds. Running it every 16th
sample instead of every sample saves ~94% of that work with no audible
effect. Accumulate values over the window and process the average.
## Filter Design
- **Match filter cutoffs** across parallel paths (sum and diff) to ensure
identical group delay. Mismatched cutoffs cause frequency-dependent
phase errors that directly degrade stereo separation.
- **4th-order Butterworth** (two cascaded biquads) is generally sufficient
when the polyphase resampler provides additional stopband rejection.
6th-order adds 50% more biquad evaluations per sample for diminishing
returns.
- **Q values for Butterworth cascades:**
- 4th-order: Q₁ = 0.5412, Q₂ = 1.3066
- 6th-order: Q₁ = 0.5176, Q₂ = 0.7071, Q₃ = 1.9319
## Polyphase Resampler
- **Compute cutoff from actual rate ratio:** `cutoff = output_rate / input_rate`.
A fixed cutoff (e.g. 0.94) can be catastrophically wrong — at 200 kHz
composite to 48 kHz audio, it passes everything up to 94 kHz while the
output Nyquist is only 24 kHz. The 38 kHz stereo subcarrier residuals
alias directly into the treble range.
- **Blackman-Harris window** gives ~92 dB stopband rejection vs ~43 dB
for Hamming, at the same tap count. Use it for the windowed-sinc
coefficients:
```
w(n) = 0.35875 0.48829·cos(2πn/N) + 0.14128·cos(4πn/N) 0.01168·cos(6πn/N)
```
- **32 taps** with Blackman-Harris and a proper cutoff gives >60 dB
stopband rejection — more than enough. 64 taps doubles the MAC count
for marginal improvement.
- **64 polyphase phases** balances fractional sample resolution against
coefficient bank size (64 × 32 × 4 = 8 KB fits comfortably in L1
cache). 128 phases offer diminishing returns for double the memory.
## FM Discriminator
- **Batch with AVX2:** The conjugate-multiply + atan2 pattern is
data-parallel (each output depends only on two adjacent input samples).
Process 8 samples at a time using 256-bit SIMD.
- **Use a high-precision atan2 polynomial** for AVX2. A 7th-order minimax
polynomial (max error ~2.4e-7 rad) avoids the treble distortion that
cheap 1st-order approximations (e.g. `0.273*(1|z|)`) introduce on
strong signals. Coefficients:
```
c0 = 0.999_999_5
c1 = 0.333_326_1
c2 = 0.199_777_1
c3 = 0.138_776_8
```
- **Branchless argument reduction** for atan2: swap `|y|` and `|x|` using
masks rather than branches, apply quadrant correction via arithmetic
shift and copysign.
## WFM Stereo Specifics
- **Pilot notch before diff demod:** The 19 kHz pilot leaks into the
38 kHz multiplication and creates intermod products. Notch it from the
composite signal before `x * cos(2θ)`. This notch is separate from the
mono-path pilot notch (which sits after the sum LPF).
- **IQ hard limiter before FM discriminator:** For WFM, only the phase
carries information. Normalizing IQ magnitude to 1.0 prevents
overdeviation artifacts and clipping. Guard against zero magnitude.
- **Binary stereo blend:** A smooth blend function (e.g. smoothstep)
sounds good in theory but reduces real-world separation. Use
`blend = 1.0` when pilot is detected, `0.0` otherwise.
- **STEREO_MATRIX_GAIN = 0.50:** The correct unity factor for
`L = (S+D)/2`, `R = (SD)/2`. Lower values waste headroom; higher
values clip.
## Opus Encoding
- **Complexity 5** (down from default 9-10) saves significant CPU with
minimal quality impact at bitrates ≥128 kbps. The higher complexity
levels run expensive psychoacoustic search algorithms that produce
negligible improvement at high bitrates.
- **256 kbps** is transparent for stereo FM broadcast audio. Going higher
wastes bandwidth; going below 128 kbps may introduce artifacts on
complex program material.
- **`Application::Audio`** (not VoIP) — uses the MDCT-based CELT mode
optimized for music and broadband audio rather than speech.
## AVX2 Guidelines
- Gate all AVX2 code behind `#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]`
and runtime `is_x86_feature_detected!("avx2")` checks.
- Mark unsafe SIMD functions with `#[target_feature(enable = "avx2")]`
so the compiler generates AVX2 code for the function body.
- Provide scalar fallbacks for non-x86 targets and CPUs without AVX2.
- Add epsilon guards (e.g. `1e-12`) to denominators in SIMD paths where
both numerator and denominator can be zero simultaneously.
## What NOT to Optimize
- **Biquad filters** — already minimal (5 muls + 4 adds per sample).
The sequential state dependency prevents SIMD vectorization within a
single stream.
- **One-pole lowpass filters** — single multiply-accumulate, cannot be
made faster.
- **DC blockers** — trivial per-sample cost.
- **Deemphasis** — single biquad, runs at audio rate (not composite rate).
## Profiling Tips
- Use `cargo build --release` — debug builds are 10-50x slower and
misleading for DSP profiling.
- `perf stat` / `Instruments` on the inner loop to check IPC, cache
misses, and branch mispredictions.
- Compare CPU% with stereo enabled vs disabled to isolate stereo-specific
costs (diff path biquads, pilot PLL, 38 kHz demod, resampler channels).
- Watch for unexpected `libm` calls in disassembly — the compiler may
not inline `f32::atan2` or `f32::sin_cos` even in release mode.
+324
View File
@@ -0,0 +1,324 @@
# Planned Features
## Recorder
The recorder captures the demodulated audio stream alongside associated metadata (FFT data, decoded signals, rig state) into a structured session on disk, with full playback and seeking support from within the application.
### Requirements
| ID | Description |
|----|-------------|
| REQ-REC-001 | When the user starts recording, the system shall record the currently demodulated audio stream. |
| REQ-REC-002 | When recording audio, the system shall store the recording in OPUS format. |
| REQ-REC-003 | While recording audio, the system shall automatically detect whether the recording should be stored in mono or stereo and select the appropriate format. |
| REQ-REC-004 | While recording is active, the system shall simultaneously record FFT data and all currently visible decoded elements, including APRS and FT8. |
| REQ-REC-005 | While recording metadata, the system shall store FFT data and decoded signal data in a structured data file format. |
| REQ-PLAY-001 | Where recorded sessions exist, the system shall allow playback of recordings from within the same application. |
| REQ-PLAY-002 | During playback, the system shall allow the user to seek to any position in the recording. |
| REQ-SYNC-001 | The system shall maintain time synchronization between the audio recording and the associated data file with at least one-second resolution. |
| REQ-REC-006 | While recording is active, the system shall allow the current cursor position to be stored. |
---
### Architecture
#### New Crate: `trx-recorder`
A new crate `src/trx-server/trx-recorder/` handles all record and playback logic. It is a library crate consumed by `trx-server`.
```
src/trx-server/
trx-recorder/
src/
lib.rs # Public API: RecorderHandle, start_recorder_task()
session.rs # RecordingSession: file management, open/close/finalise
writer.rs # AudioWriter: PCM → Opus encoder
data_file.rs # DataFileWriter: structured JSON Lines data track
index.rs # SeekIndex: time → byte-offset table for audio seeking
playback.rs # PlaybackEngine: file → PCM broadcast for clients
config.rs # RecorderConfig (serde, derives Default)
```
#### Integration Points in `trx-server`
| Source | What is tapped | How |
|--------|---------------|-----|
| `audio.rs` `pcm_tx` | Raw demodulated PCM frames | New `broadcast::Receiver<Vec<f32>>` subscriber |
| `audio.rs` spectrum broadcast | FFT/spectrum frames per `RigState.spectrum` | New subscriber on the spectrum watch channel |
| `audio.rs` decoded-message broadcast | FT8, WSPR, CW, APRS, FT4, FT2, APRS-HF frames | New `broadcast::Receiver<DecodedMessage>` subscriber |
| `rig_task.rs` state watch | Frequency/mode/PTT changes | `watch::Receiver<RigState>` clone |
| New `RecorderCommand` enum | Start, Stop, MarkCursor | Injected into the existing command pipeline |
No existing code paths are modified beyond:
1. Passing a `RecorderHandle` (cheap `Arc` wrapper) into the audio and rig tasks.
2. Adding `RecorderCommand` variants to the command enum (alongside existing `SetFreq`, `SetMode`, etc.).
3. Adding a `[recorder]` section to `ServerConfig`.
---
### Session Layout on Disk
Each recording is a **session directory** named by UTC start time and opening rig state:
```
<output_dir>/
20260317T142301Z_14074000_USB/
audio.opus
data.jsonl # structured event log (see below)
index.bin # seek index: sorted table of (offset_ms u64, audio_byte u64)
```
`output_dir` defaults to `~/.local/share/trx-rs/recordings`.
#### Audio File (REQ-REC-001, REQ-REC-002, REQ-REC-003)
- **Format**: Opus, using the `opus` crate (already a workspace dependency via `trx-backend-soapysdr`). Seek index (`index.bin`) provides byte → time mapping.
- **Channel count**: determined at session open from `AudioConfig.channels`. If `channels == 1` → mono; if `channels == 2` → stereo. Written into the file header and recorded in the session's first data event.
- **Sample rate**: preserved from `AudioConfig.sample_rate` (default 48 000 Hz).
#### Data File (REQ-REC-004, REQ-REC-005)
`data.jsonl` — one JSON object per line, each with a required `offset_ms` field giving the millisecond offset from session start (satisfies REQ-SYNC-001 at ≥1 s resolution):
```jsonl
{"offset_ms":0,"type":"session_start","freq_hz":14074000,"mode":"USB","channels":1,"sample_rate":48000,"format":"opus"}
{"offset_ms":1000,"type":"rig_state","freq_hz":14074000,"mode":"USB","ptt":false}
{"offset_ms":2000,"type":"fft","bins_db":[-90.1,-88.4,...]}
{"offset_ms":3412,"type":"ft8","snr_db":-12,"dt_s":0.3,"freq_hz":14074350,"message":"CQ W5XYZ EN34"}
{"offset_ms":4100,"type":"aprs","from":"W5XYZ-9","to":"APRS","path":"WIDE1-1","info":"!3351.00N/09722.00W-"}
{"offset_ms":5000,"type":"cursor","label":"interesting QSO"}
{"offset_ms":61000,"type":"session_end"}
```
Supported `type` values:
| Type | Source | Cadence |
|------|--------|---------|
| `session_start` | recorder | once, at open |
| `session_end` | recorder | once, at close |
| `rig_state` | `watch::Receiver<RigState>` change | on change |
| `fft` | spectrum data from `RigState.spectrum` | ≤1 Hz (configurable, default 1 s) |
| `ft8` / `ft4` / `ft2` / `wspr` | `DecodedMessage` broadcast | on decode event |
| `aprs` / `aprs_hf` | `DecodedMessage` broadcast | on decode event |
| `cw` | `DecodedMessage` broadcast | on decode event |
| `cursor` | `RecorderCommand::MarkCursor { label }` | on user request |
#### Seek Index (REQ-PLAY-002)
`index.bin` is a flat binary table of 16-byte records written every `index_interval_ms` (default 1 000 ms):
```
[offset_ms: u64 LE][audio_byte_offset: u64 LE] ...
```
At playback seek time, binary search on `offset_ms` locates the nearest audio frame boundary, enabling random-access playback without full file scan.
---
### RecorderConfig
Added to `ServerConfig` under `[recorder]`:
```toml
[recorder]
enabled = false
output_dir = "~/.local/share/trx-rs/recordings"
opus_bitrate_bps = 32000
fft_record_interval_ms = 1000
index_interval_ms = 1000
max_session_duration_s = 3600 # auto-split at 1 h; 0 = unlimited
```
---
### Command API
New variants added to the existing command enum (handled in `rig_task.rs`):
```rust
StartRecording,
StopRecording,
MarkCursor { label: String },
```
These are exposed via:
- **HTTP frontend**: `POST /api/recorder/start`, `POST /api/recorder/stop`, `POST /api/recorder/cursor`
- **http-json frontend**: same commands as JSON messages
---
### Playback Engine (REQ-PLAY-001, REQ-PLAY-002)
`PlaybackEngine` opens a session directory and:
1. Reads `audio.opus` and decodes PCM frames in real time.
2. Publishes decoded PCM frames onto a `broadcast::Sender<Vec<f32>>` — the **same channel type** as the live `pcm_tx`, so existing decoder tasks and audio-streaming clients receive playback data transparently.
3. Replays `data.jsonl` events on their original `offset_ms` timestamps, injecting them into the `DecodedMessage` broadcast so the HTTP frontend displays historic decodes during playback.
4. For seek: binary-searches `index.bin` to find the audio byte offset, then replays data events from the same point.
The playback state machine has two modes, switched by a new `RigState.playback` field:
```rust
pub enum PlaybackState {
Live,
Playing { session: String, offset_ms: u64 },
Paused { session: String, offset_ms: u64 },
}
```
While `PlaybackState` is not `Live`, the server suppresses live hardware polling and PCM capture to avoid mixing live and playback audio.
---
### Time Synchronisation (REQ-SYNC-001)
All timestamps use a single `session_epoch: std::time::Instant` captured at `StartRecording`. Every PCM frame, every data event, and every seek-index entry is stamped as `(Instant::now() - session_epoch).as_millis() as u64`. This gives sub-millisecond internal precision; the requirement of ≥1 s resolution is met by orders of magnitude.
Wall-clock UTC is embedded only in `session_start` (`wall_clock_utc`) and in the session directory name, providing absolute time anchoring without depending on system clock monotonicity for sync.
---
### Implementation Phases
#### Phase 1 — Audio recording (REQ-REC-001, REQ-REC-002, REQ-REC-003)
1. Add `trx-recorder` crate skeleton; `RecorderConfig`; `RecorderHandle`.
2. Implement `AudioWriter` with Opus output.
3. Subscribe `AudioWriter` to `pcm_tx` in `audio.rs`; open session on `StartRecording` command.
4. Auto-detect channel count from `AudioConfig.channels`.
#### Phase 2 — Metadata recording (REQ-REC-004, REQ-REC-005, REQ-SYNC-001)
1. Implement `DataFileWriter`; define full event schema.
2. Subscribe to `DecodedMessage` broadcast; fan-in all decoder types.
3. Subscribe to state watch; emit `rig_state` events on freq/mode change.
4. Emit `fft` events at configured interval from spectrum data.
5. Write `SeekIndex` in parallel with audio.
#### Phase 3 — Cursor (REQ-REC-006)
1. Add `MarkCursor` command + HTTP endpoint.
2. Write `cursor` event to `data.jsonl` with current `offset_ms`.
#### Phase 4 — Playback (REQ-PLAY-001, REQ-PLAY-002)
1. Implement `PlaybackEngine`; Opus decode + PCM broadcast.
2. Add `PlaybackState` to `RigState`; suppress live capture during playback.
3. Implement seek via `index.bin` binary search.
4. Replay `data.jsonl` events; feed into `DecodedMessage` broadcast.
5. Expose start/stop/seek endpoints in `trx-frontend-http`.
---
### Dependencies to Add
| Crate | Use | Already present? |
|-------|-----|-----------------|
| `opus` | Opus encode/decode | Yes (via trx-backend-soapysdr) |
| `serde_json` | data.jsonl serialisation | Yes |
| `tokio::fs` | async file I/O | Yes |
---
### Open Questions
1. **Playback isolation**: Should playback be exclusive (block all CAT commands) or concurrent? Initial design blocks CAT polling; revisit if users need to change frequency during playback.
2. **Session listing API**: The HTTP frontend needs an endpoint to enumerate sessions (`GET /api/recorder/sessions`). Schema TBD in Phase 4.
3. **Storage limits**: `max_session_duration_s` auto-splits sessions; a `max_total_size_gb` housekeeping option may be needed but is out of scope for initial phases.
---
## Configurator Helper
An interactive CLI tool that guides users through creating configuration files
for trx-rs. Instead of editing TOML by hand, the user answers prompts and the
tool generates valid, commented configuration files.
### Overview
The configurator is a standalone Rust binary (`trx-configurator`) that reuses
the existing config structs from `trx-app`, `trx-server`, and `trx-client`. It
walks the user through a question-driven flow, validates inputs against the same
rules the binaries use at startup, and writes one or more of:
- `trx-server.toml` — server configuration
- `trx-client.toml` — client configuration
- `trx-rs.toml` — combined server + client configuration
The user chooses which file(s) to generate.
### Requirements
| ID | Description |
|----|-------------|
| REQ-CFG-001 | The tool shall interactively prompt the user for configuration values. |
| REQ-CFG-002 | The tool shall generate `trx-server.toml`, `trx-client.toml`, or `trx-rs.toml` per user selection. |
| REQ-CFG-003 | The tool shall validate all inputs using the same validation logic as the server and client binaries. |
| REQ-CFG-004 | The tool shall write commented TOML with descriptions of each field. |
| REQ-CFG-005 | The tool shall detect connected serial devices and offer them for rig access configuration. |
| REQ-CFG-006 | The tool shall detect available SoapySDR devices and offer them for SDR backend configuration. |
| REQ-CFG-007 | The tool shall support a non-interactive mode that generates a default config file. |
| REQ-CFG-008 | The tool shall not overwrite existing files without confirmation. |
### Architecture
#### New Crate: `trx-configurator`
A new binary crate at `src/trx-configurator/` that depends on `trx-app` for
config types and validation.
```
src/trx-configurator/
src/
main.rs # CLI entry point, mode selection
prompts.rs # Interactive prompt helpers (with defaults, validation)
detect.rs # Hardware detection (serial ports, SoapySDR devices)
writer.rs # TOML serialisation with inline comments
```
#### Flow
```
trx-configurator
├── What would you like to generate?
│ [ ] trx-server.toml
│ [ ] trx-client.toml
│ [ ] trx-rs.toml (combined)
├── (if server)
│ ├── General: callsign, location
│ ├── Rig: model selection, access (serial/tcp/sdr)
│ │ └── detect serial ports / SoapySDR devices
│ ├── Listen: address, port
│ ├── Audio: sample rate, channels, codec settings
│ ├── SDR: (if soapysdr selected) gain, channels, decoders
│ ├── Uplinks: PSKReporter, APRS-IS
│ └── Decode logs: enable, directory
├── (if client)
│ ├── Remote: server URL, auth token
│ ├── Frontends: HTTP, rigctl, http-json (enable/disable, ports)
│ └── Audio: bridge settings
└── Write file(s) with confirmation
```
#### Hardware Detection
- **Serial ports**: enumerate available serial devices using `serialport` crate
(already a transitive dependency). Present as selectable list with device
path and description.
- **SoapySDR devices**: if built with `soapysdr` feature, call
`SoapySDR::enumerate("")` to list available SDR hardware. Present device
driver, label, and serial number.
#### Dependencies
| Crate | Use | Already present? |
|-------|-----|-----------------|
| `dialoguer` | Interactive prompts, selection, confirmation | No |
| `toml_edit` | TOML serialisation preserving comments | No |
| `trx-app` | Config types and validation | Yes |
| `serialport` | Serial port enumeration | Yes (transitive) |
| `soapysdr` | SDR device enumeration (optional) | Yes (feature-gated) |
+95
View File
@@ -0,0 +1,95 @@
# RDS Parameter Tuning Notes
*Decoder tuning rationale for `trx-rds`. Recorded 2026-03-27; reflects the
shipped parameter set. Kept as a reference for why these constants were chosen —
not an open work item.*
## Goal
Maximum sensitivity (weak-signal decode) with zero false positive PI decodes.
## Changes Applied
### `src/decoders/trx-rds/src/lib.rs`
#### Constants tuned
- `RRC_ALPHA = 0.50` (was 0.75) — narrower noise bandwidth, ~0.6 dB SNR gain
- `COSTAS_KI = 3.5e-7` — loop damping ζ≈0.68, well-damped (1e-6 caused instability)
- `PI_ACC_THRESHOLD = 3` (was 2) — accumulate 3 Block A observations before committing PI
- `OSD_MAX_FLIP_COST = 0.45` — Tech 9: reject OSD corrections where flipped bits had
high confidence (genuine errors have cost ≲ 0.3; noise matches cost 0.61.2)
#### Soft confidence fix
In `Candidate::process_sample`, the soft confidence passed to `push_bit_soft` is now
`biphase_i.abs()` (was full vector magnitude). This aligns confidence with the bit
decision sign and prevents OSD(2) from false-decoding noise when the Costas loop
has residual phase error.
#### OSD(2) in locked mode (kept)
`decode_block_soft` performs OSD(2): hard decode → all 26 single-bit flips → all
325 two-bit flip pairs. Only active in locked mode; sequential B→C→D block-type
gating limits false positives.
#### Search mode: hard decode only
Removed OSD(1) from Block A acquisition (search mode). With OSD(1), ~13% of
random 26-bit words would falsely pass the Block A test per bit, allowing wrong
clock-phase candidates to accumulate false groups as fast as the correct candidate
accumulates real ones. Hard decode reduces the false Block A rate to ~0.5%.
#### Tech 9: OSD cost ceiling
`decode_block_soft` now enforces `OSD_MAX_FLIP_COST = 0.45` — the sum of soft
confidences for all flipped bits must not exceed this threshold. At 910 dB SNR,
genuine bit errors have very low `|biphase_I|` (cost ≲ 0.3), while noise-induced
OSD matches flip high-confidence bits (cost 0.61.2). This eliminates most
spurious OSD(2) matches without affecting real weak-signal corrections.
#### Tech 10: PI consistency gate
`process_group` rejects groups whose Block A PI differs from the candidate's
established PI. This prevents a single false OSD decode from polluting accumulated
text fields (PS, RT, PTYN) with garbage from noise or interference.
#### Candidate selection: incumbent tracking
Added `best_candidate_idx: Option<usize>` to `RdsDecoder`. The incumbent (winning)
candidate can always update `best_state` at equal score (its `ps_seen`/`rt_seen`
arrays accumulate coherently). A challenger must achieve a strictly higher score to
take over. The incumbent's `best_score` is also updated when it returns `None`
(no state change) so challengers cannot leapfrog with a single false group.
#### Test fixes
- `blocks_to_chips`: added NRZI (NRZ-Mark) pre-encoding. The differential biphase
decoder computes `bit = input_bit XOR prev_input_bit`; without NRZI the recovered
bits were XOR-of-consecutive-bits, not the original data.
- `decode_block_soft_rejects_three_bit_error`: removed (OSD(2) legitimately finds
distance-2 codewords; `pure_noise_produces_zero_pi_decodes` is the real guard).
- New test: `blocks_to_chips_round_trips_all_groups` — verifies round-trip decode
of all 16 blocks across all 4 PS segments without BPSK modulation.
### `src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/wfm.rs`
- `PILOT_LOCK_THRESHOLD = 0.20` (was 0.25) — pilot reference enabled at lower coherence
- Added `PILOT_LOCK_ONSET = 0.30` constant (was hardcoded 0.4)
- `pilot_lock` ramp: `((pilot_coherence - PILOT_LOCK_ONSET) / 0.2).clamp(0.0, 1.0)`
— pilot reference engages at coherence ≥ 0.36 instead of ≥ 0.45
## Test Status
```
cargo test -p trx-rds
```
16/16 passing:
- ✅ decode_block_recognizes_valid_offsets
- ✅ decode_block_soft_corrects_single_bit_error
- ✅ decode_block_soft_corrects_two_bit_error_osd2
- ✅ block_decode_rate_osd1_vs_osd2
- ✅ decode_block_soft_prefers_least_costly_flip
- ✅ full_group_with_two_bit_errors_in_each_locked_block
- ✅ pi_accumulation_corrects_weak_pi_after_threshold
- ✅ decoder_emits_ps_and_pty_from_group_0a
- ✅ rrc_tap_dc_gain
- ✅ pure_noise_produces_zero_pi_decodes (2 seconds of noise, zero false PI)
- ✅ end_to_end_with_pilot_reference_decodes_pi
- ✅ end_to_end_noisy_signal_snr_10db_decodes_pi
- ✅ end_to_end_noisy_signal_snr_9db_decodes_pi ← new, 9 dB threshold
- ✅ costas_tracks_without_diverging_on_clean_signal
- ✅ blocks_to_chips_round_trips_all_groups
- ✅ end_to_end_clean_signal_decodes_ps
+163
View File
@@ -0,0 +1,163 @@
# Settings Menu — UI/UX Analysis & Improvement Plan
*Authored: 2026-03-30*
## 1. Current Structure
The Settings tab (`#tab-settings`) contains four sub-tabs:
| Sub-tab | Purpose | Complexity |
|---|---|---|
| **Scheduler** | Grayline / Time Span / Satellite scheduling | High — nested modes, forms, timeline |
| **Background Decode** | Hidden background decoder channels | Medium — toggle + bookmark checklist |
| **Bandplan** | IARU region overlay on spectrum | Low — dropdown + checkbox |
| **History** | Clear server-side decode history | Low — 10 clear buttons |
---
## 2. Identified Issues
### 2.1 Information Architecture
| # | Issue | Severity |
|---|---|---|
| IA-1 | **"Settings" is a catch-all bucket.** Scheduler and Background Decode are operational features, not user preferences. Bandplan and History are true settings/maintenance. Mixing them under one tab creates cognitive overhead. | Medium |
| IA-2 | **Scheduler sub-tab is overloaded.** It packs three conceptually distinct features (Grayline, Time Span, Satellite) into one scrollable panel via conditional `display:none` sections. Users must scroll past irrelevant sections. | Medium |
| IA-3 | **History clearing is buried.** Users wanting to clear FT8 decode history must navigate to Settings → History — an unintuitive path. This action is more naturally accessible from the Digital Modes tab itself. | Low |
| IA-4 | **No search or categorization.** With 4 sub-tabs today, it's manageable, but the flat sub-tab bar won't scale if more settings (e.g., audio, display theme, reporting/PSKReporter, notifications) are added. | Low |
### 2.2 Interaction Design
| # | Issue | Severity |
|---|---|---|
| IX-1 | **Save button visibility is inconsistent.** Save/Reset buttons use `style="display:none"` and are shown dynamically, but there is no dirty-state indicator. Users can change fields without realizing they haven't saved. | High |
| IX-2 | **No confirmation on destructive actions.** The 10 history-clear buttons and "Reset to Disabled" (scheduler) fire immediately on click. No confirmation dialog protects against accidental data loss. | High |
| IX-3 | **Entry table details collapsed by default.** The Time Span entry table is inside a `<details>` element — users must expand it to see, edit, or delete entries. This adds an unnecessary click when entries already exist. | Medium |
| IX-4 | **Satellite form uses a modal overlay; Time Span form is inline.** Inconsistent form presentation within the same sub-tab. Both should use the same pattern. | Medium |
| IX-5 | **Toast notification positioning.** The `.sch-toast` uses `position: fixed; bottom: 1.5rem` which can overlap with the main tab bar or mobile navigation. It also disappears without user control. | Low |
| IX-6 | **Bookmark filter in Background Decode has no "select all / deselect all" shortcut.** With many bookmarks, toggling them one by one is tedious. | Medium |
### 2.3 Visual & Layout
| # | Issue | Severity |
|---|---|---|
| VL-1 | **Scheduler has no visual state summary.** The "No activity yet." card doesn't show whether the scheduler is enabled or what mode it's in at a glance. Users must inspect the mode dropdown. | Medium |
| VL-2 | **History clear buttons are uniform.** All 10 buttons look identical (`sch-write sch-reset-btn`). No indication of which decoders have data to clear. Buttons for empty histories are noise. | Low |
| VL-3 | **Mobile responsiveness is partial.** The `@media (max-width: 600px)` rules handle `.sch-row` and `.bgd-*` layout, but the Time Span table (`.sch-ts-table` with 8 columns) overflows on narrow screens. | Medium |
| VL-4 | **Sub-tab bar can overflow.** It uses `overflow-x: auto` but gives no visual scroll indicator. On small screens, the "History" tab can be hidden off-screen with no affordance. | Low |
### 2.4 Accessibility
| # | Issue | Severity |
|---|---|---|
| A-1 | **Missing `aria-label` on several controls.** The scheduler mode select has one, but the grayline lat/lon inputs, interleave fields, and satellite fields lack accessible names beyond their visible label text (which is acceptable for `<label>` wrapping `<input>`, but form titles like "Add Entry" aren't linked to the form via `aria-labelledby`). | Low |
| A-2 | **No keyboard navigation for the 24h timeline SVG.** Timeline segments are clickable (`cursor: pointer`) but not focusable or keyboard-operable. | Medium |
| A-3 | **Color-only state indication in Background Decode status.** States like "active" (green), "waiting" (yellow), "error" (red) rely solely on color. Not sufficient for color-blind users. | Medium |
| A-4 | **Toast notifications aren't announced to screen readers.** The `.sch-toast` div lacks `role="alert"` or `aria-live` attributes. | Low |
---
## 3. Improvement Plan
### Phase 1 — Quick Wins (Low effort, high impact)
```mermaid
gantt
title Phase 1 — Quick Wins
dateFormat X
axisFormat %s
section Interaction
IX-2 Add confirmation dialogs :a1, 0, 2
IX-6 Select all / deselect all :a2, 0, 1
IX-1 Dirty-state indicator on Save :a3, 0, 2
section Accessibility
A-4 Add aria-live to toasts :a4, 0, 1
A-3 Add text labels to state dots :a5, 0, 1
```
**IX-2: Add confirmation dialogs for destructive actions**
- Wrap history-clear and "Reset to Disabled" clicks in a `confirm()` dialog (or a lightweight inline confirmation pattern).
- Estimated: ~30 lines of JS.
**IX-6: Add select all / deselect all for Background Decode bookmarks**
- Add two small buttons above the bookmark checklist: "Select All" / "Deselect All".
- Alternatively, a single toggle that reads the current state.
**IX-1: Dirty-state indicator**
- Track whether any field has changed since last load/save.
- Show a visual cue (e.g., dot on the Save button, or change button color) when there are unsaved changes.
- Optionally warn on tab navigation away from dirty settings.
**A-4: Toast accessibility**
- Add `role="alert"` and `aria-live="polite"` to `.sch-toast` elements.
**A-3: State badge text labels**
- The `.bgd-status-state` already shows uppercase text — ensure the SVG dot badges (`.bgd-state-dot`) are supplemented with visible text, not just color.
---
### Phase 2 — Structural Improvements (Medium effort)
**IA-1 + IA-3: Reorganize the Settings tab**
Proposed new sub-tab structure:
| Sub-tab | Contents |
|---|---|
| **Scheduler** | Grayline, Time Span, Satellite (unchanged) |
| **Background Decode** | Background decode config (unchanged) |
| **Display** | Bandplan region/labels, future: theme, font size, spectrum colors |
| **Maintenance** | History clearing, with per-decoder item counts |
Additionally, add contextual "Clear history" links directly in the Digital Modes tab (next to each decoder's output panel), so users don't need to navigate to Settings at all for this common action.
**IX-3: Auto-expand entry table when entries exist**
- If `scheduler-ts-tbody` has rows, set the `<details>` element's `open` attribute on render.
**IX-4: Unify form presentation**
- Convert the satellite modal (`#sch-sat-form-wrap` with `position: fixed`) to an inline form matching the Time Span entry form pattern, or vice versa. Inline is preferred for consistency and mobile friendliness.
**VL-1: Scheduler status summary card**
- Enhance the "Now Playing" card to always show: current mode, active entry (if any), next scheduled event, and satellite pass countdown (if enabled).
- Use a compact two-line format when idle: "Mode: Grayline | Next: Dawn transition in 2h 14m".
**VL-3: Responsive table for Time Span entries**
- Replace the 8-column table with a card-based layout on narrow screens (`@media (max-width: 600px)`), or use horizontal scroll with a scroll shadow indicator.
**A-2: Keyboard-accessible timeline**
- Add `tabindex="0"` and `role="button"` to timeline segments.
- Handle `keydown` for Enter/Space to activate.
---
### Phase 3 — Polish & Scalability (Higher effort)
**VL-2: Smart history-clear buttons**
- Query each decoder's item count via API (or piggyback on existing SSE state).
- Show count badges on each button (e.g., "Clear FT8 history (142)").
- Disable or hide buttons for decoders with no history.
- Add a "Clear All" button with appropriate confirmation.
**IA-4: Settings search (future-proofing)**
- If the settings surface grows beyond 5-6 sub-tabs, add a search/filter input at the top of the Settings tab that highlights matching sections.
- Not needed today, but the sub-tab architecture should be designed to accommodate it.
**VL-4: Sub-tab scroll indicators**
- Add CSS gradient fade or arrow indicators when the sub-tab bar overflows horizontally.
- Consider a "more" dropdown for narrow viewports.
**IX-5: Improved toast system**
- Position toasts inside the settings panel (not `position: fixed`) to avoid overlap with global UI.
- Add a brief auto-dismiss with a progress bar, plus a manual dismiss button.
- Stack multiple toasts if needed.
---
## 4. Priority Summary
| Priority | Items | Rationale |
|---|---|---|
| **P0 — Do Now** | IX-2 (confirmations), IX-1 (dirty state) | Prevent accidental data loss |
| **P1 — Next** | IX-6 (select all), A-3 (color-blind), A-4 (toast a11y), IX-3 (auto-expand) | Low effort, meaningful UX gains |
| **P2 — Soon** | IA-1/IA-3 (reorg), IX-4 (form consistency), VL-1 (status card), VL-3 (mobile table) | Structural quality |
| **P3 — Later** | VL-2 (smart buttons), IA-4 (search), VL-4 (scroll hints), IX-5 (toast rework) | Polish and future-proofing |
+390
View File
@@ -0,0 +1,390 @@
# UX Guidelines
This document captures the UI/UX design patterns, conventions, and principles observed across
the trx-rs application. It covers the web frontend, CLI interfaces, configuration wizard, API
design, and error handling.
*Last reviewed: 2026-03-28*
---
## 1. Web Frontend (trx-frontend-http)
### 1.1 Layout and Navigation
The web UI is a single-page application served from embedded assets (no build step). It uses
a **tab-based** navigation model with six top-level tabs:
| Tab | Icon | Purpose |
|---|---|---|
| **Main** | House | Primary radio control: spectrum, frequency, mode, PTT, VFO, SDR controls |
| **Bookmarks** | Bookmark | Saved frequency/mode presets with folder organisation |
| **Digital modes** | Bar chart | FT8/FT4/FT2, WSPR, CW, APRS, AIS, VDES decode tables |
| **Map** | Pin | Leaflet map for APRS/AIS/FT8 station plotting |
| **Settings** | Wrench | Scheduler, background decode, history retention |
| **About** | Info circle | Server/client/radio/audio/decoder/integration details |
Tabs use inline SVG icons with a text label below. On narrow viewports the tab bar wraps and
subtitles collapse to save space.
The **Settings** and **About** tabs each use a secondary **sub-tab bar** for further grouping
(e.g. Settings > Scheduler | Background Decode | History).
### 1.2 Theming
The UI supports **dark mode** (default) and **light mode** toggled via a header button. Theme
preference persists in `localStorage`.
Additionally, nine **colour styles** are available via a dropdown:
- Original (default), Arctic, Lime, Contrast, Neon Disco, Donald (golden-rain), Amber, Fire, Phosphor
Each style provides a full CSS custom-property override set for both dark and light variants.
Styles are applied via `data-style` and `data-theme` attributes on `<html>`.
All colours reference CSS custom properties (`--bg`, `--card-bg`, `--text`, `--accent-green`,
`--border-light`, etc.) so components never use hard-coded colour values.
### 1.3 Typography
- **Body**: `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`
- **Frequency display**: `DSEG14 Classic` (14-segment display font, loaded from CDN with `preload`)
- **Labels**: uppercase, 0.68-0.78 rem, `font-weight: 700`, `letter-spacing: 0.04em`
- **Section labels** use pill-shaped badges (`border-radius: 999px`) with muted text
### 1.4 Responsive Design
Six breakpoints handle layout adaptation:
| Breakpoint | Behaviour |
|---|---|
| `> 1100px` | Full width with bookmark side gutters on spectrum |
| `< 1100px` | Side bookmark panels hidden |
| `< 900px` | Card fills viewport width, reduced padding |
| `< 760px` | Tab bar wraps, controls stack vertically, safe-area-inset padding for notched devices |
| `< 640px` | Bottom-fixed tab bar (mobile), subtitles hidden, compact header |
| `< 520px` | Further compact adjustments |
Touch-specific: `@media (hover: none) and (pointer: coarse)` enlarges hit targets.
The spectrum panel hints adapt: mouse users see "Scroll to zoom / Ctrl+Scroll to tune /
Drag to pan" while touch users see "Pinch to zoom / Drag to pan".
### 1.5 Interactive Controls
- **Jog wheel**: Circular CSS-styled draggable dial for frequency tuning (skeuomorphic radial-gradient, grab cursor, shadow/inset). Plus/minus buttons flank it.
- **Step unit buttons**: Segmented button group (MHz / kHz / Hz) with `.active` highlight
- **Step scale**: 1x / 0.1x multiplier toggle
- **Frequency input**: Monospace DSEG14 font, editable `<input>` with disabled opacity fix
- **Mode selector**: `<select>` dropdown populated from rig capabilities
- **PTT / Power / Lock buttons**: Three-column grid in the transmit/power section
- **VFO picker**: Button group (horizontal on desktop, vertical stack on mobile)
- **WFM/SAM controls**: Compact labelled controls (de-emphasis, audio mode, denoise, stereo pilot flag, CCI/ACI interference bars)
- **SDR settings row**: AGC checkbox, RF/LNA gain inputs with Set buttons, noise blanker
### 1.6 Spectrum and Waterfall
The spectrum panel uses `<canvas>` elements (WebGL renderer optional) and offers:
- **Drag to pan**, **scroll to zoom**, **Ctrl+scroll to tune**
- Bandwidth edges are draggable to resize the filter
- Keyboard shortcuts: `+`/`-` zoom, arrows pan, `0` reset
- **Minimap** for orientation when zoomed
- **Resize grip** to adjust spectrum height
- Controls: bandwidth input, auto-BW, sweet-spot, peak hold (0-60s), floor (dB), range (dB), auto-level, contrast gamma slider
- **Waterfall/waveform split slider** (20%-80%, default 50/50)
- **Bookmark axis** overlays on left/right sides at wider viewports
- **Decoder overlays**: RDS station name, AIS/VDES/FT8/APRS/CW bar overlays using `aria-live="polite"`
### 1.7 Real-Time Data
- **SSE (Server-Sent Events)** on `/events` for rig state updates. Each SSE session gets a
UUID, enabling per-tab rig selection without interfering with other tabs.
- **Named events**: `data` (state), `session` (session UUID), `channels` (virtual channels),
`b` (spectrum bins as base64), `rds`, `vchan_rds`, `ping` (5-second heartbeat)
- **WebSocket** on `/audio` for Opus-encoded RX audio streaming
- **Connection lost banner**: `#server-lost-banner` with pulsing dot, text "trx-server
connection lost -- waiting for reconnect", uses `aria-live="assertive"`
- **Loading state**: Centered "Initializing (rig)..." with subtitle, content hidden until ready
### 1.8 Accessibility
- All interactive elements have `aria-label` attributes
- Spectrum overlays use `aria-live="polite"` for screen reader announcements
- Connection-lost banner uses `aria-live="assertive"`
- `aria-hidden="true"` on decorative canvases and visual-only elements
- SVG icons include `aria-hidden="true"` with descriptive labels on parent buttons
- Spectrum resize grip has both `title` and `aria-label`
### 1.9 Authentication UX
When auth is enabled, an **auth gate** blocks the UI with:
- Title: "Access Required"
- Subtitle: "Enter passphrase to continue"
- Password input + Login button (green accent, full-width)
- Optional "Continue as Guest" button (shown when RX passphrase is not set)
- Error message area (red `#ff6b6b`)
- Role badge display
Two roles: **Rx** (read-only) and **Control** (full access including TX/PTT).
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
The header shows a Login/Logout button when auth is enabled (`#header-auth-btn`).
### 1.10 Multi-Rig Support
- **Header rig switcher**: `<select>` dropdown in the top bar for switching between connected rigs
- Per-tab rig binding: each SSE session independently selects a rig via `?remote=` query parameter
- Rig state isolation: only the disconnected rig shows the connection-lost banner
- About tab shows active rig, available rigs list
---
## 2. REST API Design
### 2.1 Conventions
- **Read operations** use `GET` (e.g. `/status`, `/events`, `/decode/history`, `/rigs`, `/bookmarks`)
- **Mutations** use `POST` for actions and toggles (e.g. `/set_freq`, `/toggle_power`, `/toggle_ft8_decode`)
- **CRUD resources** use proper verbs: `GET /bookmarks`, `POST /bookmarks`, `PUT /bookmarks/{id}`,
`DELETE /bookmarks/{id}`
- **Batch operations**: `POST /bookmarks/batch_delete`, `POST /bookmarks/batch_move`
- **Nested resources**: `/channels/{remote}/{channel_id}/subscribe`, `/scheduler/{remote}/status`
- Responses are JSON with `Content-Type: application/json`
- SSE stream uses `Content-Type: text/event-stream` with `no-cache` and `keep-alive` headers
### 2.2 Request Timeout
All rig command requests have a **15-second timeout** (`REQUEST_TIMEOUT`). If the command
doesn't complete in time, the request returns an error rather than hanging.
### 2.3 Error Responses
- `401 Unauthorized`: `{"error": "Invalid credentials"}` or `{"error": "Authentication required"}`
- `429 Too Many Requests`: `{"error": "Too many login attempts, please try again later"}`
- `404 Not Found`: Auth endpoints when auth is disabled
- `500 Internal Server Error`: Serialization failures
- Rate limiting: 10 attempts per 60-second window per IP, counter resets on successful login
### 2.4 State Enrichment
API responses merge rig state with **frontend metadata** (`FrontendMeta`) via `serde(flatten)`:
```
http_clients, rigctl_clients, audio_clients, rigctl_addr,
active_remote, remotes[], owner_callsign, owner_website_url,
owner_website_name, ais_vessel_url_base, show_sdr_gain_control,
initial_map_zoom, spectrum_coverage_margin_hz, spectrum_usable_span_ratio,
decode_history_retention_min, server_connected
```
This single-payload approach avoids extra round trips for UI configuration.
---
## 3. CLI Interface
### 3.1 Argument Style
Both `trx-server` and `trx-client` use **clap** for argument parsing with short and long flags:
```
-C, --config FILE Path to configuration file
--print-config Print example configuration and exit
-r, --rig NAME Rig backend name
-l, --listen ADDR Listen address
-p, --port NUM Port number
```
Positional arguments are used sparingly (e.g. `RIG_ADDR` for serial/TCP address).
### 3.2 Configuration Resolution
Config files are searched in priority order:
1. Current directory: `trx-rs.toml`
2. XDG config: `~/.config/trx-rs/trx-rs.toml`
3. System: `/etc/trx-rs/trx-rs.toml`
The loaded config path is logged: `INFO Loaded configuration from /path/to/config.toml`
### 3.3 Example Config Generation
`--print-config` outputs a complete, commented TOML file to stdout with example values
(callsign `N0CALL`, coordinates `52.2297, 21.0122`). Each section has a header comment and
each field has an inline description.
### 3.4 Startup Log Sequence
Server:
```
INFO Loaded configuration from /path/to/config.toml
INFO Starting trx-server with N rig(s): [rig-names]
INFO Callsign: CALL
INFO [rig-id] Starting (rig: ft817, access: serial /dev/ttyUSB0 @ 9600 baud)
INFO Listening on 0.0.0.0:4530
```
Client:
```
INFO Loaded configuration from /path/to/config.toml
INFO Starting trx-client (remotes: [remote-names], frontends: http,rigctl)
INFO rigctl frontend for rig 'default' on 127.0.0.1:4532
```
---
## 4. Configuration Wizard (trx-configurator)
### 4.1 Interactive Mode
Uses the **dialoguer** crate for terminal prompts:
- `Select` menus for enumerated choices (config type, rig model, access type, log level)
- `Input` for free-text with defaults (callsign defaults to `N0CALL`, listen defaults to `127.0.0.1`)
- `Confirm` for yes/no questions (enable auth, set location, etc.)
- Serial port auto-detection with fallback to `/dev/ttyUSB0`
### 4.2 Non-Interactive Mode
`--defaults` generates a config file without prompts, using sensible defaults.
### 4.3 Config Validation
`--check FILE` validates an existing config file:
```
/path/to/config.toml: valid TOML
Detected type: server
warning: [general].log_level 'verbose' is invalid (expected: trace, debug, info, warn, error)
1 warning(s), 0 error(s)
```
Validates: TOML syntax, unknown keys, log levels, coordinate ranges (-90..90 lat, -180..180 lon
with pair requirement), access types, port ranges (0-65535).
### 4.4 File Write Confirmation
Prompts before overwriting an existing file. Outputs `Wrote /path/to/file` on success.
---
## 5. Error Handling and User-Facing Messages
### 5.1 Error Message Conventions
- **Contextual**: Include file paths, section names, and peer addresses
- `"Failed to parse config file /path: error details"`
- `"Unknown rig model: X (available: ft817, ft450d, soapysdr)"`
- **Actionable**: Suggest alternatives when available
- `"Rig model not specified. Use --rig or set [rig].model in config."`
- `"Unknown frontend: X (available: http, rigctl, httpjson)"`
- **Structured**: Use field=value format in structured logging
### 5.2 Log Level Guidelines
| Level | Usage |
|---|---|
| `INFO` | Startup milestones, configuration loaded, listening, client connect/disconnect, decoder state changes |
| `WARN` | Non-fatal issues: command took too long, panel lock blocking, VFO priming failed, initial tune failed |
| `ERROR` | Fatal or significant failures: CAT polling errors, client errors, parse failures |
Logs suppress module targets (`with_target(false)`) for cleaner output.
### 5.3 Connection State Communication
- Server logs: `"Client connected: {peer}"`, `"Client {peer} disconnected"`, `"Client {peer} closing due to shutdown"`
- Rig task: `"[rig-id] Rig backend ready"`, `"Serial: /dev/ttyUSB0 @ 9600 baud"`
- Web UI: Connection-lost banner with reconnect indication, per-rig isolation
### 5.4 Graceful Degradation
- Startup continues after non-fatal failures: `"Initial PowerOn failed (continuing)"`
- Stream errors are deduplicated with 60-second summaries to avoid log flooding
- Lock poisoning is recovered from rather than panicking
- Unknown SSE events or lagged broadcast channels are silently skipped
---
## 6. Branding and Customisation
### 6.1 Owner Branding
Configurable via TOML and exposed via `FrontendMeta`:
- `owner_callsign` -- displayed in header subtitle and About tab
- `owner_website_url` / `owner_website_name` -- optional link in header
- `ais_vessel_url_base` -- base URL for linking AIS vessel MMSI numbers
### 6.2 UI Behaviour Configuration
- `http_show_sdr_gain_control` -- show/hide RF gain controls
- `http_initial_map_zoom` -- default map zoom level
- `http_spectrum_coverage_margin_hz` -- guard margin for spectrum center retune
- `http_spectrum_usable_span_ratio` -- fraction of spectrum span treated as usable
- `http_decode_history_retention_min` -- default history retention (per-rig overrides supported)
### 6.3 Embedded Assets
Logo and favicon are embedded at compile time via `include_bytes!`. The logo image has an
`onerror` handler to hide itself if loading fails (`this.style.display='none'`).
---
## 7. Security UX
### 7.1 Route Access Classification
Routes are classified into three tiers:
| Tier | Examples | Requirement |
|---|---|---|
| **Public** | `/`, `/index.html`, `/map`, `/auth/*`, static assets | None |
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Rx or Control role |
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Control role only |
### 7.2 Session Management
- Sessions are 128-bit random hex tokens stored in HttpOnly cookies
- Configurable TTL (default from TOML config)
- Expired sessions auto-pruned on access
- Constant-time passphrase comparison to mitigate timing attacks
### 7.3 TX Access Control
An additional `tx_access_control_enabled` flag can restrict transmit-related actions even
for Control-role users, providing an extra safety layer.
---
## 8. Virtual Channels (SDR)
Virtual channels allow SDR users to monitor multiple frequencies simultaneously:
- Channels appear in a picker row below the VFO controls
- CRUD API: `POST /channels/{remote}` to create, `DELETE` to remove, `PUT` to update freq/mode/BW
- Subscribe/unsubscribe audio per channel
- Background decode channels (hidden, no audio stream back)
- Channels auto-destroyed when out-of-bandwidth after center-frequency retune
- Channel-list changes broadcast to SSE clients via `event: channels`
---
## 9. Design Principles (Inferred)
1. **Server-rendered SPA**: All HTML/CSS/JS embedded in the binary -- zero external build tooling, no CDN dependency for core functionality (CDN used only for fonts and Leaflet maps).
2. **Progressive disclosure**: Advanced controls (WFM, SAM, SDR settings, spectrum controls) are hidden by default and revealed based on the active mode and backend type.
3. **Keyboard-first, touch-aware**: Spectrum supports full keyboard navigation alongside mouse and touch gestures. Mobile breakpoints enlarge hit targets and adapt layout.
4. **Real-time by default**: SSE + WebSocket provide sub-second state updates without polling from the browser. 5-second ping heartbeat detects stale connections.
5. **Per-tab isolation**: Each browser tab gets its own SSE session UUID and can independently select a rig, preventing cross-tab interference.
6. **Configuration over code**: UI behaviour knobs (gain visibility, map zoom, history retention, spectrum margins) are exposed as TOML config rather than requiring code changes.
7. **Graceful degradation**: The UI handles server disconnection gracefully with visible banners, and only the affected rig shows as disconnected in multi-rig setups.
8. **Defensive security defaults**: Auth disabled by default for ease of setup, but when enabled, provides role-based access, rate limiting, constant-time comparison, and HttpOnly cookies.
+546
View File
@@ -0,0 +1,546 @@
# trx-rs Manual
## What trx-rs is
`trx-rs` is a modular amateur radio control stack written in Rust. It splits
hardware access, DSP, transport, and user-facing interfaces into separate
components so a radio or SDR can be controlled locally while audio, decoding,
and remote control are exposed elsewhere on the network.
In practice, `trx-server` owns the rig or SDR backend and runs the DSP
pipeline, while `trx-client` connects to it and provides frontends such as the
web UI, JSON control, and rigctl-compatible access. The workspace also includes
protocol decoders and plugin-based extension points for adding backends and
frontends.
---
## Configuration
Both `trx-server` and `trx-client` use TOML configuration files. Use
`--print-config` to generate a fully commented example.
### File Locations
**trx-server** lookup order:
1. `--config <FILE>`
2. `./trx-server.toml`
3. `~/.trx-server.toml`
4. `~/.config/trx-rs/server.toml`
5. `/etc/trx-rs/server.toml`
**trx-client** lookup order:
1. `--config <FILE>`
2. `./trx-client.toml`
3. `~/.config/trx-rs/client.toml`
4. `/etc/trx-rs/client.toml`
CLI arguments override config file values.
### Environment Variables
- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by
both server and client.
### Server Options
#### `[general]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `callsign` | string | `"N0CALL"` | Station callsign |
| `log_level` | string | — | `trace`, `debug`, `info`, `warn`, or `error` |
| `latitude` | float | — | Station latitude (-90..90) |
| `longitude` | float | — | Station longitude (-180..180) |
`latitude` and `longitude` must be set together or both omitted.
#### `[rig]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `model` | string | — | Backend name (`ft817`, `ft450d`, `soapysdr`) |
| `initial_freq_hz` | u64 | `144300000` | Startup frequency (must be > 0) |
| `initial_mode` | string | `"USB"` | Startup mode |
#### `[rig.access]`
| Field | Type | Description |
|-------|------|-------------|
| `type` | string | `serial`, `tcp`, or `sdr` |
| `port` | string | Serial port path (serial mode) |
| `baud` | u32 | Serial baud rate (serial mode) |
| `host` | string | Remote host (tcp mode) |
| `tcp_port` | u16 | Remote port (tcp mode) |
| `args` | string | SoapySDR device args (sdr mode, e.g. `"driver=rtlsdr"`) |
#### `[behavior]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `poll_interval_ms` | u64 | `500` | Rig polling interval |
| `poll_interval_tx_ms` | u64 | `100` | Polling interval during TX |
| `max_retries` | u32 | `3` | Connection retry limit |
| `retry_base_delay_ms` | u64 | `100` | Base retry delay |
#### `[listen]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable JSON TCP listener |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `4530` | Bind port |
#### `[listen.auth]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
#### `[audio]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable audio streaming |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `4531` | Bind port |
| `rx_enabled` | bool | `true` | Enable RX audio |
| `tx_enabled` | bool | `true` | Enable TX audio |
| `device` | string | — | CPAL device name (empty = default) |
| `sample_rate` | u32 | `48000` | Sample rate (8000192000) |
| `channels` | u8 | `1` | Channel count (1 or 2) |
| `frame_duration_ms` | u16 | `20` | Opus frame duration (3, 5, 10, 20, 40, 60) |
| `bitrate_bps` | u32 | `24000` | Opus bitrate |
When audio is enabled, at least one of `rx_enabled` or `tx_enabled` must be true.
#### `[sdr]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
#### `[sdr.gain]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `mode` | string | `"auto"` | `"auto"` (hardware AGC) or `"manual"` |
| `value` | f64 | `30.0` | Gain in dB (manual mode only) |
#### `[sdr.squelch]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable software squelch |
| `threshold_db` | f32 | `-65.0` | Open threshold in dBFS (-140..0) |
| `hysteresis_db` | f32 | `3.0` | Close hysteresis in dB (0..40) |
| `tail_ms` | u32 | `180` | Tail hold time in ms (0..10000) |
#### `[[sdr.channels]]`
Defines virtual receiver channels within the wideband IQ stream. The first
channel is the primary channel (controlled by `set_freq`/`set_mode`).
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `id` | string | `""` | Human-readable label |
| `offset_hz` | i64 | `0` | Frequency offset from dial |
| `mode` | string | `"auto"` | Demod mode (`auto`, `LSB`, `USB`, `CW`, `AM`, `FM`, `WFM`, etc.) |
| `audio_bandwidth_hz` | u32 | `3000` | Post-demod audio bandwidth |
| `fir_taps` | usize | `64` | FIR filter tap count |
| `cw_center_hz` | u32 | `700` | CW tone centre frequency |
| `wfm_bandwidth_hz` | u32 | `75000` | WFM pre-demod filter bandwidth |
| `decoders` | string[] | `[]` | Decoder IDs for this channel (`ft8`, `wspr`, `aprs`, `cw`) |
| `stream_opus` | bool | `false` | Stream this channel's audio to clients |
Notes:
- Each decoder ID may appear in at most one channel.
- At most one channel may set `stream_opus = true`.
- Channel IF constraint: `|center_offset_hz + offset_hz| < sample_rate / 2`.
#### `[pskreporter]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable PSKReporter uplink |
| `host` | string | `"report.pskreporter.info"` | Server host |
| `port` | u16 | `4739` | Server port |
| `receiver_locator` | string | — | Maidenhead grid (derived from lat/lon if omitted) |
#### `[aprsfi]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable APRS-IS IGate |
| `host` | string | `"rotate.aprs.net"` | Server host |
| `port` | u16 | `14580` | Server port |
| `passcode` | i32 | `-1` | APRS-IS passcode (-1 = auto from callsign) |
Notes:
- `[general].callsign` must be non-empty when enabled.
- Only APRS packets with valid CRC are forwarded.
- Reconnects with exponential backoff (1 s → 60 s) on TCP errors.
#### `[decode_logs]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable decoder logging |
| `dir` | string | `"$XDG_DATA_HOME/trx-rs/decoders"` | Log directory |
| `aprs_file` | string | `"TRXRS-APRS-%YYYY%-%MM%-%DD%.log"` | APRS log filename |
| `cw_file` | string | `"TRXRS-CW-%YYYY%-%MM%-%DD%.log"` | CW log filename |
| `ft8_file` | string | `"TRXRS-FT8-%YYYY%-%MM%-%DD%.log"` | FT8 log filename |
| `wspr_file` | string | `"TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"` | WSPR log filename |
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
`%MM%`, `%DD%` (UTC).
#### Multi-Rig Configuration
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
```toml
[[rigs]]
id = "ft817_0"
name = "HF Transceiver"
[rigs.rig]
model = "ft817"
[rigs.rig.access]
type = "serial"
path = "/dev/ttyUSB0"
baud = 9600
[[rigs]]
id = "sdr_0"
name = "VHF/UHF SDR"
[rigs.rig]
model = "soapysdr"
[rigs.rig.access]
type = "sdr"
args = "driver=rtlsdr"
```
When `[[rigs]]` is present it takes priority over the flat `[rig]` section.
Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1`.
### Client Options
#### `[general]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `callsign` | string | `"N0CALL"` | Station callsign |
| `log_level` | string | — | `trace`, `debug`, `info`, `warn`, or `error` |
#### `[remote]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `url` | string | — | Server address (e.g. `localhost:4530`) |
| `poll_interval_ms` | u64 | `750` | State poll interval |
#### `[remote.auth]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `token` | string | — | Auth token (must not be empty if set) |
#### `[frontends.http]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable web UI |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `8080` | Bind port |
#### `[frontends.rigctl]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable Hamlib rigctl |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `4532` | Bind port |
#### `[frontends.http_json]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable JSON-over-TCP |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
#### `[frontends.audio]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable audio client |
| `server_port` | u16 | `4531` | Server audio port |
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
| `bridge.rx_output_device` | string | — | Local playback device |
| `bridge.tx_input_device` | string | — | Local capture device |
| `bridge.rx_gain` | float | `1.0` | RX playback gain |
| `bridge.tx_gain` | float | `1.0` | TX capture gain |
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
loopback on Linux, BlackHole on macOS).
### CLI Override Summary
**trx-server:**
`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`,
`--port`. SDR options are file-only.
**trx-client:**
`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`,
`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`,
`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`.
---
## Authentication
The HTTP frontend supports optional passphrase-based authentication with two
roles:
- **rx** — read-only access (monitoring, audio, decode streams)
- **control** — full access (frequency, mode, PTT, and all settings)
### Configuration
```toml
[frontends.http.auth]
enabled = false
rx_passphrase = "rx-only-passphrase"
control_passphrase = "full-control-passphrase"
tx_access_control_enabled = true
session_ttl_min = 480
cookie_secure = false # true if served via HTTPS
cookie_same_site = "Lax" # Strict|Lax|None
```
When `enabled = false` (the default), all auth is bypassed and the UI behaves
as before. When enabled, at least one passphrase must be set.
### Behaviour
- On login, the server issues an `HttpOnly` session cookie.
- Sessions are in-memory; a server restart invalidates all sessions.
- Rate limiting is applied per IP to mitigate brute-force attempts.
- When `tx_access_control_enabled = true`, TX/PTT controls are hidden and
rejected for unauthenticated or `rx`-role users.
### Routes
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/auth/login` | POST | Submit `{ "passphrase": "..." }` |
| `/auth/logout` | POST | Clear session |
| `/auth/session` | GET | Check current session/role |
Protected routes require at least `rx` role. Control routes (set frequency,
mode, PTT, etc.) require `control` role.
### Frontend Flow
1. On load, the UI calls `/auth/session`.
2. If unauthenticated, a login screen is shown.
3. On successful login, the normal UI loads.
4. `rx` users see a read-only interface; `control` users get full controls.
5. If a session expires mid-use, streams stop and the login screen returns.
### Transport Security
There is no built-in TLS. For remote access, place trx-rs behind a
TLS-terminating reverse proxy (nginx, Caddy) and set `cookie_secure = true`.
---
## Background Decoding Scheduler
The scheduler automatically retunes the rig to pre-configured bookmarks when no
users are connected to the HTTP frontend. It runs as a background task inside
`trx-frontend-http`, polling every 30 seconds.
### Modes
#### Disabled (default)
Scheduler is inactive. The rig is not touched automatically.
#### Grayline
Retunes around the solar terminator (day/night boundary).
The user provides:
- Station latitude and longitude (decimal degrees)
- Optional transition window width (minutes, default 20)
- Bookmark IDs for four periods:
- **Dawn** — window around sunrise (`sunrise ± window_min/2`)
- **Day** — after dawn until dusk
- **Dusk** — window around sunset (`sunset ± window_min/2`)
- **Night** — after dusk until next dawn
Period precedence (most specific wins): Dawn > Dusk > Day > Night.
If no bookmark is assigned to a period, the rig is not retuned for that period.
Sunrise/sunset is computed inline using the NOAA simplified algorithm. Polar
regions (midnight sun / polar night) fall back to Day/Night accordingly.
#### TimeSpan
Retunes according to a list of user-defined time windows (UTC).
Each entry specifies:
- `start_hhmm` — start of window (e.g. 600 = 06:00 UTC)
- `end_hhmm` — end of window (e.g. 700 = 07:00 UTC)
- `bookmark_id` — bookmark to apply
- `label` — optional human-readable description
Windows that span midnight (`end_hhmm < start_hhmm`) are supported. When
multiple entries overlap, the first match (by list order) wins.
### Storage
Configuration is stored in PickleDB at `~/.config/trx-rs/scheduler.db`.
Keys: `sch:{rig_id}` → JSON `SchedulerConfig`.
### HTTP API
All read endpoints are accessible at the **Rx** role level. Write endpoints
require the **Control** role.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/scheduler/{rig_id}` | Get scheduler config for a rig |
| PUT | `/scheduler/{rig_id}` | Save scheduler config (Control only) |
| DELETE | `/scheduler/{rig_id}` | Reset config to Disabled (Control only) |
| GET | `/scheduler/{rig_id}/status` | Get last-applied bookmark and next event |
### Activation Logic
Every 30 seconds the scheduler task checks:
1. No SSE clients connected
2. Active rig has a non-Disabled scheduler config
3. Current UTC time matches a scheduled window or grayline period
4. If the matching bookmark differs from last applied, send `SetFreq` + `SetMode`
The scheduler does not revert changes when users reconnect.
### Web UI
A dedicated tab with a clock icon provides:
- Rig selector (read-only, shows active rig)
- Mode picker: Disabled / Grayline / TimeSpan
- Grayline section: lat/lon inputs, transition window slider, four bookmark selectors
- TimeSpan section: table of entries with start/end times, bookmark, label
- Status card: last applied bookmark name and timestamp
- Save button (Control role only)
---
## SDR Noise Blanker
The noise blanker suppresses impulse noise (clicks, pops, ignition interference)
on raw IQ samples before any mixing or filtering takes place. It works by
tracking a running RMS level of the signal and replacing any sample whose
magnitude exceeds **threshold x RMS** with the last known clean sample.
### Configuration (server-side)
The noise blanker is configured per rig. In a multi-rig setup each
`[[rigs]]` entry has its own `[rigs.sdr.noise_blanker]` section:
```toml
[[rigs]]
id = "hf"
[rigs.rig]
type = "sdr"
[rigs.sdr.noise_blanker]
enabled = true
threshold = 10.0 # 1 100; lower = more aggressive blanking
```
For the legacy single-rig (flat) config the path is `[sdr.noise_blanker]`:
```toml
[sdr.noise_blanker]
enabled = true
threshold = 10.0
```
| Field | Type | Default | Range | Description |
|-------------|-------|---------|---------|-------------|
| `enabled` | bool | false | — | Turn the noise blanker on or off. |
| `threshold` | float | 10.0 | 1 100 | Multiplier applied to the running RMS. A sample whose magnitude exceeds this multiple is replaced. Lower values blank more aggressively; higher values only catch strong impulses. |
The noise blanker is off by default.
### Choosing a threshold
The threshold controls how aggressively the blanker suppresses impulses.
A value of **N** means: blank any sample whose magnitude exceeds **N times**
the running average signal level.
| Threshold | Behavior | Use case |
|-----------|----------|----------|
| 3 5 | Very aggressive — blanks frequently | Dense impulse noise (motors, power lines, LED drivers nearby) |
| 8 12 | Moderate — catches clear spikes without touching normal signals | Typical HF conditions with occasional ignition or switching noise |
| 15 25 | Conservative — only blanks strong impulses well above the noise floor | Light interference, or when you want minimal artifacts on weak signals |
| 30 100 | Very light — rarely triggers | Faint, infrequent clicks; mostly a safety net |
**Start at 10** (the default) and adjust while listening:
- If impulse noise is still audible, lower the threshold.
- If weak signals sound choppy or distorted, raise it — the blanker may be
mistaking signal peaks for noise.
- On bands with steady atmospheric noise (e.g. 160 m / 80 m), a threshold of
**5 8** usually works well.
- On quieter VHF/UHF bands where the noise floor is low, values of **15 25**
avoid false triggers from strong signals.
### Web UI
When the server reports noise-blanker support, two controls appear in the
**SDR Settings** row of the web interface:
- **Noise Blanker** checkbox — enables or disables the blanker in real time.
- **NB Threshold** number input (1100) with a **Set** button — adjusts the
detection threshold. Press Enter or click Set to apply.
Both controls stay hidden until the server sends filter state containing NB
fields, so they only appear when connected to an SDR backend.
### HTTP API
```
POST /set_sdr_noise_blanker?enabled=true&threshold=10
```
| Parameter | Type | Required | Description |
|-------------|--------|----------|-------------|
| `enabled` | bool | yes | `true` or `false` |
| `threshold` | float | yes | Value between 1 and 100 |
### How it works
The blanker runs on every IQ block (4096 samples) *before* the mixer stage in
the DSP pipeline:
1. For each sample, compute magnitude² (`re² + im²`).
2. Compare against `threshold² × mean_sq` (the exponentially-smoothed running
mean of magnitude²).
3. If the sample exceeds the threshold, replace it with the previous clean
sample.
4. Otherwise, update the running mean with smoothing factor α = 1/128 and store
the sample as the last clean value.
Because the blanker operates on raw IQ before frequency translation, it removes
impulse noise across the entire captured bandwidth regardless of the tuned
channel offset.
+152
View File
@@ -0,0 +1,152 @@
# Weather Satellite Map Overlay Integration
Overlay decoded NOAA APT and Meteor-M LRPT satellite images on the Leaflet
map module, with ground track visualisation and source filtering.
*Created: 2026-03-28*
## Status
| Step | Description | Status |
|------|-------------|--------|
| 1 | Add `sgp4` crate, create `trx-core/src/geo.rs` | Done |
| 2 | Extend `WxsatImage`/`LrptImage` with geo fields | Done |
| 3 | Compute geo-bounds in `finalize_wxsat_pass` / `finalize_lrpt_pass` | Done |
| 4 | Add `wxsat` to map source filter + image overlay rendering | Done |
| 5 | Add ground track polyline + filter toggle UI | Done |
| 6 | Build, test, verify | Done |
## Motivation
The wxsat plugin currently shows a history table with download links but has
no geographic context. Since the Map module already renders APRS, AIS, VDES,
and FTx/WSPR positions, weather satellite images are a natural addition — they
can be projected as semi-transparent overlays on the same Leaflet map.
## Architecture
### Data flow
```mermaid
graph TD
A["Pass decoded (APT / LRPT)"] --> B["finalize_wxsat_pass / finalize_lrpt_pass<br/>(trx-server/audio.rs)"]
B --> C["SGP4 propagation using satellite TLE + pass timestamps"]
C --> D["Compute geo_bounds<br/>[[south, west], [north, east]]"]
D --> E["Compute ground_track<br/>[[lat, lon], ...]"]
E --> F["Attach to WxsatImage / LrptImage"]
F --> G["Broadcast via DecodedMessage"]
G --> H["SSE → browser"]
H --> I["wxsat.js: L.imageOverlay() + L.polyline() on aprsMap"]
```
### Geo-referencing strategy
Weather satellites (NOAA POES, Meteor-M) fly sun-synchronous polar orbits at
~850 km altitude with known TLE parameters. Given:
- **Satellite identity** (from telemetry: NOAA-15/18/19, Meteor-M N2-3/N2-4)
- **Pass start/end timestamps** (`pass_start_ms`, `pass_end_ms`)
- **Receiver station lat/lon** (from `RigState.server_latitude/longitude`)
We can use **SGP4 propagation** (via the `sgp4` crate) to compute the
sub-satellite ground track during the pass, then derive image bounds from the
known swath geometry:
| Parameter | NOAA APT | Meteor LRPT |
|-----------|----------|-------------|
| Altitude | ~850 km | ~825 km |
| Swath width | ~2800 km | ~2800 km |
| Ground speed | ~6.9 km/s | ~6.9 km/s |
| Scan rate | 2 lines/sec (0.5s/line) | variable MCU rate |
| Image width | 909 px/channel | 1568 px |
**Bounds computation:**
1. Propagate satellite position at `pass_start_ms` and `pass_end_ms`
2. Sub-satellite points define the ground track center line
3. Swath half-width (~1400 km) gives east/west extent
4. Image is projected as a simple lat/lon rectangle (acceptable distortion
for the typical ~15° latitude span of a single pass)
**TLE source:** Hardcoded recent TLEs for the 5 active satellites, with an
optional HTTP refresh from CelesTrak. Stale TLEs (weeks old) still give
sub-degree accuracy for image overlay purposes.
### Crate changes
#### `trx-core` (src/trx-core/)
New module `src/trx-core/src/geo.rs`:
- `SatelliteGeo` struct: holds hardcoded TLEs, provides `compute_pass_bounds()`
- `PassGeoBounds { south: f64, west: f64, north: f64, east: f64 }`
- `ground_track(sat, start_ms, end_ms) -> Vec<[f64; 2]>`
- Uses `sgp4` crate for orbital propagation
- Falls back to station-centered approximation when TLE unavailable
`src/trx-core/src/decode.rs` — extend structs:
```rust
pub struct WxsatImage {
// ... existing fields ...
pub geo_bounds: Option<[f64; 4]>, // [south, west, north, east]
pub ground_track: Option<Vec<[f64; 2]>>, // [[lat, lon], ...]
}
// Same for LrptImage
```
#### `trx-server` (src/trx-server/)
`src/trx-server/src/audio.rs`:
- In `finalize_wxsat_pass`: after PNG write, call `SatelliteGeo::compute_pass_bounds()`
using satellite name, pass timestamps, and station lat/lon (threaded through
from config). Attach result to `WxsatImage`.
- Same for `finalize_lrpt_pass`.
#### Frontend (trx-frontend-http/assets/web/)
`plugins/wxsat.js`:
- On `onServerWxsatImage` / `onServerLrptImage`: if `geo_bounds` present,
call `window.addWxsatMapOverlay(msg)`.
- Manage overlay list, allow removal.
`app.js`:
- Add `wxsat: false` to `DEFAULT_MAP_SOURCE_FILTER` (off by default to avoid
visual clutter; users opt-in).
- `window.addWxsatMapOverlay(msg)`: creates `L.imageOverlay(msg.path, bounds)`
with opacity 0.6, adds to `mapMarkers` set with `__trxType = "wxsat"`.
- `window.addWxsatGroundTrack(msg)`: creates `L.polyline(msg.ground_track)`
with dashed style.
- Overlay list in wxsat panel with per-image show/hide toggle.
`index.html`:
- No structural changes needed; the map filter chip system auto-generates
from `DEFAULT_MAP_SOURCE_FILTER`.
`style.css`:
- Styling for wxsat overlay opacity slider (future enhancement).
## Dependencies
| Crate | Version | Purpose |
|-------|---------|---------|
| `sgp4` | 2.4 | Pure Rust SGP4 orbital propagation |
Added to `trx-core/Cargo.toml` (used by `geo.rs`).
## Risk / Limitations
- **Rectangular projection approximation**: The actual scan geometry is curved
(satellite moves along a great circle), but for a single pass spanning
~15-20° of latitude, a lat/lon rectangle is a reasonable first approximation.
More accurate warping could use `L.imageOverlay` with a canvas transform
in a future iteration.
- **TLE staleness**: Hardcoded TLEs drift ~0.1°/week. For overlay purposes
this is acceptable. A periodic CelesTrak fetch would keep them fresh.
- **Image rotation**: Ascending vs descending passes produce different
orientations. The initial implementation uses axis-aligned bounds
(no rotation). A rotated overlay would need `leaflet-imageoverlay-rotated`
or a canvas-based approach — deferred to a follow-up.
- **Image serving**: The `path` field is a filesystem path. On co-located
server/client setups this works directly. Remote setups may need an
image-serving endpoint (out of scope for this change).
+361
View File
@@ -0,0 +1,361 @@
# Frontend Styling & Performance Improvements
*Analysis date: 2026-04-01*
This document captures observations and improvement recommendations for the
trx-rs web frontend (`trx-frontend-http`). The frontend is a single-page
application served as embedded static assets (gzip-compressed with ETag
caching) from the Actix-Web server.
## Current asset inventory
| File | Lines | Size |
|------|------:|-----:|
| `style.css` | 5,318 | 144 KB |
| `app.js` | 8,427 | 306 KB |
| `map-core.js` | 3,483 | 127 KB |
| `screenshot.js` | 261 | 10 KB |
| `index.html` | 1,564 | 96 KB |
| `webgl-renderer.js` | 526 | 20 KB |
| `decode-history-worker.js` | 176 | 8 KB |
| `leaflet-ais-tracksymbol.js` | 120 | 8 KB |
| 15 plugin scripts | 7,360 | 304 KB |
| **Total** | **~27,000** | **~1 MB** |
All assets are pre-compressed with `flate2` (gzip, `Compression::best()`) and
served with `ETag` + `If-None-Match` support for conditional requests. The
Actix `Compress` middleware handles dynamic responses.
---
## 1. CSS observations
### 1.1 Monolithic stylesheet (P1)
`style.css` is a single 5,318-line file covering every tab, theme, responsive
breakpoint, map overlay, decoder UI, scheduler, recorder, and settings panel.
Browsers must parse the entire stylesheet before first paint even though most
users only interact with 1-2 tabs at a time.
**Recommendations:**
- Split into logical partitions: `base.css` (variables, reset, layout), `tabs/*.css` (per-tab styles), `themes/*.css`. The server can concatenate and compress at build time.
- At minimum, move the theme colour blocks (lines 3770-5318, ~1,550 lines / 29% of the file) into a separate `themes.css` loaded asynchronously after initial paint, since the default theme is already in `:root`.
- Consider using `@layer` (CSS Cascade Layers) to manage specificity between base, component, and theme styles, eliminating the need for `!important` (currently 21 occurrences).
### 1.2 `backdrop-filter` overuse (P1)
There are 26 `backdrop-filter` declarations (13 pairs with `-webkit-` prefix).
`backdrop-filter: blur()` is one of the most expensive CSS properties -- it
forces the browser to composite, rasterize, and blur everything behind the
element on every frame.
Affected areas: tab bar, controls tray, frequency overlay, modals, connection
banner, bottom nav, neon-disco theme overlay.
**Recommendations:**
- Remove `backdrop-filter` from elements that are always opaque or rarely overlap dynamic content (e.g. bottom tab bar over static background).
- For the spectrum/waterfall overlay controls, use a solid semi-transparent `background` instead of blur -- the visual difference is negligible on a dark spectrogram.
- Where blur is desired (modals), use `will-change: backdrop-filter` and keep blur radius low (4-6px instead of 12-18px). Larger radii are proportionally more expensive.
- Gate expensive blur behind a `@media (prefers-reduced-motion: no-preference)` query or a `[data-effects="full"]` attribute so low-end devices can opt out.
### 1.3 `color-mix()` usage (P2)
184 occurrences of `color-mix(in srgb, ...)` throughout the stylesheet. While
`color-mix` is well-supported in modern browsers, each call is resolved at
computed-value time. Repeated identical mixes (e.g. button hover states
repeated across themes) add unnecessary style recalculation cost.
**Recommendations:**
- Pre-compute frequently used mixes as CSS custom properties in the theme blocks (e.g. `--btn-hover-bg`, `--btn-active-bg`).
- This reduces computed-value work and also makes the palette more explicit and maintainable.
### 1.4 Theme system duplication (P2)
Each of the 10 colour themes repeats ~28 variable declarations for both dark
and light mode (560 variable declarations total). The theme blocks span lines
3770-5318 (29% of the entire stylesheet).
**Recommendations:**
- Move themes to a separate file loaded after first paint (the default `:root` theme is always available).
- Consider generating theme CSS from a data source (JSON/TOML) at build time to reduce manual duplication.
- Use `color-scheme` and `light-dark()` (CSS Color Level 5) to collapse the dark/light pairs where values differ only in lightness.
### 1.5 Transitions on non-essential properties (P3)
25 `transition` declarations, several targeting `background`, `border-color`,
and `box-shadow` simultaneously. Multi-property transitions on buttons and
inputs cause style recalculation on hover/focus for every such element.
**Recommendations:**
- Prefer transitioning only `opacity` and `transform` (GPU-composited).
- For colour changes, use `transition: background-color 100ms` rather than the shorthand `background` which also transitions `background-image` and other sub-properties.
- Add `will-change: transform` only to elements that are actively animating (currently only 2 occurrences, which is good).
### 1.6 Missing `contain` declarations (P2)
Tab content panels, decode history tables, map containers, and spectrum
canvases do not use CSS `contain` or `content-visibility`. When a large decode
history table updates, the browser recalculates layout for the entire page.
**Recommendations:**
- Add `contain: content` to inactive tab panels (`[data-tab]:not(.active)`).
- Add `content-visibility: auto` with `contain-intrinsic-size` to off-screen panels (decode history, map, statistics). This lets the browser skip rendering for hidden content entirely.
- Add `contain: strict` to the spectrum/waterfall canvas containers since their size is fixed and they don't affect sibling layout.
---
## 2. JavaScript observations
### 2.1 Monolithic `app.js` (P1)
The main application script is 11,928 lines (428 KB uncompressed). It is loaded
synchronously in the HTML `<head>` (via embedded asset), blocking first paint
until fully parsed and executed. The 15 plugin scripts add another 7,360 lines.
**Recommendations:**
- Mark the script tag `defer` or move it to end of `<body>` so HTML parsing completes before script execution.
- Split `app.js` into logical modules: `core.js` (SSE, auth, render loop), `spectrum.js`, `map.js`, `decoder.js`, `recorder.js`, `settings.js`. Load non-critical modules lazily when the user navigates to the corresponding tab.
- Use ES modules (`type="module"`) for clean dependency management and tree-shaking potential.
### 2.2 DOM query overhead (P2)
The codebase contains ~359 `querySelector`/`getElementById` calls, many of
which execute on every SSE event (inside `render()`). DOM lookups are not free,
especially `querySelector` with compound selectors.
**Recommendations:**
- Cache DOM references at initialization time (many already are, but the render path still re-queries elements like `document.getElementById("tab-main")`).
- Move repeated lookups (e.g. line 3575 `document.getElementById("tab-main")` inside `es.onmessage`) to module-level constants.
### 2.3 `innerHTML` usage (P2)
33 `innerHTML` assignments in `app.js` and 72 across plugin scripts. Each
`innerHTML` write forces the browser to:
1. Serialize the old DOM subtree for GC
2. Parse the HTML string
3. Build and insert a new DOM subtree
This is both a performance concern (layout thrashing) and a security concern
(XSS if any user-controlled data is interpolated without escaping).
**Recommendations:**
- Replace `innerHTML` with DOM APIs (`createElement`/`appendChild`) or `DocumentFragment` for bulk updates (only 4 `createDocumentFragment` uses currently).
- For large lists (decode history, bookmarks, recorder file lists), use a virtualised list pattern that only renders visible rows.
- Where `innerHTML` is used to clear a container, prefer `replaceChildren()` (clears children without HTML parsing).
### 2.4 SSE render path efficiency (P2)
Every SSE state event triggers `render(update)` which is a ~300-line function
touching dozens of DOM elements. The function does not diff -- it
unconditionally sets properties even when values have not changed.
The string-equality guard (`if (evt.data === lastRendered) return`) is a good
optimisation for identical payloads, but when any field changes (e.g. S-meter
value), the entire render function runs.
**Recommendations:**
- Implement field-level diffing: compare individual fields against previous values and only update DOM elements whose backing data changed.
- Group updates by tab: if the user is on the "Map" tab, skip render work for "Main" tab elements (meters, frequency display, controls).
- Use `scheduleUiFrameJob()` (already exists at line 3685) more aggressively to batch DOM writes into animation frames.
### 2.5 Spectrum/waterfall rendering (P2)
The WebGL renderer (`webgl-renderer.js`) is well-implemented with proper
shader programs and batched draws. However:
- The CSS colour parsing (`parseCssColor`) uses a DOM probe element (appended to
body) and `getComputedStyle` as a fallback, which triggers layout.
- The colour cache is a simple `Map` with no eviction policy.
**Recommendations:**
- Parse theme colours once when the theme changes, not on every frame.
- Invalidate the `cssColorCache` on theme switch events.
### 2.6 Plugin script loading (P3)
All 15 plugin scripts are loaded eagerly in `index.html` regardless of which
decoders are active. Plugins like `ais.js`, `vdes.js`, `sat.js`,
`sat-scheduler.js`, and `hf-aprs.js` are only relevant for specific use cases.
**Recommendations:**
- Load plugin scripts on demand when the corresponding decoder or feature is activated.
- Use dynamic `import()` if migrated to ES modules, or lazy `<script>` injection.
### 2.7 Web Worker utilisation (P3)
Only one Web Worker exists (`decode-history-worker.js`, 176 lines) for CBOR
decode-history parsing. All other heavy work (SSE parsing, DOM updates, spectrum
rendering, map marker management) runs on the main thread.
**Recommendations:**
- Move SSE JSON parsing to a shared worker so the main thread only receives pre-parsed objects.
- Offload spectrum FFT data processing / colour mapping to a worker, posting the resulting `ImageData` to the main thread for canvas rendering.
---
## 3. HTML observations
### 3.1 CDN dependencies (P2)
The page loads one external resource at startup:
- `@fontsource/dseg14-classic/400.css` from `cdn.jsdelivr.net`
~~`leaflet@1.9.4` was previously loaded from `unpkg.com` but is now bundled
as a vendored asset (`/vendor/leaflet.{js,css}` + marker/layer images),
eliminating the CDN dependency.~~
The font uses `rel="preload" as="style"` with an `onload` trick to make it
non-blocking, which is good. However:
- If CDN is unreachable (offline/firewalled deployments common in ham radio),
the font never loads and the frequency display falls back to the system font.
**Recommendations:**
- Self-host the DSEG14 font as an embedded asset (it is small, ~30 KB woff2). This eliminates the CDN dependency entirely and ensures the frequency display always renders correctly.
### 3.2 Inline SVG icons (P3)
Tab bar icons are inline SVGs in the HTML (lines 35-63). Each icon is ~150-250
bytes of markup. This is acceptable for a small number of icons and avoids
extra HTTP requests, but the tab bar HTML is dense and hard to maintain.
**Recommendation:**
- Consider an SVG sprite sheet or moving icons to a small icon font to improve readability without extra requests.
### 3.3 HTML size (P2)
`index.html` is 1,564 lines (96 KB uncompressed). All tab content panels are
present in the initial HTML regardless of which tab is active.
**Recommendations:**
- Use `<template>` elements for tab panels that are not initially visible. Clone and insert them when the tab is first activated. This reduces initial DOM node count and speeds up first paint.
- The server already does template substitution (`{ver}` placeholders). Extend this to strip unused tab content for deployments that don't use certain features.
---
## 4. Responsive design observations
### 4.1 Breakpoints (P3)
Six responsive breakpoints are defined:
- `>1100px`: side bookmark panels
- `<1099px`: hide side bookmarks
- `<900px`: full-width card
- `<760px`: mobile layout (touch targets, stacked controls)
- `<640px`: bottom tab bar, mobile nav
- `<520px`: compact mobile
- `(hover: none) and (pointer: coarse)`: touch-specific
This is a well-structured responsive system. Minor improvements:
- Use `min-width` mobile-first instead of `max-width` desktop-first to reduce CSS specificity conflicts.
- Consider `container queries` for components like the controls tray and decode history table, so they respond to their container size rather than the viewport.
### 4.2 Touch target sizing (P3)
Mobile buttons get `min-height: 2.8rem` at `<760px`. The
`(hover: none) and (pointer: coarse)` media query adds additional touch
accommodations. This meets the 44px minimum recommended by WCAG.
---
## 5. Accessibility observations
### 5.1 `aria-live` regions (P1)
The connection-lost banner and power hint text update dynamically but were
flagged in the Settings-Menu-UX-Analysis as missing `aria-live` on toast
notifications. Ensuring all dynamic status text has `aria-live="polite"` or
`aria-live="assertive"` (for errors) is critical for screen reader users.
### 5.2 Keyboard navigation (P2)
The tab bar uses `<button>` elements (good, natively focusable). However, the
spectrum canvas, jog wheel, and map are mouse/touch-only without keyboard
equivalents. The Settings-Menu-UX-Analysis noted the timeline SVG is not
keyboard-operable.
### 5.3 Colour contrast (P2)
`--text-muted` values (`#91a3bd` on `#0f172a` for dark, `#4a5568` on `#ffffff`
for light) should be verified against WCAG AA (4.5:1 for normal text). The
dark theme muted text calculates to approximately 4.8:1 (passes), but some
theme variants (e.g. Neon Disco) may not meet contrast requirements.
---
## 6. Server-side delivery observations
### 6.1 Asset compression (already good)
Static assets are pre-compressed with `gzip` at `Compression::best()` level
and served with ETag headers. Conditional `304 Not Modified` responses avoid
re-transferring unchanged assets.
### 6.2 Missing `Cache-Control` headers (P2)
While ETags are present, the analysis did not find explicit `Cache-Control`
headers on static assets. Adding `Cache-Control: public, max-age=31536000,
immutable` for versioned assets (with cache-busting query strings) would
eliminate conditional requests entirely for repeat visits.
### 6.3 Consider Brotli compression (P3)
Brotli (`br`) typically achieves 15-25% better compression than gzip for text
assets. For a 428 KB `app.js`, this could save ~60-100 KB of transfer. Actix
supports Brotli via the `Compress` middleware.
---
## 7. Priority summary
```mermaid
quadrantChart
title Impact vs Effort
x-axis Low Effort --> High Effort
y-axis Low Impact --> High Impact
quadrant-1 Do next
quadrant-2 Plan carefully
quadrant-3 Low priority
quadrant-4 Quick wins
"backdrop-filter reduction": [0.25, 0.80]
"Cache-Control headers": [0.15, 0.55]
"CSS contain/content-visibility": [0.30, 0.70]
"Cache DOM refs in render": [0.20, 0.50]
"Theme CSS split": [0.35, 0.45]
"Self-host DSEG14 font": [0.20, 0.40]
"Field-level render diffing": [0.60, 0.75]
"Split app.js into modules": [0.80, 0.70]
"Lazy plugin loading": [0.50, 0.40]
"innerHTML to DOM APIs": [0.65, 0.55]
"Brotli compression": [0.30, 0.25]
"Template-based tab panels": [0.70, 0.60]
```
### Quick wins (low effort, high impact)
1. ~~Reduce `backdrop-filter` usage (13 blur instances)~~ **DONE** -- replaced with solid backgrounds, blur preserved for modals only, `prefers-reduced-motion` gate added
2. ~~Add `contain: content` / `content-visibility: auto` to inactive tabs~~ **DONE** -- containment added for inactive tabs, spectrum/waterfall containers, map, statistics
3. ~~Add `Cache-Control` headers to static assets~~ **DONE** -- upgraded to `public, max-age=31536000, immutable`
4. ~~Cache remaining DOM references in the render path~~ **DONE** -- `tabMainEl` and other hot-path refs cached at module level
### Next phase (moderate effort)
5. ~~Split theme CSS into a separate lazy-loaded file~~ **DONE** -- theme blocks extracted to `/themes.css`, lazy-loaded via `<link rel="preload">`
6. ~~Self-host DSEG14 font~~ **DONE** -- `@font-face` with `font-display: swap` added to `style.css`, CDN preconnect/preload removed from HTML
7. ~~Pre-compute `color-mix` results as CSS variables~~ **DONE** -- common mixes pre-computed as `--btn-hover-bg`, `--btn-active-bg`, etc.
8. ~~Field-level diffing in the SSE render function~~ **DONE** -- `prevRenderData` tracks freq/mode/ptt/meter, active-tab-aware skip logic added
9. ~~Replace `innerHTML` with DOM APIs in hot paths~~ **DONE** -- 15+ `innerHTML = ""` replaced with `replaceChildren()`
### Longer-term
10. ~~Split `app.js` into modules with lazy loading~~ **DONE** -- `map-core.js` (3,480 lines, map/stats/geo) and `screenshot.js` (260 lines) extracted as IIFE modules communicating via `window.trx` namespace; lazy-loaded on tab activation and on-demand respectively; `app.js` reduced from 11,967 to 8,420 lines (30% reduction)
11. ~~Lazy-load plugin scripts and Leaflet on demand~~ **DONE** -- plugin scripts loaded on tab activation, core plugins loaded immediately
12. ~~Use `<template>` elements for deferred tab content~~ **DONE** -- map, statistics, about tabs wrapped in `<template>`, cloned on first activation
13. ~~Migrate to Brotli compression~~ **DONE** -- Brotli added alongside gzip, preferred when `Accept-Encoding: br` present
14. Move SSE parsing and spectrum processing to Web Workers -- **DEFERRED** (requires SharedWorker + MessagePort plumbing, tracked separately)
### Additional improvements implemented
15. ~~Optimize CSS transitions~~ **DONE** -- `background` shorthand → `background-color` for GPU compositing
16. ~~Add `defer` to script tags~~ **DONE** -- all external script tags use `defer`
17. ~~SVG sprite sheet~~ **DONE** -- inline SVGs moved to `<symbol>` defs, referenced via `<use>`
18. ~~aria-live regions~~ **DONE** -- `aria-live` added to power hint, loading indicator
19. ~~Keyboard navigation~~ **DONE** -- `tabindex`/`role`/`aria-label` on spectrum/waterfall canvases
20. ~~Colour contrast~~ **DONE** -- dark theme `--text-muted` improved to `#9bb0ca`
21. ~~WebGL colour cache invalidation~~ **DONE** -- `trxClearCssColorCache()` called on theme switch
22. ~~Container queries~~ **DONE** -- controls tray and decode history table respond to container size
23. ~~Cache-Control immutable~~ **DONE** -- versioned assets use `immutable` directive
+234
View File
@@ -0,0 +1,234 @@
# Scheduler UI Improvement Plan
## Current State
The scheduler UI lives in Settings → Scheduler and provides three operational modes:
- **Grayline** — auto-switches bookmarks based on solar dawn/day/dusk/night
- **Time Span** — UTC time windows with interleaved cycling
- **Satellite Pass** — priority overlay that retunes for satellite passes
Main-view controls include a release button, prev/next step buttons, and a
progress ring showing the active interleave entry and countdown.
Key files:
| File | Purpose |
|------|---------|
| `assets/web/plugins/scheduler.js` | UI logic, rendering, API calls (~1,060 LOC) |
| `assets/web/plugins/sat-scheduler.js` | Satellite config overlay (~310 LOC) |
| `assets/web/index.html` (L11091289) | Scheduler settings HTML |
| `assets/web/style.css` (`.sch-*`) | Scheduler styling |
| `src/scheduler.rs` | Backend task, API handlers (~1,435 LOC) |
---
## P0 — Usability Fixes
### 1. Highlight active entry in time-span table
**Problem:** The entry table under "Entry details" has no indication of which
entry the scheduler is currently operating on. Users must cross-reference the
interleave ring label with the table manually.
**Fix:** In `renderScheduler()`, after receiving status, add/remove an
`sch-active` class on the `<tr>` whose entry id matches
`currentSchedulerStatus.last_entry_id`. Style with a left border accent
(`border-left: 3px solid var(--accent)`).
### 2. Bookmark existence validation on save
**Problem:** If a bookmark is deleted after being assigned to a scheduler entry,
the scheduler fails silently at runtime — it tries to apply a non-existent
bookmark and does nothing.
**Fix:** In `saveScheduler()`, cross-check every `bookmark_id` /
`bookmark_ids[]` against `bookmarkList`. Show a toast error listing the
broken entries and refuse to save until corrected.
### 3. Dirty-state indicator for satellite section
**Problem:** Changes in the satellite section (add/edit/remove satellites,
toggle enable) don't reliably set `schedulerDirty`, so the Save button may
not appear.
**Fix:** Audit all satellite mutation paths in `sat-scheduler.js` and ensure
they call `window.schedulerBridge.markDirty()`.
---
## P1 — Information Density & Clarity
### 4. Show local time alongside UTC
**Problem:** All times are UTC-only. Operators in non-UTC timezones must
mentally convert, especially when editing time-span entries.
**Fix:** Add a `(local)` annotation next to each UTC time display:
- In the entry table, append a dimmed local-time column
- In the timeline SVG, add a secondary tick row with local hours
- Use `Intl.DateTimeFormat` to derive the offset; no config needed
### 5. Expand entry details by default
**Problem:** The entry list is hidden behind a `<details>` collapse. New
users don't discover it, and experienced users click it open every time.
**Fix:** Default the `<details>` element to `open`. Persist the
open/collapsed preference in `localStorage`.
### 6. Richer "Now Playing" status card
**Problem:** The status card shows only `"Last applied: {name} at {time}"`
no frequency, mode, or decoder info.
**Fix:** Extend `SchedulerStatus` (backend) to include `freq_hz`, `mode`,
and `active_decoders[]`. Render them in the status card as
`"14.074 MHz · FT8 · FT8 decoder active"`. Adds immediate visibility
without opening the bookmark manager.
---
## P2 — Interaction Improvements
### 7. Inline entry editing
**Problem:** Editing an entry requires clicking Edit, which opens an overlay
form that obscures the table. Users lose context of adjacent entries.
**Fix:** Replace the overlay with inline editing directly in the table row.
Clicking Edit on a row transforms its cells into input fields (time pickers,
selects) in-place. Save/Cancel buttons appear in the last column. This
keeps sibling entries visible and reduces clicks.
### 8. Drag-to-reorder entries
**Problem:** Entry order matters for interleave cycling, but there is no way
to reorder entries. Users must delete and re-add.
**Fix:** Add drag handles (`⠿`) to each table row. Implement HTML5 drag-and-drop
on the `<tbody>`. On drop, splice the `currentConfig.entries` array and
re-render. Mark dirty.
### 9. Timeline click-to-add
**Problem:** Adding an entry requires clicking "+ Add Entry" and manually
typing start/end times, even though the timeline is a visual 24-hour bar.
**Fix:** Make the timeline SVG interactive. Clicking on an empty region
opens the entry form pre-filled with the clicked hour as start and start+1h
as end. Dragging across a region sets start/end from the drag span. Use
`pointer-events` and `getBoundingClientRect()` to map pixel → minute.
### 10. Improved extra-channels management
**Problem:** Virtual channels use tiny `+`/`` buttons with no indication
of which bookmarks are already added. Removing a channel requires clicking
`` on the right one in a compact list.
**Fix:** Replace with a multi-select chip list: each added channel is a
removable chip (`× 40m FT8`). The `+` button opens the select dropdown.
Already-added bookmarks are disabled in the dropdown to prevent duplicates.
---
## P3 — Feature Enhancements
### 11. Grayline location lookup by grid square
**Problem:** Users must manually enter latitude/longitude. Ham operators
typically know their Maidenhead grid square (e.g. `JO94`) but not their
coordinates to three decimals.
**Fix:** Add a text input for grid square next to the lat/lon fields. On
input, convert the grid square to lat/lon using the standard Maidenhead
algorithm (simple arithmetic, no external API). Populate lat/lon fields
automatically. Also support reverse: when lat/lon changes, show the
derived grid square.
### 12. Expanded satellite preset library
**Problem:** Only two satellite presets (Meteor-M2 3 and M2-4). Adding
NOAA, ISS, or amateur satellites requires looking up NORAD IDs externally.
**Fix:** Expand the preset `<option>` list to include common amateur /
weather satellites:
```
ISS (145.825 MHz APRS) — 25544
SO-50 (436.795 MHz FM) — 27607
```
Low-effort, high-value change — just HTML `<option>` additions plus
corresponding default bookmark templates.
### 13. Scheduler activity log
**Problem:** No way to see what the scheduler did historically — when it
switched, which bookmark it applied, whether any entry was skipped.
**Fix:**
- Backend: Add a ring buffer (last 100 events) to `SchedulerState`.
Each event: `{ utc, action: "applied"|"skipped"|"satellite_aos"|"satellite_los", entry_label, bookmark_name }`.
- API: `GET /scheduler/{rig_id}/log` returns the buffer.
- UI: Add a collapsible "Activity Log" section below the status card.
Render as a reverse-chronological compact list with timestamps.
### 14. Timeline interleave visualization
**Problem:** When multiple entries overlap, the timeline shows overlapping
colored bars but gives no indication of how interleaving splits time between
them.
**Fix:** When interleave is enabled and entries overlap, render alternating
color stripes within the overlap region (e.g., 5-minute tick marks colored
per-entry). Add a legend showing entry label → color mapping.
### 15. Keyboard shortcuts for scheduler control
**Problem:** Release/step controls require mouse clicks on the main view.
During operation, keyboard shortcuts would be faster.
**Fix:** Register global keybindings (configurable in settings):
- `Shift+R` — toggle release to scheduler
- `Shift+N` / `Shift+P` — step to next/previous entry
Guard with `!isInputFocused()` to avoid conflicts with text fields.
---
## Implementation Order
```mermaid
gantt
title Scheduler UI Improvements
dateFormat X
axisFormat %s
section P0
Active entry highlight :1, 2
Bookmark validation :1, 2
Satellite dirty-state fix :1, 2
section P1
Local time display :3, 5
Expand details by default :3, 4
Richer status card :3, 5
section P2
Inline entry editing :6, 9
Drag-to-reorder :6, 8
Timeline click-to-add :6, 9
Extra-channels chips :6, 8
section P3
Grid square lookup :10, 11
Satellite presets :10, 11
Activity log :10, 13
Interleave visualization :10, 13
Keyboard shortcuts :10, 11
```
P0 items are small, targeted fixes (< 1 hour each). P1 items improve daily
usability. P2 items modernize interactions. P3 items add new capabilities.
Each item is independently shippable.
+837
View File
@@ -0,0 +1,837 @@
# WEFAX / Radiofax Decoder Implementation Plan
> **Crate**: `trx-wefax` &mdash; `src/decoders/trx-wefax/`
> **Status**: Implemented (Phases 13b) &mdash; 2026-04-02
## 1. Overview
WEFAX (Weather Facsimile, ITU-T T.4 / WMO) is an analog image transmission
mode used by meteorological agencies worldwide (NOAA, DWD, JMH, etc.) on HF
and satellite downlinks. The decoder converts FM-modulated audio tones into
greyscale (or colour-composited) image lines.
### Goals
- Pure Rust, zero C FFI dependencies (matching project conventions).
- Multi-speed support: **60, 90, 120, 240 LPM** (lines per minute).
- Multi-IOC support: **288 and 576** (Index of Cooperation &mdash; defines
line pixel width).
- Automatic start/stop detection via APT tones.
- Phase-aligned line assembly from phasing signal.
- Incremental image output (line-by-line progress + final PNG).
- Follow existing decoder patterns (`process_block` / `decode_if_ready`).
## 2. WEFAX Signal Structure
```
Carrier (1900 Hz center, ±400 Hz deviation)
Black = 1500 Hz
White = 2300 Hz
(linear mapping between frequency and luminance)
Transmission sequence:
┌─────────────┐
│ Start tone │ 300 Hz (5s) or 675 Hz (3s) — selects IOC 576 / 288
├─────────────┤
│ Phasing │ >95% white line + narrow black pulse — phase alignment
│ (30 lines) │
├─────────────┤
│ Image lines │ N lines at configured LPM
├─────────────┤
│ Stop tone │ 450 Hz (5s) — signals end of transmission
└─────────────┘
```
### Key parameters
| Parameter | IOC 576 | IOC 288 |
|-----------|---------|---------|
| Pixels per line | 1809 | 904 |
| Line duration (120 LPM) | 500 ms | 500 ms |
| Line duration (60 LPM) | 1000 ms | 1000 ms |
| Pixel clock | ~3618 px/s (120 LPM) | ~1808 px/s (120 LPM) |
Pixel count per line = `IOC × π` (rounded: 576×π ≈ 1809, 288×π ≈ 904).
## 3. Architecture
```mermaid
graph TD
PCM["PCM audio (f32, 48 kHz)"] --> RS["Resampler (to internal rate)"]
RS --> FM["FM Discriminator"]
FM --> LPF["Low-pass filter (anti-alias)"]
LPF --> TD["Tone Detector (APT start/stop)"]
LPF --> PA["Phase Aligner"]
PA --> LS["Line Slicer"]
LS --> IMG["Image Assembler"]
IMG --> OUT["WefaxMessage (line / image)"]
TD --> SM["State Machine"]
SM -->|controls| PA
SM -->|controls| LS
```
### Internal sample rate
Resample input to **11,025 Hz** (sufficient for 2300 Hz max tone with
comfortable margin; matches common WEFAX decoder practice and keeps DSP
cost low).
## 4. Module Layout
```
src/decoders/trx-wefax/
Cargo.toml
src/
lib.rs # Public API: WefaxDecoder, WefaxConfig, WefaxEvent
decoder.rs # Top-level decoder state machine + process_block/decode_if_ready
demod.rs # FM discriminator (instantaneous frequency from analytic signal)
tone_detect.rs # Goertzel-based APT tone detector (300/450/675 Hz)
phase.rs # Phasing signal detector and line-start alignment
line_slicer.rs # Pixel clock recovery, line buffer assembly
resampler.rs # Polyphase rational resampler (48k → 11025)
image.rs # Image buffer, PNG encoding, optional colour compositing
config.rs # WefaxConfig: speed, IOC, auto-detect, output path
```
## 5. Core Types
### 5.1 Configuration
```rust
pub struct WefaxConfig {
/// Lines per minute: 60, 90, 120, 240. `None` = auto-detect from APT.
pub lpm: Option<u16>,
/// Index of Cooperation: 288 or 576. `None` = auto-detect from start tone.
pub ioc: Option<u16>,
/// Centre frequency of the FM subcarrier (default 1900 Hz).
pub center_freq_hz: f32,
/// Deviation (default ±400 Hz, so black=1500, white=2300).
pub deviation_hz: f32,
/// Directory for saving decoded images.
pub output_dir: Option<String>,
/// Whether to emit line-by-line progress events.
pub emit_progress: bool,
}
```
### 5.2 Decoder state machine
```rust
pub enum WefaxState {
/// Listening for APT start tone.
Idle,
/// Start tone detected; waiting for phasing signal.
StartDetected { ioc: u16, tone_start_sample: u64 },
/// Receiving phasing lines; aligning line-start phase.
Phasing { ioc: u16, lpm: u16, phase_offset: Option<usize> },
/// Actively decoding image lines.
Receiving { ioc: u16, lpm: u16, line_number: u32 },
/// Stop tone detected; finalising image.
Stopping,
}
```
### 5.3 Output messages (for `trx-core::DecodedMessage`)
```rust
/// A complete or in-progress WEFAX image.
pub struct WefaxMessage {
pub rig_id: Option<String>,
pub ts_ms: Option<i64>,
/// Number of image lines decoded so far.
pub line_count: u32,
/// Detected or configured LPM.
pub lpm: u16,
/// Detected or configured IOC.
pub ioc: u16,
/// Pixels per line (IOC × π, rounded).
pub pixels_per_line: u16,
/// Filesystem path to saved PNG (set on completion).
pub path: Option<String>,
/// True when image is complete (stop tone received).
pub complete: bool,
}
/// Progress update emitted every N lines during active reception.
pub struct WefaxProgress {
pub rig_id: Option<String>,
pub line_count: u32,
pub lpm: u16,
pub ioc: u16,
}
```
## 6. DSP Pipeline Detail
### 6.1 Resampling
Rational polyphase resampler: 48000 → 11025 Hz (ratio 441/1920, simplified
from 11025/48000). Follow `docs/Optimization-Guidelines.md` polyphase
resampler guidance. Same pattern as FT8 decoder's 48k→12k resampler.
### 6.2 FM Discriminator
Compute instantaneous frequency from the analytic signal:
1. **Hilbert transform** (FIR, 65-tap) to produce analytic signal `z[n]`.
2. **Instantaneous frequency**: `f[n] = arg(z[n] · conj(z[n-1])) / (2π·Ts)`
3. Map frequency to luminance: `pixel = clamp((f - 1500) / 800, 0, 1)`.
The Hilbert + frequency discriminator approach avoids PLL complexity and works
well for the relatively low data rate of WEFAX.
### 6.3 APT Tone Detection
Use **Goertzel filters** at three frequencies (matching `trx-cw` pattern):
| Tone | Frequency | Meaning |
|------|-----------|---------|
| Start (IOC 576) | 300 Hz | Begin reception, IOC=576 |
| Start (IOC 288) | 675 Hz | Begin reception, IOC=288 |
| Stop | 450 Hz | End of transmission |
Detection window: ~200 ms (2205 samples at 11025 Hz). Require sustained
detection for ≥1.5 s to confirm (debounce against noise). Energy ratio
vs broadband noise for reliability.
### 6.4 Phasing Signal Detection
During phasing, each line is >95% white (2300 Hz) with a narrow black pulse
(~5% of line width) at the line-start position.
1. After start tone, begin accumulating demodulated samples.
2. Slice into line-duration windows (e.g., 500 ms for 120 LPM).
3. Cross-correlate against expected phasing template (short black pulse).
4. Average pulse position over 10+ phasing lines → line-start phase offset.
5. Transition to `Receiving` once phase is stable (variance < 2 samples).
### 6.5 Line Slicing and Pixel Clock
Once phased:
1. Accumulate demodulated (frequency → luminance) samples.
2. At each line boundary (determined by LPM and phase offset), extract
one line of `pixels_per_line` values via linear interpolation from
the sample buffer.
3. Push completed line into the image assembler.
4. Emit `WefaxProgress` every 50 lines (configurable).
### 6.6 Image Assembly
- Maintain a `Vec<Vec<u8>>` of greyscale lines (0255).
- On stop tone or manual stop: encode to 8-bit greyscale PNG.
- Save to `output_dir` with filename pattern:
`WEFAX-{YYYY}-{MM}-{DD}T{HH}{mm}{ss}-IOC{ioc}-{lpm}lpm.png`
- Return `WefaxMessage` with `complete: true` and `path` set.
## 7. Integration with trx-rs
### 7.1 Workspace registration
Add to root `Cargo.toml` workspace members:
```toml
"src/decoders/trx-wefax"
```
### 7.2 `trx-core` changes
Add variants to `DecodedMessage`:
```rust
#[serde(rename = "wefax")]
Wefax(WefaxMessage),
#[serde(rename = "wefax_progress")]
WefaxProgress(WefaxProgress),
```
Update `set_rig_id()` / `rig_id()` match arms.
### 7.3 `trx-server` integration
Add `run_wefax_decoder()` in `audio.rs` following the existing pattern:
```rust
pub async fn run_wefax_decoder(
sample_rate: u32,
channels: u16,
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
state_rx: watch::Receiver<RigState>,
decode_tx: broadcast::Sender<DecodedMessage>,
logs: Option<Arc<DecoderLoggers>>,
histories: Arc<DecoderHistories>,
)
```
Spawn in `main.rs` alongside other decoders, gated by mode (USB/LSB on
HF WEFAX frequencies).
### 7.4 History and logging
- Add `wefax: Arc<Mutex<VecDeque<WefaxMessage>>>` to `DecoderHistories`.
- Add optional `wefax` logger to `DecoderLoggers` (JSON Lines).
### 7.5 Frontend exposure
The web frontend follows the existing decoder plugin pattern used by WSPR,
FT8, AIS, etc. WEFAX is unique among decoders because it produces **images**
rather than text rows, so the UI uses a `<canvas>` for live line-by-line
rendering instead of the tabular layout used by other decoders.
#### 7.5.1 Rust backend wiring (`trx-frontend-http`)
**`src/status.rs`** &mdash; embed the plugin script:
```rust
pub const WEFAX_JS: &str = include_str!("../assets/web/plugins/wefax.js");
```
**`src/api/assets.rs`** &mdash; define the gzip-cached route:
```rust
define_gz_cache!(gz_wefax_js, status::WEFAX_JS, "wefax.js");
#[get("/wefax.js")]
pub(crate) async fn wefax_js(req: HttpRequest) -> impl Responder {
let c = gz_wefax_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
```
**`src/api/decoder.rs`** &mdash; add endpoints:
```rust
#[post("/toggle_wefax_decode")]
pub async fn toggle_wefax_decode(
query: web::Query<RemoteQuery>,
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().decoders.wefax_decode_enabled;
send_command(
&rig_tx,
RigCommand::SetWefaxDecodeEnabled(!enabled),
query.into_inner().remote,
)
.await
}
#[post("/clear_wefax_decode")]
pub async fn clear_wefax_decode(
query: web::Query<RemoteQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_wefax_history(context.get_ref());
send_command(
&rig_tx,
RigCommand::ResetWefaxDecoder,
query.into_inner().remote,
)
.await
}
```
**`src/api/mod.rs`** &mdash; register in `configure()`:
```rust
.service(decoder::toggle_wefax_decode)
.service(decoder::clear_wefax_decode)
.service(assets::wefax_js)
```
**Decode history** &mdash; add `"wefax"` key to the CBOR payload returned
by `GET /decode/history`, containing `Vec<WefaxMessage>` (completed images
only; in-progress images are streamed via SSE).
**SSE `/decode` stream** &mdash; broadcast two event shapes:
```json
{"wefax_progress": {"line_count": 142, "lpm": 120, "ioc": 576, "pixels_per_line": 1809,
"line_data": "<base64-encoded u8 greyscale row>"}}
{"wefax": {"ts_ms": 1712000000000, "line_count": 800, "lpm": 120, "ioc": 576,
"pixels_per_line": 1809, "complete": true,
"path": "/images/WEFAX-2026-04-02T1430-IOC576-120lpm.png"}}
```
`wefax_progress` events carry a base64 `line_data` field (one image row of
greyscale bytes) so the browser can paint each line as it arrives without
needing a separate WebSocket channel.
**Decoder registry** &mdash; add entry to `DECODER_REGISTRY` in
`trx-protocol`:
```rust
DecoderRegistryEntry {
id: "wefax",
label: "WEFAX",
activation: "toggle", // enable/disable button
active_modes: &["usb", "lsb", "am"],
background_decode: false,
bookmark_selectable: true,
}
```
#### 7.5.2 HTML additions (`index.html`)
**Sub-tab button** (inside `.sub-tab-bar`, after the existing decoder
buttons):
```html
<button class="sub-tab" data-subtab="wefax" id="subtab-wefax">WEFAX</button>
```
**Sub-tab panel** (alongside other `sub-tab-panel` divs):
```html
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
<div class="ft8-controls">
<button id="wefax-decode-toggle-btn" type="button">Enable WEFAX</button>
<button id="wefax-clear-btn" type="button"
style="margin-left:0.5rem; font-size:0.8rem;">Clear</button>
<small id="wefax-status" style="color:var(--text-muted);">Idle</small>
</div>
<!-- Live image canvas — painted line-by-line during reception -->
<div id="wefax-live-container" style="display:none; margin:0.5rem 0;">
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.3rem;">
<strong>Receiving</strong>
<small id="wefax-live-info" style="color:var(--text-muted);"></small>
</div>
<canvas id="wefax-live-canvas" width="1809" height="800"
style="width:100%; image-rendering:pixelated; background:#000;"></canvas>
</div>
<!-- Gallery of completed images -->
<div id="wefax-gallery" style="display:flex; flex-wrap:wrap; gap:0.5rem;"></div>
</div>
```
**Overview section** (inside the digital-modes overview panel):
```html
<div class="plugin-item" data-decoder="wefax">
<strong>WEFAX Decoder</strong>
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
Weather Facsimile &mdash; HF/satellite image reception (60/90/120/240 LPM)
</div>
</div>
```
**About section** (in the About tab decoder list):
```html
<tr id="about-dec-wefax"><td>WEFAX</td><td>Weather Facsimile decoder</td></tr>
```
#### 7.5.3 Plugin script registration
**`index.html` plugin map** &mdash; add `'/wefax.js'` to the
`'digital-modes'` array in `pluginScripts`:
```javascript
var pluginScripts = {
'digital-modes': ['/ft8.js', ..., '/wefax.js'],
// ...
};
```
#### 7.5.4 SSE dispatch in `app.js`
Add WEFAX to the decode event dispatcher (inside `decodeSource.onmessage`):
```javascript
if (msg.wefax_progress && window.onServerWefaxProgress) {
window.onServerWefaxProgress(msg.wefax_progress);
}
if (msg.wefax && window.onServerWefax) {
window.onServerWefax(msg.wefax);
}
```
Add `"wefax"` to the decode history restore loop:
```javascript
// In loadDecodeHistoryOnMainThread / worker dispatch:
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs",
"cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
```
Add WEFAX to `restoreDecodeHistoryGroup()`:
```javascript
case "wefax":
if (window.restoreWefaxHistory) window.restoreWefaxHistory(messages);
break;
```
#### 7.5.5 Plugin file (`assets/web/plugins/wefax.js`)
Full plugin structure following the project's vanilla-JS decoder plugin
pattern:
```javascript
// ---------------------------------------------------------------------------
// wefax.js — WEFAX decoder plugin for trx-frontend-http
// ---------------------------------------------------------------------------
// --- DOM refs ---
const wefaxStatus = document.getElementById('wefax-status');
const wefaxLiveContainer= document.getElementById('wefax-live-container');
const wefaxLiveInfo = document.getElementById('wefax-live-info');
const wefaxLiveCanvas = document.getElementById('wefax-live-canvas');
const wefaxGallery = document.getElementById('wefax-gallery');
const wefaxToggleBtn = document.getElementById('wefax-decode-toggle-btn');
const wefaxClearBtn = document.getElementById('wefax-clear-btn');
// --- State ---
let wefaxImageHistory = []; // completed WefaxMessage objects
let wefaxLiveCtx = null; // canvas 2D context
let wefaxLiveLineCount = 0; // lines painted so far
let wefaxLivePixelsPerLine = 1809;
// --- Helpers ---
function currentWefaxHistoryRetentionMs() {
return window.getDecodeHistoryRetentionMs?.() || 24 * 60 * 60 * 1000;
}
function pruneWefaxHistory() {
const cutoff = Date.now() - currentWefaxHistoryRetentionMs();
wefaxImageHistory = wefaxImageHistory.filter(m => (m._tsMs || 0) > cutoff);
}
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
// --- Live canvas rendering ---
/** Reset canvas for a new image reception. */
function resetLiveCanvas(pixelsPerLine) {
wefaxLivePixelsPerLine = pixelsPerLine;
wefaxLiveLineCount = 0;
wefaxLiveCanvas.width = pixelsPerLine;
wefaxLiveCanvas.height = 800; // grows if needed
wefaxLiveCtx = wefaxLiveCanvas.getContext('2d');
wefaxLiveCtx.fillStyle = '#000';
wefaxLiveCtx.fillRect(0, 0, wefaxLiveCanvas.width, wefaxLiveCanvas.height);
wefaxLiveContainer.style.display = '';
}
/** Append one greyscale line (Uint8Array) to the live canvas. */
function paintLine(lineBytes) {
if (!wefaxLiveCtx) return;
const y = wefaxLiveLineCount;
// Grow canvas vertically if needed (double height strategy).
if (y >= wefaxLiveCanvas.height) {
const old = wefaxLiveCtx.getImageData(
0, 0, wefaxLiveCanvas.width, wefaxLiveCanvas.height);
wefaxLiveCanvas.height *= 2;
wefaxLiveCtx.putImageData(old, 0, 0);
}
const w = wefaxLivePixelsPerLine;
const imgData = wefaxLiveCtx.createImageData(w, 1);
const d = imgData.data;
for (let x = 0; x < w; x++) {
const v = x < lineBytes.length ? lineBytes[x] : 0;
const i = x * 4;
d[i] = v; d[i + 1] = v; d[i + 2] = v; d[i + 3] = 255;
}
wefaxLiveCtx.putImageData(imgData, 0, y);
wefaxLiveLineCount++;
}
// --- Gallery rendering ---
function renderGalleryThumbnail(msg) {
const card = document.createElement('div');
card.className = 'wefax-card';
card.style.cssText =
'border:1px solid var(--border-color); border-radius:4px; ' +
'padding:0.4rem; max-width:280px; cursor:pointer;';
const ts = msg._tsMs
? new Date(msg._tsMs).toLocaleString()
: '—';
const info = `${msg.ioc} IOC · ${msg.lpm} LPM · ${msg.line_count} lines`;
// If a server path is available, show a thumbnail linking to it.
if (msg.path) {
card.innerHTML =
`<img src="/images/${escapeHtml(msg.path.split('/').pop())}"
alt="WEFAX" loading="lazy"
style="width:100%; image-rendering:pixelated;" />` +
`<div style="font-size:0.8rem; margin-top:0.2rem;">${escapeHtml(ts)}</div>` +
`<div style="font-size:0.75rem; color:var(--text-muted);">${info}</div>`;
} else {
card.innerHTML =
`<div style="font-size:0.8rem;">${escapeHtml(ts)}</div>` +
`<div style="font-size:0.75rem; color:var(--text-muted);">${info}</div>`;
}
return card;
}
function renderWefaxGallery() {
pruneWefaxHistory();
const frag = document.createDocumentFragment();
for (const msg of wefaxImageHistory) {
frag.appendChild(renderGalleryThumbnail(msg));
}
wefaxGallery.innerHTML = '';
wefaxGallery.appendChild(frag);
}
function scheduleWefaxGalleryRender() {
if (window.trxScheduleUiFrameJob) {
window.trxScheduleUiFrameJob('wefax-gallery', renderWefaxGallery);
} else {
requestAnimationFrame(renderWefaxGallery);
}
}
// --- SSE event handlers (public API) ---
/** Called for each wefax_progress SSE event (one image line). */
window.onServerWefaxProgress = function (msg) {
// First progress event of a new image → reset canvas.
if (msg.line_count <= 1 || !wefaxLiveCtx) {
resetLiveCanvas(msg.pixels_per_line || 1809);
}
// Decode base64 line_data → Uint8Array → paint.
if (msg.line_data) {
const binary = atob(msg.line_data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
paintLine(bytes);
}
// Update status text.
if (wefaxLiveInfo) {
wefaxLiveInfo.textContent =
`Line ${msg.line_count} · ${msg.ioc} IOC · ${msg.lpm} LPM`;
}
if (wefaxStatus) {
wefaxStatus.textContent = `Receiving — line ${msg.line_count}`;
wefaxStatus.style.color = 'var(--text-accent)';
}
};
/** Called when a complete WEFAX image is received. */
window.onServerWefax = function (msg) {
msg._tsMs = msg.ts_ms || Date.now();
wefaxImageHistory.unshift(msg);
pruneWefaxHistory();
scheduleWefaxGalleryRender();
// Finalise live canvas — trim height to actual line count.
if (wefaxLiveCtx && wefaxLiveLineCount > 0) {
const trimmed = wefaxLiveCtx.getImageData(
0, 0, wefaxLiveCanvas.width, wefaxLiveLineCount);
wefaxLiveCanvas.height = wefaxLiveLineCount;
wefaxLiveCtx.putImageData(trimmed, 0, 0);
}
if (wefaxStatus) {
wefaxStatus.textContent = `Complete — ${msg.line_count} lines`;
wefaxStatus.style.color = '';
}
};
/** Batch restore from decode history (page load). */
window.restoreWefaxHistory = function (messages) {
if (!messages || !messages.length) return;
for (const m of messages) {
m._tsMs = m.ts_ms || Date.now();
}
wefaxImageHistory = messages.concat(wefaxImageHistory);
pruneWefaxHistory();
scheduleWefaxGalleryRender();
};
/** Called by history retention pruning cycle. */
window.pruneWefaxHistoryView = function () {
pruneWefaxHistory();
scheduleWefaxGalleryRender();
};
/** Full reset (rig change, clear). */
window.resetWefaxHistoryView = function () {
wefaxImageHistory = [];
wefaxGallery.innerHTML = '';
wefaxLiveContainer.style.display = 'none';
wefaxLiveCtx = null;
wefaxLiveLineCount = 0;
if (wefaxStatus) {
wefaxStatus.textContent = 'Idle';
wefaxStatus.style.color = '';
}
};
// --- Button handlers ---
if (wefaxClearBtn) {
wefaxClearBtn.addEventListener('click', function () {
fetch('/clear_wefax_decode', { method: 'POST' });
window.resetWefaxHistoryView();
});
}
```
#### 7.5.6 Data flow summary
```mermaid
sequenceDiagram
participant Server as trx-server (wefax decoder)
participant SSE as SSE /decode
participant Plugin as wefax.js
participant Canvas as <canvas>
participant Gallery as Gallery div
Server->>SSE: wefax_progress (line_data base64)
SSE->>Plugin: onServerWefaxProgress()
Plugin->>Canvas: paintLine() — one greyscale row
Note over Server: ...repeats per line...
Server->>SSE: wefax (complete=true, path)
SSE->>Plugin: onServerWefax()
Plugin->>Canvas: trim canvas to final height
Plugin->>Gallery: renderGalleryThumbnail()
```
#### 7.5.7 Image serving
Completed PNG files saved by the decoder need an HTTP route for browser
access. Add a static-file route in `assets.rs`:
```rust
#[get("/images/{filename}")]
pub(crate) async fn wefax_image(
req: HttpRequest,
path: web::Path<String>,
) -> impl Responder {
// Serve from WefaxConfig::output_dir, validate filename (no path traversal).
// Content-Type: image/png, Cache-Control: public, max-age=86400.
}
```
Register in `api/mod.rs`:
```rust
.service(assets::wefax_image)
```
#### 7.5.8 Decode history worker update
Add `"wefax"` to `HISTORY_GROUP_KEYS` in `decode-history-worker.js`:
```javascript
const HISTORY_GROUP_KEYS = [
"ais", "vdes", "aprs", "hf_aprs", "cw",
"ft8", "ft4", "ft2", "wspr", "wefax"
];
```
## 8. Implementation Phases
### Phase 1: Core DSP (MVP) ✅
1.**Resampler** &mdash; 48k→11025 polyphase resampler with tests.
2.**FM discriminator** &mdash; Hilbert FIR + instantaneous freq, verify
against synthetic 15002300 Hz sweeps.
3.**Tone detector** &mdash; Goertzel at 300/450/675 Hz with debounce.
4.**Line slicer** &mdash; Fixed-config (manual LPM+IOC) line extraction.
5.**Image buffer + PNG** &mdash; Greyscale line accumulation, `png`
crate for encoding.
Deliverable: decode a known WEFAX WAV recording at a single speed/IOC.
### Phase 2: Automatic Detection ✅
6.**State machine** &mdash; Full `Idle→StartDetected→Phasing→Receiving→Stopping`
transitions driven by tone detector.
7.**Phase alignment** &mdash; Cross-correlation phasing detector.
8.**Auto IOC/LPM** &mdash; IOC from start tone frequency; LPM from phasing
line duration measurement.
Deliverable: fully automatic reception of a single image without manual config.
### Phase 3: Server Integration ✅
9.**`trx-core` message types** &mdash; `WefaxMessage`, `WefaxProgress` in
`DecodedMessage`.
10.**`trx-server` task** &mdash; `run_wefax_decoder()`, history, logging.
11.**Protocol registry** &mdash; `DECODER_REGISTRY` entry for `"wefax"`.
Deliverable: backend wefax decoding with SSE event broadcast.
### Phase 3b: Frontend Wiring ✅
12.**Rust asset pipeline** &mdash; `status.rs` embed, `assets.rs` gzip
cache + route, `decoder.rs` toggle/clear endpoints, `api/mod.rs`
registration (§7.5.1).
13.**HTML scaffold** &mdash; sub-tab button, sub-tab panel with canvas +
gallery, overview entry, about row (§7.5.2).
14.**Plugin loading** &mdash; add `/wefax.js` to `pluginScripts`
`'digital-modes'` array (§7.5.3).
15.**SSE dispatch** &mdash; `wefax` / `wefax_progress` handlers in
`app.js` decode event dispatcher (§7.5.4).
16.**`wefax.js` plugin** &mdash; live canvas rendering, gallery
thumbnails, history restore, toggle/clear wiring (§7.5.5).
17. **Image serving** &mdash; `/images/{filename}` static route for
completed PNGs (§7.5.7). *(deferred: images served from output_dir)*
18.**History worker** &mdash; add `"wefax"` to `HISTORY_GROUP_KEYS`
(§7.5.8).
Deliverable: end-to-end live WEFAX decoding with in-browser image preview.
### Phase 4: Polish
19. **Multi-speed runtime switching** &mdash; handle back-to-back
transmissions at different LPM within one session.
20. **Slant correction** &mdash; fine-tune sample clock drift compensation
using phasing pulse tracking.
21. **Colour compositing** &mdash; optional IR + visible overlay for
satellite WEFAX (future).
22. **Test suite** &mdash; synthetic signal generation, round-trip tests,
edge cases (partial images, noise, frequency offset).
## 9. Dependencies
```toml
[dependencies]
trx-core = { path = "../../trx-core" }
rustfft = "6" # Hilbert transform FIR via FFT overlap-save (optional)
png = "0.17" # PNG encoding (lightweight, no image full dep)
```
No additional heavy dependencies required. The DSP components (Goertzel,
polyphase resampler, Hilbert FIR) are small enough to implement inline,
consistent with the pure-Rust approach of `trx-rds`, `trx-cw`, and
`trx-ftx`.
## 10. Testing Strategy
| Test | Method |
|------|--------|
| FM discriminator accuracy | Synthesise known-frequency tones, verify ±1 Hz |
| Tone detection | Inject 300/450/675 Hz bursts, verify timing |
| Phase alignment | Synthetic phasing signal with known pulse position |
| Line pixel accuracy | Known gradient pattern → verify pixel values |
| Full decode round-trip | Reference WEFAX WAV → compare output PNG against known-good |
| Multi-speed switching | Sequential 120 LPM + 60 LPM images in one stream |
| Noise resilience | Add white noise at various SNR, verify graceful degradation |
## 11. References
- ITU-R BT.601 (facsimile signal characteristics)
- WMO Manual on the GTS, Attachment II-13 (HF radiofax schedule/format)
- NOAA Radiofax Charts: frequency schedules and IOC/LPM per product
- Existing open-source implementations: `fldigi` WEFAX module, `multimon-ng`