diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cbfade9..7f2fbf9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Playback — macOS stutter from background device checks + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1039](https://github.com/Psychotoxical/psysonic/pull/1039)** + +* On some macOS setups playback stuttered at a steady ~3-second cadence: a background check that scans every audio output device ran on each poll and briefly contended with playback. It now runs only when a specific output device is pinned; with the system default (the common case) a single lightweight check runs instead. + + + ### Track preview — Symphonia 0.6 format hints and fast stream start **By [@cucadmuh](https://github.com/cucadmuh), PR [#1006](https://github.com/Psychotoxical/psysonic/pull/1006)** diff --git a/src-tauri/crates/psysonic-audio/src/device_watcher.rs b/src-tauri/crates/psysonic-audio/src/device_watcher.rs index 23079a4f..2f83b2fb 100644 --- a/src-tauri/crates/psysonic-audio/src/device_watcher.rs +++ b/src-tauri/crates/psysonic-audio/src/device_watcher.rs @@ -224,9 +224,18 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) { } } - // Enumerate all available output devices and the current default. + // The full `output_devices()` + per-device `description()` scan is the + // CoreAudio HAL call that contends with the audio render thread and + // produces a brief dropout once per poll interval (issue #996: stutter + // every ~3s, cadence tracking the poll exactly). It is only needed to + // detect a *pinned* output device disappearing. With no pin — system + // default, the common case — only the current default is needed, a + // single cheap query, so the full enumeration is skipped entirely. + let pinned = selected_device.lock().unwrap().clone(); + let need_full_enum = pinned.is_some(); + // Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix). - let (current_default, available) = tauri::async_runtime::spawn_blocking(|| { + let (current_default, available) = tauri::async_runtime::spawn_blocking(move || { use rodio::cpal::traits::{DeviceTrait, HostTrait}; #[cfg(unix)] let _guard = unsafe { @@ -244,26 +253,28 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) { let default = host .default_output_device() .and_then(|d| d.description().ok().map(|desc| desc.name().to_string())); - let available: Vec = host - .output_devices() - .map(|iter| { - iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string())) - .collect() - }) - .unwrap_or_default(); + let available: Vec = if need_full_enum { + host.output_devices() + .map(|iter| { + iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string())) + .collect() + }) + .unwrap_or_default() + } else { + Vec::new() + }; (default, available) }).await.unwrap_or((None, vec![])); - // Empty list almost always means a transient enumeration failure, not - // that every output device vanished. Treating it as "pinned missing" - // caused false audio:device-reset (UI jumped back to system default) - // when switching to external USB / class-compliant interfaces. - if available.is_empty() { + // Empty list (only when we actually enumerated for a pinned device) + // almost always means a transient enumeration failure, not that every + // output device vanished. Treating it as "pinned missing" caused false + // audio:device-reset (UI jumped back to system default) when switching + // to external USB / class-compliant interfaces. + if need_full_enum && available.is_empty() { continue; } - let pinned = selected_device.lock().unwrap().clone(); - #[cfg(target_os = "linux")] if pinned.is_some() { // Do not infer "unplugged" from `output_devices()` when a device is pinned.