Describes the per-rig dynamic virtual channel architecture for SDR rigs: session binding via SSE session_id, channel lifecycle (ref-counted, auto-freed on last subscriber disconnect), center-freq conflict rules, per-channel audio WebSocket and decode SSE, frontend picker UI, and phased implementation plan. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
7.6 KiB
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):
[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
[
{
"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):
{ "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}
{ "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)
// 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:
- Switches the picker to channel 0
- Shows a brief toast: "Virtual channel removed — switched to primary"
Implementation Order
SdrPipelinedynamic channels —add_channel()/remove_channel()via command channel to IQ read threadChannelManagerintrx-server— tracks channels, ref-counts, DSP handles- HTTP API in
trx-frontend-http— channel CRUD, audio WebSocket per channel - SSE events —
channelssnapshot on connect,channel-updated,channel-evicted - Frontend — channel picker, +/✕ buttons, VFO/mode reflecting selected channel