From 858feb184d7269a8613804fa7e9e1b8f8f076bc6 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Tue, 23 Dec 2025 20:54:21 +0100 Subject: [PATCH 1/2] feat: add Stream::buffer_size() and improve AAudio buffer configuration Add buffer_size() method to Stream trait that returns the number of frames passed to each data callback invocation (actual size or upper limit depending on platform). AAudio improvements: - BufferSize::Default now explicitly configures using optimal burst size from AudioManager, following Android low-latency audio best practices - buffer_size() query falls back to burst size if frames_per_data_callback was not explicitly set - Refactored buffer configuration to eliminate code duplication Addresses #1042 Relates to #964, #942 --- CHANGELOG.md | 8 ++++++ src/host/aaudio/mod.rs | 39 ++++++++++++++++++++++++------ src/host/alsa/mod.rs | 3 +++ src/host/asio/mod.rs | 4 +++ src/host/asio/stream.rs | 9 +++++++ src/host/coreaudio/ios/mod.rs | 5 +++- src/host/coreaudio/macos/device.rs | 4 ++- src/host/coreaudio/macos/mod.rs | 8 ++++++ src/host/jack/stream.rs | 4 +++ src/host/wasapi/stream.rs | 17 +++++++++++++ src/platform/mod.rs | 11 +++++++++ src/traits.rs | 22 +++++++++++++++++ 12 files changed, 125 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 975abbdf7..0c6c5902d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `StreamTrait::buffer_size` method to query the callback buffer size (actual size or upper limit depending on platform). +- **AAudio**: `BufferSize::Default` now explicitly configures using the optimal burst size from AudioManager. + ## [0.17.0] - 2025-12-20 ### Added @@ -1017,6 +1024,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial commit. +[Unreleased]: https://github.com/RustAudio/cpal/compare/v0.17.0...HEAD [0.17.0]: https://github.com/RustAudio/cpal/compare/v0.16.0...v0.17.0 [0.16.0]: https://github.com/RustAudio/cpal/compare/v0.15.3...v0.16.0 [0.15.3]: https://github.com/RustAudio/cpal/compare/v0.15.2...v0.15.3 diff --git a/src/host/aaudio/mod.rs b/src/host/aaudio/mod.rs index c2afe3b21..71e7350a4 100644 --- a/src/host/aaudio/mod.rs +++ b/src/host/aaudio/mod.rs @@ -277,13 +277,22 @@ fn configure_for_device( }; builder = builder.sample_rate(config.sample_rate.try_into().unwrap()); - // Note: Buffer size validation is not needed - the native AAudio API validates buffer sizes - // when `open_stream()` is called. - match &config.buffer_size { - BufferSize::Default => builder, - BufferSize::Fixed(size) => builder - .frames_per_data_callback(*size as i32) - .buffer_capacity_in_frames((*size * 2) as i32), // Double-buffering + let buffer_size = match config.buffer_size { + BufferSize::Default => { + // Use the optimal burst size from AudioManager: + // https://developer.android.com/ndk/guides/audio/audio-latency#buffer-size + AudioManager::get_frames_per_buffer().ok() + } + BufferSize::Fixed(size) => Some(size), + }; + + if let Some(size) = buffer_size { + builder + .frames_per_data_callback(size as i32) + .buffer_capacity_in_frames((size * 2) as i32) // Double-buffering + } else { + // If we couldn't determine a buffer size, let AAudio choose defaults + builder } } @@ -606,4 +615,20 @@ impl StreamTrait for Stream { .map_err(PauseStreamError::from), } } + + fn buffer_size(&self) -> Option { + let stream = match self { + Self::Input(stream) => stream.lock().ok()?, + Self::Output(stream) => stream.lock().ok()?, + }; + + // If frames_per_data_callback was not explicitly set (returning 0), + // fall back to the burst size as that's what AAudio uses by default. + match stream.get_frames_per_data_callback() { + Some(size) if size > 0 => Some(size as crate::FrameCount), + _ => stream + .get_frames_per_burst() + .map(|f| f as crate::FrameCount), + } + } } diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index ac5f48677..871cb5fea 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -1220,6 +1220,9 @@ impl StreamTrait for Stream { self.inner.channel.pause(true).ok(); Ok(()) } + fn buffer_size(&self) -> Option { + Some(self.inner.period_frames as FrameCount) + } } // Convert ALSA frames to FrameCount, clamping to valid range. diff --git a/src/host/asio/mod.rs b/src/host/asio/mod.rs index 87e3adfea..6b13d2cec 100644 --- a/src/host/asio/mod.rs +++ b/src/host/asio/mod.rs @@ -153,4 +153,8 @@ impl StreamTrait for Stream { fn pause(&self) -> Result<(), PauseStreamError> { Stream::pause(self) } + + fn buffer_size(&self) -> Option { + Stream::buffer_size(self) + } } diff --git a/src/host/asio/stream.rs b/src/host/asio/stream.rs index 958a52bc3..feb0c81b5 100644 --- a/src/host/asio/stream.rs +++ b/src/host/asio/stream.rs @@ -37,6 +37,15 @@ impl Stream { self.playing.store(false, Ordering::SeqCst); Ok(()) } + + pub fn buffer_size(&self) -> Option { + let streams = self.asio_streams.lock().ok()?; + streams + .output + .as_ref() + .or(streams.input.as_ref()) + .map(|s| s.buffer_size as crate::FrameCount) + } } impl Device { diff --git a/src/host/coreaudio/ios/mod.rs b/src/host/coreaudio/ios/mod.rs index 4fbc9c104..7dd637a3e 100644 --- a/src/host/coreaudio/ios/mod.rs +++ b/src/host/coreaudio/ios/mod.rs @@ -274,11 +274,14 @@ impl StreamTrait for Stream { let err = BackendSpecificError { description }; return Err(err.into()); } - stream.playing = false; } Ok(()) } + + fn buffer_size(&self) -> Option { + Some(get_device_buffer_frames() as crate::FrameCount) + } } struct StreamInner { diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index 1302299f3..522284012 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -1007,7 +1007,9 @@ fn setup_callback_vars( /// /// Buffer frame size is a device-level property that always uses Scope::Global + Element::Output, /// regardless of whether the audio unit is configured for input or output streams. -fn get_device_buffer_frame_size(audio_unit: &AudioUnit) -> Result { +pub(crate) fn get_device_buffer_frame_size( + audio_unit: &AudioUnit, +) -> Result { // Device-level property: always use Scope::Global + Element::Output // This is consistent with how we set the buffer size and query the buffer size range let frames: u32 = audio_unit.get_property( diff --git a/src/host/coreaudio/macos/mod.rs b/src/host/coreaudio/macos/mod.rs index a7a025166..7bc0d5990 100644 --- a/src/host/coreaudio/macos/mod.rs +++ b/src/host/coreaudio/macos/mod.rs @@ -261,6 +261,14 @@ impl StreamTrait for Stream { stream.pause() } + + fn buffer_size(&self) -> Option { + let stream = self.inner.lock().ok()?; + + device::get_device_buffer_frame_size(&stream.audio_unit) + .ok() + .map(|size| size as crate::FrameCount) + } } #[cfg(test)] diff --git a/src/host/jack/stream.rs b/src/host/jack/stream.rs index 25b91e0f3..91b2efbe5 100644 --- a/src/host/jack/stream.rs +++ b/src/host/jack/stream.rs @@ -220,6 +220,10 @@ impl StreamTrait for Stream { self.playing.store(false, Ordering::SeqCst); Ok(()) } + + fn buffer_size(&self) -> Option { + Some(self.async_client.as_client().buffer_size() as crate::FrameCount) + } } type InputDataCallback = Box; diff --git a/src/host/wasapi/stream.rs b/src/host/wasapi/stream.rs index 6e681b9a7..1945d98bb 100644 --- a/src/host/wasapi/stream.rs +++ b/src/host/wasapi/stream.rs @@ -29,6 +29,11 @@ pub struct Stream { // This event is signalled after a new entry is added to `commands`, so that the `run()` // method can be notified. pending_scheduled_event: Foundation::HANDLE, + + // Number of frames in the WASAPI buffer. + // + // Note: the actual callback size is variable and may be less than this value. + max_frames_in_buffer: u32, } // SAFETY: Windows Event HANDLEs are safe to send between threads - they are designed for @@ -115,6 +120,8 @@ impl Stream { .expect("cpal: could not create input stream event"); let (tx, rx) = channel(); + let max_frames_in_buffer = stream_inner.max_frames_in_buffer; + let run_context = RunContext { handles: vec![pending_scheduled_event, stream_inner.event], stream: stream_inner, @@ -130,6 +137,7 @@ impl Stream { thread: Some(thread), commands: tx, pending_scheduled_event, + max_frames_in_buffer, } } @@ -148,6 +156,8 @@ impl Stream { .expect("cpal: could not create output stream event"); let (tx, rx) = channel(); + let max_frames_in_buffer = stream_inner.max_frames_in_buffer; + let run_context = RunContext { handles: vec![pending_scheduled_event, stream_inner.event], stream: stream_inner, @@ -163,6 +173,7 @@ impl Stream { thread: Some(thread), commands: tx, pending_scheduled_event, + max_frames_in_buffer, } } @@ -198,6 +209,12 @@ impl StreamTrait for Stream { .map_err(|_| crate::error::PauseStreamError::DeviceNotAvailable)?; Ok(()) } + + fn buffer_size(&self) -> Option { + // WASAPI uses event-driven callbacks with variable callback sizes. + // We return the total buffer size allocated by Windows as an upper bound. + Some(self.max_frames_in_buffer as crate::FrameCount) + } } impl Drop for StreamInner { diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 0d6a01b72..c93a54540 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -591,6 +591,17 @@ macro_rules! impl_platform_host { )* } } + + fn buffer_size(&self) -> Option { + match self.0 { + $( + $(#[cfg($feat)])? + StreamInner::$HostVariant(ref s) => { + s.buffer_size() + } + )* + } + } } impl From for Device { diff --git a/src/traits.rs b/src/traits.rs index 6e682d59f..299fc66e4 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -304,6 +304,28 @@ pub trait StreamTrait { /// Note: Not all devices support suspending the stream at the hardware level. This method may /// fail in these cases. fn pause(&self) -> Result<(), PauseStreamError>; + + /// Query the stream's callback buffer size in frames. + /// + /// Returns the actual buffer size chosen by the platform, which may differ from a requested + /// `BufferSize::Fixed` value due to hardware constraints, or is determined by the platform + /// when using `BufferSize::Default`. + /// + /// # Returns + /// + /// Returns `Some(frames)` if the callback buffer size is known, or `None` if: + /// - The platform doesn't support querying buffer size at runtime + /// - The stream hasn't been fully initialized yet + /// + /// # Note on Variable Callback Sizes + /// + /// Some platforms (notably WASAPI and mobile) may deliver variable-sized buffers to callbacks + /// that are smaller than the reported buffer size. When `buffer_size()` returns a value, it + /// should be treated as the maximum expected size, but applications should always check the + /// actual buffer size passed to each callback. + fn buffer_size(&self) -> Option { + None + } } /// Compile-time assertion that a stream type implements [`Send`]. From 401e5b90d8231942c28c2d0df4faeba6df766a9c Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Tue, 23 Dec 2025 21:05:24 +0100 Subject: [PATCH 2/2] refactor(aaudio): simplify Stream from enum to struct Replace `Stream` enum with a struct containing `inner: Arc>` and `direction: DeviceDirection` fields. This eliminates code duplication while maintaining the same functionality. --- src/host/aaudio/mod.rs | 64 ++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 34 deletions(-) diff --git a/src/host/aaudio/mod.rs b/src/host/aaudio/mod.rs index 71e7350a4..742b9e3fd 100644 --- a/src/host/aaudio/mod.rs +++ b/src/host/aaudio/mod.rs @@ -131,9 +131,9 @@ pub struct Device(Option); /// - The pointer in AudioStream (NonNull) is valid for the lifetime /// of the stream and AAudio C API functions are thread-safe at the C level #[derive(Clone)] -pub enum Stream { - Input(Arc>), - Output(Arc>), +pub struct Stream { + inner: Arc>, + direction: DeviceDirection, } // SAFETY: AudioStream can be safely sent between threads. The AAudio C API is thread-safe @@ -281,7 +281,7 @@ fn configure_for_device( BufferSize::Default => { // Use the optimal burst size from AudioManager: // https://developer.android.com/ndk/guides/audio/audio-latency#buffer-size - AudioManager::get_frames_per_buffer().ok() + AudioManager::get_frames_per_buffer().ok().map(|s| s as u32) } BufferSize::Fixed(size) => Some(size), }; @@ -339,7 +339,10 @@ where // is safe because the Mutex provides exclusive access and AudioStream's thread safety // is documented in the AAudio C API. #[allow(clippy::arc_with_non_send_sync)] - Ok(Stream::Input(Arc::new(Mutex::new(stream)))) + Ok(Stream { + inner: Arc::new(Mutex::new(stream)), + direction: DeviceDirection::Input, + }) } fn build_output_stream( @@ -385,7 +388,10 @@ where // is safe because the Mutex provides exclusive access and AudioStream's thread safety // is documented in the AAudio C API. #[allow(clippy::arc_with_non_send_sync)] - Ok(Stream::Output(Arc::new(Mutex::new(stream)))) + Ok(Stream { + inner: Arc::new(Mutex::new(stream)), + direction: DeviceDirection::Output, + }) } impl DeviceTrait for Device { @@ -588,47 +594,37 @@ impl DeviceTrait for Device { impl StreamTrait for Stream { fn play(&self) -> Result<(), PlayStreamError> { - match self { - Self::Input(stream) => stream - .lock() - .unwrap() - .request_start() - .map_err(PlayStreamError::from), - Self::Output(stream) => stream - .lock() - .unwrap() - .request_start() - .map_err(PlayStreamError::from), - } + self.inner + .lock() + .unwrap() + .request_start() + .map_err(PlayStreamError::from) } fn pause(&self) -> Result<(), PauseStreamError> { - match self { - Self::Input(_) => Err(BackendSpecificError { - description: "Pause called on the input stream.".to_owned(), - } - .into()), - Self::Output(stream) => stream + match self.direction { + DeviceDirection::Output => self + .inner .lock() .unwrap() .request_pause() .map_err(PauseStreamError::from), + _ => Err(BackendSpecificError { + description: "Pause only supported on output streams.".to_owned(), + } + .into()), } } fn buffer_size(&self) -> Option { - let stream = match self { - Self::Input(stream) => stream.lock().ok()?, - Self::Output(stream) => stream.lock().ok()?, - }; + let stream = self.inner.lock().ok()?; // If frames_per_data_callback was not explicitly set (returning 0), // fall back to the burst size as that's what AAudio uses by default. - match stream.get_frames_per_data_callback() { - Some(size) if size > 0 => Some(size as crate::FrameCount), - _ => stream - .get_frames_per_burst() - .map(|f| f as crate::FrameCount), - } + let frames = match stream.frames_per_data_callback() { + Some(size) if size > 0 => size, + _ => stream.frames_per_burst(), + }; + Some(frames as crate::FrameCount) } }