Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 = (S−D)/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.
|
||||
@@ -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) |
|
||||
@@ -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.6–1.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 9–10 dB SNR,
|
||||
genuine bit errors have very low `|biphase_I|` (cost ≲ 0.3), while noise-induced
|
||||
OSD matches flip high-confidence bits (cost 0.6–1.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
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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 (8000–192000) |
|
||||
| `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 (1–100) 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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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` (L1109–1289) | 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.
|
||||
@@ -0,0 +1,837 @@
|
||||
# WEFAX / Radiofax Decoder Implementation Plan
|
||||
|
||||
> **Crate**: `trx-wefax` — `src/decoders/trx-wefax/`
|
||||
> **Status**: Implemented (Phases 1–3b) — 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 — 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 (0–255).
|
||||
- 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`** — embed the plugin script:
|
||||
|
||||
```rust
|
||||
pub const WEFAX_JS: &str = include_str!("../assets/web/plugins/wefax.js");
|
||||
```
|
||||
|
||||
**`src/api/assets.rs`** — 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`** — 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`** — register in `configure()`:
|
||||
|
||||
```rust
|
||||
.service(decoder::toggle_wefax_decode)
|
||||
.service(decoder::clear_wefax_decode)
|
||||
.service(assets::wefax_js)
|
||||
```
|
||||
|
||||
**Decode history** — 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** — 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** — 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 — 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** — 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
// --- 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** — 48k→11025 polyphase resampler with tests.
|
||||
2. ✅ **FM discriminator** — Hilbert FIR + instantaneous freq, verify
|
||||
against synthetic 1500–2300 Hz sweeps.
|
||||
3. ✅ **Tone detector** — Goertzel at 300/450/675 Hz with debounce.
|
||||
4. ✅ **Line slicer** — Fixed-config (manual LPM+IOC) line extraction.
|
||||
5. ✅ **Image buffer + PNG** — 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** — Full `Idle→StartDetected→Phasing→Receiving→Stopping`
|
||||
transitions driven by tone detector.
|
||||
7. ✅ **Phase alignment** — Cross-correlation phasing detector.
|
||||
8. ✅ **Auto IOC/LPM** — 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** — `WefaxMessage`, `WefaxProgress` in
|
||||
`DecodedMessage`.
|
||||
10. ✅ **`trx-server` task** — `run_wefax_decoder()`, history, logging.
|
||||
11. ✅ **Protocol registry** — `DECODER_REGISTRY` entry for `"wefax"`.
|
||||
|
||||
Deliverable: backend wefax decoding with SSE event broadcast.
|
||||
|
||||
### Phase 3b: Frontend Wiring ✅
|
||||
|
||||
12. ✅ **Rust asset pipeline** — `status.rs` embed, `assets.rs` gzip
|
||||
cache + route, `decoder.rs` toggle/clear endpoints, `api/mod.rs`
|
||||
registration (§7.5.1).
|
||||
13. ✅ **HTML scaffold** — sub-tab button, sub-tab panel with canvas +
|
||||
gallery, overview entry, about row (§7.5.2).
|
||||
14. ✅ **Plugin loading** — add `/wefax.js` to `pluginScripts`
|
||||
`'digital-modes'` array (§7.5.3).
|
||||
15. ✅ **SSE dispatch** — `wefax` / `wefax_progress` handlers in
|
||||
`app.js` decode event dispatcher (§7.5.4).
|
||||
16. ✅ **`wefax.js` plugin** — live canvas rendering, gallery
|
||||
thumbnails, history restore, toggle/clear wiring (§7.5.5).
|
||||
17. **Image serving** — `/images/{filename}` static route for
|
||||
completed PNGs (§7.5.7). *(deferred: images served from output_dir)*
|
||||
18. ✅ **History worker** — 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** — handle back-to-back
|
||||
transmissions at different LPM within one session.
|
||||
20. **Slant correction** — fine-tune sample clock drift compensation
|
||||
using phasing pulse tracking.
|
||||
21. **Colour compositing** — optional IR + visible overlay for
|
||||
satellite WEFAX (future).
|
||||
22. **Test suite** — 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`
|
||||
Reference in New Issue
Block a user