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:
1 file changed
+98
-41
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user