|
| 1 | +//! Receive-side playout loop for the huddle audio relay. |
| 2 | +//! |
| 3 | +//! Owns the per-peer state map (one `NetEq` + one `rodio::Player` per remote |
| 4 | +//! peer), the 10 ms playout clock, and the 500 ms active-speaker tick. Sibling |
| 5 | +//! to [`relay_api`](super::relay_api), which keeps the encode/send half. |
| 6 | +//! |
| 7 | +//! ## Architecture |
| 8 | +//! |
| 9 | +//! ```text |
| 10 | +//! WS binary frame ──► insert_packet ──► NetEq jitter buffer |
| 11 | +//! │ |
| 12 | +//! playout_tick (10 ms) ──┘──► get_audio ─► per-peer |
| 13 | +//! rodio::Player |
| 14 | +//! │ |
| 15 | +//! ▼ |
| 16 | +//! device mixer (sums |
| 17 | +//! concurrent peers) |
| 18 | +//! ``` |
| 19 | +//! |
| 20 | +//! The pre-fix shape used a single `rodio::Player` shared across every peer. |
| 21 | +//! `Player` is a FIFO queue, so 3+ simultaneous speakers serialized into one |
| 22 | +//! voice flipping speakers every 20 ms with unbounded queue growth. See |
| 23 | +//! `desktop/src-tauri/tests/rodio_mixer_diagnostic.rs` for the deterministic |
| 24 | +//! repro that pins this diagnosis in CI. |
| 25 | +
|
| 26 | +use std::sync::atomic::{AtomicBool, Ordering}; |
| 27 | +use std::sync::Arc; |
| 28 | + |
| 29 | +use futures_util::{SinkExt, StreamExt}; |
| 30 | +use tokio_tungstenite::tungstenite::Message as WsMsg; |
| 31 | +use tokio_util::sync::CancellationToken; |
| 32 | + |
| 33 | +use super::jitter::{PeerJitterBuffer, FRAME_TIMESTAMP_DELTA, SAMPLE_RATE_HZ}; |
| 34 | +use super::relay_api::{WsStream, REMOTE_SPEECH_THRESHOLD}; |
| 35 | + |
| 36 | +/// Speaker-tick window for emitting `huddle-active-speakers`. Active set is |
| 37 | +/// cleared each tick — peers that didn't send a frame in the last window are |
| 38 | +/// considered silent. |
| 39 | +const SPEAKER_TICK_MS: u64 = 500; |
| 40 | +/// Per-peer arrival window for the TTS interrupt frame counter. |
| 41 | +const FRAME_WINDOW: std::time::Duration = std::time::Duration::from_millis(500); |
| 42 | +/// Playout clock: NetEq emits 10 ms frames, so we tick at 10 ms. |
| 43 | +const PLAYOUT_TICK_MS: u64 = 10; |
| 44 | + |
| 45 | +/// One remote peer's slot: jitter buffer, dedicated rodio Player, and the |
| 46 | +/// synthesized seq/timestamp pair we feed NetEq for v1 wire frames. |
| 47 | +/// |
| 48 | +/// On v1 wire (this commit) the protocol carries no per-frame seq/ts, so we |
| 49 | +/// generate them locally. The WebSocket is over TCP — frames arrive in order |
| 50 | +/// end-to-end — so monotonic-on-arrival is a safe approximation. Protocol v2 |
| 51 | +/// (next commit) replaces these with sender-authored values. |
| 52 | +struct PeerSlot { |
| 53 | + jitter: PeerJitterBuffer, |
| 54 | + player: rodio::Player, |
| 55 | + seq: u16, |
| 56 | + ts_48k: u32, |
| 57 | +} |
| 58 | + |
| 59 | +impl PeerSlot { |
| 60 | + fn new(peer_idx: u8, sink_mixer: &rodio::mixer::Mixer) -> Option<Self> { |
| 61 | + match PeerJitterBuffer::new(peer_idx) { |
| 62 | + Ok(jitter) => Some(Self { |
| 63 | + jitter, |
| 64 | + player: rodio::Player::connect_new(sink_mixer), |
| 65 | + seq: 0, |
| 66 | + ts_48k: 0, |
| 67 | + }), |
| 68 | + Err(e) => { |
| 69 | + eprintln!("sprout-desktop: jitter buffer init peer {peer_idx}: {e}"); |
| 70 | + None |
| 71 | + } |
| 72 | + } |
| 73 | + } |
| 74 | +} |
| 75 | + |
| 76 | +/// Drive the receive loop until cancelled or the WS closes. |
| 77 | +/// |
| 78 | +/// `ws_tx_for_pongs` is shared with the encode-side task and only used here to |
| 79 | +/// reply to Pings; it is locked briefly per Ping and never held across the |
| 80 | +/// audio fast path. |
| 81 | +#[allow(clippy::too_many_arguments)] |
| 82 | +pub(crate) async fn run_playout_recv_loop( |
| 83 | + mut ws_rx: futures_util::stream::SplitStream<WsStream>, |
| 84 | + ws_tx_for_pongs: Arc<tokio::sync::Mutex<futures_util::stream::SplitSink<WsStream, WsMsg>>>, |
| 85 | + sink_handle: rodio::MixerDeviceSink, |
| 86 | + cancel: CancellationToken, |
| 87 | + app_handle: Option<tauri::AppHandle>, |
| 88 | + initial_peers: Vec<(u8, String)>, |
| 89 | + tts_active: Arc<AtomicBool>, |
| 90 | + tts_cancel: Arc<AtomicBool>, |
| 91 | +) { |
| 92 | + use rodio::buffer::SamplesBuffer; |
| 93 | + use std::num::NonZero; |
| 94 | + |
| 95 | + let mut peers: std::collections::HashMap<u8, PeerSlot> = std::collections::HashMap::new(); |
| 96 | + let channels = NonZero::new(1u16).expect("1 is non-zero"); |
| 97 | + let rate = NonZero::new(SAMPLE_RATE_HZ).expect("48k is non-zero"); |
| 98 | + |
| 99 | + let mut index_to_pubkey: std::collections::HashMap<u8, String> = |
| 100 | + initial_peers.into_iter().collect(); |
| 101 | + let mut active_indices: std::collections::HashSet<u8> = std::collections::HashSet::new(); |
| 102 | + let mut frame_counts: std::collections::HashMap<u8, u16> = std::collections::HashMap::new(); |
| 103 | + let mut last_frame_reset = tokio::time::Instant::now(); |
| 104 | + let mut tts_was_active = false; |
| 105 | + |
| 106 | + let mut speaker_tick = tokio::time::interval(std::time::Duration::from_millis(SPEAKER_TICK_MS)); |
| 107 | + speaker_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); |
| 108 | + let mut playout_tick = tokio::time::interval(std::time::Duration::from_millis(PLAYOUT_TICK_MS)); |
| 109 | + playout_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); |
| 110 | + |
| 111 | + loop { |
| 112 | + tokio::select! { |
| 113 | + biased; |
| 114 | + _ = cancel.cancelled() => break, |
| 115 | + _ = playout_tick.tick() => { |
| 116 | + // Drain one 10 ms frame from each peer's NetEq into its Player. |
| 117 | + // NetEq's contract is to always emit a frame (Expand/silence |
| 118 | + // when empty), so the audio device's pull from the mixer never |
| 119 | + // starves. |
| 120 | + for (peer_idx, slot) in peers.iter_mut() { |
| 121 | + match slot.jitter.get_audio() { |
| 122 | + Ok((samples, _vad)) => { |
| 123 | + slot.player.append(SamplesBuffer::new(channels, rate, samples)); |
| 124 | + } |
| 125 | + Err(e) => { |
| 126 | + eprintln!( |
| 127 | + "sprout-desktop: jitter get_audio peer {peer_idx}: {e}" |
| 128 | + ); |
| 129 | + } |
| 130 | + } |
| 131 | + } |
| 132 | + } |
| 133 | + _ = speaker_tick.tick() => { |
| 134 | + if let Some(ref app) = app_handle { |
| 135 | + use tauri::Emitter; |
| 136 | + let pubkeys: Vec<String> = active_indices |
| 137 | + .iter() |
| 138 | + .filter_map(|idx| index_to_pubkey.get(idx).cloned()) |
| 139 | + .collect(); |
| 140 | + let _ = app.emit("huddle-active-speakers", &pubkeys); |
| 141 | + } |
| 142 | + active_indices.clear(); |
| 143 | + } |
| 144 | + msg = ws_rx.next() => { |
| 145 | + match msg { |
| 146 | + Some(Ok(WsMsg::Binary(data))) => { |
| 147 | + if data.len() < 2 { |
| 148 | + continue; |
| 149 | + } |
| 150 | + let peer_idx = data[0]; |
| 151 | + let opus_bytes = &data[1..]; |
| 152 | + active_indices.insert(peer_idx); |
| 153 | + |
| 154 | + // TTS interrupt frame counter — reset on TTS rising edge. |
| 155 | + let tts_now = tts_active.load(Ordering::Acquire); |
| 156 | + if tts_now && !tts_was_active { |
| 157 | + frame_counts.clear(); |
| 158 | + last_frame_reset = tokio::time::Instant::now(); |
| 159 | + } |
| 160 | + tts_was_active = tts_now; |
| 161 | + |
| 162 | + let slot = match peers.entry(peer_idx) { |
| 163 | + std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), |
| 164 | + std::collections::hash_map::Entry::Vacant(e) => { |
| 165 | + let Some(slot) = PeerSlot::new(peer_idx, sink_handle.mixer()) |
| 166 | + else { |
| 167 | + continue; |
| 168 | + }; |
| 169 | + e.insert(slot) |
| 170 | + } |
| 171 | + }; |
| 172 | + |
| 173 | + if let Err(err) = |
| 174 | + slot.jitter.insert_packet(slot.seq, slot.ts_48k, opus_bytes) |
| 175 | + { |
| 176 | + eprintln!( |
| 177 | + "sprout-desktop: jitter insert peer {peer_idx}: {err}" |
| 178 | + ); |
| 179 | + } else { |
| 180 | + slot.seq = slot.seq.wrapping_add(1); |
| 181 | + slot.ts_48k = slot.ts_48k.wrapping_add(FRAME_TIMESTAMP_DELTA); |
| 182 | + } |
| 183 | + |
| 184 | + if tts_now { |
| 185 | + if last_frame_reset.elapsed() >= FRAME_WINDOW { |
| 186 | + frame_counts.clear(); |
| 187 | + last_frame_reset = tokio::time::Instant::now(); |
| 188 | + } |
| 189 | + let count = frame_counts.entry(peer_idx).or_insert(0); |
| 190 | + *count = count.saturating_add(1); |
| 191 | + if *count >= REMOTE_SPEECH_THRESHOLD { |
| 192 | + tts_cancel.store(true, Ordering::Release); |
| 193 | + } |
| 194 | + } |
| 195 | + } |
| 196 | + Some(Ok(WsMsg::Text(text))) => { |
| 197 | + if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) { |
| 198 | + match v["type"].as_str() { |
| 199 | + Some("joined") => { |
| 200 | + if let Some(peer_list) = v["peers"].as_array() { |
| 201 | + for p in peer_list { |
| 202 | + if let (Some(pk), Some(idx)) = ( |
| 203 | + p["pubkey"].as_str(), |
| 204 | + p["peer_index"].as_u64(), |
| 205 | + ) { |
| 206 | + let key = idx as u8; |
| 207 | + // peer_index reuse with a new pubkey: |
| 208 | + // flush the old peer's NetEq + Player so |
| 209 | + // the next frame starts clean. |
| 210 | + if index_to_pubkey |
| 211 | + .get(&key) |
| 212 | + .map(|s| s.as_str()) |
| 213 | + != Some(pk) |
| 214 | + { |
| 215 | + peers.remove(&key); |
| 216 | + frame_counts.remove(&key); |
| 217 | + active_indices.remove(&key); |
| 218 | + } |
| 219 | + index_to_pubkey.insert(key, pk.to_string()); |
| 220 | + } |
| 221 | + } |
| 222 | + } |
| 223 | + } |
| 224 | + Some("left") => { |
| 225 | + if let Some(idx) = v["peer_index"].as_u64() { |
| 226 | + let key = idx as u8; |
| 227 | + index_to_pubkey.remove(&key); |
| 228 | + frame_counts.remove(&key); |
| 229 | + // Dropping Player detaches its queue from the |
| 230 | + // device mixer, freeing the per-peer slot. |
| 231 | + peers.remove(&key); |
| 232 | + } |
| 233 | + } |
| 234 | + _ => {} |
| 235 | + } |
| 236 | + } |
| 237 | + } |
| 238 | + Some(Ok(WsMsg::Ping(data))) => { |
| 239 | + let mut tx = ws_tx_for_pongs.lock().await; |
| 240 | + let _ = tx.send(WsMsg::Pong(data)).await; |
| 241 | + } |
| 242 | + Some(Ok(WsMsg::Close(_))) | None => break, |
| 243 | + Some(Ok(_)) => {} // non-binary/text frame |
| 244 | + Some(Err(_)) => break, |
| 245 | + } |
| 246 | + } |
| 247 | + } |
| 248 | + } |
| 249 | +} |
0 commit comments