From ab58a76dcd7f15bd9066aba39cdf9f07ec6affd6 Mon Sep 17 00:00:00 2001 From: Alban Gicquel <6368237+albanmgicquel@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:46:02 -0400 Subject: [PATCH] tecs-audio: a late buffer no longer ends the output stream cpal reports more than failures through a stream's error callback: on macOS an Xrun for every processor-overload notification (a callback that missed its deadline), a DeviceChanged when the default output reroutes and a RealtimeDenied when the thread is not promoted. The output and the capture took any of them as terminal, so one late buffer (a slow first callback of a debug build is enough) marked the stream failed for good: the callback rendered silence from then on, tecs.audio stopped every voice, and nothing played again until the mixer was made anew. Only the kinds that leave the stream dead now end it. Tests: the classification of each error kind. --- native/rust/tecs-audio/src/capture.rs | 7 +++-- native/rust/tecs-audio/src/device.rs | 42 +++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/native/rust/tecs-audio/src/capture.rs b/native/rust/tecs-audio/src/capture.rs index 460da816..436d1578 100644 --- a/native/rust/tecs-audio/src/capture.rs +++ b/native/rust/tecs-audio/src/capture.rs @@ -550,8 +550,11 @@ where Ok(mut guard) => converter.feed(span, &mut guard), } }, - move |_error| { - failure_callback.store(true, Ordering::Release); + move |error: cpal::Error| { + // An overrun or a reroute leaves the stream recording. + if crate::device::ends_the_stream(error.kind()) { + failure_callback.store(true, Ordering::Release); + } }, None, ) diff --git a/native/rust/tecs-audio/src/device.rs b/native/rust/tecs-audio/src/device.rs index 13e3c449..d0a38353 100644 --- a/native/rust/tecs-audio/src/device.rs +++ b/native/rust/tecs-audio/src/device.rs @@ -107,6 +107,21 @@ fn accepts(device: &cpal::Device, wanted: &cpal::StreamConfig, format: cpal::Sam }) } +/// Reports whether a stream error leaves the output dead. +/// +/// `cpal` reports more than failures through the error callback. A buffer +/// that missed its deadline (`Xrun`: CoreAudio's processor-overload +/// notification, which a slow first callback or a busy machine raises), a +/// default output followed to a new device (`DeviceChanged`) and a refused +/// realtime promotion (`RealtimeDenied`) all leave the stream playing. Taking +/// them as terminal silenced the whole mixer for good on one late buffer. +pub(crate) fn ends_the_stream(kind: cpal::ErrorKind) -> bool { + !matches!( + kind, + cpal::ErrorKind::Xrun | cpal::ErrorKind::DeviceChanged | cpal::ErrorKind::RealtimeDenied + ) +} + /// Builds the output stream for one sample format. fn build( device: &cpal::Device, @@ -149,12 +164,35 @@ where *target = T::from_sample(*source); } }, - move |_error| { + move |error: cpal::Error| { // A sticky observation, polled on the frame thread. No callback // enters Nupp, allocates a message or attempts to reopen a device. - failure_callback.store(true, Ordering::Release); + if ends_the_stream(error.kind()) { + failure_callback.store(true, Ordering::Release); + } }, None, ) .map_err(|error| format!("the output stream did not open: {error}")) } + +#[cfg(test)] +mod tests { + use super::ends_the_stream; + use cpal::ErrorKind; + + #[test] + fn a_late_buffer_or_a_reroute_does_not_end_the_stream() { + assert!(!ends_the_stream(ErrorKind::Xrun)); + assert!(!ends_the_stream(ErrorKind::DeviceChanged)); + assert!(!ends_the_stream(ErrorKind::RealtimeDenied)); + } + + #[test] + fn a_lost_device_or_an_invalidated_stream_does() { + assert!(ends_the_stream(ErrorKind::DeviceNotAvailable)); + assert!(ends_the_stream(ErrorKind::HostUnavailable)); + assert!(ends_the_stream(ErrorKind::StreamInvalidated)); + assert!(ends_the_stream(ErrorKind::BackendError)); + } +}