[fix](trx-frontend-http): fix virtual channel audio streaming

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 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-11 20:24:48 +01:00
parent 6131d7a1d6
commit 4e93dcc82a
3 changed files with 37 additions and 13 deletions
@@ -170,19 +170,23 @@ async function vchanSubscribe(channelId) {
// Reconnect the audio WebSocket to the appropriate endpoint:
// - virtual channel: /audio?channel_id=<uuid>
// - 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().
@@ -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<Bytes> = if let Some(ch_id) = query.channel_id {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
match context.vchan_audio.read() {
Ok(map) => match map.get(&ch_id) {
Some(tx) => tx.subscribe(),
None => {
Ok(map) => {
if let Some(tx) = map.get(&ch_id) {
break tx.subscribe();
}
}
Err(_) => return Ok(HttpResponse::InternalServerError().finish()),
}
if Instant::now() >= deadline {
return Ok(HttpResponse::NotFound().body("channel not found"));
}
},
Err(_) => return Ok(HttpResponse::InternalServerError().finish()),
tokio::time::sleep(Duration::from_millis(50)).await;
}
} else {
let Some(rx) = context.audio_rx.as_ref() else {
@@ -318,6 +318,12 @@ impl ClientChannelManager {
changed = true;
}
}
// Collect IDs of non-permanent channels about to be evicted (0 subscribers).
let to_remove: Vec<Uuid> = 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).