[chore](trx-rs): add local copy of docs
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, and performance
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# 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-26*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolved
|
||||||
|
|
||||||
|
The following items have been fixed across PRs #58, #59, and #60:
|
||||||
|
|
||||||
|
### Quick Wins (all complete)
|
||||||
|
- ✅ **Session cleanup timer** — 5-minute periodic `cleanup_expired()` task
|
||||||
|
- ✅ **`DecodeHistory<T>` type alias** — replaces 9 repeated `Arc<Mutex<VecDeque<...>>>` patterns
|
||||||
|
- ✅ **`mode_to_string()` allocation** — returns `Cow<'static, str>` (zero-alloc for known modes)
|
||||||
|
- ✅ **FTx dedup** — `HashSet<u16>` for O(1) lookups
|
||||||
|
- ✅ **Unbounded channels** — `VChanAudioCmd` channels bounded at 256
|
||||||
|
- ✅ **JSON serialization** — `#[serde(flatten)]` wrapper replaces string-level splice
|
||||||
|
- ✅ **`AtomicUsize` counter** — `estimated_total_count()` avoids 9 mutex acquisitions
|
||||||
|
- ✅ **Cookie security warning** — startup warning when `cookie_secure` is false
|
||||||
|
- ✅ **Spectrum encoding** — pre-allocated output string replaces `format!` overhead
|
||||||
|
- ✅ **`pub(crate)` state data** — `ReadyStateData`/`TransmittingStateData` fields restricted with constructors + getters
|
||||||
|
- ✅ **Lock ordering docs** — module-level documentation in `<vchan.rs>` establishing `rigs → sessions → audio_cmd`
|
||||||
|
|
||||||
|
### Critical (P0)
|
||||||
|
- ✅ **Plugin loading validation** — rejects world-writable files on Unix; `TRX_PLUGINS_DISABLED` env var
|
||||||
|
- ✅ **Audio pipeline mutex panics** — all `.expect()` on history mutexes and `.unwrap()` on audio ring buffers replaced with `.unwrap_or_else(|e| e.into_inner())` poison recovery
|
||||||
|
- ✅ **vchan lock panics** — ~25 `.unwrap()` on RwLock/Mutex replaced with poison recovery
|
||||||
|
|
||||||
|
### High (P1)
|
||||||
|
- ✅ **RigCat trait split** — 13 SDR-specific methods extracted into `RigSdr` extension trait; `RigCat` retains core CAT ops + `as_sdr()`/`as_sdr_ref()`; SoapySdrRig implements both; FT-817/FT-450D/DummyRig unchanged
|
||||||
|
- ✅ **Decoder history contention** — `AtomicUsize` total counter maintained by record/prune/clear
|
||||||
|
|
||||||
|
### Medium (P2)
|
||||||
|
- ✅ **Silent state machine failures** — debug-level tracing for rejected transitions
|
||||||
|
- ✅ **User input in logs** — raw JSON truncated to 128 chars
|
||||||
|
- ✅ **Rate limiting** — per-IP `LoginRateLimiter` (10 attempts/60s) on `/auth/login`
|
||||||
|
- ✅ **Lock-holding serialization** — clone data out under lock, serialize after release
|
||||||
|
- ✅ **Overly-public API** — state data fields `pub(crate)` with controlled accessors
|
||||||
|
- ✅ **Cookie security flag** — startup warning for non-TLS deployments
|
||||||
|
- ✅ **Lock ordering** — documented in `<vchan.rs>` module header
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remaining Issues
|
||||||
|
|
||||||
|
### Critical (P0)
|
||||||
|
|
||||||
|
#### Plugin signing and cross-platform validation
|
||||||
|
|
||||||
|
**Location:** `src/trx-app/src/<plugins.rs>`
|
||||||
|
|
||||||
|
Current protections: file permission checks (Unix), `TRX_PLUGINS_DISABLED` env var,
|
||||||
|
loaded plugins logged at startup.
|
||||||
|
|
||||||
|
**Still missing:**
|
||||||
|
- No SHA-256 checksum verification — an attacker who passes the permission check
|
||||||
|
can still load a tampered library
|
||||||
|
- No per-plugin permission scoping (all plugins get full context access)
|
||||||
|
- Windows has no file permission validation
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
- SHA-256 checksum manifest (`plugins.toml`) verified before `Library::new`
|
||||||
|
- Config option to allowlist specific plugin filenames
|
||||||
|
- On Windows, verify file owner via `GetSecurityInfo` or equivalent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### High Priority (P1)
|
||||||
|
|
||||||
|
#### Synchronous locks in async contexts
|
||||||
|
|
||||||
|
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/<background_decode.rs>`,
|
||||||
|
`src/trx-client/trx-frontend/trx-frontend-http/src/<vchan.rs>`
|
||||||
|
|
||||||
|
`std::sync::RwLock` is used inside async tasks. Current code is safe (no locks held
|
||||||
|
across await points), but not idiomatic. Migrating to `tokio::sync::RwLock` would
|
||||||
|
prevent future regressions.
|
||||||
|
|
||||||
|
#### Large functions in audio pipeline
|
||||||
|
|
||||||
|
**Locations:**
|
||||||
|
- `src/trx-server/src/<audio.rs>` — `run_capture()` (~200 lines),
|
||||||
|
`run_playback()` (~217 lines)
|
||||||
|
|
||||||
|
These contain nested loops, device re-enumeration logic, and stream error handling
|
||||||
|
that should be extracted into focused helper functions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Medium Priority (P2)
|
||||||
|
|
||||||
|
#### Configuration duplication
|
||||||
|
|
||||||
|
**Location:** `src/trx-server/src/<config.rs>` (1512 lines),
|
||||||
|
`src/trx-client/src/<config.rs>` (1181 lines)
|
||||||
|
|
||||||
|
14 config structs each, many mirrored between server and client. Extract shared
|
||||||
|
definitions (GeneralConfig, RigConfig, defaults) into `trx-app`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Low Priority (P3)
|
||||||
|
|
||||||
|
#### Missing tests for critical paths
|
||||||
|
|
||||||
|
Serial backends (FT-817, FT-450D), plugin loading/discovery, and the audio
|
||||||
|
pipeline (Opus encode/decode) have no or minimal test coverage.
|
||||||
|
|
||||||
|
Core crates (`trx-core`, `trx-server`, `trx-client`, `trx-app`) have limited
|
||||||
|
`[dev-dependencies]` and use only inline `#[test]` functions. Adding test
|
||||||
|
utilities (mock serial ports, test fixtures) would improve coverage.
|
||||||
|
|
||||||
|
#### Plugin system lacks versioning and lifecycle
|
||||||
|
|
||||||
|
**Location:** `src/trx-app/src/<plugins.rs>`
|
||||||
|
|
||||||
|
No plugin API version, capability manifest, or unload/reload semantics. Old
|
||||||
|
plugins break silently on API changes.
|
||||||
|
|
||||||
|
**Fix:** Add a version field to the registration struct and reject incompatible
|
||||||
|
plugins at load time.
|
||||||
@@ -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,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.
|
||||||
Reference in New Issue
Block a user