From 4e93dcc82ad59e1f0f0869b254741c64e603a871 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Wed, 11 Mar 2026 20:24:48 +0100 Subject: [PATCH] [fix](trx-frontend-http): fix virtual channel audio streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs prevented vchan audio from working reliably: 1. vchan.js: `vchanReconnectAudio` returned before updating `_audioChannelOverride` when audio was inactive. Switching to a virtual channel with audio off then starting audio manually would connect to the primary channel instead. Move the override update before the rxActive guard so it always reflects the active channel. 2. audio.rs: `audio_ws` returned 404 immediately if the channel was not yet in `vchan_audio`. The entry is populated when `AUDIO_MSG_VCHAN_ALLOCATED` arrives from the audio TCP client, which can lag the HTTP allocation by up to ~100 ms. Replace the instant 404 with a 2-second polling loop (50 ms intervals) so the WebSocket upgrade waits for the channel to be ready. 3. vchan.rs: `release_session_on_rig` evicted zero-subscriber channels silently — no `VChanAudioCmd::Remove` was sent. Collect evicted channel IDs before retain() and send Remove commands so the server-side DSP pipeline and Opus encoder are torn down properly on session disconnect. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Stan Grams --- .../assets/web/plugins/vchan.js | 14 +++++++---- .../trx-frontend-http/src/audio.rs | 24 ++++++++++++------- .../trx-frontend-http/src/vchan.rs | 12 ++++++++++ 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vchan.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vchan.js index 9bea36f..ac91822 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vchan.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vchan.js @@ -170,19 +170,23 @@ async function vchanSubscribe(channelId) { // Reconnect the audio WebSocket to the appropriate endpoint: // - virtual channel: /audio?channel_id= // - primary channel: /audio (no param) -// Only reconnects if RX audio is currently active. +// Always updates _audioChannelOverride so that starting audio later +// connects to the correct channel. Only reconnects if RX audio is active. function vchanReconnectAudio() { - if (typeof rxActive === "undefined" || !rxActive) return; - // Set the channel override so startRxAudio picks up the right URL. + // Always update the override so startRxAudio picks up the right URL, + // even when audio isn't currently running. const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null; if (typeof _audioChannelOverride !== "undefined") { _audioChannelOverride = ch ? ch.id : null; } + if (typeof rxActive === "undefined" || !rxActive) return; if (typeof stopRxAudio === "function") stopRxAudio(); - // Small delay so the server has time to set up the per-channel encoder. + // Delay so the server has time to set up the per-channel encoder. + // The server-side audio_ws handler also polls for up to 2 s, so this + // just needs to be long enough for the WS upgrade to reach the server. setTimeout(() => { if (typeof startRxAudio === "function") startRxAudio(); - }, 200); + }, 300); } // Called by app.js from applyCapabilities(). diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs index 4e97d1c..15dd9f6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs @@ -380,16 +380,24 @@ pub async fn audio_ws( } // If a channel_id is specified, subscribe to the per-channel broadcaster. - // Otherwise fall back to the primary RX broadcast. + // The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives + // from the server, which may lag the HTTP allocation by up to ~100 ms. + // Poll for up to 2 s so a tight JS timer doesn't race and get a 404. let rx_sub: broadcast::Receiver = if let Some(ch_id) = query.channel_id { - match context.vchan_audio.read() { - Ok(map) => match map.get(&ch_id) { - Some(tx) => tx.subscribe(), - None => { - return Ok(HttpResponse::NotFound().body("channel not found")); + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match context.vchan_audio.read() { + Ok(map) => { + if let Some(tx) = map.get(&ch_id) { + break tx.subscribe(); + } } - }, - Err(_) => return Ok(HttpResponse::InternalServerError().finish()), + Err(_) => return Ok(HttpResponse::InternalServerError().finish()), + } + if Instant::now() >= deadline { + return Ok(HttpResponse::NotFound().body("channel not found")); + } + tokio::time::sleep(Duration::from_millis(50)).await; } } else { let Some(rx) = context.audio_rx.as_ref() else { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/vchan.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/vchan.rs index f12563e..09bcbfa 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/vchan.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/vchan.rs @@ -318,6 +318,12 @@ impl ClientChannelManager { changed = true; } } + // Collect IDs of non-permanent channels about to be evicted (0 subscribers). + let to_remove: Vec = channels + .iter() + .filter(|c| !c.permanent && c.session_ids.is_empty()) + .map(|c| c.id) + .collect(); // Remove non-permanent channels with no subscribers. let before = channels.len(); channels.retain(|c| c.permanent || !c.session_ids.is_empty()); @@ -327,6 +333,12 @@ impl ClientChannelManager { if changed { self.broadcast_change(rig_id, channels); } + drop(rigs); + // Notify the audio-client task so it can tear down the server-side + // DSP pipeline and Opus encoder for each evicted channel. + for id in to_remove { + self.send_audio_cmd(VChanAudioCmd::Remove(id)); + } } /// Explicitly delete a channel by UUID (any session may do this).