diff --git a/crates/pf-client-core/Cargo.toml b/crates/pf-client-core/Cargo.toml index d44fe6b3..ed050f8d 100644 --- a/crates/pf-client-core/Cargo.toml +++ b/crates/pf-client-core/Cargo.toml @@ -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 diff --git a/crates/pf-client-core/src/audio.rs b/crates/pf-client-core/src/audio.rs index 59636a35..31e765a7 100644 --- a/crates/pf-client-core/src/audio.rs +++ b/crates/pf-client-core/src/audio.rs @@ -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