fix(oakaudio): P1 output hardening
- output callback reuses a scratch buffer instead of allocating per call (real-time rule) - P1 consumption test probes real callback delivery and skips on headless/background sessions (CoreAudio starts the stream but never runs it outside the GUI session), with a 30s poll for slow HAL startup; restores the manager singleton state afterwards - waveform: drop leftover DBG-WF debug prints
This commit is contained in:
@@ -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::<f32>::new(
|
||||
dev,
|
||||
2,
|
||||
true,
|
||||
info.default_low_output_latency,
|
||||
);
|
||||
let settings = portaudio::OutputStreamSettings::new(params, 48000.0, 512);
|
||||
let cb = move |args: portaudio::OutputStreamCallbackArgs<f32>| {
|
||||
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"
|
||||
|
||||
@@ -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::<u8>::new());
|
||||
let callback = move |args: OutputStreamCallbackArgs<f32>| {
|
||||
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).
|
||||
|
||||
@@ -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::<AudioStreamInfo>() };
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user