From 139ff9a4e3b48c5c9c8b2e52380dd263af353fb7 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 03:50:22 +1100 Subject: [PATCH] Start Linux audio preview after the first mixed samples, on a short device buffer. Pulse and PipeWire were opening a large default period and playing silence until the feeder caught up, so the picture ran while the sound started late. The stream now waits for queued samples and requests about 10 ms of buffering. --- apps/effectcraft/src/audio_out.rs | 88 +++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 9 deletions(-) diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs index a7929b6d..7c26db51 100644 --- a/apps/effectcraft/src/audio_out.rs +++ b/apps/effectcraft/src/audio_out.rs @@ -10,11 +10,32 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc; use std::thread::JoinHandle; +use std::time::Duration; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{FromSample, SizedSample}; use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed, AudioOutput}; +/// Device callback period: about 10 ms, clamped so Pulse/PipeWire/ALSA does not open a +/// multi-hundred-millisecond default buffer that plays as silence at the start of preview (#554). +fn preferred_buffer_frames(rate: u32) -> u32 { + (rate / 100).clamp(256, 2048) +} + +/// Wait until the feeder has mixed a block, or until preview is stopped. Starting the stream +/// before that fills the device with silence, so the first real samples are heard late (#554). +fn wait_to_play(feed: &AudioFeed, stop: &mpsc::Receiver<()>) -> bool { + loop { + if feed.queued_frames() > 0 { + return true; + } + match stop.recv_timeout(Duration::from_millis(2)) { + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false, + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + } +} + pub struct CpalOut { out: AudioOutput, rate: u32, @@ -131,21 +152,36 @@ impl AudioDevice for CpalOut { }; let mut cfg: cpal::StreamConfig = sup.config(); cfg.sample_rate = cpal::SampleRate(rate); - let stream = match sup.sample_format() { - cpal::SampleFormat::F32 => run::(&dev, &cfg, feed, latency, map), - cpal::SampleFormat::I16 => run::(&dev, &cfg, feed, latency, map), - cpal::SampleFormat::U16 => run::(&dev, &cfg, feed, latency, map), - cpal::SampleFormat::I32 => run::(&dev, &cfg, feed, latency, map), + cfg.buffer_size = cpal::BufferSize::Fixed(preferred_buffer_frames(rate)); + let build = |cfg: &cpal::StreamConfig| match sup.sample_format() { + cpal::SampleFormat::F32 => run::(&dev, cfg, feed.clone(), latency.clone(), map), + cpal::SampleFormat::I16 => run::(&dev, cfg, feed.clone(), latency.clone(), map), + cpal::SampleFormat::U16 => run::(&dev, cfg, feed.clone(), latency.clone(), map), + cpal::SampleFormat::I32 => run::(&dev, cfg, feed.clone(), latency.clone(), map), f => Err(format!("unsupported sample format {f:?}")), }; - let stream = match stream.and_then(|s| s.play().map(|_| s).map_err(|e| e.to_string())) { + let stream = match build(&cfg) { Ok(s) => s, - Err(e) => { - let _ = ready_tx.send(Err(e)); - return; + Err(_) => { + // Some hosts reject a fixed period; a large default still plays, just later. + cfg.buffer_size = cpal::BufferSize::Default; + match build(&cfg) { + Ok(s) => s, + Err(e) => { + let _ = ready_tx.send(Err(e)); + return; + } + } } }; let _ = ready_tx.send(Ok(())); + if !wait_to_play(&feed, &stop_rx) { + return; + } + if let Err(e) = stream.play() { + eprintln!("effectcraft: audio output: {e}"); + return; + } let _ = stop_rx.recv(); drop(stream); }) @@ -174,3 +210,37 @@ impl Drop for CpalOut { self.stop(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preferred_buffer_is_about_ten_milliseconds() { + assert_eq!(preferred_buffer_frames(48_000), 480); + assert_eq!(preferred_buffer_frames(8_000), 256); + assert_eq!(preferred_buffer_frames(192_000), 1920); + } + + #[test] + fn wait_to_play_returns_once_the_feeder_has_samples() { + let feed = Arc::new(AudioFeed::default()); + let (_tx, rx) = mpsc::channel(); + let f = feed.clone(); + let h = std::thread::spawn(move || wait_to_play(&f, &rx)); + assert_eq!(feed.queued_frames(), 0); + std::thread::sleep(Duration::from_millis(20)); + feed.push(&[0.1, 0.2]); + assert!(h.join().unwrap()); + assert_eq!(feed.queued_frames(), 1); + } + + #[test] + fn wait_to_play_stops_without_samples() { + let feed = AudioFeed::default(); + let (tx, rx) = mpsc::channel(); + let h = std::thread::spawn(move || wait_to_play(&feed, &rx)); + tx.send(()).unwrap(); + assert!(!h.join().unwrap()); + } +}