Files
trx-rs/VCHANNELS.md
T
sjg 2f115fbec3 [docs](trx-rs): add virtual channels design document
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>
2026-03-11 00:17:52 +01:00

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:

  1. Switches the picker to channel 0
  2. Shows a brief toast: "Virtual channel removed — switched to primary"

Implementation Order

  1. SdrPipeline dynamic channelsadd_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 eventschannels snapshot on connect, channel-updated, channel-evicted
  5. Frontend — channel picker, +/✕ buttons, VFO/mode reflecting selected channel