From de4500b077150cc1de9e30dcc486947991e795a5 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Tue, 23 Dec 2025 20:54:21 +0100 Subject: [PATCH 1/6] 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 | 3 +++ 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, 120 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8245254e4..fa572c5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `StreamTrait::buffer_size` method to query the callback buffer size. - **ALSA**: `Default` implementation for `Device` (returns the ALSA "default" device). - **CI**: Checks default/no-default/all feature sets with platform-dependent MSRV for JACK. @@ -19,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **AAudio**: `BufferSize::Default` now explicitly configures using the optimal burst size from AudioManager. - **ALSA**: Devices now report direction from hint metadata and physical hardware probing. ## [0.17.0] - 2025-12-20 @@ -1033,6 +1035,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 884da59c9..8039300ce 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -1215,6 +1215,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 0f62026d7..46b76bd2c 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 2c3bccc28..02eef94eb 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -302,6 +302,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 8b2085233236f9841802baf8ab9ad703fab84ee6 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Tue, 23 Dec 2025 21:05:24 +0100 Subject: [PATCH 2/6] 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) } } From a5c98c17195bb25f6ce9d6f333f7cd65695d673c Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sun, 28 Dec 2025 23:32:55 +0100 Subject: [PATCH 3/6] feat: always set AAudio buffer size with 256 fallback --- src/host/aaudio/mod.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/host/aaudio/mod.rs b/src/host/aaudio/mod.rs index 742b9e3fd..9d499f0ff 100644 --- a/src/host/aaudio/mod.rs +++ b/src/host/aaudio/mod.rs @@ -277,23 +277,21 @@ fn configure_for_device( }; builder = builder.sample_rate(config.sample_rate.try_into().unwrap()); - let buffer_size = match config.buffer_size { + let 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().map(|s| s as u32) + match AudioManager::get_frames_per_buffer() { + Ok(size) if size > 0 => size as u32, + _ => 256, + } } - BufferSize::Fixed(size) => Some(size), + BufferSize::Fixed(size) => 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 - } + builder + .frames_per_data_callback(size as i32) + .buffer_capacity_in_frames((size * 2) as i32) // Double-buffering } fn build_input_stream( From 450ca5ac6e5d1fbf7a2d84ee3696e060c34c1294 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 2 Jan 2026 17:27:39 +0100 Subject: [PATCH 4/6] fix(aaudio): use system mixer bursts for buffer capacity --- .../aaudio/java_interface/audio_manager.rs | 23 ++++++++++- src/host/aaudio/java_interface/utils.rs | 20 ++++++++++ src/host/aaudio/mod.rs | 40 +++++++++++++------ 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/host/aaudio/java_interface/audio_manager.rs b/src/host/aaudio/java_interface/audio_manager.rs index f96d8e2cf..cd71eb150 100644 --- a/src/host/aaudio/java_interface/audio_manager.rs +++ b/src/host/aaudio/java_interface/audio_manager.rs @@ -1,6 +1,7 @@ use super::{ utils::{ - get_context, get_property, get_system_service, with_attached, JNIEnv, JObject, JResult, + get_context, get_property, get_system_property, get_system_service, with_attached, JNIEnv, + JObject, JResult, }, AudioManager, Context, }; @@ -13,6 +14,15 @@ impl AudioManager { with_attached(context, |env, context| get_frames_per_buffer(env, &context)) .map_err(|error| error.to_string()) } + + /// Get the AAudio mixer burst count from system property + /// Returns the value from aaudio.mixer_bursts property, defaulting to 2 + pub fn get_mixer_bursts() -> Result { + let context = get_context(); + + with_attached(context, |env, _context| get_mixer_bursts(env)) + .map_err(|error| error.to_string()) + } } fn get_frames_per_buffer<'j>(env: &mut JNIEnv<'j>, context: &JObject<'j>) -> JResult { @@ -31,3 +41,14 @@ fn get_frames_per_buffer<'j>(env: &mut JNIEnv<'j>, context: &JObject<'j>) -> JRe .parse::() .map_err(|_| jni::errors::Error::JniCall(jni::errors::JniError::Unknown)) } + +fn get_mixer_bursts<'j>(env: &mut JNIEnv<'j>) -> JResult { + let mixer_bursts = get_system_property(env, "aaudio.mixer_bursts", "2")?; + + let mixer_bursts_string = String::from(env.get_string(&mixer_bursts)?); + + // TODO: Use jni::errors::Error::ParseFailed instead of jni::errors::Error::JniCall once jni > v0.21.1 is released + mixer_bursts_string + .parse::() + .map_err(|_| jni::errors::Error::JniCall(jni::errors::JniError::Unknown)) +} diff --git a/src/host/aaudio/java_interface/utils.rs b/src/host/aaudio/java_interface/utils.rs index 5671ee6ae..092b0f5c4 100644 --- a/src/host/aaudio/java_interface/utils.rs +++ b/src/host/aaudio/java_interface/utils.rs @@ -165,6 +165,26 @@ pub fn get_property<'j>( call_method_string_arg_ret_string(env, subject, "getProperty", name) } +/// Read an Android system property +pub fn get_system_property<'j>( + env: &mut JNIEnv<'j>, + name: &str, + default_value: &str, +) -> JResult> { + Ok(env + .call_static_method( + "android/os/SystemProperties", + "get", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", + &[ + (&env.new_string(name)?).into(), + (&env.new_string(default_value)?).into(), + ], + )? + .l()? + .into()) +} + pub fn get_devices<'j>( env: &mut JNIEnv<'j>, subject: &JObject<'j>, diff --git a/src/host/aaudio/mod.rs b/src/host/aaudio/mod.rs index 9d499f0ff..6d2fc27ff 100644 --- a/src/host/aaudio/mod.rs +++ b/src/host/aaudio/mod.rs @@ -277,21 +277,35 @@ fn configure_for_device( }; builder = builder.sample_rate(config.sample_rate.try_into().unwrap()); - let size = match config.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 - match AudioManager::get_frames_per_buffer() { - Ok(size) if size > 0 => size as u32, - _ => 256, - } - } - BufferSize::Fixed(size) => size, - }; + // Following the pattern from Oboe and Google's AAudio samples, we only set the buffer + // capacity and let AAudio choose the optimal callback size dynamically. See: + // - https://developer.android.com/ndk/reference/group/audio + // - https://developer.android.com/ndk/guides/audio/audio-latency#buffer-size + let burst = match AudioManager::get_frames_per_buffer() { + Ok(size) if size > 0 => size, + _ => 256, // default from Android docs + }; - builder - .frames_per_data_callback(size as i32) - .buffer_capacity_in_frames((size * 2) as i32) // Double-buffering + // Determine the buffer capacity multiplier. This matches AOSP's + // AAudioServiceEndpointPlay buffer sizing strategy. + let mixer_bursts = match AudioManager::get_mixer_bursts() { + Ok(bursts) if bursts > 1 => bursts, + _ => 2, // double-buffering: default from AOSP + }; + + let capacity = burst * mixer_bursts; + builder.buffer_capacity_in_frames(capacity) + } + BufferSize::Fixed(size) => { + // For fixed sizes, the user explicitly wants control over the callback size, + // so we set both the callback size and capacity (with double-buffering). + builder + .frames_per_data_callback(size as i32) + .buffer_capacity_in_frames((size * 2) as i32) + } + } } fn build_input_stream( From c89557e7fa0fcc0ba3a626ca26a4ec3af79a25cf Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Fri, 2 Jan 2026 23:59:55 +0100 Subject: [PATCH 5/6] feat(aaudio): tune buffers dynamically --- CHANGELOG.md | 2 +- .../aaudio/java_interface/audio_manager.rs | 1 - src/host/aaudio/mod.rs | 103 +++++++++++++----- 3 files changed, 76 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa572c5d7..a191ec92e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **AAudio**: `BufferSize::Default` now explicitly configures using the optimal burst size from AudioManager. +- **AAudio**: Buffer sizes are now dynamically tuned. - **ALSA**: Devices now report direction from hint metadata and physical hardware probing. ## [0.17.0] - 2025-12-20 diff --git a/src/host/aaudio/java_interface/audio_manager.rs b/src/host/aaudio/java_interface/audio_manager.rs index cd71eb150..958921506 100644 --- a/src/host/aaudio/java_interface/audio_manager.rs +++ b/src/host/aaudio/java_interface/audio_manager.rs @@ -16,7 +16,6 @@ impl AudioManager { } /// Get the AAudio mixer burst count from system property - /// Returns the value from aaudio.mixer_bursts property, defaulting to 2 pub fn get_mixer_bursts() -> Result { let context = get_context(); diff --git a/src/host/aaudio/mod.rs b/src/host/aaudio/mod.rs index 6d2fc27ff..5ea331b3d 100644 --- a/src/host/aaudio/mod.rs +++ b/src/host/aaudio/mod.rs @@ -4,6 +4,7 @@ use std::cmp; use std::convert::TryInto; +use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use std::vec::IntoIter as VecIntoIter; @@ -148,6 +149,14 @@ unsafe impl Sync for Stream {} crate::assert_stream_send!(Stream); crate::assert_stream_sync!(Stream); +/// State for dynamic buffer tuning on output streams. +#[derive(Default)] +struct BufferTuningState { + previous_underrun_count: AtomicI32, + capacity: AtomicI32, + mixer_bursts: AtomicI32, +} + pub use crate::iter::{SupportedInputConfigs, SupportedOutputConfigs}; pub type Devices = std::vec::IntoIter; @@ -277,35 +286,16 @@ fn configure_for_device( }; builder = builder.sample_rate(config.sample_rate.try_into().unwrap()); - match config.buffer_size { - BufferSize::Default => { - // Following the pattern from Oboe and Google's AAudio samples, we only set the buffer - // capacity and let AAudio choose the optimal callback size dynamically. See: - // - https://developer.android.com/ndk/reference/group/audio - // - https://developer.android.com/ndk/guides/audio/audio-latency#buffer-size - let burst = match AudioManager::get_frames_per_buffer() { - Ok(size) if size > 0 => size, - _ => 256, // default from Android docs - }; - - // Determine the buffer capacity multiplier. This matches AOSP's - // AAudioServiceEndpointPlay buffer sizing strategy. - let mixer_bursts = match AudioManager::get_mixer_bursts() { - Ok(bursts) if bursts > 1 => bursts, - _ => 2, // double-buffering: default from AOSP - }; - - let capacity = burst * mixer_bursts; - builder.buffer_capacity_in_frames(capacity) - } - BufferSize::Fixed(size) => { - // For fixed sizes, the user explicitly wants control over the callback size, - // so we set both the callback size and capacity (with double-buffering). - builder - .frames_per_data_callback(size as i32) - .buffer_capacity_in_frames((size * 2) as i32) - } + // Following the pattern from Oboe and Google's AAudio, we let AAudio choose the optimal + // callback size dynamically by default. See + // - https://developer.android.com/ndk/reference/group/audio#aaudiostreambuilder_setframesperdatacallback + // - https://developer.android.com/ndk/guides/audio/audio-latency#buffer-size + if let BufferSize::Fixed(size) = config.buffer_size { + // Only for fixed sizes, the user explicitly wants control over the callback size. + builder = builder.frames_per_data_callback(size as i32); } + + builder } fn build_input_stream( @@ -347,6 +337,7 @@ where (error_callback)(StreamError::from(error)) })) .open_stream()?; + // SAFETY: Stream implements Send + Sync (see unsafe impl below). Arc> // is safe because the Mutex provides exclusive access and AudioStream's thread safety // is documented in the AAudio C API. @@ -372,8 +363,13 @@ where let builder = configure_for_device(builder, device, config); let created = Instant::now(); let channel_count = config.channels as i32; + + let tuning = Arc::new(BufferTuningState::default()); + let tuning_for_callback = tuning.clone(); + let stream = builder .data_callback(Box::new(move |stream, data, num_frames| { + // Deliver audio data to user callback let cb_info = OutputCallbackInfo { timestamp: OutputStreamTimestamp { callback: to_stream_instant(created.elapsed()), @@ -390,12 +386,63 @@ where }, &cb_info, ); + + // Dynamic buffer tuning for output streams + // See: https://developer.android.com/ndk/guides/audio/aaudio/aaudio#tuning-buffers + let underrun_count = stream.x_run_count(); + let previous = tuning_for_callback + .previous_underrun_count + .load(Ordering::Relaxed); + + if underrun_count > previous { + // The number of frames per burst can vary dynamically + let mut burst_size = stream.frames_per_burst(); + if burst_size <= 0 { + burst_size = 256; // fallback from AAudio documentation + } else if burst_size < 16 { + burst_size = 16; // floor from Oboe + } + + let mixer_bursts = tuning_for_callback + .mixer_bursts + .fetch_add(1, Ordering::Relaxed); + let mut buffer_size = burst_size * mixer_bursts; + + let buffer_capacity = tuning_for_callback.capacity.load(Ordering::Relaxed); + if buffer_size > buffer_capacity { + buffer_size = buffer_capacity; + } + let _ = stream.set_buffer_size_in_frames(buffer_size); + + tuning_for_callback + .previous_underrun_count + .store(underrun_count, Ordering::Relaxed); + } + ndk::audio::AudioCallbackResult::Continue })) .error_callback(Box::new(move |_stream, error| { (error_callback)(StreamError::from(error)) })) .open_stream()?; + + // After stream opens, query and cache the values + let capacity = stream.buffer_capacity_in_frames(); + tuning.capacity.store(capacity, Ordering::Relaxed); + + let mixer_bursts = match AudioManager::get_mixer_bursts() { + Ok(bursts) => bursts, + Err(_) => { + let burst_size = stream.frames_per_burst(); + if burst_size > 0 { + stream.buffer_size_in_frames() / burst_size + } else { + 0 // defer to dynamic tuning + } + } + }; + tuning.mixer_bursts.store(mixer_bursts, Ordering::Relaxed); + // SAFETY: Stream implements Send + Sync (see unsafe impl below). Arc> // is safe because the Mutex provides exclusive access and AudioStream's thread safety // is documented in the AAudio C API. From b7c0aa7c22d95015a3cecad90ea8deae23f88e57 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Sat, 3 Jan 2026 11:51:52 +0100 Subject: [PATCH 6/6] refactor(aaudio): update mixer_bursts only on successful set --- src/host/aaudio/mod.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/host/aaudio/mod.rs b/src/host/aaudio/mod.rs index 5ea331b3d..9efc4bfbf 100644 --- a/src/host/aaudio/mod.rs +++ b/src/host/aaudio/mod.rs @@ -403,16 +403,22 @@ where burst_size = 16; // floor from Oboe } - let mixer_bursts = tuning_for_callback + let new_mixer_bursts = tuning_for_callback .mixer_bursts - .fetch_add(1, Ordering::Relaxed); - let mut buffer_size = burst_size * mixer_bursts; + .load(Ordering::Relaxed) + .saturating_add(1); + let mut buffer_size = burst_size * new_mixer_bursts; let buffer_capacity = tuning_for_callback.capacity.load(Ordering::Relaxed); if buffer_size > buffer_capacity { buffer_size = buffer_capacity; } - let _ = stream.set_buffer_size_in_frames(buffer_size); + + if stream.set_buffer_size_in_frames(buffer_size).is_ok() { + tuning_for_callback + .mixer_bursts + .store(new_mixer_bursts, Ordering::Relaxed); + } tuning_for_callback .previous_underrun_count