Skip to content

PulseAudio backend: short / fixed-length playbacks truncated under pipewire-pulse #1190

@knz

Description

@knz

Summary

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.

Affected configurations

  • cpal master (reproduced on commit 078787e, also on 9a2c03c via a fork carrying Each pulseaudio playback leaks a PA stream #1188).
  • pulseaudio crate 0.3.1.
  • 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.

  1. 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".
  2. 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).
  3. 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_callback once with the entire ~2-second buffer in one shot.
  4. 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".
  5. 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). The fact
that the default buffer size may be very large can come as a surprise. 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};

fn main() {
    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.channels as usize;

    // 1.5 seconds of a 440 Hz sine, mono.
    let total_frames = (sr as usize) * 3 / 2;
    let buf: Vec<f32> = (0..total_frames)
        .map(|i| (2.0 * std::f32::consts::PI * 440.0 * i as f32 / sr as f32).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| {
            let mut 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;
                let mut d = lock.lock().unwrap();
                *d = true;
                cv.notify_all();
            }
        },
        |e| eprintln!("err: {e}"),
        None,
    ).unwrap();

    stream.play().unwrap();

    let (lock, cv) = &*done;
    let mut 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.

Workaround

Set an explicit small BufferSize::Fixed:

let mut stream_cfg: StreamConfig = device.default_output_config().unwrap().into();
stream_cfg.buffer_size = cpal::BufferSize::Fixed(stream_cfg.sample_rate.0 / 10); // ~100 ms

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:

  1. 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
  2. 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.
  3. 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.

Environment

  • OS: Ubuntu 24.04 (Linux 6.17.0-23-generic)
  • pipewire / pipewire-pulse: 1.0.5-1ubuntu3.2
  • cpal: master @ 078787e (unmodified) — also reproduced on a fork at 9a2c03c which carries Each pulseaudio playback leaks a PA stream #1188 but is otherwise identical for this code path
  • pulseaudio crate: 0.3.1
  • Sound device: Intel HDA, S32Le 48 kHz stereo native

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions