fix(audio): fix macOS Shared Mode playback from changing speed
This commit is contained in:
2 files changed
+169
-26
No files matched your search
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user