You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When using cpal's pulseaudio host on a Linux system where the PulseAudio socket is served by pipewire-pulse (the default on most modern Linux desktops), playing a finite-duration sample and then dropping the Stream — the standard cpal pattern for one-shot playback — results in the audio being truncated to the first ~10–100 ms. The application's data_callback was invoked, the data was written, and drop(stream) returned cleanly, so nothing looks wrong from the user's perspective; but the listener only hears a brief click.
Continuous-output applications that hold the Stream open indefinitely (synthesizers, music players, game audio loops) are unaffected.
pipewire-pulse 1.0.5 providing the PA socket. Real pulseaudio daemons happen to pick smaller defaults that mask the bug, so it has stayed latent there.
Reproduces with default StreamConfig.buffer_size = BufferSize::Default.
Mechanism
Each step is independently reasonable; the combination is what breaks finite playback.
With BufferSize::Default, cpal's pulseaudio backend (make_playback_buffer_attr in src/host/pulseaudio/mod.rs) sends CreatePlaybackStream with BufferAttr { max_length, target_length, pre_buffering, minimum_request_length, fragment_size } all set to u32::MAX — the PA wire-protocol sentinel for "server, pick whatever".
pipewire-pulse picks a ~2-second initial requested_bytes and returns it in the CreatePlaybackStreamReply (real PulseAudio typically picks much smaller defaults tuned for low latency).
The pulseaudio crate's reactor seeds PlaybackStreamState.requested_bytes from that reply (client/reactor.rs:127), then on the next write_streams iteration calls the user's data_callbackonce with the entire ~2-second buffer in one shot.
The user's data_callback fills it (real audio + trailing silence), returns. They've satisfied cpal's contract — there's no API to say "I have less data than you asked for".
The user drops the Stream. Stream::drop sends DeletePlaybackStream, which races the actual ALSA-side playback. Since most of those ~2 s have not yet reached the kernel, pipewire-pulse discards the queued audio.
The user observes: "I gave cpal 1.5 s of audio, the callback was called, the stream was dropped — but I only heard 50 ms".
Why this defies user expectations
cpal's documented model is real-time periodic callbacks at a fixed period (StreamConfig.buffer_size). Nothing in the cpal API or docs hints that the pulseaudio backend may invoke data_callback once with a multi-second buffer at startup. Most cpal examples (beep.rs, README snippets) are continuous oscillators that never drop the stream, so they're immune and the issue stays invisible to library demos.
Reproduction
Minimal repro (paste into a binary crate with cpal as a dependency, run on a Linux box where pactl info reports Server Name: PulseAudio (on PipeWire ...)):
use cpal::traits::{DeviceTrait,HostTrait,StreamTrait};use cpal::{HostId,SampleFormat,StreamConfig};use std::sync::{Arc,Mutex,Condvar};fnmain(){let host = cpal::host_from_id(HostId::PulseAudio).unwrap();let device = host.default_output_device().unwrap();let cfg = device.default_output_config().unwrap();let sample_format = cfg.sample_format();let stream_cfg:StreamConfig = cfg.into();let sr = stream_cfg.sample_rate.0;let channels = stream_cfg.channelsasusize;// 1.5 seconds of a 440 Hz sine, mono.let total_frames = (sr asusize)*3 / 2;let buf:Vec<f32> = (0..total_frames).map(|i| (2.0* std::f32::consts::PI*440.0* i asf32 / sr asf32).sin()*0.2).collect();let cursor = Arc::new(Mutex::new(0usize));let done = Arc::new((Mutex::new(false),Condvar::new()));let cb_cursor = cursor.clone();let cb_done = done.clone();let total = buf.len();let buf = Arc::new(buf);let cb_buf = buf.clone();let stream = device.build_output_stream(&stream_cfg,move |out:&mut[f32], _info| {letmut idx = cb_cursor.lock().unwrap();for frame in out.chunks_mut(channels){let v = if*idx < total { cb_buf[*idx]}else{0.0};for ch in frame {*ch = v;}if*idx < total {*idx += 1;}}if*idx >= total {let(lock, cv) = &*cb_done;letmut d = lock.lock().unwrap();*d = true;
cv.notify_all();}},
|e| eprintln!("err: {e}"),None,).unwrap();
stream.play().unwrap();let(lock, cv) = &*done;letmut d = lock.lock().unwrap();while !*d { d = cv.wait(d).unwrap();}drop(d);
std::thread::sleep(std::time::Duration::from_millis(50));// typical "tail"drop(stream);let _ = sample_format;// unused; cpal infers}
Expected: a 1.5-second 440 Hz tone.
Actual under pipewire-pulse: a brief click (~50 ms), nothing more.
This makes make_playback_buffer_attr send an explicit tlength/minreq, which pipewire-pulse honors. data_callback is then invoked periodically with ~100 ms chunks, the existing post-done tail sleep is sufficient, and the audio plays in full.
Suggested fixes (in cpal)
In rough order of invasiveness:
Don't pass u32::MAX for BufferSize::Default in make_playback_buffer_attr. Pick a sensible default (e.g. tlength = sample_rate / 10 for ~100 ms, or whatever matches default_output_config().buffer_size). This is what libpulse-based apps generally do and would make the bug go away with no user-visible API change. Related: Reviewing and deciding upon a default buffer size strategy for ALSA #446
Expose Stream::drain() on the public trait so users can explicitly wait for the server-side buffer to play out before dropping. Useful beyond pulseaudio — any backend with non-trivial output buffering benefits. This would also help with issue Allow waiting for playback to finish #41.
Make playback Stream::drop call drain() with a timeout instead of (or before) delete(). Most "correct" but blocks Drop, which has its own problems and would likely need to be opt-in.
I'm happy to send a PR for (1) if there's interest. (2) and (3) involve trait surface changes that probably want maintainer input first.
Summary
When using cpal's
pulseaudiohost on a Linux system where the PulseAudio socket is served bypipewire-pulse(the default on most modern Linux desktops), playing a finite-duration sample and then dropping theStream— the standard cpal pattern for one-shot playback — results in the audio being truncated to the first ~10–100 ms. The application'sdata_callbackwas invoked, the data was written, anddrop(stream)returned cleanly, so nothing looks wrong from the user's perspective; but the listener only hears a brief click.Continuous-output applications that hold the
Streamopen indefinitely (synthesizers, music players, game audio loops) are unaffected.Affected configurations
master(reproduced on commit078787e, also on9a2c03cvia a fork carrying Each pulseaudio playback leaks a PA stream #1188).pulseaudiocrate0.3.1.pipewire-pulse 1.0.5providing the PA socket. Realpulseaudiodaemons happen to pick smaller defaults that mask the bug, so it has stayed latent there.StreamConfig.buffer_size = BufferSize::Default.Mechanism
Each step is independently reasonable; the combination is what breaks finite playback.
BufferSize::Default, cpal's pulseaudio backend (make_playback_buffer_attrinsrc/host/pulseaudio/mod.rs) sendsCreatePlaybackStreamwithBufferAttr { max_length, target_length, pre_buffering, minimum_request_length, fragment_size }all set tou32::MAX— the PA wire-protocol sentinel for "server, pick whatever".pipewire-pulsepicks a ~2-second initialrequested_bytesand returns it in theCreatePlaybackStreamReply(real PulseAudio typically picks much smaller defaults tuned for low latency).pulseaudiocrate's reactor seedsPlaybackStreamState.requested_bytesfrom that reply (client/reactor.rs:127), then on the nextwrite_streamsiteration calls the user'sdata_callbackonce with the entire ~2-second buffer in one shot.data_callbackfills it (real audio + trailing silence), returns. They've satisfied cpal's contract — there's no API to say "I have less data than you asked for".Stream.Stream::dropsendsDeletePlaybackStream, which races the actual ALSA-side playback. Since most of those ~2 s have not yet reached the kernel, pipewire-pulse discards the queued audio.The user observes: "I gave cpal 1.5 s of audio, the callback was called, the stream was dropped — but I only heard 50 ms".
Why this defies user expectations
cpal's documented model is real-time periodic callbacks at a fixed period (
StreamConfig.buffer_size). Nothing in the cpal API or docs hints that the pulseaudio backend may invokedata_callbackonce with a multi-second buffer at startup. Most cpal examples (beep.rs, README snippets) are continuous oscillators that never drop the stream, so they're immune and the issue stays invisible to library demos.Reproduction
Minimal repro (paste into a binary crate with
cpalas a dependency, run on a Linux box wherepactl inforeportsServer Name: PulseAudio (on PipeWire ...)):Expected: a 1.5-second 440 Hz tone.
Actual under pipewire-pulse: a brief click (~50 ms), nothing more.
Workaround
Set an explicit small
BufferSize::Fixed:This makes
make_playback_buffer_attrsend an explicittlength/minreq, which pipewire-pulse honors.data_callbackis then invoked periodically with ~100 ms chunks, the existing post-donetail sleep is sufficient, and the audio plays in full.Suggested fixes (in cpal)
In rough order of invasiveness:
u32::MAXforBufferSize::Defaultinmake_playback_buffer_attr. Pick a sensible default (e.g.tlength = sample_rate / 10for ~100 ms, or whatever matchesdefault_output_config().buffer_size). This is whatlibpulse-based apps generally do and would make the bug go away with no user-visible API change. Related: Reviewing and deciding upon a default buffer size strategy for ALSA #446Stream::drain()on the public trait so users can explicitly wait for the server-side buffer to play out before dropping. Useful beyond pulseaudio — any backend with non-trivial output buffering benefits. This would also help with issue Allow waiting for playback to finish #41.Stream::dropcalldrain()with a timeout instead of (or before)delete(). Most "correct" but blocks Drop, which has its own problems and would likely need to be opt-in.I'm happy to send a PR for (1) if there's interest. (2) and (3) involve trait surface changes that probably want maintainer input first.
Environment