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:
9 files changed
+485
-195
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
+337
-154
@@ -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);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
struct CachedNominalRate {
|
||||
cached_at: std::time::Instant,
|
||||
device_name: Option<String>,
|
||||
rate: Option<u32>,
|
||||
}
|
||||
|
||||
let device_id =
|
||||
qbz_audio::coreaudio_direct::resolve_output_device_id(settings.output_device.as_deref())
|
||||
#[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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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> =
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user