[docs](trx-rs): refresh top-level docs and config example
Rewrite the README, remove AI-generated planning docs, and regenerate the combined example config. Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -4,114 +4,185 @@
|
||||
|
||||
# trx-rs
|
||||
|
||||
A modular transceiver control stack with configurable backends and frontends. The rig task is driven by controller components (state machine, handlers, and policies) with configurable polling and retry behavior via the `[behavior]` section in the config file.
|
||||
`trx-rs` is a modular amateur radio control stack written in Rust.
|
||||
It splits radio hardware access from user-facing interfaces so you can run
|
||||
rig control, SDR DSP, decoding, audio streaming, and web access as separate,
|
||||
composable pieces.
|
||||
|
||||
**Note**: This is a live project with evolving APIs. Please report issues and feature requests.
|
||||
The project is built around two primary binaries:
|
||||
|
||||
Configuration reference: see `CONFIGURATION.md` for all server/client options and defaults.
|
||||
- `trx-server`: talks to radios and SDR backends
|
||||
- `trx-client`: connects to the server and exposes frontends such as the web UI
|
||||
|
||||
## Configuration Files
|
||||
## Web UI Demo
|
||||
|
||||
`trx-server` and `trx-client` read configuration from a shared `trx-rs.toml`.
|
||||
> GIF placeholder: add an animated walkthrough of the website here.
|
||||
|
||||
- Default search order for each app:
|
||||
current directory, then `~/.config/trx-rs`, then `/etc/trx-rs`
|
||||
- At each location, the loader checks:
|
||||
`trx-rs.toml` and reads the `[trx-server]` or `[trx-client]` section
|
||||
- Config file name:
|
||||
`trx-rs.toml`
|
||||
- `--config <FILE>` loads an explicit config file path and reads the matching `[trx-server]` or `[trx-client]` section from that file.
|
||||
- `--print-config` prints an example combined config block suitable for `trx-rs.toml`.
|
||||
## What It Does
|
||||
|
||||
See `trx-rs.toml.example` for a complete combined example.
|
||||
- Controls supported radios over networked client/server boundaries
|
||||
- Exposes a browser UI, a rigctl-compatible frontend, and JSON-based control
|
||||
- Supports SDR workflows with live spectrum, waterfall, demodulation, and decode
|
||||
- Streams Opus audio between server, client, and browser
|
||||
- Runs multiple decoders including AIS, APRS, CW, FT8, RDS, VDES, and WSPR
|
||||
- Supports multi-rig deployments and SDR virtual channels
|
||||
- Loads backends and frontends via plugins
|
||||
|
||||
## Supported backends
|
||||
## Architecture
|
||||
|
||||
- Yaesu FT-817 (feature-gated crate `trx-backend-ft817`)
|
||||
- Planned: other rigs I own; contributions and reports are welcome.
|
||||
At a high level:
|
||||
|
||||
## Frontends
|
||||
1. `trx-server` owns the radio hardware and DSP pipeline.
|
||||
2. `trx-client` connects to the server over TCP for control and audio.
|
||||
3. Frontends hang off `trx-client`, including the HTTP web UI.
|
||||
|
||||
- HTTP status/control frontend (`trx-frontend-http`)
|
||||
- JSON TCP control frontend (`trx-frontend-http-json`)
|
||||
- rigctl-compatible TCP frontend (`trx-frontend-rigctl`, listens on 127.0.0.1:4532)
|
||||
This separation is intentional: it keeps hardware access local to one host while
|
||||
making control and monitoring available elsewhere on the network.
|
||||
|
||||
## HTTP Frontend Authentication
|
||||
## Workspace Layout
|
||||
|
||||
The HTTP frontend supports optional passphrase-based authentication with two roles:
|
||||
- `src/trx-core`: shared types, rig state, controller logic
|
||||
- `src/trx-protocol`: client/server protocol types and codecs
|
||||
- `src/trx-app`: shared app bootstrapping, config, logging, plugins
|
||||
- `src/trx-server`: server binary and backend integration
|
||||
- `src/trx-client`: client binary and remote connection handling
|
||||
- `src/trx-client/trx-frontend`: frontend abstraction
|
||||
- `src/decoders`: protocol-specific decoder crates
|
||||
- `examples/trx-plugin-example`: minimal plugin example
|
||||
|
||||
- **rx**: Read-only access to status, events, decode history, and audio streams
|
||||
- **control**: Full access including transmit control (TX/PTT) and power toggling
|
||||
## Supported Pieces
|
||||
|
||||
Authentication is disabled by default. When enabled, users must log in via a passphrase before accessing the web UI. Sessions are managed server-side with configurable time-to-live and cookie security settings.
|
||||
### Backends
|
||||
|
||||
### Configuration
|
||||
- Yaesu FT-817
|
||||
- Yaesu FT-450D
|
||||
- SoapySDR-based SDR backend
|
||||
|
||||
Enable authentication under `[trx-client.frontends.http.auth]` in `trx-rs.toml`.
|
||||
### Frontends
|
||||
|
||||
```toml
|
||||
[trx-client.frontends.http.auth]
|
||||
enabled = true
|
||||
rx_passphrase = "read-only-secret"
|
||||
control_passphrase = "full-control-secret"
|
||||
session_ttl_min = 480 # 8 hours
|
||||
cookie_secure = false # Set to true for HTTPS
|
||||
cookie_same_site = "Lax"
|
||||
- HTTP web frontend
|
||||
- rigctl-compatible TCP frontend
|
||||
- JSON-over-TCP frontend
|
||||
|
||||
### Decoders
|
||||
|
||||
- AIS
|
||||
- APRS
|
||||
- CW
|
||||
- FT8
|
||||
- RDS
|
||||
- VDES
|
||||
- WSPR
|
||||
|
||||
## Build Requirements
|
||||
|
||||
You will need Rust plus a few system libraries.
|
||||
|
||||
### Common dependencies
|
||||
|
||||
- `libopus`
|
||||
- `pkg-config` or `pkgconf`
|
||||
- `cmake`
|
||||
|
||||
### SDR builds
|
||||
|
||||
- `libsoapysdr`
|
||||
|
||||
### Audio builds
|
||||
|
||||
- Core Audio on macOS, or ALSA development packages on Linux
|
||||
|
||||
## Configuration
|
||||
|
||||
Both `trx-server` and `trx-client` read from a shared `trx-rs.toml`.
|
||||
|
||||
- Default lookup order: current directory, `~/.config/trx-rs`, `/etc/trx-rs`
|
||||
- Use `--config <FILE>` to point at an explicit config file
|
||||
- Use `--print-config` to print an example combined config
|
||||
|
||||
Start from [`trx-rs.toml.example`](trx-rs.toml.example).
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Build
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
### 2. Create a config file
|
||||
|
||||
- **Local/LAN use**: Default settings are safe for 127.0.0.1 or trusted local networks.
|
||||
- **Remote access**: For internet-exposed deployments:
|
||||
- Deploy behind HTTPS (reverse proxy or TLS termination)
|
||||
- Set `cookie_secure = true`
|
||||
- Use strong passphrases (random, 16+ chars)
|
||||
- Consider firewall rules and network segmentation
|
||||
- **Passphrase storage**: Passphrases are stored in plaintext in the config file. Protect the config file with appropriate file permissions.
|
||||
- **No rate limiting**: The current implementation does not include login rate limiting. For high-security scenarios, deploy behind a reverse proxy with rate limiting.
|
||||
```bash
|
||||
cp trx-rs.toml.example trx-rs.toml
|
||||
```
|
||||
|
||||
### Architecture
|
||||
Adjust backend, frontend, audio, and auth settings for your environment.
|
||||
|
||||
- **Sessions**: In-memory, expire after configured TTL (default 8 hours)
|
||||
- **Cookies**: HttpOnly, configurable Secure and SameSite attributes
|
||||
- **Route protection**: Middleware validates session on protected endpoints; public routes (static assets, login) are always accessible
|
||||
- **TX/PTT gating**: Control-only endpoints return 404 to rx-authenticated users (when `tx_access_control_enabled=true`)
|
||||
### 3. Run the server
|
||||
|
||||
## Audio streaming
|
||||
```bash
|
||||
cargo run -p trx-server
|
||||
```
|
||||
|
||||
Bidirectional Opus audio streaming between server, client, and browser.
|
||||
### 4. Run the client
|
||||
|
||||
- **Server** captures audio from a configured input device (cpal), encodes to Opus, and streams over a dedicated TCP connection (default port 4533). TX audio received from clients is decoded and played back.
|
||||
- **Client** connects to the server's audio TCP port and relays Opus frames to/from the HTTP frontend via a WebSocket at `/audio`.
|
||||
- **Browser** connects to the `/audio` WebSocket, decodes Opus via WebCodecs `AudioDecoder`, and plays RX audio. TX audio is captured via `getUserMedia` and encoded with WebCodecs `AudioEncoder`.
|
||||
```bash
|
||||
cargo run -p trx-client
|
||||
```
|
||||
|
||||
Enable with `[audio] enabled = true` in the server config and `[frontends.audio] enabled = true` in the client config.
|
||||
### 5. Open the web UI
|
||||
|
||||
## Dependencies
|
||||
Open the configured HTTP frontend address in a browser.
|
||||
|
||||
### System libraries
|
||||
## Web Frontend Highlights
|
||||
|
||||
The following system libraries are required at build time:
|
||||
- Real-time spectrum and waterfall
|
||||
- Frequency, mode, and bandwidth control
|
||||
- Decoder dashboards and history
|
||||
- SDR virtual channels
|
||||
- Browser RX/TX audio
|
||||
- Optional authentication with read-only and control roles
|
||||
|
||||
| Library | Purpose | Install |
|
||||
|---------|---------|---------|
|
||||
| **libopus** | Opus audio codec encoding/decoding | `zb install opus` (or your system package manager) |
|
||||
| **libsoapysdr** | Required for SoapySDR-based SDR backends | `zb install soapysdr` (or your system package manager) |
|
||||
| **cmake** | Required by the `audiopus_sys` build script if libopus is not found via pkg-config | `zb install cmake` |
|
||||
| **pkg-config** / **pkgconf** | Locates system libopus during build | `zb install pkgconf` |
|
||||
| **Core Audio** (macOS) / **ALSA** (Linux) | Audio device access via cpal | Provided by the OS (macOS) or `alsa-lib-dev` (Linux) |
|
||||
## Authentication
|
||||
|
||||
## Plugin discovery
|
||||
The HTTP frontend supports optional passphrase-based authentication.
|
||||
|
||||
`trx-server` and `trx-client` can load shared-library plugins that register backends/frontends
|
||||
via a `trx_register` entrypoint. Search paths:
|
||||
- `rx`: read-only access
|
||||
- `control`: full control access
|
||||
|
||||
When exposing the web UI beyond a trusted LAN, run it behind HTTPS and enable
|
||||
secure cookie settings in the config.
|
||||
|
||||
## Audio
|
||||
|
||||
Audio is transported as Opus between server, client, and browser.
|
||||
|
||||
- `trx-server` captures and encodes audio
|
||||
- `trx-client` relays audio to the HTTP frontend
|
||||
- Browsers connect over `/audio`
|
||||
|
||||
## Plugins
|
||||
|
||||
Both binaries can discover shared-library plugins through:
|
||||
|
||||
- `./plugins`
|
||||
- `~/.config/trx-rs/plugins`
|
||||
- `TRX_PLUGIN_DIRS` (path-separated)
|
||||
- `TRX_PLUGIN_DIRS`
|
||||
|
||||
Example plugin: `examples/trx-plugin-example`
|
||||
See [`examples/trx-plugin-example/README.md`](examples/trx-plugin-example/README.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`OVERVIEW.md`](OVERVIEW.md): architecture and design overview
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md): contribution and commit rules
|
||||
|
||||
## Project Status
|
||||
|
||||
This is an active project with evolving APIs and frontend behavior. Expect some
|
||||
rough edges and ongoing refactors.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the BSD-2-Clause license. See `LICENSES/` for bundled third-party license files.
|
||||
Licensed under BSD-2-Clause.
|
||||
|
||||
See [`LICENSES`](LICENSES) for bundled third-party license files.
|
||||
|
||||
-247
@@ -1,247 +0,0 @@
|
||||
# Virtual Channels
|
||||
|
||||
## Overview
|
||||
|
||||
Virtual channels allow a single SDR rig to simultaneously receive multiple signals within
|
||||
its capture bandwidth. Each virtual channel has its own frequency offset, mode, and
|
||||
independent decoder pipeline. Traditional (non-SDR) rigs expose no virtual channels.
|
||||
|
||||
## Concepts
|
||||
|
||||
### Channel 0 (Primary)
|
||||
|
||||
The permanent default channel. Always exists. Controlled by normal rig commands
|
||||
(`SetFreq`, `SetMode`, etc.). Cannot be deallocated.
|
||||
|
||||
### Virtual Channels (1+)
|
||||
|
||||
Dynamically allocated. Each has:
|
||||
- An IF offset relative to the SDR center frequency
|
||||
- Its own mode / demodulator
|
||||
- Its own Opus audio stream
|
||||
- Its own decoder subscriptions (FT8, APRS, CW, etc.)
|
||||
- A ref-count of SSE sessions currently subscribed to it
|
||||
|
||||
A virtual channel is freed when its ref-count drops to zero (last subscriber
|
||||
disconnects or switches away), except channel 0 which is permanent.
|
||||
|
||||
### Session Binding
|
||||
|
||||
Each SSE session (`/events`) is assigned a server-generated `session_id` (UUID),
|
||||
returned in the initial `session` SSE event on connect. The client uses this ID
|
||||
when allocating a virtual channel:
|
||||
|
||||
```
|
||||
POST /rigs/{rig_id}/channels
|
||||
{ "session_id": "<uuid>", "freq_hz": 14095600, "mode": "CW" }
|
||||
```
|
||||
|
||||
The server tracks (session_id → channel_id) and decrements the channel ref-count
|
||||
when the SSE stream drops. A session may only own one virtual channel at a time;
|
||||
allocating a second implicitly releases the first.
|
||||
|
||||
### Center Frequency Constraint
|
||||
|
||||
The SDR center frequency is shared across all channels. When more than one channel
|
||||
is active, attempting to set the center frequency (channel 0 freq) to a value that
|
||||
would place any other channel outside the capture bandwidth returns **409 Conflict**.
|
||||
Similarly, tuning a virtual channel outside the current capture bandwidth returns
|
||||
**409 Conflict**.
|
||||
|
||||
### Renumbering
|
||||
|
||||
Channels are identified internally by UUID. The display index (0, 1, 2, …) is
|
||||
derived from the server-returned ordered list. Indices are reassigned after
|
||||
deallocation so the list stays compact.
|
||||
|
||||
## Capacity
|
||||
|
||||
Configured in the server config (`trx-server.toml`):
|
||||
|
||||
```toml
|
||||
[rig.sdr_options]
|
||||
max_virtual_channels = 4 # default: 4 (including channel 0)
|
||||
```
|
||||
|
||||
Attempting to allocate beyond the cap returns **429 Too Many Requests**.
|
||||
|
||||
## HTTP API
|
||||
|
||||
All endpoints require at minimum the **Rx** role for reads; **Control** for writes.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/rigs/{rig_id}/channels` | List all active channels |
|
||||
| POST | `/rigs/{rig_id}/channels` | Allocate a new virtual channel |
|
||||
| DELETE | `/rigs/{rig_id}/channels/{channel_id}` | Deallocate (not channel 0) |
|
||||
| GET | `/rigs/{rig_id}/channels/{channel_id}` | Get channel state |
|
||||
| PUT | `/rigs/{rig_id}/channels/{channel_id}` | Set freq/mode for a channel |
|
||||
|
||||
### GET /rigs/{rig_id}/channels
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"index": 0,
|
||||
"freq_hz": 14074000,
|
||||
"mode": "USB",
|
||||
"subscribers": 2,
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"id": "a1b2c3d4-...",
|
||||
"index": 1,
|
||||
"freq_hz": 14095600,
|
||||
"mode": "CW",
|
||||
"subscribers": 1,
|
||||
"permanent": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### POST /rigs/{rig_id}/channels
|
||||
|
||||
Request body (optional):
|
||||
```json
|
||||
{ "freq_hz": 14095600, "mode": "CW" }
|
||||
```
|
||||
|
||||
Returns the new channel object. Errors:
|
||||
- 429: cap reached
|
||||
- 409: freq outside capture bandwidth
|
||||
- 400: rig does not support virtual channels (traditional rig)
|
||||
|
||||
### PUT /rigs/{rig_id}/channels/{channel_id}
|
||||
|
||||
```json
|
||||
{ "freq_hz": 14100000, "mode": "USB" }
|
||||
```
|
||||
|
||||
Errors:
|
||||
- 409: freq outside capture bandwidth (when other channels are active)
|
||||
- 404: channel not found
|
||||
|
||||
### DELETE /rigs/{rig_id}/channels/{channel_id}
|
||||
|
||||
Deallocates the channel. All sessions subscribed to it are moved to channel 0
|
||||
via an SSE event `channel-evicted`.
|
||||
|
||||
## SSE Events
|
||||
|
||||
New SSE event types:
|
||||
|
||||
| Event | Payload | Description |
|
||||
|-------|---------|-------------|
|
||||
| `channels` | `ChannelList` | Full channel list snapshot |
|
||||
| `channel-updated` | `Channel` | One channel changed freq/mode/subscribers |
|
||||
| `channel-evicted` | `{evicted_id, fallback_id}` | Client's channel was deallocated |
|
||||
|
||||
The `channels` snapshot is sent on connect (after the rig state snapshot) and
|
||||
whenever the channel list changes.
|
||||
|
||||
## Audio WebSocket
|
||||
|
||||
Each channel exposes an independent Opus audio stream:
|
||||
|
||||
```
|
||||
GET /rigs/{rig_id}/channels/{channel_id}/audio (WebSocket upgrade)
|
||||
```
|
||||
|
||||
The legacy `/audio` endpoint continues to work and serves channel 0.
|
||||
|
||||
## Decode SSE
|
||||
|
||||
Each channel exposes its own independent decoder event stream:
|
||||
|
||||
```
|
||||
GET /rigs/{rig_id}/channels/{channel_id}/decode (SSE)
|
||||
```
|
||||
|
||||
The legacy `/decode` endpoint continues to work and serves channel 0's decoders.
|
||||
Decoded frames are tagged with the channel's freq/mode at decode time.
|
||||
|
||||
## Data Model (Rust)
|
||||
|
||||
```rust
|
||||
// In trx-core or trx-server
|
||||
|
||||
pub struct VirtualChannel {
|
||||
pub id: Uuid,
|
||||
pub freq_hz: u64,
|
||||
pub mode: RigMode,
|
||||
pub subscribers: usize, // ref-count
|
||||
pub permanent: bool, // true for channel 0
|
||||
}
|
||||
|
||||
pub struct ChannelManager {
|
||||
channels: Vec<VirtualChannel>, // index 0 = primary
|
||||
max_channels: usize,
|
||||
// DSP handles indexed by position
|
||||
dsp_handles: Vec<ChannelDspHandle>,
|
||||
}
|
||||
```
|
||||
|
||||
### ChannelDspHandle
|
||||
|
||||
Wraps a dynamically allocated `ChannelDsp` slot in the `SdrPipeline`. The
|
||||
pipeline gains `add_channel()` / `remove_channel()` methods that operate on the
|
||||
live IQ processing loop (via a command channel to the sdr-iq-read thread).
|
||||
|
||||
## DSP Integration
|
||||
|
||||
The existing `SdrPipeline` in `trx-backend-soapysdr` has a fixed set of
|
||||
`ChannelDsp` instances (primary + AIS A + AIS B + optional VDES). Virtual
|
||||
channels extend this with a dynamic slot list:
|
||||
|
||||
```
|
||||
IQ Broadcast (broadcast::Sender<Vec<Complex<f32>>>)
|
||||
├─ ChannelDsp[0] ← channel 0 (primary, permanent)
|
||||
├─ ChannelDsp[1] ← AIS A (internal, not user-visible)
|
||||
├─ ChannelDsp[2] ← AIS B (internal, not user-visible)
|
||||
└─ ChannelDsp[3+] ← user virtual channels (dynamic)
|
||||
```
|
||||
|
||||
The IQ broadcast already fans out to all receivers; adding a new virtual channel
|
||||
simply spawns a new async task that subscribes to the IQ broadcast and runs a
|
||||
`ChannelDsp` for that slot. Removing a channel aborts that task.
|
||||
|
||||
Each virtual channel task outputs PCM frames to a `broadcast::Sender<Vec<f32>>`
|
||||
stored in `ChannelManager`, which the audio WebSocket handler subscribes to.
|
||||
|
||||
## Frontend UI
|
||||
|
||||
### Channel Picker (SDR rigs only)
|
||||
|
||||
A compact control bar visible only when the active rig supports virtual channels:
|
||||
|
||||
```
|
||||
[ Ch 0 (14.074 USB) ▼ ] [ + New Channel ] [ ✕ Remove This ]
|
||||
```
|
||||
|
||||
- **Picker**: dropdown of all active channels with freq+mode label
|
||||
- **+ New Channel**: allocates a new channel, switches picker to it, focuses the
|
||||
freq/mode controls
|
||||
- **✕ Remove This**: deallocates the current channel (disabled for channel 0);
|
||||
confirms before sending DELETE
|
||||
|
||||
### Channel State
|
||||
|
||||
When a channel is selected in the picker, the main VFO display, mode selector,
|
||||
and decoder panels reflect that channel's state (not rig-global state). Tuning
|
||||
and mode changes act on the selected channel via `PUT /rigs/{rig_id}/channels/{id}`.
|
||||
|
||||
### SSE Fallback
|
||||
|
||||
On receiving `channel-evicted`, the frontend automatically:
|
||||
1. Switches the picker to channel 0
|
||||
2. Shows a brief toast: "Virtual channel removed — switched to primary"
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **`SdrPipeline` dynamic channels** — `add_channel()` / `remove_channel()` via
|
||||
command channel to IQ read thread
|
||||
2. **`ChannelManager`** in `trx-server` — tracks channels, ref-counts, DSP handles
|
||||
3. **HTTP API** in `trx-frontend-http` — channel CRUD, audio WebSocket per channel
|
||||
4. **SSE events** — `channels` snapshot on connect, `channel-updated`, `channel-evicted`
|
||||
5. **Frontend** — channel picker, +/✕ buttons, VFO/mode reflecting selected channel
|
||||
-188
@@ -1,188 +0,0 @@
|
||||
# Virtual-Channel Audio — Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Each virtual channel (SDR DSP slice) has its own Opus audio stream. When the
|
||||
browser switches to a non-primary virtual channel the `/audio` WebSocket should
|
||||
deliver audio demodulated at that channel's frequency and mode, not the primary
|
||||
channel's audio.
|
||||
|
||||
---
|
||||
|
||||
## Current Architecture (baseline)
|
||||
|
||||
```
|
||||
SoapySDR HW
|
||||
└─ SdrPipeline (slot 0: primary, slot 1..N: virtual)
|
||||
pcm_tx[0] pcm_tx[1] ... pcm_tx[N] (broadcast::Sender<Vec<f32>>)
|
||||
|
||||
trx-server/src/main.rs
|
||||
subscribe_pcm(slot 0) → Opus encode → rx_audio_tx (broadcast::Sender<Bytes>)
|
||||
|
||||
trx-server/src/audio.rs handle_audio_client()
|
||||
writes [0x00] StreamInfo
|
||||
[0x0a] history blob
|
||||
loop: [0x01] RX frame ← only primary channel
|
||||
|
||||
trx-client/src/audio_client.rs
|
||||
reads all frames → rx_audio_tx.send(bytes) (single broadcast)
|
||||
|
||||
FrontendRuntimeContext.audio_rx (single broadcast::Sender<Bytes>)
|
||||
|
||||
audio.rs / audio_ws()
|
||||
subscribes to audio_rx → WebSocket to browser
|
||||
```
|
||||
|
||||
Only slot 0 (primary) is ever encoded/transmitted. All sessions hear the same
|
||||
audio.
|
||||
|
||||
---
|
||||
|
||||
## Planned Architecture
|
||||
|
||||
```
|
||||
SdrPipeline pcm_tx[0..N]
|
||||
│
|
||||
trx-server/src/audio.rs (extended handle_audio_client)
|
||||
┌── per-rig VChanAudioMixer ──────────────────────────────────┐
|
||||
│ tracks (server_uuid → OpusEncoder + broadcast::Sender<Bytes>) │
|
||||
│ listens for VCHAN_SUB/VCHAN_UNSUB from client │
|
||||
│ Opus-encodes each channel's PCM independently │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│ wire frames:
|
||||
│ [0x01] RX_FRAME (primary channel, unchanged)
|
||||
│ [0x0b] RX_FRAME_CH [16 B UUID][N B Opus] ← NEW
|
||||
│ [0x0c] VCHAN_ALLOCATED [16 B UUID] ← NEW
|
||||
│ client→server:
|
||||
│ [0x0d] VCHAN_SUB [16 B UUID] subscribe to channel
|
||||
│ [0x0e] VCHAN_UNSUB [16 B UUID] unsubscribe
|
||||
|
||||
trx-client/src/audio_client.rs
|
||||
demux 0x0b frames by UUID → per-channel broadcast::Sender<Bytes>
|
||||
on 0x0c (allocated): publish UUID to per-channel map
|
||||
|
||||
FrontendRuntimeContext
|
||||
audio_rx: Option<broadcast::Sender<Bytes>> (primary, unchanged)
|
||||
vchan_audio: Arc<RwLock<HashMap<Uuid, broadcast::Sender<Bytes>>>> ← NEW
|
||||
|
||||
ClientChannelManager (trx-frontend-http/src/vchan.rs)
|
||||
allocate(): after creating local entry, sends VCHAN_SUB via new
|
||||
vchan_audio_tx: mpsc::Sender<VChanAudioCmd> ← NEW
|
||||
delete_channel(): sends VCHAN_UNSUB
|
||||
expose: subscribe_audio(channel_id) → Option<broadcast::Receiver<Bytes>>
|
||||
|
||||
audio_ws() (trx-frontend-http/src/audio.rs)
|
||||
accepts ?channel_id=<uuid> query param
|
||||
if present → lookup context.vchan_audio[uuid] → subscribe
|
||||
else → context.audio_rx (primary, current behaviour)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wire Protocol Additions (trx-core/src/audio.rs)
|
||||
|
||||
```
|
||||
AUDIO_MSG_RX_FRAME_CH = 0x0b
|
||||
AUDIO_MSG_VCHAN_ALLOCATED = 0x0c
|
||||
AUDIO_MSG_VCHAN_SUB = 0x0d
|
||||
AUDIO_MSG_VCHAN_UNSUB = 0x0e
|
||||
```
|
||||
|
||||
Frame layout for `RX_FRAME_CH`:
|
||||
```
|
||||
[0x0b] [4 B BE length = 16 + opus_len] [16 B UUID bytes] [opus_len B Opus]
|
||||
```
|
||||
|
||||
Frame layout for `VCHAN_ALLOCATED`, `VCHAN_SUB`, `VCHAN_UNSUB`:
|
||||
```
|
||||
[type] [4 B BE length = 16] [16 B UUID bytes]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layer-by-Layer Changes
|
||||
|
||||
### 1. `trx-core/src/audio.rs`
|
||||
- Add four new `AUDIO_MSG_*` constants.
|
||||
- Add helper `read_vchan_frame(reader) -> (Uuid, Bytes)` and
|
||||
`write_vchan_frame(writer, msg_type, uuid, payload)`.
|
||||
|
||||
### 2. `trx-server/src/audio.rs` (`handle_audio_client`)
|
||||
- Accept `vchan_manager: Option<SharedVChanManager>` from `RigHandle`.
|
||||
- Spawn a `VChanAudioMixer` task:
|
||||
- Holds `HashMap<Uuid, (JoinHandle, broadcast::Sender<Bytes>)>`.
|
||||
- On `VCHAN_SUB { uuid }`: call `vchan_manager.subscribe_pcm(uuid)`, spawn
|
||||
Opus-encode task, write `VCHAN_ALLOCATED { uuid }` to client.
|
||||
- On `VCHAN_UNSUB { uuid }`: abort encode task, remove from map.
|
||||
- On PCM ready: Opus-encode, write `RX_FRAME_CH { uuid, opus }`.
|
||||
- Add the `vchan_manager` parameter to `run_audio_listener()` and pass it
|
||||
through from `main.rs`.
|
||||
|
||||
### 3. `trx-server/src/main.rs`
|
||||
- Pass `rig_handle.vchan_manager.clone()` to `run_audio_listener()`.
|
||||
|
||||
### 4. `trx-client/src/audio_client.rs`
|
||||
- Add `vchan_audio_tx: mpsc::Sender<VChanAudioEvent>` parameter
|
||||
(where `VChanAudioEvent = Allocated(Uuid, broadcast::Sender<Bytes>) | Frame(Uuid, Bytes)`).
|
||||
- On `RX_FRAME_CH { uuid, opus }`: forward to per-channel sender (create if
|
||||
first frame for that uuid).
|
||||
- On `VCHAN_ALLOCATED { uuid }`: signal that the channel is ready.
|
||||
|
||||
### 5. `trx-client/src/main.rs`
|
||||
- Create `vchan_audio: Arc<RwLock<HashMap<Uuid, broadcast::Sender<Bytes>>>>`
|
||||
shared between audio_client task and FrontendRuntimeContext.
|
||||
- Add an `mpsc::Sender<VChanAudioCmd>` that lets the HTTP frontend request
|
||||
SUB/UNSUB over the audio TCP; pass it into `run_audio_client()`.
|
||||
|
||||
### 6. `trx-client/trx-frontend/src/lib.rs` (`FrontendRuntimeContext`)
|
||||
- Add:
|
||||
```rust
|
||||
pub vchan_audio: Arc<RwLock<HashMap<Uuid, broadcast::Sender<Bytes>>>>,
|
||||
pub vchan_audio_cmd: Option<mpsc::Sender<VChanAudioCmd>>,
|
||||
```
|
||||
- Initialise both to empty/None in `new()`.
|
||||
|
||||
### 7. `trx-client/trx-frontend/trx-frontend-http/src/vchan.rs` (`ClientChannelManager`)
|
||||
- `allocate()`: after inserting the local record, if `vchan_audio_cmd` is
|
||||
available, send `VChanAudioCmd::Subscribe(uuid)`.
|
||||
- `delete_channel()`: send `VChanAudioCmd::Unsubscribe(uuid)`.
|
||||
- `subscribe_audio(channel_id, context) -> Option<broadcast::Receiver<Bytes>>`:
|
||||
look up `context.vchan_audio.read()[channel_id].subscribe()`.
|
||||
|
||||
### 8. `trx-client/trx-frontend/trx-frontend-http/src/audio.rs` (`audio_ws`)
|
||||
- Parse optional `channel_id: Option<Uuid>` from query string.
|
||||
- If `Some(uuid)`:
|
||||
- Look up `context.vchan_audio.read()[uuid]` → `broadcast::Sender<Bytes>`.
|
||||
- Subscribe, forward Opus frames exactly as today but from that sender.
|
||||
- Else: current primary-channel path unchanged.
|
||||
|
||||
### 9. `assets/web/plugins/vchan.js`
|
||||
- `vchanSubscribe()` and `vchanAllocate()` call `vchanReconnectAudio()`.
|
||||
- `vchanReconnectAudio()`:
|
||||
- If on virtual channel: `reconnectAudioWs(vchanActiveId)` (pass channel UUID).
|
||||
- If on primary: `reconnectAudioWs(null)`.
|
||||
- `reconnectAudioWs(channelId)` (new in `app.js` or `vchan.js`):
|
||||
- Close existing `audioWs`.
|
||||
- Reopen `new WebSocket('/audio' + (channelId ? '?channel_id=' + channelId : ''))`.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope (non-SDR rigs)
|
||||
|
||||
Non-SDR rigs (`vchan_manager === None`) are unaffected. The new message types
|
||||
are only exchanged when the server-side vchan manager is present. Primary-
|
||||
channel audio behaviour is 100% backwards-compatible.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. `trx-core/src/audio.rs` — add constants and frame helpers *(no breakage)*
|
||||
2. `trx-server/src/audio.rs` — `VChanAudioMixer` + new frame handling
|
||||
3. `trx-server/src/main.rs` — plumb vchan_manager through
|
||||
4. `trx-client/src/audio_client.rs` — demux RX_FRAME_CH
|
||||
5. `trx-client/src/main.rs` — shared vchan_audio map + cmd channel
|
||||
6. `trx-frontend/src/lib.rs` — new FrontendRuntimeContext fields
|
||||
7. `trx-frontend-http/src/vchan.rs` — SUB/UNSUB on allocate/delete
|
||||
8. `trx-frontend-http/src/audio.rs` — channel_id query param routing
|
||||
9. `vchan.js` — reconnect WebSocket on channel switch
|
||||
+55
-159
@@ -1,245 +1,141 @@
|
||||
# trx-rs Combined Configuration File
|
||||
#
|
||||
# Copy this file to one of:
|
||||
# ./trx-rs.toml (current directory)
|
||||
# ~/.config/trx-rs/trx-rs.toml (user config)
|
||||
# /etc/trx-rs/trx-rs.toml (system-wide)
|
||||
#
|
||||
# Or use per-binary files (trx-server.toml / trx-client.toml) without the
|
||||
# section headers — both formats are supported and the section headers may
|
||||
# also appear in any per-binary file passed via --config.
|
||||
#
|
||||
# CLI arguments override config file values.
|
||||
|
||||
# =============================================================================
|
||||
# trx-server — connects to radio hardware, exposes a JSON/audio TCP server
|
||||
# =============================================================================
|
||||
[trx-server]
|
||||
rigs = []
|
||||
|
||||
[trx-server.general]
|
||||
# Callsign or station identifier
|
||||
callsign = "N0CALL"
|
||||
|
||||
# Log level: trace, debug, info, warn, error
|
||||
# log_level = "info"
|
||||
log_level = "info"
|
||||
latitude = 52.2297
|
||||
longitude = 21.0122
|
||||
|
||||
[trx-server.rig]
|
||||
# Rig model: ft817 (more models coming)
|
||||
model = "ft817"
|
||||
# Initial frequency (Hz) before first CAT read
|
||||
initial_freq_hz = 144300000
|
||||
# Initial mode before first CAT read (LSB, USB, CW, CWR, AM, WFM, FM, DIG, PKT)
|
||||
initial_mode = "USB"
|
||||
|
||||
[trx-server.rig.access]
|
||||
# Access type: "serial" or "tcp"
|
||||
type = "serial"
|
||||
|
||||
# Serial port settings (when type = "serial")
|
||||
port = "/dev/ttyUSB0"
|
||||
baud = 9600
|
||||
|
||||
# TCP settings (when type = "tcp")
|
||||
# host = "192.168.1.100"
|
||||
# tcp_port = 4532
|
||||
|
||||
[trx-server.behavior]
|
||||
# Polling interval when idle (milliseconds)
|
||||
poll_interval_ms = 500
|
||||
|
||||
# Polling interval when transmitting (milliseconds)
|
||||
poll_interval_tx_ms = 100
|
||||
|
||||
# Maximum retry attempts for transient errors
|
||||
max_retries = 3
|
||||
|
||||
# Base delay for exponential backoff (milliseconds)
|
||||
retry_base_delay_ms = 100
|
||||
|
||||
[trx-server.listen]
|
||||
# Enable the JSON TCP listener for client connections
|
||||
enabled = true
|
||||
|
||||
# IP address to listen on (use "0.0.0.0" for all interfaces)
|
||||
listen = "127.0.0.1"
|
||||
|
||||
# TCP port to listen on
|
||||
port = 4530
|
||||
|
||||
[trx-server.listen.auth]
|
||||
# Authentication tokens (empty = no auth required)
|
||||
tokens = []
|
||||
|
||||
[trx-server.pskreporter]
|
||||
# Enable uploads of decoded FT8/WSPR spots to PSK Reporter
|
||||
enabled = false
|
||||
[trx-server.audio]
|
||||
enabled = true
|
||||
listen = "127.0.0.1"
|
||||
port = 4531
|
||||
rx_enabled = true
|
||||
tx_enabled = true
|
||||
sample_rate = 48000
|
||||
channels = 2
|
||||
frame_duration_ms = 20
|
||||
bitrate_bps = 256000
|
||||
|
||||
# PSK Reporter endpoint (UDP)
|
||||
[trx-server.pskreporter]
|
||||
enabled = false
|
||||
host = "report.pskreporter.info"
|
||||
port = 4739
|
||||
|
||||
# Optional receiver locator (4 or 6-char Maidenhead).
|
||||
# If omitted, it is derived from [trx-server.general] latitude/longitude.
|
||||
# receiver_locator = "JO93"
|
||||
|
||||
[trx-server.aprsfi]
|
||||
# Enable APRS-IS IGate uplink (forwards received RF APRS packets to APRS-IS / aprs.fi)
|
||||
enabled = false
|
||||
|
||||
# APRS-IS server (rotate.aprs.net does DNS round-robin across all tier-2 servers)
|
||||
host = "rotate.aprs.net"
|
||||
port = 14580
|
||||
|
||||
# APRS-IS passcode. -1 = auto-computed from [trx-server.general] callsign.
|
||||
# passcode = -1
|
||||
passcode = -1
|
||||
|
||||
[trx-server.decode_logs]
|
||||
# Optional decoder message logs to files (APRS/CW/FT8/WSPR)
|
||||
enabled = false
|
||||
|
||||
# Base directory for decoder logs.
|
||||
# Default (if omitted): $XDG_DATA_HOME/trx-rs/decoders
|
||||
# Fallback: logs/decoders
|
||||
# dir = "/path/to/decoder-logs"
|
||||
|
||||
# Per-decoder log file names (supported tokens: %YYYY% %MM% %DD%)
|
||||
dir = "/path/to/log/dir"
|
||||
aprs_file = "TRXRS-APRS-%YYYY%-%MM%-%DD%.log"
|
||||
cw_file = "TRXRS-CW-%YYYY%-%MM%-%DD%.log"
|
||||
ft8_file = "TRXRS-FT8-%YYYY%-%MM%-%DD%.log"
|
||||
wspr_file = "TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"
|
||||
|
||||
# --- SoapySDR backend example ---
|
||||
# To use an SDR device instead of a physical transceiver, set:
|
||||
#
|
||||
# [trx-server.rig]
|
||||
# model = "soapysdr"
|
||||
# initial_freq_hz = 14074000
|
||||
# initial_mode = "USB"
|
||||
#
|
||||
# [trx-server.rig.access]
|
||||
# type = "sdr"
|
||||
# args = "driver=rtlsdr"
|
||||
#
|
||||
# [trx-server.sdr]
|
||||
# sample_rate = 1920000
|
||||
# bandwidth = 1500000
|
||||
# center_offset_hz = 200000
|
||||
#
|
||||
# [trx-server.sdr.gain]
|
||||
# mode = "auto"
|
||||
# value = 30.0
|
||||
#
|
||||
# [trx-server.sdr.squelch]
|
||||
# enabled = false
|
||||
# threshold_db = -65.0
|
||||
# hysteresis_db = 3.0
|
||||
# tail_ms = 180
|
||||
#
|
||||
# [[trx-server.sdr.channels]]
|
||||
# id = "primary"
|
||||
# offset_hz = 0
|
||||
# mode = "auto"
|
||||
# audio_bandwidth_hz = 3000
|
||||
# decoders = ["ft8", "cw"]
|
||||
# stream_opus = true
|
||||
#
|
||||
# [[trx-server.sdr.channels]]
|
||||
# id = "wspr"
|
||||
# offset_hz = 21600
|
||||
# mode = "USB"
|
||||
# audio_bandwidth_hz = 3000
|
||||
# decoders = ["wspr"]
|
||||
# stream_opus = false
|
||||
[trx-server.sdr]
|
||||
sample_rate = 1920000
|
||||
bandwidth = 1500000
|
||||
wfm_deemphasis_us = 50
|
||||
center_offset_hz = 100000
|
||||
channels = []
|
||||
max_virtual_channels = 4
|
||||
|
||||
# =============================================================================
|
||||
# trx-client — connects to trx-server and exposes user-facing frontends
|
||||
# =============================================================================
|
||||
[trx-server.sdr.gain]
|
||||
mode = "auto"
|
||||
value = 30.0
|
||||
|
||||
[trx-server.sdr.squelch]
|
||||
enabled = false
|
||||
threshold_db = -65.0
|
||||
hysteresis_db = 3.0
|
||||
tail_ms = 180
|
||||
|
||||
[trx-client.general]
|
||||
# Callsign or station identifier displayed in frontends
|
||||
callsign = "N0CALL"
|
||||
|
||||
# Log level: trace, debug, info, warn, error
|
||||
# log_level = "info"
|
||||
website_url = "https://haxx.space"
|
||||
website_name = "haxx.space"
|
||||
ais_vessel_url_base = "https://www.vesselfinder.com/?mmsi="
|
||||
log_level = "info"
|
||||
|
||||
[trx-client.remote]
|
||||
# Remote trx-server URL (host:port)
|
||||
url = "192.168.1.100:9000"
|
||||
# Optional target rig ID on a multi-rig server (omit to use server default rig)
|
||||
# rig_id = "hf"
|
||||
|
||||
# Poll interval in milliseconds
|
||||
rig_id = "hf"
|
||||
poll_interval_ms = 750
|
||||
|
||||
[trx-client.remote.auth]
|
||||
# Bearer token for authenticating with the remote server
|
||||
token = "my-token"
|
||||
|
||||
[trx-client.frontends.http]
|
||||
# Enable HTTP/REST frontend with SSE for real-time updates
|
||||
enabled = true
|
||||
listen = "127.0.0.1"
|
||||
port = 8080
|
||||
default_rig_id = "hf"
|
||||
initial_map_zoom = 10
|
||||
spectrum_coverage_margin_hz = 50000
|
||||
spectrum_usable_span_ratio = 0.9200000166893005
|
||||
show_sdr_gain_control = true
|
||||
|
||||
[trx-client.frontends.http.auth]
|
||||
# Optional passphrase-based authentication for the HTTP frontend
|
||||
# Disabled by default to preserve backward compatibility
|
||||
|
||||
# Enable authentication (default: false)
|
||||
enabled = false
|
||||
|
||||
# Read-only passphrase: grants access to status/events/audio (rx role)
|
||||
# Leave unset to disable rx access
|
||||
# rx_passphrase = "rx-only-passphrase"
|
||||
|
||||
# Full control passphrase: grants access to all endpoints including TX/PTT (control role)
|
||||
# Leave unset to disable control access
|
||||
# control_passphrase = "full-control-passphrase"
|
||||
|
||||
# Enforce TX/PTT access control (default: true)
|
||||
# When true, TX/PTT endpoints return 404 to authenticated users without control role
|
||||
rx_passphrase = "rx-passphrase-example"
|
||||
control_passphrase = "control-passphrase-example"
|
||||
tx_access_control_enabled = true
|
||||
|
||||
# Session time-to-live in minutes (default: 480 = 8 hours)
|
||||
session_ttl_min = 480
|
||||
|
||||
# Set Secure flag on session cookie (default: false)
|
||||
# Should be true if served over HTTPS; false for HTTP/localhost
|
||||
cookie_secure = false
|
||||
|
||||
# Cookie SameSite attribute: Strict, Lax (default), or None
|
||||
cookie_same_site = "Lax"
|
||||
|
||||
[trx-client.frontends.rigctl]
|
||||
# Enable rigctl-compatible TCP interface (hamlib compatible)
|
||||
enabled = false
|
||||
listen = "127.0.0.1"
|
||||
port = 4532
|
||||
|
||||
[trx-client.frontends.rigctl.rig_ports]
|
||||
|
||||
[trx-client.frontends.http_json]
|
||||
# Enable JSON-over-TCP control interface
|
||||
enabled = true
|
||||
listen = "127.0.0.1"
|
||||
# Set to 0 to bind an ephemeral port
|
||||
port = 0
|
||||
# List of accepted bearer tokens (empty = no auth)
|
||||
# auth.tokens = ["example-token"]
|
||||
|
||||
[trx-client.frontends.http_json.auth]
|
||||
tokens = []
|
||||
|
||||
[trx-client.frontends.audio]
|
||||
# Enable remote audio stream and decode transport
|
||||
enabled = true
|
||||
# Remote trx-server audio port
|
||||
server_port = 4531
|
||||
# Optional per-rig audio ports for multi-rig servers:
|
||||
# rig_ports.ft817 = 4531
|
||||
# rig_ports.airspyhf = 4532
|
||||
|
||||
[trx-client.frontends.audio.rig_ports]
|
||||
|
||||
[trx-client.frontends.audio.bridge]
|
||||
# Enable local cpal bridge for WSJT-X virtual audio routing
|
||||
enabled = false
|
||||
# Optional exact output device name for RX playback
|
||||
# rx_output_device = "BlackHole 2ch"
|
||||
# Optional exact input device name for TX capture
|
||||
# tx_input_device = "BlackHole 2ch"
|
||||
# Playback/capture gain multipliers
|
||||
bitrate_bps = 192000
|
||||
rx_gain = 1.0
|
||||
tx_gain = 1.0
|
||||
|
||||
Reference in New Issue
Block a user