fix: eliminate playback crackle by pinning the PipeWire buffer to one quantum
The playback stream was negotiated with a ~256ms (12288-frame) maxsize buffer. The sink drains the graph quantum (1024 frames) per cycle, so one of our buffers lasted ~12 cycles and `process` was called only ~4x/sec, each time asking us to fill all 12288 frames -- far more than the 200ms (9600-sample) playout ring could ever hold. So ~half of every buffer was silence-fill: a steady ~46% underrun, audible as constant crackle. This is a consumer-side buffer-size bug, upstream of production pacing, which is why earlier mixer-pacing attempts never moved the numbers. Fix: pass an explicit SPA_TYPE_OBJECT_ParamBuffers param on connect, pinning buffer size to one 1024-frame quantum (2048 bytes mono S16LE). PipeWire now hands us a quantum-sized buffer ~47x/sec, the ring satisfies every callback, and slice.len()/stride equals the quantum so we never over-pull. A node.latency hint is added too (not load-bearing on its own -- the hint alone changed nothing; the Buffers param is the fix). Note: pipewire 0.9.2 only exposes feature v0_3_32, so Buffer::requested() is unreachable -- pinning the buffer size is the available lever. Verified with the probe (quantum=1024, 47 cb/s, underrun +0 steady) and by ear: clean 440Hz tone, no clicks. Local playout path only -- not yet verified on a live two-peer call. Also in this commit (the investigation scaffolding that proved it out): - Fill-paced mixer: production tracks the hardware clock via a shared exact ring-occupancy gauge (Arc<AtomicUsize>) kept near PLAYBACK_TARGET_SAMPLES, replacing the fixed 20ms timer that beat against the 1024 quantum. - src/bin/audio_probe.rs: drives a sine through the real start_playback path with no network/mic, for isolating the local output stage. - playout-health logging: quiet in normal use (logs only on underrun/ dropped > 0); set PEERSPEAK_AUDIO_VERBOSE=1 for the per-second heartbeat (audio_probe sets it automatically). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+24
-1
@@ -1,6 +1,18 @@
|
||||
use std::sync::mpsc::{Sender, Receiver};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Target depth of the playback ring buffer, in samples (48kHz mono).
|
||||
///
|
||||
/// The playout chain is paced to keep the ring near this level: production is
|
||||
/// driven by how fast PipeWire actually drains the ring (the hardware clock),
|
||||
/// not by a fixed software timer — which is what eliminates the producer/
|
||||
/// consumer beat that otherwise churns ~20% of audio into drops + silence.
|
||||
/// 2880 = 60ms = 3×20ms frames, comfortably above the 2048-sample max quantum
|
||||
/// so a single hardware pull can never empty the ring before the mixer refills.
|
||||
pub const PLAYBACK_TARGET_SAMPLES: usize = 2880;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AudioError {
|
||||
#[error("Failed to initialize audio backend: {0}")]
|
||||
@@ -22,7 +34,18 @@ pub trait AudioBackend: Send + Sync {
|
||||
|
||||
/// Starts playing back raw PCM audio to the output device (speaker),
|
||||
/// reading mixed/incoming chunks of samples from the provided Receiver.
|
||||
fn start_playback(&self, rx: Receiver<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError>;
|
||||
///
|
||||
/// `ring_fill` is updated with the playback ring's current occupancy (in
|
||||
/// samples) as the device drains and the worker fills it. The caller (the
|
||||
/// mixer) reads it to pace production to the hardware clock — produce only
|
||||
/// while the ring is below [`PLAYBACK_TARGET_SAMPLES`] — instead of on a
|
||||
/// fixed timer that beats against the device quantum.
|
||||
fn start_playback(
|
||||
&self,
|
||||
rx: Receiver<Vec<i16>>,
|
||||
target_node: Option<String>,
|
||||
ring_fill: Arc<AtomicUsize>,
|
||||
) -> Result<(), AudioError>;
|
||||
|
||||
/// Stops both capture and playback streams.
|
||||
fn stop(&self) -> Result<(), AudioError>;
|
||||
|
||||
Reference in New Issue
Block a user