fix(audio): poll only the default device when none is pinned (#996) (#1039)

* fix(audio): poll only the default device when none is pinned (#996)

The device watcher ran a full output_devices() + per-device description()
CoreAudio enumeration every 3s, even with no pinned device. On some macOS
setups this contends with the audio render thread and causes a brief dropout
once per poll — a stutter whose cadence tracks the poll interval exactly.

The full enumeration is only needed to detect a pinned device disappearing.
With no pin (system default, the common case) only the current default is
needed, so the enumeration is skipped entirely in that case; the cheap
single default_output_device() query still detects default-device changes.

Confirmed with a diagnostic build: throttling the enumeration to ~60s moved
the reporter's stutter cadence from ~3s to ~60s, isolating the enumeration
as the cause.

* docs: add CHANGELOG entry for PR #1039
This commit is contained in:
Psychotoxical
2026-06-09 00:16:29 +02:00
committed by GitHub
parent 37089ea0f1
commit c6298d8c25
2 changed files with 35 additions and 16 deletions
+8
View File
@@ -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 ### 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)** **By [@cucadmuh](https://github.com/cucadmuh), PR [#1006](https://github.com/Psychotoxical/psysonic/pull/1006)**
@@ -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). // 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}; use rodio::cpal::traits::{DeviceTrait, HostTrait};
#[cfg(unix)] #[cfg(unix)]
let _guard = unsafe { let _guard = unsafe {
@@ -244,26 +253,28 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let default = host let default = host
.default_output_device() .default_output_device()
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string())); .and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
let available: Vec<String> = host let available: Vec<String> = if need_full_enum {
.output_devices() host.output_devices()
.map(|iter| { .map(|iter| {
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string())) iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
.collect() .collect()
}) })
.unwrap_or_default(); .unwrap_or_default()
} else {
Vec::new()
};
(default, available) (default, available)
}).await.unwrap_or((None, vec![])); }).await.unwrap_or((None, vec![]));
// Empty list almost always means a transient enumeration failure, not // Empty list (only when we actually enumerated for a pinned device)
// that every output device vanished. Treating it as "pinned missing" // almost always means a transient enumeration failure, not that every
// caused false audio:device-reset (UI jumped back to system default) // output device vanished. Treating it as "pinned missing" caused false
// when switching to external USB / class-compliant interfaces. // audio:device-reset (UI jumped back to system default) when switching
if available.is_empty() { // to external USB / class-compliant interfaces.
if need_full_enum && available.is_empty() {
continue; continue;
} }
let pinned = selected_device.lock().unwrap().clone();
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if pinned.is_some() { if pinned.is_some() {
// Do not infer "unplugged" from `output_devices()` when a device is pinned. // Do not infer "unplugged" from `output_devices()` when a device is pinned.