diff --git a/crates/oakaudio/src/manager.rs b/crates/oakaudio/src/manager.rs index 44029fae8..d7512ed88 100644 --- a/crates/oakaudio/src/manager.rs +++ b/crates/oakaudio/src/manager.rs @@ -473,16 +473,45 @@ mod tests { use super::*; /// M12 P1 acceptance: the PortAudio output callback pulls pushed - /// samples and advances the playback clock. Requires real audio - /// hardware; skips (returns) when PortAudio is unavailable. + /// samples and advances the playback clock. Requires a working audio + /// session; skips (returns) when PortAudio cannot deliver callbacks + /// (CI boxes, background/headless macOS sessions where CoreAudio + /// starts the stream but never runs it). #[test] fn output_callback_consumes_pushed_samples() { - // Skip when there is no audio system (CI boxes). - let pa = match portaudio::PortAudio::new() { - Ok(pa) => pa, - Err(_) => return, - }; - if pa.default_output_device().is_err() { + // Skip when the audio system cannot actually run a stream: open a + // silent stream and require at least one callback within 2 s. A + // device existing is not enough — headless sessions report + // is_active=true while delivering zero callbacks. + use std::sync::atomic::AtomicI64 as A; + static PROBE: A = A::new(0); + let can_play = (|| { + let pa = portaudio::PortAudio::new().ok()?; + let dev = pa.default_output_device().ok()?; + let info = pa.device_info(dev).ok()?; + let params = portaudio::StreamParameters::::new( + dev, + 2, + true, + info.default_low_output_latency, + ); + let settings = portaudio::OutputStreamSettings::new(params, 48000.0, 512); + let cb = move |args: portaudio::OutputStreamCallbackArgs| { + PROBE.fetch_add(args.frames as i64, Ordering::Relaxed); + portaudio::Continue + }; + let mut stream = pa.open_non_blocking_stream(settings, cb).ok()?; + stream.start().ok()?; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline && PROBE.load(Ordering::Relaxed) == 0 { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + let got = PROBE.load(Ordering::Relaxed) > 0; + let _ = stream.stop(); + Some(got) + })(); + if can_play != Some(true) { + eprintln!("audio session cannot deliver callbacks; skipping"); return; } @@ -516,12 +545,22 @@ mod tests { ) .expect("push succeeds even without an explicit device"); - // Give the audio thread time to consume. - std::thread::sleep(std::time::Duration::from_millis(300)); - let consumed = { - let m = with_instance(&h).unwrap(); - m.output_buffer.output_frames_consumed() - }; + // Give the audio thread time to consume. PortAudio/CoreAudio + // stream startup can take SECONDS in some environments (audio HAL + // device probing), so poll with a generous deadline instead of a + // fixed sleep. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let mut consumed = 0i64; + while std::time::Instant::now() < deadline { + { + let m = with_instance(&h).unwrap(); + consumed = m.output_buffer.output_frames_consumed(); + } + if consumed > 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } assert!( consumed > 0, "the output callback must consume pushed frames" diff --git a/crates/oakaudio/src/outputdevice.rs b/crates/oakaudio/src/outputdevice.rs index b96e10b47..a91b82f1b 100644 --- a/crates/oakaudio/src/outputdevice.rs +++ b/crates/oakaudio/src/outputdevice.rs @@ -116,14 +116,18 @@ impl PortAudioOutput { // The callback pulls whole frames from the shared device and // advances the output clock (underrun → silence). `read` locks // the device internally; no other locks are taken on the audio - // thread. + // thread. The scratch buffer is allocated once and reused — + // allocating per callback would violate the real-time rule. let sink_cb = sink.clone(); + let scratch = std::cell::RefCell::new(Vec::::new()); let callback = move |args: OutputStreamCallbackArgs| { let out = args.buffer; let frames = args.frames; let total = frames * channels.max(1) as usize; - let mut byte_buf = vec![0u8; total * 4]; - let got = sink_cb.read(&mut byte_buf); + let mut scratch = scratch.borrow_mut(); + scratch.resize(total * 4, 0); + let byte_buf = &mut *scratch; + let got = sink_cb.read(byte_buf); let frames_got = (got as usize) / (channels.max(1) as usize * 4); // Convert the interleaved f32 bytes in place to a sample // slice (PortAudio writes f32s directly). diff --git a/crates/oakaudio/src/waveform.rs b/crates/oakaudio/src/waveform.rs index f6b85e964..7ec8ce313 100644 --- a/crates/oakaudio/src/waveform.rs +++ b/crates/oakaudio/src/waveform.rs @@ -731,12 +731,10 @@ pub fn extract( // Probe for the stream's native rate/layout (stateless). // SAFETY: `filename` is a NUL-terminated C string (validated by the FFI // layer); the probe handle is freed on every path below. - eprintln!("DBG-WF: probing"); let mut probe = unsafe { crate::bridge::codec::oakcodec_decoder_probe(filename.as_ptr()) }; if probe.is_null() { return Err(Error::NotFound); } - eprintln!("DBG-WF: probed"); let mut info = unsafe { std::mem::zeroed::() }; let r = unsafe { crate::bridge::codec::oakcodec_decoder_probe_get_audio_stream( @@ -764,7 +762,6 @@ pub fn extract( // (`retrieve_audio` delivers interleaved f32 at the requested native // rate/layout; the C++ path ran the decode through an identity // fb_audio_graph to obtain planar f32). - eprintln!("DBG-WF: opening decoder stream {}", info.stream_index); let decoder = FFmpegDecoder::new(); let stream = CodecStream::with_block( filename.to_string_lossy().into_owned(), @@ -774,7 +771,6 @@ pub fn extract( if let Err(e) = decoder.open(&stream) { return Err(Error::Failed(format!("failed to open decoder: {e:?}"))); } - eprintln!("DBG-WF: opened"); if info.time_base_num <= 0 || info.time_base_den <= 0 || info.duration_ts <= 0 { let _ = decoder.close(); @@ -803,7 +799,6 @@ pub fn extract( Rational::new(offset, i64::from(info.sample_rate)), Rational::new(offset + frames, i64::from(info.sample_rate)), ); - eprintln!("DBG-WF: decoding chunk at {offset}"); let mut buf = vec![0f32; frames as usize * channels as usize]; match decoder.retrieve_audio(&mut buf, &range, info.sample_rate, layout_mask) { Ok(RetrieveAudioStatus::Success) => {