From 675faad03e7c7648a039b7109b94edb995461eb2 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 15 Jun 2026 23:09:35 +0200 Subject: [PATCH 1/5] fix(alsa): don't floor a sub-millisecond poll timeout to a non-blocking poll A nonzero timeout under 1ms truncated to 0, which ALSA reads as a non-blocking poll. That busy-spins instead of waiting the short interval the caller asked for. Round up to 1ms. An explicit Duration::ZERO still maps to 0, so a deliberate non-blocking poll still works. --- src/host/alsa/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index 2974bb593..f8c0c4519 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -856,7 +856,9 @@ struct StreamWorkerContext { impl StreamWorkerContext { fn new(poll_timeout: &Option, stream: &StreamInner, rx: &TriggerReceiver) -> Self { let poll_timeout: i32 = if let Some(d) = poll_timeout { - d.as_millis().min(i32::MAX as u128) as i32 + // Round up: a nonzero sub-millisecond timeout must not floor to 0 (a non-blocking poll), + // but an explicit Duration::ZERO stays 0 so a non-blocking poll can still be requested. + d.as_nanos().div_ceil(1_000_000).min(i32::MAX as u128) as i32 } else { -1 // Don't timeout, wait forever. }; From 8d63da5c74e8fb3e77b9838acbd6fe308ff6574b Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 15 Jun 2026 23:10:20 +0200 Subject: [PATCH 2/5] fix(wasapi): round frame/REFERENCE_TIME so buffer sizes aren't off by one A frame count and its 100ns duration aren't exact multiples. Truncating on the way out and again on the way back drops common sizes (512, 1024, ...) by a frame, so the supported range and default buffer size came back one short. Round both halves of the round-trip. --- src/host/wasapi/device.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/host/wasapi/device.rs b/src/host/wasapi/device.rs index f00c7d6cb..9656e99cf 100644 --- a/src/host/wasapi/device.rs +++ b/src/host/wasapi/device.rs @@ -1452,11 +1452,16 @@ fn shared_mode_period_frames( fn buffer_size_to_duration(buffer_size: &BufferSize, sample_rate: SampleRate) -> i64 { match buffer_size { - BufferSize::Fixed(frames) => *frames as i64 * (1_000_000_000 / 100) / sample_rate as i64, + // Round: a frame count and its 100ns duration are not exact multiples, so truncating here + // and again on the way back drops common sizes (512, 1024, ...) by one frame. + BufferSize::Fixed(frames) => { + let rate = sample_rate as i64; + (*frames as i64 * (1_000_000_000 / 100) + rate / 2) / rate + } BufferSize::Default => 0, } } fn buffer_duration_to_frames(buffer_duration: i64, sample_rate: SampleRate) -> FrameCount { - (buffer_duration * sample_rate as i64 * 100 / 1_000_000_000) as FrameCount + ((buffer_duration * sample_rate as i64 * 100 + 500_000_000) / 1_000_000_000) as FrameCount } From 38cd91487a11e012bd3d52f01f5aa3c3f1f1f755 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 15 Jun 2026 23:12:39 +0200 Subject: [PATCH 3/5] fix: keep timestamps monotonic across reroutes A host's reported latency can jump between callbacks: a device reroute, an audio graph reconnection, or a timestamp read that fails and falls back to now() with no latency offset. Folding that into the timestamp can walk the derived instant backward. Wrap the data callbacks in a shared non-decreasing clamp, applied only to the hosts that can actually regress. iOS and JACK didn't account for buffer depth yet, so I added that here too: - iOS: capture and playback now include hardware latency, and refresh it when the audio route changes. - JACK: timestamps now include port latency. --- CHANGELOG.md | 6 +- src/host/aaudio/mod.rs | 6 ++ src/host/alsa/mod.rs | 6 ++ src/host/asio/stream.rs | 12 ++- src/host/audioworklet/mod.rs | 94 +++++++++++++------ src/host/coreaudio/ios/mod.rs | 93 +++++++++++++----- .../coreaudio/ios/session_event_manager.rs | 29 +++++- src/host/coreaudio/macos/device.rs | 43 +++++++-- src/host/coreaudio/macos/mod.rs | 32 +++++-- src/host/jack/device.rs | 6 ++ src/host/jack/stream.rs | 58 +++++++++--- src/host/mod.rs | 42 +++++++++ src/host/pipewire/device.rs | 6 ++ src/host/pulseaudio/mod.rs | 6 ++ src/host/wasapi/device.rs | 3 + src/host/webaudio/mod.rs | 3 + 16 files changed, 364 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff3da38e0..c8751a4aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **iOS**: Timestamps now include hardware latency and update when the audio route changes. +- **JACK**: Timestamps now include port latency. - **WASAPI**: The `windows` and `windows-core` dependencies are now both pinned to 0.62. ### Fixed -- **WASAPI**: Fix output `playback` timestamps occasionally stepping backwards. +- Timestamps now stay monotonic across device and graph changes. +- **ALSA**: A nonzero but sub-millisecond stream timeout is no longer treated as a non-blocking poll. +- **WASAPI**: Reported buffer sizes are no longer off by one frame. ## [0.18.1] - 2026-06-07 diff --git a/src/host/aaudio/mod.rs b/src/host/aaudio/mod.rs index 92e216f82..b6cdb4584 100644 --- a/src/host/aaudio/mod.rs +++ b/src/host/aaudio/mod.rs @@ -677,6 +677,9 @@ impl DeviceTrait for Device { .channel_count(config.channels as i32) .format(format); + // Keep `capture` monotonic: a transient getTimestamp() failure falls back to `now()` + // (no latency offset), so the next successful read can pull `capture` backward. + let data_callback = crate::host::monotonic_input_callback(data_callback); build_input_stream( self, config, @@ -721,6 +724,9 @@ impl DeviceTrait for Device { .channel_count(config.channels as i32) .format(format); + // Keep `playback` monotonic: a transient getTimestamp() failure falls back to `now()` + // (no latency offset), pulling `playback` backward. + let data_callback = crate::host::monotonic_output_callback(data_callback); build_output_stream( self, config, diff --git a/src/host/alsa/mod.rs b/src/host/alsa/mod.rs index f8c0c4519..e4bdc492f 100644 --- a/src/host/alsa/mod.rs +++ b/src/host/alsa/mod.rs @@ -265,6 +265,9 @@ impl DeviceTrait for Device { D: FnMut(&Data, &InputCallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static, { + // Keep `capture` monotonic: avail_delay() varies between cycles, and a capture overrun + // can make it jump up enough to pull `capture` backward. + let data_callback = crate::host::monotonic_input_callback(data_callback); let stream_inner = self.build_stream_inner(conf, sample_format, alsa::Direction::Capture)?; let stream = Self::Stream::new_input( @@ -288,6 +291,9 @@ impl DeviceTrait for Device { D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static, { + // Keep `playback` monotonic: avail_delay() varies between cycles, and a playback + // underrun can drain the buffer enough to pull `playback` backward. + let data_callback = crate::host::monotonic_output_callback(data_callback); let stream_inner = self.build_stream_inner(conf, sample_format, alsa::Direction::Playback)?; let stream = Self::Stream::new_output( diff --git a/src/host/asio/stream.rs b/src/host/asio/stream.rs index cc0bfab60..93cc75631 100644 --- a/src/host/asio/stream.rs +++ b/src/host/asio/stream.rs @@ -125,7 +125,7 @@ impl Device { &self, config: StreamConfig, sample_format: SampleFormat, - mut data_callback: D, + data_callback: D, error_callback: E, _timeout: Option, ) -> Result @@ -134,6 +134,10 @@ impl Device { E: FnMut(Error) + Send + 'static, { crate::validate_stream_config(&config)?; + // Keep `capture` monotonic: kAsioLatenciesChanged (e.g. the user changes the buffer + // size in the ASIO driver's control panel) can raise inputLatency, pulling `capture` + // backward. + let mut data_callback = crate::host::monotonic_input_callback(data_callback); com::com_initialized(); let description = self.description()?; let driver = super::GLOBAL_ASIO @@ -458,7 +462,7 @@ impl Device { &self, config: StreamConfig, sample_format: SampleFormat, - mut data_callback: D, + data_callback: D, error_callback: E, _timeout: Option, ) -> Result @@ -467,6 +471,10 @@ impl Device { E: FnMut(Error) + Send + 'static, { crate::validate_stream_config(&config)?; + // Keep `playback` monotonic: kAsioLatenciesChanged (e.g. the user changes the buffer + // size in the ASIO driver's control panel) can lower outputLatency, pulling `playback` + // backward. + let mut data_callback = crate::host::monotonic_output_callback(data_callback); com::com_initialized(); let description = self.description()?; let driver = super::GLOBAL_ASIO diff --git a/src/host/audioworklet/mod.rs b/src/host/audioworklet/mod.rs index 922e3c047..d63c60e61 100644 --- a/src/host/audioworklet/mod.rs +++ b/src/host/audioworklet/mod.rs @@ -45,8 +45,12 @@ pub struct Host; pub struct Stream { audio_context: web_sys::AudioContext, buffer_size_frames: Arc, + _latency_poller: Option, } +/// How often the main thread re-reads `outputLatency` to publish it to the worklet. +const LATENCY_POLL_INTERVAL: Duration = Duration::from_millis(500); + pub use crate::iter::{SupportedInputConfigs, SupportedOutputConfigs}; // https://webaudio.github.io/web-audio-api/#dom-audioworkletnode-audioworkletnode @@ -250,7 +254,7 @@ impl DeviceTrait for Device { &self, config: StreamConfig, sample_format: SampleFormat, - mut data_callback: D, + data_callback: D, mut error_callback: E, _timeout: Option, ) -> Result @@ -259,6 +263,9 @@ impl DeviceTrait for Device { E: FnMut(Error) + Send + 'static, { crate::validate_stream_config(&config)?; + // Keep `playback` monotonic: the polled outputLatency can drop when the + // page calls `setSinkId()` to switch output devices, pulling `playback` backward. + let mut data_callback = crate::host::monotonic_output_callback(data_callback); if config.channels > MAX_CHANNELS { return Err(Error::with_message( ErrorKind::UnsupportedConfig, @@ -346,6 +353,9 @@ impl DeviceTrait for Device { }); let buffer_size_frames = Arc::new(AtomicU64::new(initial_quantum)); let buffer_size_frames_cb = buffer_size_frames.clone(); + // The worklet realm cannot read AudioContext properties, so share the value via an atomic. + let latency_nanos = Arc::new(AtomicU64::new(total_latency_nanos(&audio_context))); + let latency_nanos_cb = latency_nanos.clone(); let ctx = audio_context.clone(); wasm_bindgen_futures::spawn_local(async move { let result: Result<(), JsValue> = async move { @@ -361,28 +371,6 @@ impl DeviceTrait for Device { options.set_output_channel_count(&js_array); options.set_number_of_inputs(0); - // Capture audio output latency here: the closure runs in a separate worker and cannot access AudioContext properties directly. - // While baseLatency is fixed for the context lifetime, outputLatency can change but not be re-read from inside the worklet; - // we snapshot it here. - let base_latency_secs = - js_sys::Reflect::get(ctx.as_ref(), &JsValue::from("baseLatency")) - .ok() - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - let output_latency_secs = - js_sys::Reflect::get(ctx.as_ref(), &JsValue::from("outputLatency")) - .ok() - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - let total_output_latency_secs = { - let sum = base_latency_secs + output_latency_secs; - if sum.is_finite() { - sum.max(0.0) - } else { - 0.0 - } - }; - options.set_processor_options(Some(&js_sys::Array::of3( &wasm_bindgen::module(), &wasm_bindgen::memory(), @@ -397,9 +385,9 @@ impl DeviceTrait for Device { let callback = StreamInstant::from_secs_f64(now); let buffer_duration = frames_to_duration(frame_size as FrameCount, sample_rate); - let playback = callback - + (buffer_duration - + Duration::from_secs_f64(total_output_latency_secs)); + let latency = + Duration::from_nanos(latency_nanos_cb.load(Ordering::Relaxed)); + let playback = callback + (buffer_duration + latency); let timestamp = OutputStreamTimestamp { callback, playback }; let info = OutputCallbackInfo { timestamp }; (data_callback)(&mut data, &info); @@ -428,9 +416,31 @@ impl DeviceTrait for Device { } }); + // outputLatency can change at runtime (e.g. an output-device switch) but is only readable + // on the main thread, so poll it here and publish it to the worklet via the shared atomic. + let latency_poller = web_sys::window().and_then(|window| { + let poll_ctx = audio_context.clone(); + let poll_latency = latency_nanos.clone(); + let closure = Closure::::new(move || { + poll_latency.store(total_latency_nanos(&poll_ctx), Ordering::Relaxed); + }); + window + .set_interval_with_callback_and_timeout_and_arguments_0( + closure.as_ref().unchecked_ref(), + LATENCY_POLL_INTERVAL.as_millis() as i32, + ) + .ok() + .map(|interval_id| LatencyPoller { + window, + interval_id, + _closure: closure, + }) + }); + Ok(Self::Stream { audio_context, buffer_size_frames, + _latency_poller: latency_poller, }) } } @@ -486,7 +496,7 @@ impl Iterator for Devices { type AudioProcessorCallback = Box; -/// WasmAudioProcessor provides an interface for the Javascript code +/// WasmAudioProcessor provides an interface for the JavaScript code /// running in the AudioWorklet to interact with Rust. #[wasm_bindgen] pub struct WasmAudioProcessor { @@ -558,3 +568,33 @@ impl WasmAudioProcessor { *Box::from_raw(val as *mut _) } } + +/// Drives a `setInterval` that refreshes the shared output-latency value. +struct LatencyPoller { + window: web_sys::Window, + interval_id: i32, + _closure: Closure, +} + +impl Drop for LatencyPoller { + fn drop(&mut self) { + self.window.clear_interval_with_handle(self.interval_id); + } +} + +/// Reads the playback buffer depth from a context. +fn total_latency_nanos(ctx: &web_sys::AudioContext) -> u64 { + let read = |key: &str| { + js_sys::Reflect::get(ctx.as_ref(), &JsValue::from(key)) + .ok() + .and_then(|v| v.as_f64()) + .unwrap_or(0.0) + }; + // `baseLatency` is fixed for the context lifetime; `outputLatency` can change. + let secs = read("baseLatency") + read("outputLatency"); + if secs.is_finite() && secs > 0.0 { + (secs * 1_000_000_000.0) as u64 + } else { + 0 + } +} diff --git a/src/host/coreaudio/ios/mod.rs b/src/host/coreaudio/ios/mod.rs index 2b560d321..ac1932185 100644 --- a/src/host/coreaudio/ios/mod.rs +++ b/src/host/coreaudio/ios/mod.rs @@ -3,7 +3,10 @@ use std::{ fmt, ptr::NonNull, - sync::{Arc, Mutex}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }, time::Duration, }; @@ -174,21 +177,28 @@ impl DeviceTrait for Device { E: FnMut(Error) + Send + 'static, { crate::validate_stream_config(&config)?; + // Keep `capture` monotonic: a route change (e.g. the user connects a headset) can + // raise the input buffer depth, pulling `capture` backward. + let data_callback = crate::host::monotonic_input_callback(data_callback); // Configure buffer size and create audio unit let mut audio_unit = setup_stream_audio_unit(config, sample_format, true)?; - - // Query device buffer size for latency calculation - let device_buffer_frames = Some(get_device_buffer_frames()); + // Buffer depth used to offset capture timestamps. AVAudioSession auto-reroutes to a new device, + // which changes this depth, and is then refreshed by the session event manager. + let latency_frames = Arc::new(AtomicUsize::new(input_latency_frames())); let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); - let session_manager = SessionEventManager::new(error_callback.clone(), Latch::new()); + let session_manager = SessionEventManager::new( + error_callback.clone(), + Latch::new(), + Some((latency_frames.clone(), true)), + ); // Set up input callback setup_input_callback( &mut audio_unit, sample_format, config.sample_rate, - device_buffer_frames, + latency_frames, data_callback, move |e| { let _ = try_emit_error(&error_callback, e); @@ -220,21 +230,30 @@ impl DeviceTrait for Device { E: FnMut(Error) + Send + 'static, { crate::validate_stream_config(&config)?; + // Keep `playback` monotonic: a route change (e.g. the user disconnects a headset + // and audio falls back to the built-in speaker) can lower the output buffer + // depth, pulling `playback` backward. + let data_callback = crate::host::monotonic_output_callback(data_callback); // Configure buffer size and create audio unit let mut audio_unit = setup_stream_audio_unit(config, sample_format, false)?; - // Query device buffer size for latency calculation - let device_buffer_frames = Some(get_device_buffer_frames()); + // Buffer depth used to offset playback timestamps. AVAudioSession auto-reroutes to a new device, + // which changes this depth, and is then refreshed by the session event manager. + let latency_frames = Arc::new(AtomicUsize::new(output_latency_frames())); let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); - let session_manager = SessionEventManager::new(error_callback.clone(), Latch::new()); + let session_manager = SessionEventManager::new( + error_callback.clone(), + Latch::new(), + Some((latency_frames.clone(), false)), + ); // Set up output callback setup_output_callback( &mut audio_unit, sample_format, config.sample_rate, - device_buffer_frames, + latency_frames, data_callback, move |e| { let _ = try_emit_error(&error_callback, e); @@ -387,10 +406,32 @@ fn get_device_buffer_frames() -> usize { let audio_session = AVAudioSession::sharedInstance(); let buffer_duration = audio_session.IOBufferDuration(); let sample_rate = audio_session.sampleRate(); - (buffer_duration * sample_rate) as usize + // Round: the duration comes from an integer frame count, so the product is a whole number + // that floating-point can render as N.9999, which truncation would drop to N-1. + (buffer_duration * sample_rate).round() as usize } } +/// Total capture buffer depth in frames: the IO buffer plus the hardware input latency. +pub(super) fn input_latency_frames() -> usize { + // SAFETY: AVAudioSession methods are safe to call on the singleton instance + let extra = unsafe { + let audio_session = AVAudioSession::sharedInstance(); + (audio_session.inputLatency() * audio_session.sampleRate()).round() as usize + }; + get_device_buffer_frames() + extra +} + +/// Total playback buffer depth in frames: the IO buffer plus the hardware output latency. +pub(super) fn output_latency_frames() -> usize { + // SAFETY: AVAudioSession methods are safe to call on the singleton instance + let extra = unsafe { + let audio_session = AVAudioSession::sharedInstance(); + (audio_session.outputLatency() * audio_session.sampleRate()).round() as usize + }; + get_device_buffer_frames() + extra +} + // Typical iOS hardware buffer frame limits according to Apple Technical Q&A QA1631. const BUFFER_SIZE_MIN: FrameCount = 256; const BUFFER_SIZE_MAX: FrameCount = 4096; @@ -517,7 +558,7 @@ fn setup_input_callback( audio_unit: &mut AudioUnit, sample_format: SampleFormat, sample_rate: SampleRate, - device_buffer_frames: Option, + latency_frames: Arc, mut data_callback: D, mut error_callback: E, ) -> Result<(), Error> @@ -541,10 +582,15 @@ where Ok(cb) => cb, }; - let latency_frames = device_buffer_frames.unwrap_or_else(|| { - let channels = buffer.mNumberChannels as usize; - data.len().checked_div(channels).unwrap_or(0) - }); + // Refreshed on route changes by the session event manager; fall back to this buffer's + // own frame count if the depth is unknown (zero). + let latency_frames = match latency_frames.load(Ordering::Relaxed) { + 0 => { + let channels = buffer.mNumberChannels as usize; + data.len().checked_div(channels).unwrap_or(0) + } + n => n, + }; let delay = frames_to_duration(latency_frames as FrameCount, sample_rate); let capture = callback.checked_sub(delay).unwrap_or(StreamInstant::ZERO); let timestamp = InputStreamTimestamp { callback, capture }; @@ -562,7 +608,7 @@ fn setup_output_callback( audio_unit: &mut AudioUnit, sample_format: SampleFormat, sample_rate: SampleRate, - device_buffer_frames: Option, + latency_frames: Arc, mut data_callback: D, mut error_callback: E, ) -> Result<(), Error> @@ -586,10 +632,15 @@ where Ok(cb) => cb, }; - let latency_frames = device_buffer_frames.unwrap_or_else(|| { - let channels = buffer.mNumberChannels as usize; - data.len().checked_div(channels).unwrap_or(0) - }); + // Refreshed on route changes by the session event manager; fall back to this buffer's + // own frame count if the depth is unknown (zero). + let latency_frames = match latency_frames.load(Ordering::Relaxed) { + 0 => { + let channels = buffer.mNumberChannels as usize; + data.len().checked_div(channels).unwrap_or(0) + } + n => n, + }; let delay = frames_to_duration(latency_frames as FrameCount, sample_rate); let playback = callback + delay; let timestamp = OutputStreamTimestamp { callback, playback }; diff --git a/src/host/coreaudio/ios/session_event_manager.rs b/src/host/coreaudio/ios/session_event_manager.rs index e0b017c49..5bab1d889 100644 --- a/src/host/coreaudio/ios/session_event_manager.rs +++ b/src/host/coreaudio/ios/session_event_manager.rs @@ -1,6 +1,12 @@ //! Monitors AVAudioSession lifecycle events and reports them as stream errors. -use std::ptr::NonNull; +use std::{ + ptr::NonNull, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, +}; use block2::RcBlock; use objc2::runtime::AnyObject; @@ -11,11 +17,16 @@ use objc2_avf_audio::{ }; use objc2_foundation::{NSNotification, NSNotificationCenter, NSNumber, NSString}; +use super::{input_latency_frames, output_latency_frames}; use crate::{ host::{emit_error, latch::Latch, ErrorCallbackArc}, Error, ErrorKind, }; +/// Shared buffer-depth value to refresh on route changes, paired with `is_input` to select the +/// input or output latency. `true` means an input stream. +type LatencyRefresh = (Arc, bool); + unsafe fn route_change_error(notification: &NSNotification) -> Option { let user_info = notification.userInfo()?; let key = AVAudioSessionRouteChangeReasonKey?; @@ -58,7 +69,11 @@ unsafe impl Send for SessionEventManager {} unsafe impl Sync for SessionEventManager {} impl SessionEventManager { - pub(super) fn new(error_callback: ErrorCallbackArc, latch: Latch) -> Self { + pub(super) fn new( + error_callback: ErrorCallbackArc, + latch: Latch, + latency_refresh: Option, + ) -> Self { let nc = NSNotificationCenter::defaultCenter(); let mut observers = Vec::new(); let waiter = latch.waiter(); @@ -68,6 +83,16 @@ impl SessionEventManager { let w = waiter.clone(); let block = RcBlock::new(move |notif: NonNull| { if w.is_released() { + // The route may have changed the active device; recompute the buffer depth so + // capture/playback timestamps track the new latency. + if let Some((frames, is_input)) = &latency_refresh { + let depth = if *is_input { + input_latency_frames() + } else { + output_latency_frames() + }; + frames.store(depth, Ordering::Relaxed); + } if let Some(err) = unsafe { route_change_error(notif.as_ref()) } { emit_error(&cb, err); } diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index d14d3c9d9..5db92ba35 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -3,6 +3,7 @@ use std::{ mem::{self, size_of}, ptr::{null, NonNull}, sync::{ + atomic::{AtomicUsize, Ordering}, mpsc::{channel, RecvTimeoutError}, Arc, Mutex, }, @@ -705,9 +706,8 @@ impl Device { E: FnMut(Error) + Send + 'static, { crate::validate_stream_config(&config)?; - // The scope and element for working with a device's input stream. - let scope = Scope::Output; - let element = Element::Input; + + // Input is not automatically rerouted, so its buffer depth is constant and its timestamp monotonic. // Set the physical stream format (bit depth + sample rate) on the hardware device. // This avoids unnecessary format conversions, which is especially important on aggregate @@ -734,6 +734,10 @@ impl Device { )? }; + // The scope and element for working with a device's input stream. + let scope = Scope::Output; + let element = Element::Input; + // Configure stream format and buffer size for predictable callback behavior. let effective_device_id = loopback_aggregate .as_ref() @@ -815,7 +819,7 @@ impl Device { &self, config: StreamConfig, sample_format: SampleFormat, - mut data_callback: D, + data_callback: D, error_callback: E, timeout: Option, ) -> Result @@ -824,6 +828,11 @@ impl Device { E: FnMut(Error) + Send + 'static, { crate::validate_stream_config(&config)?; + + // Keep `playback` monotonic: a default output device reroute can lower the device buffer depth, + // pulling `playback` backward. + let mut data_callback = crate::host::monotonic_output_callback(data_callback); + // Best-effort: set the physical stream format (bit depth + sample rate) on the hardware. // This avoids unnecessary conversions, especially on aggregate devices. Not an error if // it fails — the AudioUnit will handle format conversion as before. @@ -867,6 +876,13 @@ impl Device { let (bytes_per_channel, sample_rate, device_buffer_frames, extra_latency_frames) = setup_callback_vars(&audio_unit, config, sample_format, Scope::Output); + // A DefaultOutput unit auto-reroutes to a new device without rebuilding the stream, + // which changes this depth, and is then refreshed by DefaultOutputMonitor. + let latency_frames = Arc::new(AtomicUsize::new( + device_buffer_frames.map_or(0, |frames| frames + extra_latency_frames), + )); + let callback_latency_frames = latency_frames.clone(); + type Args = render_callback::Args; audio_unit.set_render_callback(move |args: Args| unsafe { // SAFETY: We configure the stream format as interleaved (via asbd_from_config which @@ -889,10 +905,12 @@ impl Device { } Ok(cb) => cb, }; - let buffer_frames = len / channels as usize; - // Use device buffer size for latency calculation if available - let latency_frames = - device_buffer_frames.unwrap_or(buffer_frames) + extra_latency_frames; + let latency_frames = match callback_latency_frames.load(Ordering::Relaxed) { + // Depth unknown (query failed): estimate the device buffer from this callback, but + // still add the safety offset and device latency, matching the input path. + 0 => len / channels as usize + extra_latency_frames, + n => n, + }; let delay = frames_to_duration(latency_frames as FrameCount, sample_rate); let playback = callback + delay; let timestamp = OutputStreamTimestamp { callback, playback }; @@ -914,7 +932,12 @@ impl Device { })); let weak_inner = Arc::downgrade(&inner_arc); let monitor: Box = if matches!(mode, AudioUnitMode::DefaultOutput) { - Box::new(DefaultOutputMonitor::new(weak_inner, error_callback)?) + // Refresh the buffer depth whenever the default device reroutes automatically. + Box::new(DefaultOutputMonitor::new( + weak_inner, + error_callback, + Some((latency_frames, Scope::Output)), + )?) } else { Box::new(DisconnectManager::new( self.audio_device_id, @@ -1012,7 +1035,7 @@ fn configure_stream_format_and_buffer( } /// Returns the sum of the device latency and safety offset in frames. -fn get_device_extra_latency_frames(audio_unit: &AudioUnit, scope: Scope) -> usize { +pub(crate) fn get_device_extra_latency_frames(audio_unit: &AudioUnit, scope: Scope) -> usize { let device_latency: u32 = audio_unit .get_property(kAudioDevicePropertyLatency, scope, Element::Output) .unwrap_or(0); diff --git a/src/host/coreaudio/macos/mod.rs b/src/host/coreaudio/macos/mod.rs index 82b0b0ee1..3fe7370e5 100644 --- a/src/host/coreaudio/macos/mod.rs +++ b/src/host/coreaudio/macos/mod.rs @@ -1,7 +1,9 @@ -#![allow(deprecated)] -use std::sync::{mpsc, Arc, Mutex, Weak}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + mpsc, Arc, Mutex, Weak, +}; -use coreaudio::audio_unit::AudioUnit; +use coreaudio::audio_unit::{AudioUnit, Scope}; use objc2_core_audio::{ kAudioDevicePropertyDeviceIsAlive, kAudioDevicePropertyNominalSampleRate, kAudioHardwarePropertyDefaultOutputDevice, kAudioObjectPropertyElementMain, @@ -24,7 +26,7 @@ mod loopback; mod property_listener; pub use device::Device; -/// Coreaudio host, the default host on macOS. +/// CoreAudio host, the default host on macOS. #[derive(Debug)] pub struct Host; @@ -231,6 +233,7 @@ impl DefaultOutputMonitor { fn new( stream_weak: Weak>, error_callback: Arc>, + latency_refresh: Option<(Arc, Scope)>, ) -> Result { let (change_rx, shutdown_tx) = spawn_property_listener_thread( kAudioObjectSystemObject as AudioObjectID, @@ -251,11 +254,11 @@ impl DefaultOutputMonitor { return; } while let Ok(()) = change_rx.recv() { - let Some(arc) = stream_weak.upgrade() else { + let Some(stream) = stream_weak.upgrade() else { break; }; if default_output_device().is_none() { - if let Ok(mut inner) = arc.try_lock() { + if let Ok(mut inner) = stream.try_lock() { let _ = inner.pause(); } emit_error( @@ -266,7 +269,22 @@ impl DefaultOutputMonitor { ), ); } else { - // DefaultOutput AudioUnit rerouted automatically; notify the caller. + // DefaultOutput AudioUnit rerouted automatically: recompute and notify + // the buffer depth for the new device. + if let Some((frames, scope)) = &latency_refresh { + if let Ok(inner) = stream.lock() { + let depth = device::get_device_buffer_frame_size(&inner.audio_unit) + .ok() + .map_or(0, |buffer| { + buffer + + device::get_device_extra_latency_frames( + &inner.audio_unit, + *scope, + ) + }); + frames.store(depth, Ordering::Relaxed); + } + } emit_error( &error_callback, Error::with_message( diff --git a/src/host/jack/device.rs b/src/host/jack/device.rs index bdc9db23c..9052c7d42 100644 --- a/src/host/jack/device.rs +++ b/src/host/jack/device.rs @@ -209,6 +209,9 @@ impl DeviceTrait for Device { )); } + // Keep `capture` monotonic: re-patching cpal's ports to a different hardware port + // can raise the capture port's latency, pulling `capture` backward. + let data_callback = crate::host::monotonic_input_callback(data_callback); let name = self.name.clone(); let start_server_automatically = self.start_server_automatically; let connect_ports_automatically = self.connect_ports_automatically; @@ -288,6 +291,9 @@ impl DeviceTrait for Device { )); } + // Keep `playback` monotonic: re-patching cpal's ports to a different hardware port + // can lower the playback port's latency, pulling `playback` backward. + let data_callback = crate::host::monotonic_output_callback(data_callback); let name = self.name.clone(); let start_server_automatically = self.start_server_automatically; let connect_ports_automatically = self.connect_ports_automatically; diff --git a/src/host/jack/stream.rs b/src/host/jack/stream.rs index 72a6fc46d..fc271354a 100644 --- a/src/host/jack/stream.rs +++ b/src/host/jack/stream.rs @@ -456,8 +456,14 @@ impl jack::ProcessHandler for LocalProcessHandler { ); // Create timestamp let callback = start_callback_instant; - // Input data was made available at the start of the cycle (current_usecs). - let capture = start_cycle_instant; + // `capture` is when the first frame in this buffer was sampled at the ADC. JACK's + // capture latency is the hardware-to-port distance, measured from the cycle start. + let latency = hardware_latency_frames(&self.in_ports, jack::LatencyType::Capture) + .map(|frames| frames_to_duration(frames, self.sample_rate)) + .unwrap_or_default(); + let capture = start_cycle_instant + .checked_sub(latency) + .unwrap_or(StreamInstant::ZERO); let timestamp = InputStreamTimestamp { callback, capture }; let info = InputCallbackInfo { timestamp }; input_callback(&data, &info); @@ -473,15 +479,27 @@ impl jack::ProcessHandler for LocalProcessHandler { let mut data = temp_buffer_to_data(&mut self.temp_output_buffer, total); // Create timestamp let callback = start_callback_instant; - // Use next_usecs (the hardware deadline for this cycle) when available; it is the - // exact instant at which the last sample written here will be consumed by the device. - let playback = match next_usecs_opt { - Some(next_usecs) => micros_to_stream_instant(next_usecs), - None => { - start_cycle_instant - + frames_to_duration(current_frame_count as FrameCount, self.sample_rate) - } - }; + // `playback` is when the first frame written here reaches the DAC. + let playback = + match hardware_latency_frames(&self.out_ports, jack::LatencyType::Playback) { + // Prefer JACK's port-to-hardware latency, measured from the cycle start. + Some(frames) => { + start_cycle_instant + frames_to_duration(frames, self.sample_rate) + } + // When no latency is reported, fall back to next_usecs, the hardware + // deadline for this cycle. + None => match next_usecs_opt { + Some(next_usecs) => micros_to_stream_instant(next_usecs), + // Fallback to one buffer ahead if that is unavailable too. + None => { + start_cycle_instant + + frames_to_duration( + current_frame_count as FrameCount, + self.sample_rate, + ) + } + }, + }; let timestamp = OutputStreamTimestamp { callback, playback }; let info = OutputCallbackInfo { timestamp }; output_callback(&mut data, &info); @@ -519,6 +537,24 @@ fn micros_to_stream_instant(micros: u64) -> StreamInstant { StreamInstant::from_micros(micros) } +/// Maximum latency, in frames, between `ports` and the hardware for the given direction, +/// or `None` if JACK reports zero. +#[inline] +fn hardware_latency_frames( + ports: &[jack::Port], + mode: jack::LatencyType, +) -> Option { + let frames = ports + .iter() + .map(|port| { + // This reads a cached value and is documented as safe to call from the process callback. + port.get_latency_range(mode).1 + }) + .max() // conservative: use worst-case latency across all ports + .unwrap_or(0); + (frames > 0).then_some(frames as FrameCount) +} + /// Receives notifications from the JACK server on JACK's notification thread (single-threaded). struct JackNotificationHandler { error_callback_ptr: ErrorCallbackArc, diff --git a/src/host/mod.rs b/src/host/mod.rs index ae14ca818..c96b5ab5e 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -256,3 +256,45 @@ pub(crate) fn frames_to_duration( let nanos = rem_frames * 1_000_000_000 / rate; std::time::Duration::new(secs, nanos as u32) } + +/// Clamps a timestamp so it never precedes one we've already returned. +#[allow(dead_code)] +fn non_decreasing(floor: &mut u64, instant: crate::StreamInstant) -> crate::StreamInstant { + // u64 nanos covers ~585 years of runtime. + let nanos = instant.as_nanos().min(u64::MAX as u128) as u64; + *floor = (*floor).max(nanos); + crate::StreamInstant::from_nanos(*floor) +} + +/// Wraps an input data callback so the `capture` timestamp never regresses across callbacks. +#[allow(dead_code)] +pub(crate) fn monotonic_input_callback( + mut data_callback: D, +) -> impl FnMut(&crate::Data, &crate::InputCallbackInfo) + Send + 'static +where + D: FnMut(&crate::Data, &crate::InputCallbackInfo) + Send + 'static, +{ + // FnMut runs on one thread at a time, so the floor needs no synchronization. + let mut floor = 0u64; + move |data, info| { + let mut info = *info; + info.timestamp.capture = non_decreasing(&mut floor, info.timestamp.capture); + data_callback(data, &info); + } +} + +/// Wraps an output data callback so the `playback` timestamp never regresses across callbacks. +#[allow(dead_code)] +pub(crate) fn monotonic_output_callback( + mut data_callback: D, +) -> impl FnMut(&mut crate::Data, &crate::OutputCallbackInfo) + Send + 'static +where + D: FnMut(&mut crate::Data, &crate::OutputCallbackInfo) + Send + 'static, +{ + let mut floor = 0u64; + move |data, info| { + let mut info = *info; + info.timestamp.playback = non_decreasing(&mut floor, info.timestamp.playback); + data_callback(data, &info); + } +} diff --git a/src/host/pipewire/device.rs b/src/host/pipewire/device.rs index 0e548d4f7..a0c374532 100644 --- a/src/host/pipewire/device.rs +++ b/src/host/pipewire/device.rs @@ -381,6 +381,9 @@ impl DeviceTrait for Device { }; let last_quantum = Arc::new(AtomicU64::new(initial_quantum)); let last_quantum_clone = last_quantum.clone(); + // Keep `capture` monotonic: pw_time delay() grows when another client joins + // needing a larger buffer, which can pull `capture` backward. + let data_callback = crate::host::monotonic_input_callback(data_callback); let start = std::time::Instant::now(); let handle = thread::Builder::new() .name("pw_in".to_owned()) @@ -554,6 +557,9 @@ impl DeviceTrait for Device { }; let last_quantum = Arc::new(AtomicU64::new(initial_quantum)); let last_quantum_clone = last_quantum.clone(); + // Keep `playback` monotonic: pw_time delay() shrinks when other clients that needed + // a larger buffer leave the graph, which can pull `playback` backward. + let data_callback = crate::host::monotonic_output_callback(data_callback); let start = std::time::Instant::now(); let handle = thread::Builder::new() .name("pw_out".to_owned()) diff --git a/src/host/pulseaudio/mod.rs b/src/host/pulseaudio/mod.rs index c81d82883..81d671e72 100644 --- a/src/host/pulseaudio/mod.rs +++ b/src/host/pulseaudio/mod.rs @@ -374,6 +374,9 @@ impl DeviceTrait for Device { ..Default::default() }; + // Keep `capture` monotonic: the latency can step up when the server switches + // to a different source, pulling `capture` backward. + let data_callback = crate::host::monotonic_input_callback(data_callback); let client = client.clone(); let stream = if let Some(dur) = timeout { // Run stream creation on a thread so we can bound the wait. If the PulseAudio server @@ -466,6 +469,9 @@ impl DeviceTrait for Device { ..Default::default() }; + // Keep `playback` monotonic: the latency can decrease when the server switches + //to a different sink, pulling `playback` forward. + let data_callback = crate::host::monotonic_output_callback(data_callback); let client = client.clone(); let stream = if let Some(dur) = timeout { // Run stream creation on a thread so we can bound the wait. If the PulseAudio server diff --git a/src/host/wasapi/device.rs b/src/host/wasapi/device.rs index 9656e99cf..a9330ad85 100644 --- a/src/host/wasapi/device.rs +++ b/src/host/wasapi/device.rs @@ -146,6 +146,9 @@ impl DeviceTrait for Device { D: FnMut(&mut Data, &OutputCallbackInfo) + Send + 'static, E: FnMut(Error) + Send + 'static, { + // Keep `playback` monotonic: an underrun can saturate `buffered` to zero, pulling + // `playback` backward. + let data_callback = crate::host::monotonic_output_callback(data_callback); let stream_inner = self.build_output_stream_raw_inner(config, sample_format, timeout)?; let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let monitor = self.default_device_monitor()?; diff --git a/src/host/webaudio/mod.rs b/src/host/webaudio/mod.rs index 6dfa14573..3c9119a20 100644 --- a/src/host/webaudio/mod.rs +++ b/src/host/webaudio/mod.rs @@ -283,6 +283,9 @@ impl DeviceTrait for Device { })?; let buffer_time_step_secs = buffer_time_step_secs(buffer_size_frames, config.sample_rate); + // Keep `playback` monotonic: outputLatency can drop (e.g. the page calls `setSinkId()` to + // switch output devices), which would pull `playback` backward. + let data_callback = crate::host::monotonic_output_callback(data_callback); let data_callback: OutputDataCallbackArc = Arc::new(Mutex::new(data_callback)); let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback)); let is_started = Arc::new(AtomicBool::new(false)); From 6e60360a3e0fce42f92c047fb1e63b7c576e7b8f Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Mon, 15 Jun 2026 23:44:13 +0200 Subject: [PATCH 4/5] fix: round conversions to remove a downward bias --- src/host/audioworklet/mod.rs | 2 +- src/host/mod.rs | 3 ++- src/host/pulseaudio/mod.rs | 2 +- src/timestamp.rs | 31 ++++++++++++++++++++----------- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/host/audioworklet/mod.rs b/src/host/audioworklet/mod.rs index d63c60e61..a6173f16e 100644 --- a/src/host/audioworklet/mod.rs +++ b/src/host/audioworklet/mod.rs @@ -593,7 +593,7 @@ fn total_latency_nanos(ctx: &web_sys::AudioContext) -> u64 { // `baseLatency` is fixed for the context lifetime; `outputLatency` can change. let secs = read("baseLatency") + read("outputLatency"); if secs.is_finite() && secs > 0.0 { - (secs * 1_000_000_000.0) as u64 + (secs * 1_000_000_000.0).round() as u64 } else { 0 } diff --git a/src/host/mod.rs b/src/host/mod.rs index c96b5ab5e..5a266e072 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -253,7 +253,8 @@ pub(crate) fn frames_to_duration( let secs = frames as u64 / rate; // rem_frames < rate <= u32::MAX, so rem_frames * 1_000_000_000 < u64::MAX let rem_frames = frames as u64 % rate; - let nanos = rem_frames * 1_000_000_000 / rate; + // Round to nearest so the duration isn't biased. + let nanos = (rem_frames * 1_000_000_000 + rate / 2) / rate; std::time::Duration::new(secs, nanos as u32) } diff --git a/src/host/pulseaudio/mod.rs b/src/host/pulseaudio/mod.rs index 81d671e72..7ddad8d9e 100644 --- a/src/host/pulseaudio/mod.rs +++ b/src/host/pulseaudio/mod.rs @@ -470,7 +470,7 @@ impl DeviceTrait for Device { }; // Keep `playback` monotonic: the latency can decrease when the server switches - //to a different sink, pulling `playback` forward. + // to a different sink, pulling `playback` backward. let data_callback = crate::host::monotonic_output_callback(data_callback); let client = client.clone(); let stream = if let Some(dur) = timeout { diff --git a/src/timestamp.rs b/src/timestamp.rs index a34edaf27..110237de5 100644 --- a/src/timestamp.rs +++ b/src/timestamp.rs @@ -152,19 +152,21 @@ impl StreamInstant { /// /// # Panics /// - /// Panics if `secs` is negative, not finite, or overflows the range of `StreamInstant`. + /// Panics if `secs` is negative, NaN, or too large to represent. pub fn from_secs_f64(secs: f64) -> Self { const NANOS_PER_SEC: u128 = 1_000_000_000; - const MAX_NANOS: f64 = ((u64::MAX as u128 + 1) * NANOS_PER_SEC) as f64; - let nanos = secs * NANOS_PER_SEC as f64; - if !(0.0..MAX_NANOS).contains(&nanos) { - panic!("StreamInstant::from_secs_f64 called with invalid value: {secs}"); - } - let nanos = nanos as u128; - Self::new( - (nanos / NANOS_PER_SEC) as u64, - (nanos % NANOS_PER_SEC) as u32, - ) + // Check the sign before rounding: a tiny negative would round to 0 and slip through. + // `>= 0.0` is also false for NaN, so this rejects both. + assert!( + secs >= 0.0, + "StreamInstant::from_secs_f64 called with a negative or NaN value: {secs}" + ); + // `f64 as u128` saturates, so +inf and absurd magnitudes land on u128::MAX and are caught + // as overflow below instead of wrapping. + let nanos = (secs * NANOS_PER_SEC as f64).round() as u128; + let secs_whole = u64::try_from(nanos / NANOS_PER_SEC) + .unwrap_or_else(|_| panic!("StreamInstant::from_secs_f64 value too large: {secs}")); + Self::new(secs_whole, (nanos % NANOS_PER_SEC) as u32) } /// Creates a new `StreamInstant` from the specified number of whole seconds and additional @@ -350,4 +352,11 @@ mod tests { fn test_stream_instant_from_secs_f64_infinite() { StreamInstant::from_secs_f64(f64::INFINITY); } + + #[test] + #[should_panic] + fn test_stream_instant_from_secs_f64_overflow() { + // Rounds up into u64::MAX + 1 seconds; must panic rather than wrap to 0. + StreamInstant::from_secs_f64(u64::MAX as f64); + } } From b1cf416f606ef2dc4a926ee6af9a8d689be01982 Mon Sep 17 00:00:00 2001 From: Roderick van Domburg Date: Tue, 16 Jun 2026 23:04:44 +0200 Subject: [PATCH 5/5] refactor(coreaudio): clippy fixes for deprecated items --- src/host/coreaudio/macos/device.rs | 24 ++++++++++++------------ src/host/coreaudio/macos/enumerate.rs | 8 ++++---- src/host/coreaudio/macos/loopback.rs | 4 ++-- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/host/coreaudio/macos/device.rs b/src/host/coreaudio/macos/device.rs index 5db92ba35..cdd0b4807 100644 --- a/src/host/coreaudio/macos/device.rs +++ b/src/host/coreaudio/macos/device.rs @@ -28,16 +28,15 @@ use objc2_core_audio::{ kAudioDevicePropertyDeviceUID, kAudioDevicePropertyLatency, kAudioDevicePropertyNominalSampleRate, kAudioDevicePropertySafetyOffset, kAudioDevicePropertyStreamConfiguration, kAudioDevicePropertyStreamFormat, - kAudioObjectPropertyClass, kAudioObjectPropertyElementMain, kAudioObjectPropertyElementMaster, - kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyScopeInput, - kAudioObjectPropertyScopeOutput, AudioClassID, AudioDeviceID, AudioObjectGetPropertyData, - AudioObjectGetPropertyDataSize, AudioObjectID, AudioObjectPropertyAddress, - AudioObjectPropertyScope, AudioObjectSetPropertyData, + kAudioObjectPropertyClass, kAudioObjectPropertyElementMain, kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyScopeInput, kAudioObjectPropertyScopeOutput, AudioClassID, AudioDeviceID, + AudioObjectGetPropertyData, AudioObjectGetPropertyDataSize, AudioObjectID, + AudioObjectPropertyAddress, AudioObjectPropertyScope, AudioObjectSetPropertyData, }; use objc2_core_audio_types::{ AudioBuffer, AudioBufferList, AudioStreamBasicDescription, AudioValueRange, }; -use objc2_core_foundation::{CFString, Type}; +use objc2_core_foundation::{CFRetained, CFString}; pub use super::enumerate::{SupportedInputConfigs, SupportedOutputConfigs}; use super::{ @@ -98,7 +97,7 @@ fn set_sample_rate( let mut property_address = AudioObjectPropertyAddress { mSelector: kAudioDevicePropertyNominalSampleRate, mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMaster, + mElement: kAudioObjectPropertyElementMain, }; let mut sample_rate: f64 = 0.0; let mut data_size = mem::size_of::() as u32; @@ -259,7 +258,7 @@ fn get_io_buffer_frame_size_range(device_id: AudioDeviceID) -> Result() as u32; @@ -456,7 +455,8 @@ impl Device { // SAFETY: Status was successful, meaning the API call succeeded. // We now check if the returned uid is non-null before use. if !uid.is_null() { - let uid_string = unsafe { CFString::wrap_under_create_rule(uid).to_string() }; + let uid_string = + unsafe { CFRetained::from_raw(NonNull::new(uid).unwrap()).to_string() }; Ok(DeviceId::new( crate::platform::HostId::CoreAudio, uid_string, @@ -475,7 +475,7 @@ impl Device { let mut property_address = AudioObjectPropertyAddress { mSelector: kAudioDevicePropertyStreamConfiguration, mScope: scope, - mElement: kAudioObjectPropertyElementMaster, + mElement: kAudioObjectPropertyElementMain, }; unsafe { @@ -603,7 +603,7 @@ impl Device { let property_address = AudioObjectPropertyAddress { mSelector: kAudioDevicePropertyStreamFormat, mScope: scope, - mElement: kAudioObjectPropertyElementMaster, + mElement: kAudioObjectPropertyElementMain, }; unsafe { diff --git a/src/host/coreaudio/macos/enumerate.rs b/src/host/coreaudio/macos/enumerate.rs index db08f6850..f17f14dc3 100644 --- a/src/host/coreaudio/macos/enumerate.rs +++ b/src/host/coreaudio/macos/enumerate.rs @@ -7,7 +7,7 @@ use std::{ use objc2_core_audio::{ kAudioHardwareNoError, kAudioHardwarePropertyDefaultInputDevice, kAudioHardwarePropertyDefaultOutputDevice, kAudioHardwarePropertyDevices, - kAudioObjectPropertyElementMaster, kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject, + kAudioObjectPropertyElementMain, kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject, AudioDeviceID, AudioObjectGetPropertyData, AudioObjectGetPropertyDataSize, AudioObjectID, AudioObjectPropertyAddress, }; @@ -20,7 +20,7 @@ unsafe fn audio_devices() -> Result, Error> { let property_address = AudioObjectPropertyAddress { mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMaster, + mElement: kAudioObjectPropertyElementMain, }; macro_rules! try_status_or_return { @@ -84,7 +84,7 @@ pub fn default_input_device() -> Option { let property_address = AudioObjectPropertyAddress { mSelector: kAudioHardwarePropertyDefaultInputDevice, mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMaster, + mElement: kAudioObjectPropertyElementMain, }; let mut audio_device_id: AudioDeviceID = 0; @@ -114,7 +114,7 @@ pub fn default_output_device() -> Option { let property_address = AudioObjectPropertyAddress { mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMaster, + mElement: kAudioObjectPropertyElementMain, }; let mut audio_device_id: AudioDeviceID = 0; diff --git a/src/host/coreaudio/macos/loopback.rs b/src/host/coreaudio/macos/loopback.rs index b38e9a992..d57e093ad 100644 --- a/src/host/coreaudio/macos/loopback.rs +++ b/src/host/coreaudio/macos/loopback.rs @@ -23,7 +23,7 @@ use objc2_core_audio::{ use objc2_core_foundation::{ kCFAllocatorDefault, kCFTypeArrayCallBacks, kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks, CFArray, CFDictionary, CFMutableDictionary, CFRetained, - CFString, CFStringCreateWithCString, + CFString, }; use objc2_foundation::{NSArray, NSNumber, NSString}; @@ -153,7 +153,7 @@ impl Drop for LoopbackDevice { fn to_cfstring(cstr: &'static CStr) -> CFRetained { unsafe { - CFStringCreateWithCString( + CFString::with_c_string( kCFAllocatorDefault, cstr.as_ptr(), 0x08000100, /* UTF8 */