Skip to content

fix(desktop): keep the huddle playout tick on schedule on Windows - #5973

Open
kaalph wants to merge 2 commits into
block:mainfrom
kaalph:fix-windows-playout-tick
Open

fix(desktop): keep the huddle playout tick on schedule on Windows#5973
kaalph wants to merge 2 commits into
block:mainfrom
kaalph:fix-windows-playout-tick

Conversation

@kaalph

@kaalph kaalph commented Aug 15, 2026

Copy link
Copy Markdown

Huddle audio on my Windows 11 box was constantly choppy — metallic voices, dropped words, every call, every peer. Sounded exactly like the reports in #2652. Network was my first suspect and it was a dead end: I measured the incoming frame stream over ten minutes and got p99 inter-frame gap of 22 ms with zero losses. So the frames arrive fine and something eats them locally.

Root cause is the 10 ms playout tick in run_playout_recv_loop. It uses MissedTickBehavior::Delay, and Delay 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). Put those together and the loop settles at ~62 pulls per second instead of 100. The per-peer rodio queues run dry and the mixer plays silence gaps, while NetEq sits at its 200 ms cap and time-compresses playback forever — that's the robot voice. #4281 later moved the drop threshold to speed recovery, but the tick rate itself never changed, which would be why the reports kept coming.

To verify I wrote a small standalone program with exactly this interval setup and ran it on Windows 11 (IoT LTSC 2024): 62.4 ticks/s with Delay at default resolution, 100.2 ticks/s with timeBeginPeriod(1) active and Burst. The machine dependence falls out of this too — any other process can raise the global timer resolution, so some machines never show the bug.

The fix does two things:

  1. Raises the timer resolution to 1 ms for the lifetime of the playout loop, handed back through a drop guard (timeEndPeriod). Gated behind #[cfg(windows)], so other platforms are untouched.
  2. Switches the playout tick to Burst, so missed ticks are made up instead of silently lost. The short catch-up bursts stay bounded by the queue recovery that's already there (hysteresis 10→4, emergency trim at 30).

We've been running this in real calls since yesterday and the audio is clean — no gaps, no acceleration, and I couldn't hear any regression. I haven't exercised macOS or Linux beyond the fact that the resolution guard doesn't compile there and Burst only changes behavior when ticks are actually missed.

The unsafe blocks are the two winmm FFI calls; there was no way around them that I could find, and the desktop crate already does OS-level FFI the same way in mouse_nav.rs and shutdown.rs.

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 block#2652; block#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 <alexsanker@hotmail.com>
@kaalph
kaalph requested a review from a team as a code owner August 15, 2026 17:31

@themiguelamador themiguelamador left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two blocking findings:

  1. The new WinMM calls introduce production unsafe blocks, which violates this repository's no-new-unsafe gate. They also ignore timeBeginPeriod failure and unconditionally call timeEndPeriod, so the begin/end contract is not reliably balanced.
  2. MissedTickBehavior::Burst leaves catch-up unbounded. After suspend or a long runtime stall, the biased select loop can replay a very large 10 ms backlog and monopolize playout; the player queue high-water mark bounds queued audio, not timer iterations.

I fixed both in Complear/buzz@abbb15ce5 (branch review/pr-5973-fix): Windows now uses a safe per-timer high-resolution waitable timer, and stale catch-up is reset after 300 ms while short catch-up remains intact. Added focused deadline tests. Verification: full Tauri suite passed before the reset-only refinement (2442 passed, 15 ignored); strict Clippy and formatting pass after it; the Windows timer API path cross-compiles for x86_64-pc-windows-msvc. A final focused relink was blocked by the Sherpa prebuilt archive download timing out after its local cache disappeared.

…h-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 <alexsanker@hotmail.com>
@kaalph

kaalph commented Aug 16, 2026

Copy link
Copy Markdown
Author

Thanks for the look. Both points are addressed in 15a1b23.

On the begin/end contract: fair catch. The guard now only gets created when timeBeginPeriod(1) actually returns TIMERR_NOERROR, so timeEndPeriod can't fire unpaired anymore. If the call fails we just run at default resolution and Burst still recovers the mean rate.

On the unbounded catch-up: agreed, that was a real gap. A tick arriving more than 300 ms after the previous one now resets the interval instead of replaying the backlog. NetEq holds at most 200 ms of audio, so anything older than that would only have drained silence while hogging the biased select — dropping it and realigning the cadence is strictly better. Short catch-up (the case the fix is actually for) is untouched.

On the unsafe blocks: I don't see a way to raise the multimedia timer resolution without crossing the Win32 FFI boundary, and that boundary is unsafe by definition — a waitable timer via CreateWaitableTimerExW would be the same kind of call, just more of them. The desktop crate already does OS-level FFI this way in mouse_nav.rs, shutdown.rs and the macOS notification code, so I kept it to the two minimal winmm calls. If the maintainers prefer a different mechanism here I'm happy to rework it.

One thing I couldn't do: your Complear/buzz@abbb15ce5 link 404s for me, so I wasn't able to compare against your branch. If it's public somewhere else, point me at it.

Verified on my side: the touched paths type-check for x86_64-pc-windows-msvc, rustfmt is clean, and a full Windows build of the revised code went through our CI. The earlier revision has been running in real calls on Windows 11 since the 14th without regressions; I'll keep running the new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants