Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
- CoreAudio: Add `i8`, `i32` and `I24` sample format support (24-bit samples stored in 4 bytes).
- CoreAudio: Add support for loopback recording (recording system audio output) on macOS.
- CoreAudio: Update `mach2` to 0.5.
- CoreAudio: Configure device buffer to ensure predictable callback buffer sizes.
- CoreAudio: Fix timestamp accuracy.
- iOS: Fix example by properly activating audio session.
- WASAPI: Expose `IMMDevice` from WASAPI host Device.
- WASAPI: Add `I24` and `U24` sample format support (24-bit samples stored in 4 bytes).
Expand Down
63 changes: 54 additions & 9 deletions src/host/coreaudio/ios/mod.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
//!
//! coreaudio on iOS looks a bit different from macOS. A lot of configuration needs to use
//! the AVAudioSession objc API which doesn't exist on macOS.
//! CoreAudio implementation for iOS using RemoteIO Audio Units.
//!
//! TODO:
//! - Use AVAudioSession to enumerate buffer size / sample rate / number of channels and set
//! buffer size.
//! ## Implementation Details
//!
//! This implementation uses **RemoteIO Audio Units** to interface with iOS audio hardware:
//!
//! - **RemoteIO**: A special Audio Unit that acts as a proxy to the actual hardware
//! - **Direct queries**: Buffer sizes are queried directly from the RemoteIO unit
//! - **System control**: iOS controls buffer sizes, sample rates, and device routing
//! - **Single device model**: iOS presents audio as a single system-managed device
//!
//! ## Limitations
//!
//! - **No device enumeration**: iOS doesn't allow direct hardware device access
//! - **No fixed buffer sizes**: `BufferSize::Fixed` returns `StreamConfigNotSupported`
//! - **System-determined parameters**: Buffer sizes and sample rates are set by iOS

// TODO:
// - Use AVAudioSession to enumerate buffer size / sample rate / number of channels and set
// buffer size.

use std::cell::RefCell;

use coreaudio::audio_unit::render_callback::data;
use coreaudio::audio_unit::{render_callback, AudioUnit, Element, Scope};
use objc2_audio_toolbox::{kAudioOutputUnitProperty_EnableIO, kAudioUnitProperty_StreamFormat};
use objc2_core_audio::kAudioDevicePropertyBufferFrameSize;
use objc2_core_audio_types::{AudioBuffer, AudioStreamBasicDescription};

use super::{asbd_from_config, frames_to_duration, host_time_to_stream_instant};
Expand Down Expand Up @@ -197,6 +211,10 @@ impl DeviceTrait for Device {
BufferSize::Default => (),
}

// Query the actual device buffer size for more accurate latency calculation. On iOS,
// BufferSize::Fixed is not supported, so this always gets the current device buffer size.
let device_buffer_frames = get_device_buffer_frame_size(&audio_unit).ok();

// Register the callback that is being called by coreaudio whenever it needs data to be
// fed to the audio buffer.
let bytes_per_channel = sample_format.sample_size();
Expand All @@ -218,7 +236,6 @@ impl DeviceTrait for Device {
let len = (data_byte_size as usize / bytes_per_channel) as usize;
let data = Data::from_parts(data, len, sample_format);

// TODO: Need a better way to get delay, for now we assume a double-buffer offset.
let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) {
Err(err) => {
error_callback(err.into());
Expand All @@ -227,7 +244,12 @@ impl DeviceTrait for Device {
Ok(cb) => cb,
};
let buffer_frames = len / channels as usize;
let delay = frames_to_duration(buffer_frames, sample_rate);
// Use device buffer size for latency calculation if available
let latency_frames = device_buffer_frames.unwrap_or(
// Fallback to callback buffer size if device buffer size is unknown
buffer_frames,
);
let delay = frames_to_duration(latency_frames, sample_rate);
let capture = callback
.sub(delay)
.expect("`capture` occurs before origin of alsa `StreamInstant`");
Expand Down Expand Up @@ -276,6 +298,10 @@ impl DeviceTrait for Device {
let asbd = asbd_from_config(config, sample_format);
audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?;

// Query the actual device buffer size for more accurate latency calculation. On iOS,
// BufferSize::Fixed is not supported, so this always gets the current device buffer size.
let device_buffer_frames = get_device_buffer_frame_size(&audio_unit).ok();

// Register the callback that is being called by coreaudio whenever it needs data to be
// fed to the audio buffer.
let bytes_per_channel = sample_format.sample_size();
Expand All @@ -302,9 +328,13 @@ impl DeviceTrait for Device {
}
Ok(cb) => cb,
};
// TODO: Need a better way to get delay, for now we assume a double-buffer offset.
let buffer_frames = len / channels as usize;
let delay = frames_to_duration(buffer_frames, sample_rate);
// Use device buffer size for latency calculation if available
let latency_frames = device_buffer_frames.unwrap_or(
// Fallback to callback buffer size if device buffer size is unknown
buffer_frames,
);
let delay = frames_to_duration(latency_frames, sample_rate);
let playback = callback
.add(delay)
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
Expand Down Expand Up @@ -427,3 +457,18 @@ fn stream_config_from_asbd(asbd: AudioStreamBasicDescription) -> SupportedStream
sample_format: SUPPORTED_SAMPLE_FORMAT,
}
}

/// Query the current device buffer frame size from CoreAudio.
///
/// On iOS, this queries the RemoteIO audio unit which acts as a proxy to the hardware.
/// RemoteIO uses Global scope because it represents the system-wide audio session,
/// not a specific hardware device like on macOS.
fn get_device_buffer_frame_size(audio_unit: &AudioUnit) -> Result<usize, coreaudio::Error> {
// For iOS RemoteIO, we query the global scope since RemoteIO represents
// the system audio session rather than direct hardware access
audio_unit.get_property::<usize>(
kAudioDevicePropertyBufferFrameSize,
Scope::Global,
Element::Output,
)
}
175 changes: 115 additions & 60 deletions src/host/coreaudio/macos/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -694,40 +694,34 @@ impl Device {
audio_unit_from_device(&loopback_aggregate.as_ref().unwrap().aggregate_device, true)?
};

// Set the stream in interleaved mode.
let asbd = asbd_from_config(config, sample_format);
audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?;

// Set the buffersize
match config.buffer_size {
BufferSize::Fixed(v) => {
let buffer_size_range = get_io_buffer_frame_size_range(&audio_unit)?;
match buffer_size_range {
SupportedBufferSize::Range { min, max } => {
if v >= min && v <= max {
audio_unit.set_property(
kAudioDevicePropertyBufferFrameSize,
scope,
element,
Some(&v),
)?
} else {
return Err(BuildStreamError::StreamConfigNotSupported);
}
}
SupportedBufferSize::Unknown => (),
}
}
BufferSize::Default => (),
}
// Configure device buffer to ensure predictable callback behavior and accurate latency.
//
// CoreAudio double-buffering model:
// - CPAL buffer size (from user) = total buffer size that CPAL manages
// - Device buffer size = actual hardware buffer size (CPAL buffer size / 2)
// - Callback buffer size = size of each callback invocation (≈ device buffer size)
//
// CoreAudio automatically delivers callbacks with buffer_size ≈ device_buffer_size.
// To ensure applications receive callbacks of the size they requested,
// we configure device_buffer_size = requested_buffer_size / 2.
//
// This provides:
// - Predictable callback buffer sizes matching application requests
// - Efficient double-buffering (device buffer + callback buffer)
// - Low latency determined by the device buffer size
//
// For latency calculation, we need the device buffer size, not the callback buffer size,
// because latency represents the delay from when audio is written to when it's heard.
configure_stream_format_and_buffer(&mut audio_unit, config, sample_format, scope, element)?;

let error_callback = Arc::new(Mutex::new(error_callback));
let error_callback_disconnect = error_callback.clone();

// Register the callback that is being called by coreaudio whenever it needs data to be
// fed to the audio buffer.
let bytes_per_channel = sample_format.sample_size();
let sample_rate = config.sample_rate;
let (bytes_per_channel, sample_rate, device_buffer_frames) =
setup_callback_vars(&audio_unit, config, sample_format, scope, element);

type Args = render_callback::Args<data::Raw>;
audio_unit.set_input_callback(move |args: Args| unsafe {
let ptr = (*args.data.data).mBuffers.as_ptr();
Expand All @@ -745,7 +739,6 @@ impl Device {
let len = data_byte_size as usize / bytes_per_channel;
let data = Data::from_parts(data, len, sample_format);

// TODO: Need a better way to get delay, for now we assume a double-buffer offset.
let callback = match host_time_to_stream_instant(args.time_stamp.mHostTime) {
Err(err) => {
(error_callback.lock().unwrap())(err.into());
Expand All @@ -754,7 +747,13 @@ impl Device {
Ok(cb) => cb,
};
let buffer_frames = len / channels as usize;
let delay = frames_to_duration(buffer_frames, sample_rate);
// Use device buffer size for latency calculation if available
let latency_frames = device_buffer_frames.unwrap_or(
// Fallback to callback buffer size if device buffer size is unknown
// (may overestimate latency for BufferSize::Default)
buffer_frames,
);
let delay = frames_to_duration(latency_frames, sample_rate);
let capture = callback
.sub(delay)
.expect("`capture` occurs before origin of alsa `StreamInstant`");
Expand Down Expand Up @@ -802,40 +801,17 @@ impl Device {
let scope = Scope::Input;
let element = Element::Output;

// Set the stream in interleaved mode.
let asbd = asbd_from_config(config, sample_format);
audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?;

// Set the buffersize
match config.buffer_size {
BufferSize::Fixed(v) => {
let buffer_size_range = get_io_buffer_frame_size_range(&audio_unit)?;
match buffer_size_range {
SupportedBufferSize::Range { min, max } => {
if v >= min && v <= max {
audio_unit.set_property(
kAudioDevicePropertyBufferFrameSize,
scope,
element,
Some(&v),
)?
} else {
return Err(BuildStreamError::StreamConfigNotSupported);
}
}
SupportedBufferSize::Unknown => (),
}
}
BufferSize::Default => (),
}
// Configure device buffer (see comprehensive documentation in input stream above)
configure_stream_format_and_buffer(&mut audio_unit, config, sample_format, scope, element)?;

let error_callback = Arc::new(Mutex::new(error_callback));
let error_callback_disconnect = error_callback.clone();

// Register the callback that is being called by coreaudio whenever it needs data to be
// fed to the audio buffer.
let bytes_per_channel = sample_format.sample_size();
let sample_rate = config.sample_rate;
let (bytes_per_channel, sample_rate, device_buffer_frames) =
setup_callback_vars(&audio_unit, config, sample_format, scope, element);

type Args = render_callback::Args<data::Raw>;
audio_unit.set_render_callback(move |args: Args| unsafe {
// If `run()` is currently running, then a callback will be available from this list.
Expand All @@ -858,9 +834,14 @@ impl Device {
}
Ok(cb) => cb,
};
// TODO: Need a better way to get delay, for now we assume a double-buffer offset.
let buffer_frames = len / channels as usize;
let delay = frames_to_duration(buffer_frames, sample_rate);
// Use device buffer size for latency calculation if available
let latency_frames = device_buffer_frames.unwrap_or(
// Fallback to callback buffer size if device buffer size is unknown
// (may overestimate latency for BufferSize::Default)
buffer_frames,
);
let delay = frames_to_duration(latency_frames, sample_rate);
let playback = callback
.add(delay)
.expect("`playback` occurs beyond representation supported by `StreamInstant`");
Expand Down Expand Up @@ -890,3 +871,77 @@ impl Device {
Ok(stream)
}
}

/// Configure stream format and buffer size for CoreAudio stream.
///
/// This handles the common setup tasks for both input and output streams:
/// - Sets the stream format (ASBD)
/// - Configures buffer size for Fixed buffer size requests
/// - Validates buffer size ranges
fn configure_stream_format_and_buffer(
audio_unit: &mut AudioUnit,
config: &StreamConfig,
sample_format: SampleFormat,
scope: Scope,
element: Element,
) -> Result<(), BuildStreamError> {
// Set the stream in interleaved mode
let asbd = asbd_from_config(config, sample_format);
audio_unit.set_property(kAudioUnitProperty_StreamFormat, scope, element, Some(&asbd))?;

// Configure device buffer size if requested
match config.buffer_size {
BufferSize::Fixed(cpal_buffer_size) => {
let buffer_size_range = get_io_buffer_frame_size_range(audio_unit)?;
let device_buffer_size = cpal_buffer_size / 2;

if let SupportedBufferSize::Range { min, max } = buffer_size_range {
if !(min..=max).contains(&device_buffer_size) {
// The calculated device buffer size doesn't fit in the supported range
// or is zero (due to integer division). This means the requested
// cpal_buffer_size is too small or too large for this device.
return Err(BuildStreamError::StreamConfigNotSupported);
}
}
Comment on lines 898 to 905
Copy link

Copilot AI Sep 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The comment mentions 'or is zero (due to integer division)' but the code doesn't explicitly check for zero. Since zero would fail the range check anyway, consider either removing this comment or adding an explicit zero check for clarity.

Copilot uses AI. Check for mistakes.
audio_unit.set_property(
kAudioDevicePropertyBufferFrameSize,
scope,
element,
Some(&device_buffer_size),
)?;
}
BufferSize::Default => (),
}

Ok(())
}

/// Setup common callback variables and query device buffer size.
///
/// Returns (bytes_per_channel, sample_rate, device_buffer_frames)
fn setup_callback_vars(
audio_unit: &AudioUnit,
config: &StreamConfig,
sample_format: SampleFormat,
scope: Scope,
element: Element,
) -> (usize, crate::SampleRate, Option<usize>) {
let bytes_per_channel = sample_format.sample_size();
let sample_rate = config.sample_rate;

// Query the actual device buffer size for latency calculation.
// For Fixed: verifies CoreAudio actually set what we requested
// For Default: gets the device's current buffer size
let device_buffer_frames = get_device_buffer_frame_size(audio_unit, scope, element).ok();

(bytes_per_channel, sample_rate, device_buffer_frames)
}

/// Query the current device buffer frame size from CoreAudio.
fn get_device_buffer_frame_size(
audio_unit: &AudioUnit,
scope: Scope,
element: Element,
) -> Result<usize, coreaudio::Error> {
audio_unit.get_property::<usize>(kAudioDevicePropertyBufferFrameSize, scope, element)
}
Loading
Loading