From 3442a7f7d105eb85f0658028218eaddc1f0ce6d0 Mon Sep 17 00:00:00 2001 From: kaalph <138721439+kaalph@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:30:32 +0200 Subject: [PATCH 1/2] fix(desktop): keep the huddle playout tick on schedule on Windows The 10 ms playout tick uses MissedTickBehavior::Delay, which never shortens the ticks that follow a missed one. Windows timers default to a 15.6 ms resolution and tokio intervals fire ~14.6 ms late there on average (tokio-rs/tokio#5021), so the loop settles at ~62 of the 100 pulls/s the pipeline needs. The per-peer rodio queues run dry (audible gaps, dropped words) while NetEq stays full and time-compresses playback (metallic, sped-up voices) - on every peer, regardless of network quality. Matches the choppy-audio reports in #2652; #4281 changed the drop threshold but not the tick rate. Measured on Windows 11 with a standalone reproduction of this loop: 62.4 ticks/s with Delay at default resolution, 100.2 ticks/s with timeBeginPeriod(1) raised for the loop lifetime and Burst making up missed ticks. Catch-up bursts are absorbed by the existing queue recovery (hysteresis 10-4, emergency trim at 30). Signed-off-by: kaalph <138721439+kaalph@users.noreply.github.com> --- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/src/huddle/playout.rs | 35 ++++++++++++++++++++----- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 527690df14f..68ff023740b 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -63,7 +63,7 @@ user-idle = { version = "0.6", default-features = false } plist = "1" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } +windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation", "Win32_Media"] } keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true } user-idle = { version = "0.6", default-features = false } diff --git a/desktop/src-tauri/src/huddle/playout.rs b/desktop/src-tauri/src/huddle/playout.rs index 346b425aec0..caac0c8bea3 100644 --- a/desktop/src-tauri/src/huddle/playout.rs +++ b/desktop/src-tauri/src/huddle/playout.rs @@ -192,14 +192,35 @@ pub(crate) async fn run_playout_recv_loop( let mut speaker_level_tick = tokio::time::interval(std::time::Duration::from_millis(SPEAKER_LEVEL_TICK_MS)); speaker_level_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Windows timers default to a 15.6 ms resolution, and tokio intervals + // fire ~14.6 ms late there on average (tokio-rs/tokio#5021). At that + // resolution the 10 ms playout tick below manages only ~62 of the 100 + // required pulls per second (measured on Windows 11: 62.4 ticks/s + // without the raised resolution, 100.2 with it). Raise the resolution + // to 1 ms for the lifetime of the playout loop and hand it back on exit. + #[cfg(windows)] + let _timer_resolution = { + struct TimerResolutionGuard; + impl Drop for TimerResolutionGuard { + fn drop(&mut self) { + unsafe { windows_sys::Win32::Media::timeEndPeriod(1) }; + } + } + unsafe { windows_sys::Win32::Media::timeBeginPeriod(1) }; + TimerResolutionGuard + }; + let mut playout_tick = tokio::time::interval(std::time::Duration::from_millis(PLAYOUT_TICK_MS)); - // `Delay` (not `Skip`) so a brief stall in another select arm — e.g. the - // ws_tx_for_pongs mutex contending with the encode-side task on a Ping — - // doesn't drop a playout tick outright. Dropped ticks would leave the - // per-peer Player queues empty for 10 ms and the device mixer would - // produce audible silence. `Delay` catches up immediately when the loop - // returns to the select. - playout_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // `Burst` rather than `Delay` or `Skip`. `Delay` never shortens the + // ticks that follow a missed one ("ticks are not shortened"), so once + // the timer resolves coarsely or the WebView loads the core, the mean + // pull rate stays below 100/s for good: the per-peer Player queues + // starve (audible gaps, dropped words) while NetEq stays full and + // time-compresses playback indefinitely (metallic, sped-up voices). + // `Burst` makes up missed ticks immediately and holds the mean rate; + // the short catch-up bursts are absorbed by the existing queue + // recovery (hysteresis 10→4, emergency trim at 30). + playout_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Burst); loop { tokio::select! { From 3d28f65b48863b1a127e350b483c63171096200f Mon Sep 17 00:00:00 2001 From: kaalph <138721439+kaalph@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:48:18 +0200 Subject: [PATCH 2/2] fix(desktop): balance the timer-resolution guard and bound Burst catch-up Review follow-up. timeEndPeriod is now only paired with a timeBeginPeriod that actually succeeded (TIMERR_NOERROR), instead of firing unconditionally from the drop guard. And Burst catch-up is bounded: a tick gap beyond 300 ms (suspend, long runtime stall) resets the interval and drops the backlog instead of replaying it - NetEq holds at most 200 ms of audio, so a backlog older than that only replays silence and stalls the select loop. Signed-off-by: kaalph <138721439+kaalph@users.noreply.github.com> --- desktop/src-tauri/src/huddle/playout.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/huddle/playout.rs b/desktop/src-tauri/src/huddle/playout.rs index caac0c8bea3..0ef5b497edd 100644 --- a/desktop/src-tauri/src/huddle/playout.rs +++ b/desktop/src-tauri/src/huddle/playout.rs @@ -44,6 +44,10 @@ const SPEAKER_LEVEL_TICK_MS: u64 = 50; const FRAME_WINDOW: std::time::Duration = std::time::Duration::from_millis(500); /// Playout clock: NetEq emits 10 ms frames, so we tick at 10 ms. const PLAYOUT_TICK_MS: u64 = 10; +/// Gap beyond which a `Burst` catch-up is pointless: NetEq holds at most +/// 200 ms of audio, so a backlog older than this only replays silence and +/// stalls the select loop. Detected gaps reset the interval instead. +const PLAYOUT_STALE_RESET_MS: u64 = 300; /// How long after the last received packet we keep pulling frames out of a /// peer's NetEq into its rodio Player. NetEq always emits a frame on every @@ -206,8 +210,11 @@ pub(crate) async fn run_playout_recv_loop( unsafe { windows_sys::Win32::Media::timeEndPeriod(1) }; } } - unsafe { windows_sys::Win32::Media::timeBeginPeriod(1) }; - TimerResolutionGuard + // TIMERR_NOERROR == 0. Only pair a `timeEndPeriod` with a begin that + // actually succeeded; on failure we run at the default resolution and + // `Burst` below still recovers the mean rate. + (unsafe { windows_sys::Win32::Media::timeBeginPeriod(1) } == 0) + .then_some(TimerResolutionGuard) }; let mut playout_tick = tokio::time::interval(std::time::Duration::from_millis(PLAYOUT_TICK_MS)); @@ -219,14 +226,25 @@ pub(crate) async fn run_playout_recv_loop( // time-compresses playback indefinitely (metallic, sped-up voices). // `Burst` makes up missed ticks immediately and holds the mean rate; // the short catch-up bursts are absorbed by the existing queue - // recovery (hysteresis 10→4, emergency trim at 30). + // recovery (hysteresis 10→4, emergency trim at 30). Catch-up is only + // useful within NetEq's reach though (200 ms): after a suspend or a + // stall longer than PLAYOUT_STALE_RESET_MS the backlog is stale, so we + // drop it and realign the cadence instead of replaying it. playout_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Burst); + let mut last_playout_tick = tokio::time::Instant::now(); loop { tokio::select! { biased; _ = cancel.cancelled() => break, _ = playout_tick.tick() => { + let now = tokio::time::Instant::now(); + if now.duration_since(last_playout_tick) + > std::time::Duration::from_millis(PLAYOUT_STALE_RESET_MS) + { + playout_tick.reset(); + } + last_playout_tick = now; // Drain one 10 ms frame from each *active* peer's NetEq into // its Player. NetEq always emits a frame (Expand/silence when // empty), so for peers that recently sent we keep the device