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). 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};

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions