diff --git a/crates/qbz-audio/src/backend.rs b/crates/qbz-audio/src/backend.rs index 0d982c7b2..d282762d7 100644 --- a/crates/qbz-audio/src/backend.rs +++ b/crates/qbz-audio/src/backend.rs @@ -488,6 +488,14 @@ impl AudioBackend for CpalDefaultBackend { #[cfg(not(target_os = "macos"))] let effective_device_id = config.device_id.as_ref(); + #[cfg(target_os = "macos")] + if !config.exclusive_mode { + return self.open_macos_shared_stream_with_retry( + effective_device_id.map(|name| name.as_str()), + MACOS_SHARED_OPEN_MAX_ATTEMPTS, + ); + } + let device = if let Some(device_id) = effective_device_id { self.host .output_devices() @@ -504,54 +512,13 @@ impl AudioBackend for CpalDefaultBackend { .ok_or_else(|| "No default output device found".to_string())? }; - #[cfg(target_os = "macos")] - let shared_mode_override = if !config.exclusive_mode { - Self::shared_mode_nominal_stream_config( - &device, - effective_device_id.map(|name| name.as_str()), - ) - } else { - None - }; - let builder = DeviceSinkBuilder::from_device(device) .map_err(|e| format!("Failed to create device sink builder: {}", e))?; - #[cfg(target_os = "macos")] - let mixer_sink = if let Some(override_config) = shared_mode_override { - let buffer_size = - rodio::cpal::BufferSize::Fixed((override_config.sample_rate() / 20).max(64)); - builder - .with_supported_config(&override_config) - .with_buffer_size(buffer_size) - .open_stream() - .map_err(|e| format!("Failed to create output stream: {}", e))? - } else { - builder - .open_stream() - .map_err(|e| format!("Failed to create output stream: {}", e))? - }; - - #[cfg(not(target_os = "macos"))] let mixer_sink = builder .open_stream() .map_err(|e| format!("Failed to create output stream: {}", e))?; - #[cfg(target_os = "macos")] - if !config.exclusive_mode { - if let Some(nominal_rate) = - Self::current_macos_nominal_rate(effective_device_id.map(|name| name.as_str())) - { - let output_rate = mixer_sink.config().sample_rate().get(); - if output_rate != nominal_rate { - return Err(format!( - "CoreAudio shared stream opened at {}Hz while device nominal rate is {}Hz", - output_rate, nominal_rate - )); - } - } - } - Ok(mixer_sink) } @@ -602,8 +569,98 @@ impl AudioBackend for CpalDefaultBackend { } } +#[cfg(target_os = "macos")] +const MACOS_SHARED_OPEN_MAX_ATTEMPTS: usize = 2; +#[cfg(target_os = "macos")] +const MACOS_SHARED_OPEN_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(50); + #[cfg(target_os = "macos")] impl CpalDefaultBackend { + /// Open a macOS shared-mode CPAL stream and verify it actually opened at + /// CoreAudio's current nominal rate. CPAL caches device configs and can + /// return a stale rate when the OS rate has just changed; passing the + /// stale rate to CPAL produces a stream that runs at the wrong speed. + /// + /// Retries up to `max_attempts` times because the mismatch is racy — a + /// fresh resolve + reopen usually lands on the correct rate. + fn open_macos_shared_stream_with_retry( + &self, + effective_device_name: Option<&str>, + max_attempts: usize, + ) -> BackendResult { + debug_assert!(max_attempts >= 1, "max_attempts must be at least 1"); + + let mut last_err: Option = None; + for attempt in 0..max_attempts { + let device = if let Some(device_name) = effective_device_name { + self.host + .output_devices() + .map_err(|e| format!("Failed to enumerate devices: {}", e))? + .find(|d| { + d.description() + .map(|desc| desc.name() == device_name) + .unwrap_or(false) + }) + .ok_or_else(|| format!("Device '{}' not found", device_name))? + } else { + self.host + .default_output_device() + .ok_or_else(|| "No default output device found".to_string())? + }; + + let override_config = + Self::shared_mode_nominal_stream_config(&device, effective_device_name); + + let builder = DeviceSinkBuilder::from_device(device) + .map_err(|e| format!("Failed to create device sink builder: {}", e))?; + + let open_result = if let Some(ref cfg) = override_config { + let buffer_size = rodio::cpal::BufferSize::Fixed((cfg.sample_rate() / 20).max(64)); + builder + .with_supported_config(cfg) + .with_buffer_size(buffer_size) + .open_stream() + } else { + builder.open_stream() + }; + + let mixer_sink = match open_result { + Ok(s) => s, + Err(e) => { + last_err = Some(format!("Failed to create output stream: {}", e)); + break; + } + }; + + let nominal_rate = Self::current_macos_nominal_rate(effective_device_name); + let output_rate = mixer_sink.config().sample_rate().get(); + match nominal_rate { + Some(nominal) if nominal != output_rate => { + let msg = format!( + "CoreAudio shared stream opened at {}Hz while device nominal rate is {}Hz", + output_rate, nominal + ); + drop(mixer_sink); + if attempt + 1 < max_attempts { + log::warn!( + "[CoreAudio] {} — retrying open (attempt {}/{})", + msg, + attempt + 2, + max_attempts + ); + std::thread::sleep(MACOS_SHARED_OPEN_RETRY_DELAY); + } + last_err = Some(msg); + continue; + } + _ => return Ok(mixer_sink), + } + } + + Err(last_err + .unwrap_or_else(|| "Failed to open CoreAudio shared stream after retries".to_string())) + } + fn current_macos_nominal_rate(effective_device_name: Option<&str>) -> Option { use crate::coreaudio_direct;