From 3b34a734b4a6255207c954f30b12953cec95cdc5 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Mon, 11 May 2026 15:13:29 +0200 Subject: [PATCH 01/10] refactor(player): rename current_sample_rate to current_track_sample_rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field tracks the decoded source format, not the output stream's hardware rate. The names are now explicit so future readers don't conflate the two — on macOS shared mode these can legitimately differ since Rodio resamples internally. --- crates/qbz-player/src/player/mod.rs | 58 ++++++++++++++--------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index 9d9201719..7827ce5d0 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -1228,8 +1228,8 @@ impl Player { // Initialize audio device lazily on first playback to avoid idle CPU usage. let mut current_device_name = device_name.clone(); let mut stream_opt: Option = None; - let mut current_sample_rate: Option = None; - let mut current_channels: Option = None; + let mut current_track_sample_rate: Option = None; + let mut current_track_channels: Option = None; #[allow(dead_code)] const MAX_INIT_RETRIES: u32 = 5; @@ -1269,8 +1269,8 @@ impl Player { current_device_name: &mut Option, consecutive_sink_failures: &mut u32, pause_suspend_deadline: &mut Option, - current_sample_rate: &mut Option, - current_channels: &mut Option, + current_track_sample_rate: &mut Option, + current_track_channels: &mut Option, current_normalization_gain: &mut Option, current_gain_atomic: &mut Option>, gapless_pending: &mut Option, @@ -1305,8 +1305,8 @@ impl Player { // Check if we need to recreate the stream // Recreate on format change if DAC passthrough OR ALSA Direct is enabled (both require bit-perfect) - let format_changed = *current_sample_rate != Some(sample_rate) - || *current_channels != Some(channels); + let format_changed = *current_track_sample_rate != Some(sample_rate) + || *current_track_channels != Some(channels); // Check if using a backend where the output stream's // format/rate must be actively managed. @@ -1375,8 +1375,8 @@ impl Player { }; log::info!( "Sample rate/channels changed from {:?}Hz/{:?}ch to {}Hz/{}ch - recreating audio stream ({})", - *current_sample_rate, - *current_channels, + *current_track_sample_rate, + *current_track_channels, sample_rate, channels, mode @@ -1588,8 +1588,8 @@ impl Player { // Format changed but DAC passthrough is disabled - reuse existing stream log::info!( "Audio format changed from {:?}Hz/{:?}ch to {}Hz/{}ch - reusing audio stream (DAC passthrough disabled, gapless enabled)", - *current_sample_rate, - *current_channels, + *current_track_sample_rate, + *current_track_channels, sample_rate, channels ); @@ -1599,8 +1599,8 @@ impl Player { // 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); + *current_track_sample_rate = Some(sample_rate); + *current_track_channels = Some(channels); let Some(ref stream) = *stream_opt else { log::error!("Audio thread: no audio device available"); @@ -1651,8 +1651,8 @@ impl Player { std::thread::sleep(Duration::from_millis(200)); // Use last known sample rate/channels to maintain DAC passthrough - let sr = current_sample_rate.unwrap_or(48000); - let ch = current_channels.unwrap_or(2); + let sr = current_track_sample_rate.unwrap_or(48000); + let ch = current_track_channels.unwrap_or(2); *stream_opt = init_device( current_device_name, &thread_state, @@ -1815,8 +1815,8 @@ impl Player { .unwrap_or(false); // Check if we need to recreate the stream - let format_changed = *current_sample_rate != Some(sample_rate) - || *current_channels != Some(channels); + let format_changed = *current_track_sample_rate != Some(sample_rate) + || *current_track_channels != Some(channels); let using_alsa_direct = thread_settings .lock() @@ -2004,8 +2004,8 @@ 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); + *current_track_sample_rate = Some(sample_rate); + *current_track_channels = Some(channels); let Some(ref stream) = *stream_opt else { log::error!( @@ -2232,8 +2232,8 @@ impl Player { if stream_opt.is_none() { // Use last known sample rate/channels to maintain DAC passthrough - let sr = current_sample_rate.unwrap_or(48000); - let ch = current_channels.unwrap_or(2); + let sr = current_track_sample_rate.unwrap_or(48000); + let ch = current_track_channels.unwrap_or(2); log::info!( "Resume: reinitializing stream at {}Hz/{}ch", sr, @@ -2619,8 +2619,8 @@ impl Player { *current_device_name = new_device; // Use last known sample rate/channels to maintain DAC passthrough - let sr = current_sample_rate.unwrap_or(48000); - let ch = current_channels.unwrap_or(2); + let sr = current_track_sample_rate.unwrap_or(48000); + let ch = current_track_channels.unwrap_or(2); log::info!("ReinitDevice: reinitializing at {}Hz/{}ch", sr, ch); *stream_opt = init_device(current_device_name, &thread_state, sr, ch); @@ -2660,7 +2660,7 @@ impl Player { // Verify format compatibility (same sample rate and channels) if let (Some(cur_sr), Some(cur_ch)) = - (*current_sample_rate, *current_channels) + (*current_track_sample_rate, *current_track_channels) { if sample_rate != cur_sr || channels != cur_ch { log::info!( @@ -2776,8 +2776,8 @@ impl Player { &mut current_device_name, &mut consecutive_sink_failures, &mut pause_suspend_deadline, - &mut current_sample_rate, - &mut current_channels, + &mut current_track_sample_rate, + &mut current_track_channels, &mut current_normalization_gain, &mut current_gain_atomic, &mut gapless_pending, @@ -3022,8 +3022,8 @@ impl Player { &mut current_device_name, &mut consecutive_sink_failures, &mut pause_suspend_deadline, - &mut current_sample_rate, - &mut current_channels, + &mut current_track_sample_rate, + &mut current_track_channels, &mut current_normalization_gain, &mut current_gain_atomic, &mut gapless_pending, @@ -3050,8 +3050,8 @@ impl Player { &mut current_device_name, &mut consecutive_sink_failures, &mut pause_suspend_deadline, - &mut current_sample_rate, - &mut current_channels, + &mut current_track_sample_rate, + &mut current_track_channels, &mut current_normalization_gain, &mut current_gain_atomic, &mut gapless_pending, From ac60113cdd2af3be8ca082d5baa0ec1ff42b2407 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Mon, 11 May 2026 15:14:54 +0200 Subject: [PATCH 02/10] refactor(player): extract StreamRecreateDecision helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rate-mismatch detection block was duplicated between the Play and Stream handlers, and each handler acquired thread_settings.lock() three times in a row. Extract into one helper that reads settings once and returns every flag the recreate decision needs. Also drops a dead else-branch that logged "Creating initial audio stream" — unreachable inside if stream_opt.is_some() — and the redundant info-log that re-announced the rate-mismatch reason at recreate time (the warn-level log inside the helper already says "Recreating stream to avoid wrong-speed playback"). --- crates/qbz-player/src/player/mod.rs | 267 ++++++++++++++-------------- 1 file changed, 138 insertions(+), 129 deletions(-) diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index 7827ce5d0..dc0f73a36 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -578,6 +578,98 @@ fn coreaudio_shared_rate_mismatch( (stream_rate != nominal_rate).then_some((stream_rate, nominal_rate)) } +/// Inputs needed by both `Play` and `Stream` handlers to decide whether the +/// current output stream must be torn down and recreated. +struct StreamRecreateDecision { + needs_new_stream: bool, + format_changed: bool, + dac_passthrough: bool, + using_alsa_direct: bool, + using_coreaudio_exclusive: bool, + coreaudio_shared_rate_mismatch: Option<(u32, u32)>, +} + +/// Read settings once and evaluate every condition that forces a stream +/// rebuild: format change on backends that demand bit-perfect output (DAC +/// passthrough, ALSA Direct, CoreAudio Exclusive) and CoreAudio shared-mode +/// rate drift (CPAL caches the device rate at open time, so when the OS +/// nominal rate moves we must rebuild or playback runs at the wrong speed). +/// +/// `current_track_sample_rate` / `current_track_channels` describe the last +/// *decoded source* format and are compared to the incoming `sample_rate` / +/// `channels` — never to the output stream's hardware rate, which on macOS +/// shared mode may differ and is tracked separately via Rodio's sink config. +fn evaluate_stream_recreate( + thread_settings: &Arc>, + stream_opt: &Option, + current_track_sample_rate: Option, + current_track_channels: Option, + sample_rate: u32, + channels: u16, + context: &str, +) -> StreamRecreateDecision { + let format_changed = + current_track_sample_rate != Some(sample_rate) || current_track_channels != Some(channels); + + let settings_guard = thread_settings.lock().ok(); + + let dac_passthrough = settings_guard + .as_ref() + .map(|s| cfg!(target_os = "linux") && s.dac_passthrough) + .unwrap_or(false); + + let using_alsa_direct = settings_guard + .as_ref() + .and_then(|s| s.backend_type) + .map(|b| b == AudioBackendType::Alsa) + .unwrap_or(false); + + let using_coreaudio_exclusive = settings_guard + .as_ref() + .map(|s| { + cfg!(target_os = "macos") + && s.backend_type.unwrap_or(AudioBackendType::SystemDefault) + == AudioBackendType::SystemDefault + && s.exclusive_mode + }) + .unwrap_or(false); + + #[cfg(target_os = "macos")] + let coreaudio_shared_rate_mismatch = settings_guard + .as_ref() + .and_then(|s| coreaudio_shared_rate_mismatch(s, stream_opt)) + .inspect(|(stream_rate, nominal_rate)| { + log::warn!( + "[CoreAudio] {} shared-mode output rate changed: stream {}Hz, device nominal {}Hz. Recreating stream to avoid wrong-speed playback.", + context, + stream_rate, + nominal_rate + ); + }); + #[cfg(not(target_os = "macos"))] + let coreaudio_shared_rate_mismatch: Option<(u32, u32)> = { + let _ = (stream_opt, context); + None + }; + + drop(settings_guard); + + let needs_new_stream = stream_opt.is_none() + || (dac_passthrough && format_changed) + || (using_alsa_direct && format_changed) + || (using_coreaudio_exclusive && format_changed) + || coreaudio_shared_rate_mismatch.is_some(); + + StreamRecreateDecision { + needs_new_stream, + format_changed, + dac_passthrough, + using_alsa_direct, + using_coreaudio_exclusive, + coreaudio_shared_rate_mismatch, + } +} + /// Try to create output stream using the backend system (if configured) /// Returns None if backend system is not configured (backend_type = None) /// @@ -1296,74 +1388,32 @@ impl Player { thread_state.set_gapless_ready(false); thread_state.set_gapless_next_track_id(0); - // Get DAC passthrough setting - let dac_passthrough = thread_settings - .lock() - .ok() - .map(|s| cfg!(target_os = "linux") && s.dac_passthrough) - .unwrap_or(false); - - // Check if we need to recreate the stream - // Recreate on format change if DAC passthrough OR ALSA Direct is enabled (both require bit-perfect) - let format_changed = *current_track_sample_rate != Some(sample_rate) - || *current_track_channels != Some(channels); - - // Check if using a backend where the output stream's - // format/rate must be actively managed. - let using_alsa_direct = thread_settings - .lock() - .ok() - .and_then(|s| s.backend_type) - .map(|b| b == AudioBackendType::Alsa) - .unwrap_or(false); - let using_coreaudio_exclusive = thread_settings - .lock() - .ok() - .map(|s| { - cfg!(target_os = "macos") - && s.backend_type.unwrap_or(AudioBackendType::SystemDefault) - == AudioBackendType::SystemDefault - && s.exclusive_mode - }) - .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() - || (dac_passthrough && format_changed) - || (using_alsa_direct && format_changed) - || (using_coreaudio_exclusive && format_changed) - || coreaudio_shared_rate_mismatch.is_some(); + let StreamRecreateDecision { + needs_new_stream, + format_changed, + dac_passthrough, + using_alsa_direct, + using_coreaudio_exclusive, + coreaudio_shared_rate_mismatch, + } = evaluate_stream_recreate( + &thread_settings, + stream_opt, + *current_track_sample_rate, + *current_track_channels, + sample_rate, + channels, + "Play", + ); if needs_new_stream { if stream_opt.is_some() { - 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_coreaudio_exclusive) + // CoreAudio rate-mismatch case already + // logged at warn level by + // `evaluate_stream_recreate`. + if coreaudio_shared_rate_mismatch.is_none() + && (dac_passthrough + || using_alsa_direct + || using_coreaudio_exclusive) && format_changed { let mode = if using_coreaudio_exclusive { @@ -1381,8 +1431,6 @@ impl Player { channels, mode ); - } else { - log::info!("Creating initial audio stream"); } // Stop engine FIRST so its writer thread releases its // Arc reference before we drop the stream. @@ -1807,71 +1855,32 @@ impl Player { *current_audio_data = None; // Clear regular audio data thread_state.set_loaded_audio(true); - // Get DAC passthrough setting - let dac_passthrough = thread_settings - .lock() - .ok() - .map(|s| cfg!(target_os = "linux") && s.dac_passthrough) - .unwrap_or(false); - - // Check if we need to recreate the stream - let format_changed = *current_track_sample_rate != Some(sample_rate) - || *current_track_channels != Some(channels); - - let using_alsa_direct = thread_settings - .lock() - .ok() - .and_then(|s| s.backend_type) - .map(|b| b == AudioBackendType::Alsa) - .unwrap_or(false); - let using_coreaudio_exclusive = thread_settings - .lock() - .ok() - .map(|s| { - cfg!(target_os = "macos") - && s.backend_type.unwrap_or(AudioBackendType::SystemDefault) - == AudioBackendType::SystemDefault - && s.exclusive_mode - }) - .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() - || (dac_passthrough && format_changed) - || (using_alsa_direct && format_changed) - || (using_coreaudio_exclusive && format_changed) - || coreaudio_shared_rate_mismatch.is_some(); + let StreamRecreateDecision { + needs_new_stream, + format_changed, + dac_passthrough, + using_alsa_direct, + using_coreaudio_exclusive, + coreaudio_shared_rate_mismatch, + } = evaluate_stream_recreate( + &thread_settings, + stream_opt, + *current_track_sample_rate, + *current_track_channels, + sample_rate, + channels, + "Streaming", + ); if needs_new_stream { if stream_opt.is_some() { - 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_coreaudio_exclusive) + // CoreAudio rate-mismatch case already + // logged at warn level by + // `evaluate_stream_recreate`. + if coreaudio_shared_rate_mismatch.is_none() + && (dac_passthrough + || using_alsa_direct + || using_coreaudio_exclusive) && format_changed { let mode = if using_coreaudio_exclusive { From c408ea73cb72187569ba3eda8180ca5da89f7064 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Mon, 11 May 2026 15:15:57 +0200 Subject: [PATCH 03/10] test(player): cover compute_needs_new_stream decision rule Extract the boolean decision out of evaluate_stream_recreate into a pure function so the truth table can be tested without faking a MixerDeviceSink or AudioSettings mutex. Eight cases cover the matrix: no stream, unchanged format, format change with and without each bit-perfect backend, and the CoreAudio shared-mode rate-mismatch path. --- crates/qbz-player/src/player/mod.rs | 109 ++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index dc0f73a36..51133685a 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -654,11 +654,14 @@ fn evaluate_stream_recreate( drop(settings_guard); - let needs_new_stream = stream_opt.is_none() - || (dac_passthrough && format_changed) - || (using_alsa_direct && format_changed) - || (using_coreaudio_exclusive && format_changed) - || coreaudio_shared_rate_mismatch.is_some(); + let needs_new_stream = compute_needs_new_stream( + stream_opt.is_some(), + format_changed, + dac_passthrough, + using_alsa_direct, + using_coreaudio_exclusive, + coreaudio_shared_rate_mismatch.is_some(), + ); StreamRecreateDecision { needs_new_stream, @@ -670,6 +673,25 @@ fn evaluate_stream_recreate( } } +/// Pure decision rule for whether the output stream must be rebuilt. +/// +/// Split out so the truth table can be unit-tested without faking a real +/// `MixerDeviceSink` or `AudioSettings` mutex. +fn compute_needs_new_stream( + has_stream: bool, + format_changed: bool, + dac_passthrough: bool, + using_alsa_direct: bool, + using_coreaudio_exclusive: bool, + coreaudio_shared_rate_mismatch: bool, +) -> bool { + !has_stream + || (dac_passthrough && format_changed) + || (using_alsa_direct && format_changed) + || (using_coreaudio_exclusive && format_changed) + || coreaudio_shared_rate_mismatch +} + /// Try to create output stream using the backend system (if configured) /// Returns None if backend system is not configured (backend_type = None) /// @@ -3443,3 +3465,80 @@ pub struct PlaybackState { pub track_id: u64, pub volume: f32, } + +#[cfg(test)] +mod tests { + use super::compute_needs_new_stream; + + #[test] + fn no_stream_always_needs_new() { + // Without an existing stream there is nothing to reuse — every + // other flag is irrelevant. + assert!(compute_needs_new_stream( + false, false, false, false, false, false + )); + } + + #[test] + fn unchanged_format_on_default_backend_reuses_stream() { + // Default rodio backend resamples internally, so an unchanged + // decoded format on an existing stream needs no rebuild. + assert!(!compute_needs_new_stream( + true, false, false, false, false, false + )); + } + + #[test] + fn format_change_alone_does_not_force_rebuild_on_default_backend() { + // Rodio handles sample-rate conversion; a format change without a + // bit-perfect backend does not require a new stream. + assert!(!compute_needs_new_stream( + true, true, false, false, false, false + )); + } + + #[test] + fn format_change_with_dac_passthrough_rebuilds() { + assert!(compute_needs_new_stream( + true, true, true, false, false, false + )); + } + + #[test] + fn format_change_with_alsa_direct_rebuilds() { + assert!(compute_needs_new_stream( + true, true, false, true, false, false + )); + } + + #[test] + fn format_change_with_coreaudio_exclusive_rebuilds() { + assert!(compute_needs_new_stream( + true, true, false, false, true, false + )); + } + + #[test] + fn bit_perfect_backends_without_format_change_reuse_stream() { + // Bit-perfect flags only force a rebuild *together with* a format + // change. On their own they should not. + assert!(!compute_needs_new_stream( + true, false, true, false, false, false + )); + assert!(!compute_needs_new_stream( + true, false, false, true, false, false + )); + assert!(!compute_needs_new_stream( + true, false, false, false, true, false + )); + } + + #[test] + fn coreaudio_shared_rate_mismatch_rebuilds_regardless_of_format_change() { + // The CoreAudio shared-mode rate-drift case has nothing to do with + // track format; it must rebuild whenever detected. + assert!(compute_needs_new_stream( + true, false, false, false, false, true + )); + } +} From 9e632386b9e98b1b614442b7acb9593aea2d6cf4 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Mon, 11 May 2026 15:16:46 +0200 Subject: [PATCH 04/10] 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. --- crates/qbz-audio/src/backend.rs | 139 ++++++++++++++++++++++---------- 1 file changed, 98 insertions(+), 41 deletions(-) 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; From 1edde8a28a1fc71e70f880cab366b983437d0765 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Mon, 11 May 2026 15:19:30 +0200 Subject: [PATCH 05/10] fix(audio): rephrase CoreAudio rate-mismatch errors for end users The error strings flow up through stream_error and end up in user- visible logs. Replace the internal-shaped messages ("opened at XHz while device nominal rate is YHz") with text that names the cause, the remediation, and the reason the fallback path is refused. --- crates/qbz-audio/src/backend.rs | 19 ++++++++++--------- crates/qbz-player/src/player/mod.rs | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/crates/qbz-audio/src/backend.rs b/crates/qbz-audio/src/backend.rs index d282762d7..59a868e95 100644 --- a/crates/qbz-audio/src/backend.rs +++ b/crates/qbz-audio/src/backend.rs @@ -636,29 +636,30 @@ impl CpalDefaultBackend { 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, + "[CoreAudio] System audio rate changed during stream open (stream {}Hz, device now {}Hz). Retrying open ({}/{})", + output_rate, + nominal, attempt + 2, max_attempts ); std::thread::sleep(MACOS_SHARED_OPEN_RETRY_DELAY); } - last_err = Some(msg); + last_err = Some(format!( + "macOS audio device changed its sample rate during stream open (opened at {}Hz, device is now {}Hz). This usually self-corrects — try playing again.", + output_rate, nominal + )); continue; } _ => return Ok(mixer_sink), } } - Err(last_err - .unwrap_or_else(|| "Failed to open CoreAudio shared stream after retries".to_string())) + Err(last_err.unwrap_or_else(|| { + "Could not open the macOS audio output stream after multiple attempts. Try selecting the device again or restarting playback.".to_string() + })) } fn current_macos_nominal_rate(effective_device_name: Option<&str>) -> Option { diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index 51133685a..970c7b0b0 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -1251,7 +1251,7 @@ impl Player { == AudioBackendType::SystemDefault { log::error!( - "macOS CoreAudio init failed: {} — refusing unsafe legacy CPAL fallback", + "Could not start macOS audio output — {}. Not falling back to the legacy CPAL path because it would either play at the wrong speed (shared mode) or silently drop Exclusive Mode.", e ); state.set_current_device(None); From a7b40bf6bbc32e95ce2eba38be9e60dbf5bcd059 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 13 May 2026 09:23:39 +0100 Subject: [PATCH 06/10] perf(player): cache CoreAudio nominal rate query for 750ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit evaluate_stream_recreate runs on every track change on the audio thread, and each call issued two HAL syscalls (resolve_output_device_id + get_nominal_sample_rate). Cache the result per-thread for 750ms — long enough to absorb the bulk of repeats within an album, short enough to notice a system rate change. Cache invalidates on device-name change, so switching the output device still triggers a fresh query. --- crates/qbz-player/src/player/mod.rs | 69 ++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index 970c7b0b0..69df45837 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -542,25 +542,70 @@ fn uses_coreaudio_system_default(settings: &AudioSettings) -> bool { == AudioBackendType::SystemDefault } +/// `evaluate_stream_recreate` runs every track change on the audio thread, +/// and `coreaudio_nominal_rate` issues two CoreAudio HAL queries per call +/// (`resolve_output_device_id` + `get_nominal_sample_rate`). A short-lived +/// cache absorbs the bulk of those repeats — long enough to skip duplicate +/// queries within an album, short enough to notice when the user changes the +/// system audio rate. +#[cfg(target_os = "macos")] +const COREAUDIO_RATE_CACHE_TTL: std::time::Duration = std::time::Duration::from_millis(750); + +#[cfg(target_os = "macos")] +struct CachedNominalRate { + cached_at: std::time::Instant, + device_name: Option, + rate: Option, +} + +#[cfg(target_os = "macos")] +std::thread_local! { + static COREAUDIO_NOMINAL_RATE_CACHE: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +#[cfg(target_os = "macos")] +fn query_macos_nominal_rate(device_name: Option<&str>) -> Option { + let device_id = qbz_audio::coreaudio_direct::resolve_output_device_id(device_name) + .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_nominal_rate(settings: &AudioSettings) -> Option { 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()?; + let device_name = settings.output_device.clone(); + let now = std::time::Instant::now(); - qbz_audio::coreaudio_direct::get_nominal_sample_rate(device_id) - .inspect_err(|e| log::warn!("[CoreAudio] Failed to query nominal rate: {}", e)) - .ok() + COREAUDIO_NOMINAL_RATE_CACHE.with(|cell| { + let mut cell = cell.borrow_mut(); + if let Some(cached) = cell.as_ref() { + if cached.device_name == device_name + && now.duration_since(cached.cached_at) < COREAUDIO_RATE_CACHE_TTL + { + return cached.rate; + } + } + let rate = query_macos_nominal_rate(device_name.as_deref()); + *cell = Some(CachedNominalRate { + cached_at: now, + device_name, + rate, + }); + rate + }) } #[cfg(target_os = "macos")] From 04e17cc449024053b9dab33ae8dfe6b8a10f0904 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 13 May 2026 13:09:18 +0100 Subject: [PATCH 07/10] feat(player): expose user-readable stream_error_message channel Pairs the existing stream_error AtomicBool with an Option that the audio thread can populate when a stream init failure has a meaningful cause to surface to the user (currently: the macOS CoreAudio rate-mismatch path). set_stream_error(false) also clears the message; record_stream_error sets both atomically; take_stream_error_message drains so the UI fires its notification exactly once per error. --- crates/qbz-player/src/player/mod.rs | 30 +++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index 69df45837..0d4c24190 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -904,6 +904,10 @@ pub struct SharedState { current_device: Arc>>, /// Stream error flag (set when ALSA/audio errors are detected) stream_error: Arc, + /// Optional user-readable explanation paired with `stream_error`. + /// Drained by the Tauri polling loop to emit a frontend toast and then + /// cleared, so the UI fires the notification exactly once per error. + stream_error_message: Arc>>, /// Actual sample rate of the current stream (Hz) sample_rate: Arc, /// Actual bit depth of the current stream @@ -941,6 +945,7 @@ impl SharedState { position_at_start: Arc::new(AtomicU64::new(0)), current_device: Arc::new(std::sync::RwLock::new(None)), stream_error: Arc::new(AtomicBool::new(false)), + stream_error_message: Arc::new(std::sync::RwLock::new(None)), sample_rate: Arc::new(AtomicU32::new(0)), bit_depth: Arc::new(AtomicU32::new(0)), normalization_gain: Arc::new(AtomicU32::new(0)), @@ -953,12 +958,36 @@ impl SharedState { pub fn set_stream_error(&self, error: bool) { self.stream_error.store(error, Ordering::SeqCst); + if !error { + if let Ok(mut m) = self.stream_error_message.write() { + *m = None; + } + } } pub fn has_stream_error(&self) -> bool { self.stream_error.load(Ordering::SeqCst) } + /// Record a user-readable error explanation alongside `stream_error=true`. + /// The message is drained once via `take_stream_error_message` so the UI + /// fires the toast exactly once per error. + pub fn record_stream_error(&self, message: impl Into) { + self.stream_error.store(true, Ordering::SeqCst); + if let Ok(mut m) = self.stream_error_message.write() { + *m = Some(message.into()); + } + } + + /// Atomically take the pending stream-error message (if any). Returns + /// `None` when no message is pending or has already been read. + pub fn take_stream_error_message(&self) -> Option { + self.stream_error_message + .write() + .ok() + .and_then(|mut m| m.take()) + } + pub fn set_stream_quality(&self, sample_rate: u32, bit_depth: u32) { self.sample_rate.store(sample_rate, Ordering::SeqCst); self.bit_depth.store(bit_depth, Ordering::SeqCst); @@ -1300,6 +1329,7 @@ impl Player { e ); state.set_current_device(None); + state.record_stream_error(e.clone()); return None; } log::warn!( From 6d91ec0e028505170108814842222bc1b11d60de Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 13 May 2026 13:09:26 +0100 Subject: [PATCH 08/10] feat(audio): emit audio:init-failed Tauri event on backend init failure The playback-event polling loop now drains SharedState::take_stream_error_message and emits a Tauri audio:init-failed event with the user-readable cause. Drain semantics guarantee the toast fires exactly once per error instead of every tick of the polling loop. --- src-tauri/src/lib.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c92f16810..5f19579b4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1219,6 +1219,31 @@ pub fn run(qconnect_cli_override: Option) { let mut last_track_id: u64 = 0; loop { + // Surface backend init failures from the V2 player to + // the frontend exactly once. record_stream_error stores + // the user-readable message; take_stream_error_message + // drains it so we never re-emit the same toast on + // subsequent ticks. Only the V2 (qbz-player) path + // populates this — the legacy player's init failures + // continue to surface via existing channels. + let init_error: Option = v2_player_state + .read() + .await + .as_ref() + .and_then(qbz_player::SharedState::take_stream_error_message); + if let Some(message) = init_error { + #[derive(serde::Serialize, Clone)] + struct AudioInitFailedPayload { + message: String, + } + if let Err(e) = app_handle.emit( + "audio:init-failed", + AudioInitFailedPayload { message }, + ) { + log::warn!("Failed to emit audio:init-failed event: {}", e); + } + } + // Check V2 player state first (takes priority if active) // V2 player is accessed via async lock, but we only need a clone let v2_state_opt: Option = From a2518490943697ed38bca02dc776eadc6d6310d5 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 13 May 2026 13:09:31 +0100 Subject: [PATCH 09/10] i18n(toast): add audioInitFailed translation key Add localized strings for the new backend audio init failure toast (en, de, es, fr, pt). Reason placeholder receives the backend's already user-friendly message (e.g. "macOS audio device changed its sample rate during stream open..."). --- src/lib/i18n/locales/de.json | 1 + src/lib/i18n/locales/en.json | 1 + src/lib/i18n/locales/es.json | 1 + src/lib/i18n/locales/fr.json | 1 + src/lib/i18n/locales/pt.json | 1 + 5 files changed, 5 insertions(+) diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json index d4b686d8f..17c91d3eb 100644 --- a/src/lib/i18n/locales/de.json +++ b/src/lib/i18n/locales/de.json @@ -2941,6 +2941,7 @@ "findingSimilarArtists": "Suche nach ähnlichen Künstlern...", "awardUnavailable": "Detailansicht für diese Auszeichnung nicht verfügbar.", "audioDeviceMissing": "Ausgabegerät '{device}' ist nicht mehr verfügbar. Standardgerät wird verwendet.", + "audioInitFailed": "Audioausgabe konnte nicht gestartet werden: {reason}", "mixTrimmed": "{actual} Songs zur Warteschlange hinzugefügt (du wolltest {requested}; einige wurden zusammengeführt oder pro Album begrenzt)." }, "topMixes": { diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index d425fe204..88189c37a 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -2947,6 +2947,7 @@ "findingSimilarArtists": "Finding similar artists...", "awardUnavailable": "Award detail not available for this entry.", "audioDeviceMissing": "Output device '{device}' is no longer available. Using system default.", + "audioInitFailed": "Audio output could not start: {reason}", "mixTrimmed": "Added {actual} songs to your queue (you asked for {requested}; some merged or capped per album)." }, "topMixes": { diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index f12dccd6e..ebcd17780 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -2947,6 +2947,7 @@ "findingSimilarArtists": "Encontrar artistas similares...", "awardUnavailable": "No hay detalle disponible para este premio.", "audioDeviceMissing": "El dispositivo '{device}' ya no está disponible. Usando el predeterminado del sistema.", + "audioInitFailed": "No se pudo iniciar la salida de audio: {reason}", "mixTrimmed": "Se agregaron {actual} canciones a la cola (pediste {requested}; algunas se fusionaron o quedaron limitadas por álbum)." }, "topMixes": { diff --git a/src/lib/i18n/locales/fr.json b/src/lib/i18n/locales/fr.json index 9d0947c26..9c311fcfb 100644 --- a/src/lib/i18n/locales/fr.json +++ b/src/lib/i18n/locales/fr.json @@ -2941,6 +2941,7 @@ "findingSimilarArtists": "Trouver des artistes similaires...", "awardUnavailable": "Détail non disponible pour cette récompense.", "audioDeviceMissing": "Le périphérique '{device}' n'est plus disponible. Utilisation du périphérique par défaut.", + "audioInitFailed": "Impossible de démarrer la sortie audio : {reason}", "mixTrimmed": "{actual} chansons ajoutées à la file (tu en as demandé {requested} ; certaines fusionnées ou limitées par album)." }, "topMixes": { diff --git a/src/lib/i18n/locales/pt.json b/src/lib/i18n/locales/pt.json index 5403c66bb..5c32f7e69 100644 --- a/src/lib/i18n/locales/pt.json +++ b/src/lib/i18n/locales/pt.json @@ -2941,6 +2941,7 @@ "findingSimilarArtists": "Encontrando artistas semelhantes...", "awardUnavailable": "Detalhe não disponível para este prêmio.", "audioDeviceMissing": "Dispositivo '{device}' não está mais disponível. Usando o padrão do sistema.", + "audioInitFailed": "Não foi possível iniciar a saída de áudio: {reason}", "mixTrimmed": "{actual} músicas adicionadas à fila (pediste {requested}; algumas foram fundidas ou limitadas por álbum)." }, "topMixes": { From 57f830793468bc5e57cba577c6a1a368795d715e Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 13 May 2026 13:09:35 +0100 Subject: [PATCH 10/10] feat(ui): toast on audio:init-failed event Listen for the backend audio:init-failed event and show a user-facing error toast (8s) with the backend-provided cause. Mirrors the audio:device-missing listener pattern alongside it. Surfaces the silence when macOS CoreAudio init fails, instead of leaving the user guessing why playback didn't start. --- src/routes/+page.svelte | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 241e30953..87949955e 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -5335,6 +5335,7 @@ let unlistenQconnectDiagnostic: UnlistenFn | null = null; let unlistenQconnectRendererReportDebug: UnlistenFn | null = null; let unlistenAudioDeviceMissing: UnlistenFn | null = null; + let unlistenAudioInitFailed: UnlistenFn | null = null; (async () => { const unlisten1 = await listen('tray:play_pause', () => { @@ -5566,6 +5567,23 @@ }); if (disposed) { unlistenDeviceMissing(); return; } unlistenAudioDeviceMissing = unlistenDeviceMissing; + + // Backend audio init failed (e.g. macOS CoreAudio rate mismatch after + // the retry exhausted, Hog Mode already held by another app, device + // disconnected mid-init). The fallback to legacy CPAL is intentionally + // refused on macOS — silent wrong-speed audio is worse than no audio — + // so the user sees no playback. Surface the cause as a toast instead + // of leaving them guessing at the silence. + const unlistenInitFailed = await listen<{ message: string }>('audio:init-failed', (event) => { + const reason = event.payload?.message ?? ''; + showToast( + $t('toast.audioInitFailed', { values: { reason } }), + 'error', + 8000 + ); + }); + if (disposed) { unlistenInitFailed(); return; } + unlistenAudioInitFailed = unlistenInitFailed; })(); return () => { @@ -5585,6 +5603,7 @@ unlistenQconnectDiagnostic?.(); unlistenQconnectRendererReportDebug?.(); unlistenAudioDeviceMissing?.(); + unlistenAudioInitFailed?.(); unsubscribeWindowTitle(); // Save session before cleanup saveSessionBeforeClose();