fix(audio): retry CoreAudio shared-mode open on rate mismatch

CPAL caches device configs and can briefly return a stale sample rate
right after CoreAudio changes the device nominal rate. Before this
change, opening at the stale rate failed the post-open validation and
returned Err, leaving the user with no audio on the first track after
a rate change.

Drop the bad sink, re-resolve the device, re-query nominal rate and
try once more with a 50ms backoff. Two attempts in total. Turns the
racy window into transparent recovery.
This commit is contained in:
Afonso Jorge Ramos committed 2026-05-11 15:16:46 +02:00
1 parent c408ea73cb
commit 9e632386b9
1 file changed
+98 -41
+98 -41
View File
@@ -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<MixerDeviceSink> {
debug_assert!(max_attempts >= 1, "max_attempts must be at least 1");
let mut last_err: Option<String> = 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<u32> {
use crate::coreaudio_direct;