[feat](trx-rs): persistent multi-channel virtual channels with OOB eviction

Allow users to allocate multiple virtual channels independently of
browser tab count. Channels survive SDR center-frequency retuning as
long as they stay within the capture bandwidth; channels that fall
outside the SDR span are automatically destroyed.

Changes:
- trx-core: add AUDIO_MSG_VCHAN_DESTROYED (0x12) wire constant;
  add default subscribe_destroyed() to VirtualChannelManager trait
- trx-backend-soapysdr: update_center_hz() detects OOB channels,
  removes them, fires destroyed_tx broadcast; add destroyed_sender()
  and subscribe_destroyed() override
- trx-server/audio: recv_destroyed() helper avoids select! busy-loop
  for non-SDR backends; send AUDIO_MSG_VCHAN_DESTROYED to client when
  a channel is evicted server-side
- trx-client/audio_client: persist active_subs across TCP reconnects,
  re-subscribe on reconnect; handle AUDIO_MSG_VCHAN_DESTROYED by
  pruning vchan_audio map and forwarding UUID via vchan_destroyed_tx
- trx-frontend/lib: add vchan_destroyed broadcast field to
  FrontendRuntimeContext
- trx-client/main: wire vchan_destroyed_tx into audio client and
  frontend runtime context
- trx-frontend-http/vchan: remove per-session one-channel limit in
  allocate(); replace auto-evict in release_session_on_rig() with
  subscriber-count-only update; add remove_by_uuid() for server-
  triggered OOB destruction (skips redundant VChanAudioCmd::Remove)
- trx-frontend-http/server: spawn background task that forwards
  vchan_destroyed broadcast to ClientChannelManager.remove_by_uuid()

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:50:49 +01:00
parent 4e93dcc82a
commit 60267d450b
9 changed files with 215 additions and 45 deletions
+41 -1
View File
@@ -27,7 +27,7 @@ use trx_core::audio::{
AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE, AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT8_DECODE,
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_RX_FRAME,
AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE,
AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE,
AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WSPR_DECODE,
};
use trx_core::vchan::SharedVChanManager;
@@ -1823,6 +1823,23 @@ pub async fn run_audio_listener(
Ok(())
}
/// Returns the next destroyed-channel UUID, or `pending()` when the receiver
/// has been closed or is not present. Disables itself on close so the
/// enclosing `select!` never busy-loops on a dead channel.
async fn recv_destroyed(rx: &mut Option<broadcast::Receiver<Uuid>>) -> Option<Uuid> {
match rx {
None => std::future::pending::<Option<Uuid>>().await,
Some(r) => match r.recv().await {
Ok(uuid) => Some(uuid),
Err(broadcast::error::RecvError::Lagged(_)) => None,
Err(broadcast::error::RecvError::Closed) => {
*rx = None;
std::future::pending::<Option<Uuid>>().await
}
},
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_audio_client(
socket: TcpStream,
@@ -1921,6 +1938,10 @@ async fn handle_audio_client(
let opus_sample_rate = stream_info.sample_rate;
let opus_channels = stream_info.channels;
// Subscribe to server-side channel destruction events (SDR rigs only).
let mut destroyed_rx: Option<broadcast::Receiver<Uuid>> =
vchan_manager.as_ref().map(|m| m.subscribe_destroyed());
let rx_handle = tokio::spawn(async move {
// UUID → JoinHandle of per-channel Opus encoder task.
let mut vchan_tasks: std::collections::HashMap<Uuid, tokio::task::JoinHandle<()>> =
@@ -2047,6 +2068,25 @@ async fn handle_audio_client(
}
}
}
uuid = recv_destroyed(&mut destroyed_rx) => {
if let Some(uuid) = uuid {
// Stop encoding for this channel.
if let Some(h) = vchan_tasks.remove(&uuid) {
h.abort();
}
// Notify the client.
if let Err(e) = write_vchan_uuid_msg(
&mut writer_for_rx,
AUDIO_MSG_VCHAN_DESTROYED,
uuid,
)
.await
{
warn!("Audio vchan destroyed write to {} failed: {}", peer, e);
break;
}
}
}
}
}
@@ -87,6 +87,8 @@ pub struct SdrVirtualChannelManager {
/// Maximum total channels including the primary (enforced on `add_channel`).
max_total: usize,
channels: RwLock<Vec<ManagedChannel>>,
/// Fires whenever a channel is destroyed (e.g. went out of SDR bandwidth).
destroyed_tx: broadcast::Sender<Uuid>,
}
impl SdrVirtualChannelManager {
@@ -124,15 +126,22 @@ impl SdrVirtualChannelManager {
permanent: true,
};
let (destroyed_tx, _) = broadcast::channel::<Uuid>(16);
Self {
center_hz: pipeline.shared_center_hz.clone(),
pipeline,
fixed_slot_count,
max_total: max_total.max(1),
channels: RwLock::new(vec![primary]),
destroyed_tx,
}
}
pub fn destroyed_sender(&self) -> broadcast::Sender<Uuid> {
self.destroyed_tx.clone()
}
fn half_span_hz(&self) -> i64 {
i64::from(self.pipeline.sdr_sample_rate) / 2
}
@@ -141,16 +150,28 @@ impl SdrVirtualChannelManager {
/// Recomputes the IF offset for every virtual channel.
pub fn update_center_hz(&self, new_center_hz: i64) {
self.center_hz.store(new_center_hz, Ordering::Relaxed);
let channels = self.channels.read().unwrap();
let dsps = self.pipeline.channel_dsps.read().unwrap();
for ch in channels.iter().filter(|c| !c.permanent) {
let new_if_hz = ch.freq_hz as i64 - new_center_hz;
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
dsp_arc
.lock()
.unwrap()
.set_channel_if_hz(new_if_hz as f64);
let half_span = self.half_span_hz();
// Single pass under read lock: update in-band IF offsets and collect OOB IDs.
let oob_ids: Vec<Uuid> = {
let channels = self.channels.read().unwrap();
let dsps = self.pipeline.channel_dsps.read().unwrap();
let mut oob = Vec::new();
for ch in channels.iter().filter(|c| !c.permanent) {
let new_if_hz = ch.freq_hz as i64 - new_center_hz;
if new_if_hz.unsigned_abs() as i64 > half_span {
oob.push(ch.id);
} else if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
dsp_arc.lock().unwrap().set_channel_if_hz(new_if_hz as f64);
}
}
oob
}; // read locks released here
// Destroy OOB channels and notify subscribers.
for id in oob_ids {
let _ = self.remove_channel(id); // acquires write lock internally
let _ = self.destroyed_tx.send(id);
}
}
@@ -301,6 +322,10 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
self.max_total
}
fn subscribe_destroyed(&self) -> broadcast::Receiver<Uuid> {
self.destroyed_tx.subscribe()
}
fn ensure_channel_pcm(
&self,
id: Uuid,