fix(audio): fix macOS Shared Mode playback from changing speed

This commit is contained in:
Vudgekek committed 2026-05-09 23:43:26 -05:00
1 parent d44ee7c086
commit 7bad704e21
2 files changed
+169 -26

No files matched your search

+27 -7
View File
@@ -537,6 +537,21 @@ impl AudioBackend for CpalDefaultBackend {
.open_stream() .open_stream()
.map_err(|e| format!("Failed to create output stream: {}", e))?; .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) Ok(mixer_sink)
} }
@@ -589,6 +604,17 @@ impl AudioBackend for CpalDefaultBackend {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
impl CpalDefaultBackend { impl CpalDefaultBackend {
fn current_macos_nominal_rate(effective_device_name: Option<&str>) -> Option<u32> {
use crate::coreaudio_direct;
let device_id = match effective_device_name {
Some(name) => coreaudio_direct::find_device_by_name(name).ok().flatten(),
None => coreaudio_direct::get_default_output_device().ok(),
}?;
coreaudio_direct::get_nominal_sample_rate(device_id).ok()
}
/// In macOS shared mode, CPAL's default config can briefly report a stale /// In macOS shared mode, CPAL's default config can briefly report a stale
/// sample rate after CoreAudio changes the device's nominal rate. If we /// sample rate after CoreAudio changes the device's nominal rate. If we
/// trust the stale rate, playback can run at the wrong speed until the /// trust the stale rate, playback can run at the wrong speed until the
@@ -598,13 +624,7 @@ impl CpalDefaultBackend {
device: &rodio::cpal::Device, device: &rodio::cpal::Device,
effective_device_name: Option<&str>, effective_device_name: Option<&str>,
) -> Option<rodio::cpal::SupportedStreamConfig> { ) -> Option<rodio::cpal::SupportedStreamConfig> {
use crate::coreaudio_direct; let nominal_rate = Self::current_macos_nominal_rate(effective_device_name)?;
let device_id = match effective_device_name {
Some(name) => coreaudio_direct::find_device_by_name(name).ok().flatten(),
None => coreaudio_direct::get_default_output_device().ok(),
}?;
let nominal_rate = coreaudio_direct::get_nominal_sample_rate(device_id).ok()?;
let default_config = device.default_output_config().ok()?; let default_config = device.default_output_config().ok()?;
let default_rate = default_config.sample_rate(); let default_rate = default_config.sample_rate();
if nominal_rate == default_rate { if nominal_rate == default_rate {
+142 -19
View File
@@ -504,6 +504,16 @@ impl StreamType {
_ => false, _ => false,
} }
} }
/// Actual output stream rate. On macOS shared mode this is the rate that
/// must match CoreAudio's current nominal device rate; decoded track rates
/// may differ and are resampled by Rodio.
#[cfg(target_os = "macos")]
fn output_sample_rate(&self) -> u32 {
match self {
StreamType::Rodio { sink, .. } => sink.config().sample_rate().get(),
}
}
} }
fn apply_engine_volume( fn apply_engine_volume(
@@ -524,6 +534,50 @@ fn apply_engine_volume(
engine.set_volume(volume); engine.set_volume(volume);
} }
#[cfg(target_os = "macos")]
fn uses_coreaudio_system_default(settings: &AudioSettings) -> bool {
settings
.backend_type
.unwrap_or(AudioBackendType::SystemDefault)
== AudioBackendType::SystemDefault
}
#[cfg(target_os = "macos")]
fn coreaudio_nominal_rate(settings: &AudioSettings) -> Option<u32> {
if !uses_coreaudio_system_default(settings) {
return None;
}
let device_id =
qbz_audio::coreaudio_direct::resolve_output_device_id(settings.output_device.as_deref())
.inspect_err(|e| {
log::warn!(
"[CoreAudio] Failed to resolve output device for rate check: {}",
e
)
})
.ok()?;
qbz_audio::coreaudio_direct::get_nominal_sample_rate(device_id)
.inspect_err(|e| log::warn!("[CoreAudio] Failed to query nominal rate: {}", e))
.ok()
}
#[cfg(target_os = "macos")]
fn coreaudio_shared_rate_mismatch(
settings: &AudioSettings,
stream_opt: &Option<StreamType>,
) -> Option<(u32, u32)> {
if settings.exclusive_mode || !uses_coreaudio_system_default(settings) {
return None;
}
let stream_rate = stream_opt.as_ref().map(StreamType::output_sample_rate)?;
let nominal_rate = coreaudio_nominal_rate(settings)?;
(stream_rate != nominal_rate).then_some((stream_rate, nominal_rate))
}
/// Try to create output stream using the backend system (if configured) /// Try to create output stream using the backend system (if configured)
/// Returns None if backend system is not configured (backend_type = None) /// Returns None if backend system is not configured (backend_type = None)
/// ///
@@ -608,10 +662,12 @@ fn try_init_stream_with_backend(
// Fallback to regular rodio stream (PipeWire, Pulse, ALSA via CPAL) // Fallback to regular rodio stream (PipeWire, Pulse, ALSA via CPAL)
match backend.create_output_stream_with_exclusive_guard(&config) { match backend.create_output_stream_with_exclusive_guard(&config) {
Ok((mixer_sink, _exclusive_guard)) => { Ok((mixer_sink, _exclusive_guard)) => {
let output_sample_rate = mixer_sink.config().sample_rate().get();
log::info!( log::info!(
"Stream created via {:?} backend at {}Hz", "Stream created via {:?} backend (requested {}Hz, output {}Hz)",
backend_type, backend_type,
sample_rate sample_rate,
output_sample_rate
); );
state.set_bit_perfect_mode(Some(BitPerfectMode::Disabled)); state.set_bit_perfect_mode(Some(BitPerfectMode::Disabled));
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -1068,16 +1124,20 @@ impl Player {
} }
Some(Err(e)) => { Some(Err(e)) => {
// On macOS, the backend path is the only one that // On macOS, the backend path is the only one that
// honors Exclusive Mode (CoreAudio Hog Mode). Falling // understands CoreAudio ownership and nominal-rate
// through to legacy CPAL here would silently create a // validation. Falling through to legacy CPAL can
// shared-mode stream while the UI still shows // create a stream at a stale/source rate; in
// Exclusive — surface the failure instead so the // shared mode that can produce wrong-speed audio,
// user sees that audio is unavailable rather than // and in Exclusive Mode it would silently drop Hog
// unknowingly playing in shared mode. // Mode. Surface the failure instead.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if settings.exclusive_mode { if settings
.backend_type
.unwrap_or(AudioBackendType::SystemDefault)
== AudioBackendType::SystemDefault
{
log::error!( log::error!(
"macOS Exclusive Mode init failed: {} — refusing to fall back to shared CPAL", "macOS CoreAudio init failed: {} — refusing unsafe legacy CPAL fallback",
e e
); );
state.set_current_device(None); state.set_current_device(None);
@@ -1248,7 +1308,8 @@ impl Player {
let format_changed = *current_sample_rate != Some(sample_rate) let format_changed = *current_sample_rate != Some(sample_rate)
|| *current_channels != Some(channels); || *current_channels != Some(channels);
// Check if using ALSA Direct backend // Check if using a backend where the output stream's
// format/rate must be actively managed.
let using_alsa_direct = thread_settings let using_alsa_direct = thread_settings
.lock() .lock()
.ok() .ok()
@@ -1265,15 +1326,42 @@ impl Player {
&& s.exclusive_mode && s.exclusive_mode
}) })
.unwrap_or(false); .unwrap_or(false);
#[cfg(target_os = "macos")]
let coreaudio_shared_rate_mismatch = thread_settings
.lock()
.ok()
.and_then(|s| {
coreaudio_shared_rate_mismatch(&s, stream_opt).map(
|(stream_rate, nominal_rate)| {
log::warn!(
"[CoreAudio] Shared-mode output rate changed: stream {}Hz, device nominal {}Hz. Recreating stream to avoid wrong-speed playback.",
stream_rate,
nominal_rate
);
(stream_rate, nominal_rate)
},
)
});
#[cfg(not(target_os = "macos"))]
let coreaudio_shared_rate_mismatch: Option<(u32, u32)> = None;
let needs_new_stream = stream_opt.is_none() let needs_new_stream = stream_opt.is_none()
|| (dac_passthrough && format_changed) || (dac_passthrough && format_changed)
|| (using_alsa_direct && format_changed) || (using_alsa_direct && format_changed)
|| (using_coreaudio_exclusive && format_changed); || (using_coreaudio_exclusive && format_changed)
|| coreaudio_shared_rate_mismatch.is_some();
if needs_new_stream { if needs_new_stream {
if stream_opt.is_some() { if stream_opt.is_some() {
if (dac_passthrough if let Some((stream_rate, nominal_rate)) =
coreaudio_shared_rate_mismatch
{
log::info!(
"CoreAudio shared output rate changed from {}Hz to {}Hz - recreating audio stream",
stream_rate,
nominal_rate
);
} else if (dac_passthrough
|| using_alsa_direct || using_alsa_direct
|| using_coreaudio_exclusive) || using_coreaudio_exclusive)
&& format_changed && format_changed
@@ -1455,8 +1543,6 @@ impl Player {
match stream_result { match stream_result {
Ok(stream) => { Ok(stream) => {
*stream_opt = Some(stream); *stream_opt = Some(stream);
*current_sample_rate = Some(sample_rate);
*current_channels = Some(channels);
thread_state.set_stream_error(false); thread_state.set_stream_error(false);
// Set current device name from settings (for backend system) // Set current device name from settings (for backend system)
@@ -1509,6 +1595,13 @@ impl Player {
); );
} }
// Track the decoded source format separately from
// the OS output stream rate. In shared mode the
// stream can stay at the CoreAudio nominal rate
// while each track has its own decoded rate.
*current_sample_rate = Some(sample_rate);
*current_channels = Some(channels);
let Some(ref stream) = *stream_opt else { let Some(ref stream) = *stream_opt else {
log::error!("Audio thread: no audio device available"); log::error!("Audio thread: no audio device available");
return; return;
@@ -1741,15 +1834,42 @@ impl Player {
&& s.exclusive_mode && s.exclusive_mode
}) })
.unwrap_or(false); .unwrap_or(false);
#[cfg(target_os = "macos")]
let coreaudio_shared_rate_mismatch = thread_settings
.lock()
.ok()
.and_then(|s| {
coreaudio_shared_rate_mismatch(&s, stream_opt).map(
|(stream_rate, nominal_rate)| {
log::warn!(
"[CoreAudio] Streaming shared-mode output rate changed: stream {}Hz, device nominal {}Hz. Recreating stream to avoid wrong-speed playback.",
stream_rate,
nominal_rate
);
(stream_rate, nominal_rate)
},
)
});
#[cfg(not(target_os = "macos"))]
let coreaudio_shared_rate_mismatch: Option<(u32, u32)> = None;
let needs_new_stream = stream_opt.is_none() let needs_new_stream = stream_opt.is_none()
|| (dac_passthrough && format_changed) || (dac_passthrough && format_changed)
|| (using_alsa_direct && format_changed) || (using_alsa_direct && format_changed)
|| (using_coreaudio_exclusive && format_changed); || (using_coreaudio_exclusive && format_changed)
|| coreaudio_shared_rate_mismatch.is_some();
if needs_new_stream { if needs_new_stream {
if stream_opt.is_some() { if stream_opt.is_some() {
if (dac_passthrough if let Some((stream_rate, nominal_rate)) =
coreaudio_shared_rate_mismatch
{
log::info!(
"Streaming: CoreAudio shared output rate changed from {}Hz to {}Hz - recreating audio stream",
stream_rate,
nominal_rate
);
} else if (dac_passthrough
|| using_alsa_direct || using_alsa_direct
|| using_coreaudio_exclusive) || using_coreaudio_exclusive)
&& format_changed && format_changed
@@ -1863,8 +1983,6 @@ impl Player {
match stream_result { match stream_result {
Ok(stream) => { Ok(stream) => {
*stream_opt = Some(stream); *stream_opt = Some(stream);
*current_sample_rate = Some(sample_rate);
*current_channels = Some(channels);
thread_state.set_stream_error(false); thread_state.set_stream_error(false);
log::info!( log::info!(
"Streaming audio stream ready at {}Hz", "Streaming audio stream ready at {}Hz",
@@ -1884,6 +2002,11 @@ impl Player {
} }
} }
// Keep decoded source format current even when
// macOS shared mode reuses the same output stream.
*current_sample_rate = Some(sample_rate);
*current_channels = Some(channels);
let Some(ref stream) = *stream_opt else { let Some(ref stream) = *stream_opt else {
log::error!( log::error!(
"Audio thread: no audio device available for streaming" "Audio thread: no audio device available for streaming"