fix(client/audio): the PipeWire callback stops filling the buffer ceiling every cycle
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m12s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 23s
ci / web (pull_request) Successful in 1m0s
ci / docs-site (pull_request) Successful in 1m8s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m18s
ci / rust-arm64 (pull_request) Successful in 1m40s
android / android (pull_request) Successful in 5m24s
ci / rust (pull_request) Successful in 12m51s

The playback process callback sized its writes from the mapped buffer's
capacity — PipeWire's quantum-limit, 8192 frames ≈ 170 ms — instead of
the graph's per-cycle ask (pw_buffer.requested). Every cycle therefore
queued up to 170 ms of PCM downstream of the ring, and, worse, taught
JitterPolicy that the device drains 170 ms per callback: the underrun
floor (want + one frame) rose above any depth the A/V sync loop may
request, so sync measured audio ~280 ms late and was forbidden — by its
own continuity rule — from draining it. The first on-glass run of the
latency overhaul showed exactly that: audio buffer 272 ms, a/v +284 ms,
stable.

Honor requested (capacity remains both the ceiling and the fallback for
requested == 0), and log requested-vs-capacity once per stream in the
shape of the host's per-capture-open quantum line, so the next on-glass
report can say which one is sizing the writes.

Needs libpipewire >= 0.3.49 (2022-03) for the requested field; every
ship target clears that.

Verified on .21: cargo clippy -p pf-client-core --all-targets -D
warnings clean, 167 tests pass, fmt clean.
This commit is contained in:
2026-08-08 02:04:15 +02:00
parent bfed711921
commit be86cfcdc0
2 changed files with 33 additions and 2 deletions
+5 -1
View File
@@ -118,7 +118,11 @@ rand = "0.9"
# need the hidapi driver). Linux links the system SDL3; Windows builds it from source
# (no system SDL3 there — same choice as clients/windows).
[target.'cfg(target_os = "linux")'.dependencies]
pipewire = "0.9"
# `v0_3_49` for `Buffer::requested` — the graph's per-cycle frame ask, without which the
# playback callback can only size writes from the buffer CEILING (quantum-limit, ~170 ms).
# Pure cfg gate; needs libpipewire ≥ 0.3.49 (2022-03) at runtime, which every ship target
# (SteamOS, flatpak runtimes, Arch, Ubuntu ≥ 22.10) clears.
pipewire = { version = "0.9", features = ["v0_3_49"] }
sdl3 = { version = "0.18", features = ["hidapi"] }
# Native VAAPI decode (M6 of the native-decode program): the hand-declared libva buffer
# layouts, the profile/format/surface decisions, the AuPlan → picparams/IQ/slice
+28 -1
View File
@@ -274,14 +274,41 @@ fn pw_thread(
chunk.clear();
let _ = ud.recycle.try_send(chunk);
}
// The graph asks for `requested` frames this cycle (one quantum, after
// rate-matching); the mapped buffer is sized for the WORST case — PipeWire's
// `quantum-limit`, 8192 frames ≈ 170 ms — not for this cycle. Filling to
// capacity queued ~170 ms per buffer downstream of the ring and, worse, taught
// the jitter policy that the device drains 170 ms per callback, which lifted
// the underrun floor (`want` + one frame) above any depth the A/V sync loop is
// allowed to ask for: audio sat a stable ~270 ms late and, by the continuity
// rule, sync was FORBIDDEN from draining it. Capacity is only the ceiling;
// `requested == 0` (no adapter suggestion) falls back to it.
let requested = usize::try_from(buffer.requested()).unwrap_or(0);
let stride = 4 * ud.channels; // F32LE interleaved
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data = &mut datas[0];
let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0);
let max_frames = data.data().map(|s| s.len() / stride).unwrap_or(0);
let want_frames = if requested > 0 {
requested.min(max_frames)
} else {
max_frames
};
let want = want_frames * ud.channels;
// Once per stream, in the shape of the host's per-capture-open quantum log:
// whether the graph's request or the buffer ceiling is sizing our writes is
// exactly what an on-glass latency report needs to say.
if ud.callbacks == 0 {
tracing::info!(
requested_frames = requested,
capacity_frames = max_frames,
write_frames = want_frames,
write_ms = want_frames / 48,
"audio playback quantum"
);
}
// A/V sync: take whatever depth the decode thread's sync loop last asked for, and
// publish where the ring actually is so it can measure the result. The policy