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:
2026-08-14 05:45:05 +08:00
parent c8c282f06c
commit d460d57805
3 changed files with 60 additions and 22 deletions
+53 -14
View File
@@ -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"
+7 -3
View File
@@ -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).
-5
View File
@@ -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) => {