The 272 ms audio buffer was legal: the PipeWire callback filled the buffer ceiling, not the graph's request #106

Merged
enricobuehler merged 1 commits from fix/pw-playback-requested into main 2026-08-08 00:12:06 +00:00
Owner

Follow-up to #101, from its first on-glass run. One commit, be86cfcd, branched from #101's tip (now in main).

What the first on-glass run showed

audio buffer 272 ms · a/v +284 ms, stable. The overhaul itself was working — it measured the +284 ms correctly — but was not allowed to act on it.

Why

The playback process callback sized every write from the mapped buffer's capacity (data.len() / stride). PipeWire sizes stream buffers for the worst case — quantum-limit, 8192 frames ≈ 170 ms — not for the cycle, and the graph's actual per-cycle ask (pw_buffer.requested) was never read. Two consequences:

  1. Up to 170 ms of PCM queued per buffer downstream of the ring — invisible to buffered_ahead, so the HUD's a/v figure understates real ear latency in this state.
  2. want ≈ 170 ms feeds JitterPolicy's own safety math: the underrun floor becomes want + one frame ≈ 175 ms, and step()'s cap becomes target + want ≈ 345 ms. A 272 ms ring is legal and stable inside that band, and by the continuity-outranks-sync rule the A/V loop is forbidden from draining it. The +284 residual on the HUD is the design honestly reporting exactly this.

The tell for next time: a steady ring above hard_cap_ms (80 on PipeWire) is impossible while step() runs with honest inputs — it hard-trims instantly. A stable deep ring means the cap itself was inflated; re-derive want before chasing shed rates.

This also retroactively explains the original Deck field report: the old client overfilled identically, just without a HUD to show it.

The fix

  • Honor buffer.requested(); capacity stays as both the ceiling and the fallback for requested == 0 (no adapter suggestion).
  • One-time audio playback quantum info log (requested vs capacity vs write) per stream, mirroring the host's per-capture-open quantum line, so the next on-glass report can say which one is sizing the writes.
  • pipewire = { version = "0.9", features = ["v0_3_49"] } — pure cfg gate for the requested field; runtime floor libpipewire ≥ 0.3.49 (2022-03), which every ship target clears.

Verified

On .21 (Linux): cargo clippy -p pf-client-core --all-targets -- -D warnings clean, 167 pf-client-core tests pass (1 ignored), fmt --check clean. macOS cannot compile-check this crate (#[cfg(target_os = "linux")]).

On-glass retest expectations (Deck ← .21)

  • New info line at stream start: audio playback quantum requested_frames=… capacity_frames=…. If requested_frames=0, the graph gives no hint and we need plan B (cap writes by node.latency).
  • audio buffer should land ~15–30 ms; a/v should converge into the ±10 ms deadband.
Follow-up to #101, from its first on-glass run. One commit, `be86cfcd`, branched from #101's tip (now in main). ## What the first on-glass run showed `audio buffer 272 ms · a/v +284 ms`, stable. The overhaul itself was working — it *measured* the +284 ms correctly — but was not allowed to act on it. ## Why The playback `process` callback sized every write from the mapped buffer's capacity (`data.len() / stride`). PipeWire sizes stream buffers for the worst case — `quantum-limit`, 8192 frames ≈ **170 ms** — not for the cycle, and the graph's actual per-cycle ask (`pw_buffer.requested`) was never read. Two consequences: 1. Up to 170 ms of PCM queued per buffer *downstream* of the ring — invisible to `buffered_ahead`, so the HUD's a/v figure **understates** real ear latency in this state. 2. `want` ≈ 170 ms feeds `JitterPolicy`'s own safety math: the underrun floor becomes `want` + one frame ≈ 175 ms, and `step()`'s cap becomes `target + want` ≈ 345 ms. A 272 ms ring is **legal and stable** inside that band, and by the continuity-outranks-sync rule the A/V loop is *forbidden* from draining it. The +284 residual on the HUD is the design honestly reporting exactly this. The tell for next time: a steady ring **above** `hard_cap_ms` (80 on PipeWire) is impossible while `step()` runs with honest inputs — it hard-trims instantly. A stable deep ring means the cap itself was inflated; re-derive `want` before chasing shed rates. This also retroactively explains the original Deck field report: the old client overfilled identically, just without a HUD to show it. ## The fix - Honor `buffer.requested()`; capacity stays as both the ceiling and the fallback for `requested == 0` (no adapter suggestion). - One-time `audio playback quantum` info log (requested vs capacity vs write) per stream, mirroring the host's per-capture-open quantum line, so the next on-glass report can say which one is sizing the writes. - `pipewire = { version = "0.9", features = ["v0_3_49"] }` — pure cfg gate for the `requested` field; runtime floor libpipewire ≥ 0.3.49 (2022-03), which every ship target clears. ## Verified On .21 (Linux): `cargo clippy -p pf-client-core --all-targets -- -D warnings` clean, 167 pf-client-core tests pass (1 ignored), `fmt --check` clean. macOS cannot compile-check this crate (`#[cfg(target_os = "linux")]`). ## On-glass retest expectations (Deck ← .21) - New info line at stream start: `audio playback quantum requested_frames=… capacity_frames=…`. If `requested_frames=0`, the graph gives no hint and we need plan B (cap writes by `node.latency`). - `audio buffer` should land ~15–30 ms; `a/v` should converge into the ±10 ms deadband.
enricobuehler added 1 commit 2026-08-08 00:10:46 +00:00
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
be86cfcdc0
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.
enricobuehler merged commit cabd011f1d into main 2026-08-08 00:12:06 +00:00
enricobuehler deleted branch fix/pw-playback-requested 2026-08-08 00:12:15 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#106