fix(audio): honor per-cycle quantum in playback (A1, crackle on non-1024 hw)
The playback RT callback pinned the PipeWire buffer to exactly one 1024-frame quantum (the 2026-05-31 crackle fix). That is only correct when the machine's clock.quantum is 1024 — on hardware running quantum 512 or 2048 the pinned slice mismatches the device's per-cycle demand and the crackle returns. We just shipped a release to a friend whose quantum is unknown, so this was P1. Fix: enable the pipewire `v0_3_49` feature (exposes Buffer::requested(), the graph's per-cycle quantum) and fill exactly that many frames each callback via a new pure `frames_to_produce()` seam, with a safe ≤1024 fallback when the graph reports 0 (never the whole slice — over-pulling past the ring depth is the original crackle). Relax the Buffers size pin from a hard 1024 to a generous 8192-frame max so the mapped slice fits any plausible quantum; requested(), not the buffer size, now governs per-cycle output. Verified locally with `pw-metadata clock.force-quantum` + audio_probe at forced quanta 512/1024/2048: each shows `underrun +0` steady, `quantum=` matching the forced value, and callbacks/s ≈ rate/quantum — proving requested() is live (the health line would otherwise read the 1024 fallback). +4 unit tests on frames_to_produce (148 lib tests, clippy --all-targets clean). Still pending (field test): one real desktop<->dopedart call through the fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+5
-1
@@ -25,7 +25,11 @@ iced = { version = "0.14.0", features = ["canvas"] }
|
||||
iroh = "1.0.0-rc.0"
|
||||
iroh-gossip = "0.99.0"
|
||||
opus = "0.3.1"
|
||||
pipewire = "0.9"
|
||||
# v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle quantum), used by
|
||||
# the playback RT callback to fill exactly what the device asks for instead of
|
||||
# pinning the buffer to a hard-coded 1024-frame quantum (crackle on non-1024
|
||||
# hardware). The field has existed in libpipewire since 0.3.49 (2022).
|
||||
pipewire = { version = "0.9", features = ["v0_3_49"] }
|
||||
rand = "0.10.1"
|
||||
ringbuf = "0.5.0"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
|
||||
+79
-11
@@ -224,6 +224,27 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Frames the playback RT callback should produce this cycle.
|
||||
///
|
||||
/// `requested` is the graph's per-cycle quantum from `Buffer::requested()` (0 if
|
||||
/// the graph didn't set it — first buffers / non-driver cycles). `mapped_frames`
|
||||
/// is how many frames the mapped buffer slice can physically hold.
|
||||
///
|
||||
/// Honoring the requested quantum is what makes playback correct on any graph
|
||||
/// quantum, instead of the old hard-pinned 1024 (which crackled when a machine's
|
||||
/// `clock.quantum` wasn't 1024). Rules:
|
||||
/// - never write past the mapped slice (clamp to `mapped_frames`);
|
||||
/// - when `requested == 0`, fall back to a safe cap (≤ one 1024-frame quantum) —
|
||||
/// never the whole slice, because over-pulling past the ring depth is exactly
|
||||
/// the sustained crackle this replaces. Under-filling a cycle is a harmless
|
||||
/// brief glitch (PipeWire pads).
|
||||
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
|
||||
/// Safe per-cycle fallback when the graph doesn't report a quantum.
|
||||
const FALLBACK_FRAMES: usize = 1024;
|
||||
let want = if requested > 0 { requested } else { FALLBACK_FRAMES };
|
||||
want.min(mapped_frames)
|
||||
}
|
||||
|
||||
fn run_playback(
|
||||
cmd_rx: pw::channel::Receiver<()>,
|
||||
rx: Receiver<Vec<i16>>,
|
||||
@@ -317,13 +338,20 @@ fn run_playback(
|
||||
})
|
||||
.process(|stream, user_data| {
|
||||
if let Some(mut buffer) = stream.dequeue_buffer() {
|
||||
// Read the graph's requested quantum BEFORE the mutable
|
||||
// `datas_mut()` borrow below (`requested()` borrows `&buffer`).
|
||||
let requested = buffer.requested() as usize;
|
||||
let datas = buffer.datas_mut();
|
||||
if !datas.is_empty() {
|
||||
let data = &mut datas[0];
|
||||
let mut total_size = 0;
|
||||
if let Some(slice) = data.data() {
|
||||
let stride = 2; // S16LE Mono = 2 bytes per frame
|
||||
let n_frames = slice.len() / stride;
|
||||
// Fill exactly what the graph asked for this cycle (with
|
||||
// a safe fallback), never the whole mapped slice — that
|
||||
// over-pull past the ring depth was the original crackle.
|
||||
let mapped_frames = slice.len() / stride;
|
||||
let n_frames = frames_to_produce(requested, mapped_frames);
|
||||
// Diagnostic: record the quantum the device asked for and
|
||||
// count the callback. Wait-free, RT-safe.
|
||||
user_data.last_quantum.store(n_frames, Ordering::Relaxed);
|
||||
@@ -384,15 +412,19 @@ fn run_playback(
|
||||
.0
|
||||
.into_inner();
|
||||
|
||||
// Explicit Buffers param — THE fix for the playback crackle. Without it
|
||||
// PipeWire handed this stream a ~256ms (12288-frame) maxsize buffer and
|
||||
// only called `process` ~4x/sec; each callback then asked us to fill all
|
||||
// 12288 frames, far more than the 200ms ring could ever hold, so ~half of
|
||||
// every buffer was silence (the crackle). Pinning the buffer size to one
|
||||
// graph quantum (1024 frames = 2048 bytes mono S16LE) makes the device hand
|
||||
// us a ~1024-frame buffer ~47x/sec, which the ring satisfies comfortably and
|
||||
// keeps `slice.len()/stride` equal to the quantum so we never over-pull.
|
||||
const QUANTUM_FRAMES: i32 = 1024;
|
||||
// Explicit Buffers param. History: without ANY Buffers param PipeWire handed
|
||||
// this stream a ~256ms (12288-frame) maxsize buffer and called `process`
|
||||
// only ~4x/sec; the callback then over-pulled the whole 12288-frame slice
|
||||
// (far more than the 200ms ring holds → ~half silence → the crackle). The
|
||||
// first fix pinned the size to exactly one 1024-frame quantum, which only
|
||||
// works when the graph quantum IS 1024 — on other hardware (quantum 512 or
|
||||
// 2048) the pinned slice mismatches the device's per-cycle demand and the
|
||||
// crackle returns. The correct fix: give the buffer enough room for any
|
||||
// plausible quantum (the desktop's quantum-limit is 8192) and let the RT
|
||||
// callback fill exactly `Buffer::requested()` frames per cycle (see
|
||||
// `frames_to_produce`). `requested()`, not the buffer size, now governs
|
||||
// per-cycle output, so this is a generous max rather than a hard pin.
|
||||
const MAX_QUANTUM_FRAMES: i32 = 8192;
|
||||
const STRIDE: i32 = 2; // S16LE mono = 2 bytes/frame
|
||||
let buffers_obj = pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||
@@ -414,7 +446,7 @@ fn run_playback(
|
||||
),
|
||||
pw::spa::pod::Property::new(
|
||||
pw::spa::sys::SPA_PARAM_BUFFERS_size,
|
||||
pw::spa::pod::Value::Int(QUANTUM_FRAMES * STRIDE),
|
||||
pw::spa::pod::Value::Int(MAX_QUANTUM_FRAMES * STRIDE),
|
||||
),
|
||||
pw::spa::pod::Property::new(
|
||||
pw::spa::sys::SPA_PARAM_BUFFERS_stride,
|
||||
@@ -516,3 +548,39 @@ fn run_playback(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::frames_to_produce;
|
||||
|
||||
#[test]
|
||||
fn requested_in_range_is_honored() {
|
||||
// The graph's requested quantum is produced verbatim when it fits.
|
||||
assert_eq!(frames_to_produce(512, 8192), 512);
|
||||
assert_eq!(frames_to_produce(1024, 8192), 1024);
|
||||
assert_eq!(frames_to_produce(2048, 8192), 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_over_mapped_is_clamped() {
|
||||
// Never write past the mapped slice, even if the graph asks for more.
|
||||
assert_eq!(frames_to_produce(8192, 1024), 1024);
|
||||
assert_eq!(frames_to_produce(2048, 2048), 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_requested_uses_safe_fallback() {
|
||||
// requested()==0 (first buffers / non-driver cycles) → capped fallback,
|
||||
// never the whole mapped slice (that's the over-pull crackle).
|
||||
assert_eq!(frames_to_produce(0, 8192), 1024);
|
||||
// ...still clamped to a small mapped slice.
|
||||
assert_eq!(frames_to_produce(0, 256), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_zero_mapped_is_zero() {
|
||||
// No buffer to write into → produce nothing, no panic / underflow.
|
||||
assert_eq!(frames_to_produce(0, 0), 0);
|
||||
assert_eq!(frames_to_produce(1024, 0), 0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user