diff --git a/VCHANNELS.md b/VCHANNELS.md new file mode 100644 index 0000000..4d008f5 --- /dev/null +++ b/VCHANNELS.md @@ -0,0 +1,247 @@ +# 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": "", "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, // index 0 = primary + max_channels: usize, + // DSP handles indexed by position + dsp_handles: Vec, +} +``` + +### 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>>) + ├─ 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>` +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