From fc24dc37edb2f4d7207fd429ebd6fcfee9e5791f Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 12 Mar 2026 21:55:54 +0100 Subject: [PATCH] [feat](trx-rs): add settings tab and virtual audio plan Move Scheduler under a new Settings tab in the HTTP frontend. Add the virtual-channel audio implementation plan document. Co-authored-by: OpenAI Codex Signed-off-by: Stan Grams --- VIRTAUDIO.md | 188 +++++++++++++++++ .../trx-frontend-http/assets/web/app.js | 4 +- .../trx-frontend-http/assets/web/index.html | 195 +++++++++--------- .../trx-frontend-http/assets/web/style.css | 2 +- 4 files changed, 291 insertions(+), 98 deletions(-) create mode 100644 VIRTAUDIO.md diff --git a/VIRTAUDIO.md b/VIRTAUDIO.md new file mode 100644 index 0000000..8c6ea49 --- /dev/null +++ b/VIRTAUDIO.md @@ -0,0 +1,188 @@ +# 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>) + +trx-server/src/main.rs + subscribe_pcm(slot 0) → Opus encode → rx_audio_tx (broadcast::Sender) + +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) + +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) │ + │ 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 + on 0x0c (allocated): publish UUID to per-channel map + +FrontendRuntimeContext + audio_rx: Option> (primary, unchanged) + vchan_audio: Arc>>> ← NEW + +ClientChannelManager (trx-frontend-http/src/vchan.rs) + allocate(): after creating local entry, sends VCHAN_SUB via new + vchan_audio_tx: mpsc::Sender ← NEW + delete_channel(): sends VCHAN_UNSUB + expose: subscribe_audio(channel_id) → Option> + +audio_ws() (trx-frontend-http/src/audio.rs) + accepts ?channel_id= 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` from `RigHandle`. +- Spawn a `VChanAudioMixer` task: + - Holds `HashMap)>`. + - 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` parameter + (where `VChanAudioEvent = Allocated(Uuid, broadcast::Sender) | 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>>>` + shared between audio_client task and FrontendRuntimeContext. +- Add an `mpsc::Sender` 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>>>, + pub vchan_audio_cmd: Option>, + ``` +- 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>`: + 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` from query string. +- If `Some(uuid)`: + - Look up `context.vchan_audio.read()[uuid]` → `broadcast::Sender`. + - 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 diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index 2c96d5c..583989b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -3523,7 +3523,7 @@ if (spectrumBwSweetBtn) { } // --- Tab navigation --- -const TAB_ORDER = ["main", "bookmarks", "decoders", "map", "scheduler", "about"]; +const TAB_ORDER = ["main", "bookmarks", "decoders", "map", "settings", "about"]; function navigateToTab(name) { if (authEnabled && !authRole && name !== "main") return; @@ -5613,7 +5613,7 @@ window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null, applyMapFilter(); }; -// --- Sub-tab navigation (Decoders tab) --- +// --- Sub-tab navigation --- document.querySelectorAll(".sub-tab-bar").forEach((bar) => { bar.addEventListener("click", (e) => { const btn = e.target.closest(".sub-tab[data-subtab]"); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 131338e..eb27bec 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -46,9 +46,9 @@ Map - + +
+
+
-
-
- - - - -
-
- - diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index 81c8e7f..0b62825 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -1983,7 +1983,7 @@ button:focus-visible, input:focus-visible, select:focus-visible { bottom: calc(0.55rem + env(safe-area-inset-bottom)); z-index: 30; display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 0.25rem; padding: 0.38rem; border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent);