Merge pull request #420 from afonsojramos/refactor/internal/macos-shared-mode-followup

refactor(audio): macOS shared-mode polish + review follow-ups from #410
This commit is contained in:
Victor RH authored and GitHub committed 2026-05-13 10:56:38 -06:00
commit 0daca399cd
9 files changed
+486 -196

No files matched your search

+99 -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,99 @@ 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 => {
drop(mixer_sink);
if attempt + 1 < max_attempts {
log::warn!(
"[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(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(|| {
"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<u32> {
use crate::coreaudio_direct;
+338 -155
View File
@@ -542,14 +542,31 @@ 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")]
fn coreaudio_nominal_rate(settings: &AudioSettings) -> Option<u32> {
if !uses_coreaudio_system_default(settings) {
return None;
}
const COREAUDIO_RATE_CACHE_TTL: std::time::Duration = std::time::Duration::from_millis(750);
let device_id =
qbz_audio::coreaudio_direct::resolve_output_device_id(settings.output_device.as_deref())
#[cfg(target_os = "macos")]
struct CachedNominalRate {
cached_at: std::time::Instant,
device_name: Option<String>,
rate: Option<u32>,
}
#[cfg(target_os = "macos")]
std::thread_local! {
static COREAUDIO_NOMINAL_RATE_CACHE: std::cell::RefCell<Option<CachedNominalRate>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(target_os = "macos")]
fn query_macos_nominal_rate(device_name: Option<&str>) -> Option<u32> {
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: {}",
@@ -563,6 +580,34 @@ fn coreaudio_nominal_rate(settings: &AudioSettings) -> Option<u32> {
.ok()
}
#[cfg(target_os = "macos")]
fn coreaudio_nominal_rate(settings: &AudioSettings) -> Option<u32> {
if !uses_coreaudio_system_default(settings) {
return None;
}
let device_name = settings.output_device.clone();
let now = std::time::Instant::now();
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")]
fn coreaudio_shared_rate_mismatch(
settings: &AudioSettings,
@@ -578,6 +623,120 @@ 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<Mutex<AudioSettings>>,
stream_opt: &Option<StreamType>,
current_track_sample_rate: Option<u32>,
current_track_channels: Option<u16>,
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 = 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,
format_changed,
dac_passthrough,
using_alsa_direct,
using_coreaudio_exclusive,
coreaudio_shared_rate_mismatch,
}
}
/// 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)
///
@@ -745,6 +904,10 @@ pub struct SharedState {
current_device: Arc<std::sync::RwLock<Option<String>>>,
/// Stream error flag (set when ALSA/audio errors are detected)
stream_error: Arc<AtomicBool>,
/// 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<std::sync::RwLock<Option<String>>>,
/// Actual sample rate of the current stream (Hz)
sample_rate: Arc<AtomicU32>,
/// Actual bit depth of the current stream
@@ -782,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)),
@@ -794,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<String>) {
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<String> {
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);
@@ -1137,10 +1325,11 @@ 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);
state.record_stream_error(e.clone());
return None;
}
log::warn!(
@@ -1228,8 +1417,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<StreamType> = None;
let mut current_sample_rate: Option<u32> = None;
let mut current_channels: Option<u16> = None;
let mut current_track_sample_rate: Option<u32> = None;
let mut current_track_channels: Option<u16> = None;
#[allow(dead_code)]
const MAX_INIT_RETRIES: u32 = 5;
@@ -1269,8 +1458,8 @@ impl Player {
current_device_name: &mut Option<String>,
consecutive_sink_failures: &mut u32,
pause_suspend_deadline: &mut Option<Instant>,
current_sample_rate: &mut Option<u32>,
current_channels: &mut Option<u16>,
current_track_sample_rate: &mut Option<u32>,
current_track_channels: &mut Option<u16>,
current_normalization_gain: &mut Option<f32>,
current_gain_atomic: &mut Option<Arc<AtomicU32>>,
gapless_pending: &mut Option<GaplessPending>,
@@ -1296,72 +1485,30 @@ 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_sample_rate != Some(sample_rate)
|| *current_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
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",
);
(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();
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
// 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
@@ -1375,14 +1522,12 @@ 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
);
} else {
log::info!("Creating initial audio stream");
}
// Stop engine FIRST so its writer thread releases its
// Arc<AlsaDirectStream> reference before we drop the stream.
@@ -1588,8 +1733,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 +1744,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 +1796,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,
@@ -1807,69 +1952,30 @@ 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_sample_rate != Some(sample_rate)
|| *current_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
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",
);
(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();
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
// 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
@@ -2004,8 +2110,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 +2338,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 +2725,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 +2766,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 +2882,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 +3128,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 +3156,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,
@@ -3434,3 +3540,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
));
}
}
+25
View File
@@ -1244,6 +1244,31 @@ pub fn run(qconnect_cli_override: Option<bool>) {
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<String> = 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<qbz_player::SharedState> =
+1
View File
@@ -2982,6 +2982,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": {
+1
View File
@@ -2988,6 +2988,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": {
+1
View File
@@ -2988,6 +2988,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": {
+1
View File
@@ -2982,6 +2982,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": {
+1
View File
@@ -2982,6 +2982,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": {
+19
View File
@@ -5401,6 +5401,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', () => {
@@ -5632,6 +5633,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 () => {
@@ -5651,6 +5669,7 @@
unlistenQconnectDiagnostic?.();
unlistenQconnectRendererReportDebug?.();
unlistenAudioDeviceMissing?.();
unlistenAudioInitFailed?.();
unsubscribeWindowTitle();
// Save session before cleanup
saveSessionBeforeClose();