Skip to content

Commit 25b380f

Browse files
fix(huddle): per-peer rodio Player + NetEq jitter buffer for concurrent playout
Rewrites the WS receive path to fix the FIFO bug pinned by tests/rodio_mixer_diagnostic.rs: * OLD shape: one rodio::Player on the device mixer, every peer's decoded 20 ms frame appended to it. rodio::Player is a single-queue FIFO; 3+ simultaneous speakers serialized — peer A's frame, then peer B's, then peer C's — with audible voice-flipping every 20 ms and unbounded queue growth. * NEW shape: each peer gets its own rodio::Player (queue) added to the same device mixer. The device mixer sums concurrent peers correctly. A 10 ms playout tick drains one frame from every active peer's NetEq into its Player; each per-peer queue stays at ~10 ms depth. Each peer's NetEq instance (from huddle::jitter): * uses peer_index as a stable synthetic SSRC, * has its full state dropped + recreated on 'left' or peer_index reuse with a different pubkey (flush-on-rejoin), * receives synthesized seq + 48 kHz RTP-style timestamps for now; protocol v2 (next commit) replaces these with sender-authored values. The receive task's body moves to a new huddle::playout module so relay_api.rs stays under the desktop file-size budget. relay_api now owns WS handshake + send-side encoding; playout owns the receive-side jitter/mixer state machine. WsStream + REMOTE_SPEECH_THRESHOLD are exposed pub(crate) for the split. TTS interrupt counting moves to packet-arrival time (was decode time); behaviorally identical (DTX silence frames never produced decoder output anyway, so they weren't counted before either). Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
1 parent a2d6319 commit 25b380f

4 files changed

Lines changed: 270 additions & 160 deletions

File tree

desktop/src-tauri/src/huddle/jitter.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ pub const FRAME_DURATION_MS: u32 = 20;
5050
/// 48 kHz media-time increment per encoder frame.
5151
pub const FRAME_TIMESTAMP_DELTA: u32 = SAMPLE_RATE_HZ / 1000 * FRAME_DURATION_MS; // 960
5252
/// NetEq returns 10 ms frames; this is the sample count per `get_audio` call.
53+
/// Kept as a documented constant for the protocol-v2 follow-up and consumers
54+
/// that want to size their own buffers around the playout contract.
55+
#[allow(dead_code)]
5356
pub const PLAYOUT_SAMPLES: usize = (SAMPLE_RATE_HZ as usize / 1000) * 10; // 480
5457

5558
/// Minimum jitter delay NetEq is allowed to converge to (ms).
@@ -185,7 +188,10 @@ impl PeerJitterBuffer {
185188

186189
/// True when NetEq's buffer has no packets queued and it would emit
187190
/// expand/silence on `get_audio` — useful to skip mixing for peers that
188-
/// haven't sent in a while.
191+
/// haven't sent in a while. Currently only exercised by tests; the
192+
/// playout loop always pulls a frame regardless to keep NetEq's clock
193+
/// advancing.
194+
#[allow(dead_code)]
189195
pub fn is_empty(&self) -> bool {
190196
self.neteq.is_empty()
191197
}

desktop/src-tauri/src/huddle/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub mod audio_output;
2828
pub mod jitter;
2929
pub mod models;
3030
pub mod pipeline;
31+
pub mod playout;
3132
pub mod pocket;
3233
pub mod preprocessing;
3334
pub mod relay_api;
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
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

Comments
 (0)