diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index d8091cc3..c26c61f1 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -2936,11 +2936,7 @@ fn with_suppressed_alsa_stderr(f: impl FnOnce() -> R) -> R { f() } -/// Returns the names of all available audio output devices on the current host. -/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to -/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean. -#[tauri::command] -pub fn audio_list_devices() -> Vec { +fn enumerate_output_device_names() -> Vec { use rodio::cpal::traits::{DeviceTrait, HostTrait}; with_suppressed_alsa_stderr(|| { let host = rodio::cpal::default_host(); @@ -2950,6 +2946,92 @@ pub fn audio_list_devices() -> Vec { }) } +/// Linux ALSA-style cpal names: same physical sink can appear with different suffixes; +/// busy devices are sometimes omitted from `output_devices()` while playback works. +#[cfg(target_os = "linux")] +fn linux_alsa_sink_fingerprint(name: &str) -> Option<(String, String, u32)> { + const IFACES: &[&str] = &[ + "hdmi", "hw", "plughw", "sysdefault", "iec958", "front", "dmix", "surround40", + "surround51", "surround71", + ]; + let colon = name.find(':')?; + let iface = name[..colon].to_ascii_lowercase(); + if !IFACES.iter().any(|&i| i == iface.as_str()) { + return None; + } + let card = name.split("CARD=").nth(1)?.split(',').next()?.to_string(); + let dev = name + .split("DEV=") + .nth(1) + .and_then(|s| s.split(',').next()) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + Some((iface, card, dev)) +} + +#[cfg(not(target_os = "linux"))] +#[inline] +fn linux_alsa_sink_fingerprint(_name: &str) -> Option<(String, String, u32)> { + None +} + +fn output_devices_logically_same(a: &str, b: &str) -> bool { + if a == b { + return true; + } + match ( + linux_alsa_sink_fingerprint(a), + linux_alsa_sink_fingerprint(b), + ) { + (Some(fa), Some(fb)) => fa == fb, + _ => false, + } +} + +/// True if `pinned` is the same sink as some entry (exact or Linux ALSA logical match). +fn output_enumeration_includes_pinned(available: &[String], pinned: &str) -> bool { + available + .iter() + .any(|d| output_devices_logically_same(d, pinned)) +} + +/// If the pinned id is missing from cpal's list but another listed id is the same +/// physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form. +#[tauri::command] +pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Option { + let pinned = state.selected_device.lock().unwrap().clone()?; + if pinned.is_empty() { + return None; + } + let list = enumerate_output_device_names(); + if list.iter().any(|d| d == &pinned) { + return None; + } + let canon = list + .iter() + .find(|d| output_devices_logically_same(d, &pinned))? + .clone(); + *state.selected_device.lock().unwrap() = Some(canon.clone()); + Some(canon) +} + +/// Returns the names of all available audio output devices on the current host. +/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to +/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean. +/// +/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while +/// streaming) so the Settings dropdown still matches `audioOutputDevice`. +#[tauri::command] +pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec { + let mut list = enumerate_output_device_names(); + if let Some(ref name) = *state.selected_device.lock().unwrap() { + if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) { + list.push(name.clone()); + } + } + list +} + /// Device id string for the host default output (matches an entry from `audio_list_devices` when present). #[tauri::command] pub fn audio_default_output_device_name() -> Option { @@ -3006,9 +3088,10 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) { // Polls every 3 s for two conditions: // 1. System default device changed (Bluetooth, USB DAC plug/unplug) while no // device is pinned → reopen on new default, emit audio:device-changed. -// 2. User-pinned device disappeared (DAC unplugged) → fall back to system -// default, clear selected_device, emit audio:device-reset so the frontend -// can reset its dropdown and persist the change. +// 2. (macOS / Windows only) User-pinned device disappeared from cpal's list → +// fall back to system default, clear selected_device, emit audio:device-reset. +// Linux: case 2 is disabled — ALSA/cpal often omit the active sink from +// enumeration while streaming, which caused false resets to system default. pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) { let reopen_tx = engine.stream_reopen_tx.clone(); @@ -3026,10 +3109,8 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) { .and_then(|d| d.name().ok()) }).await.unwrap_or(None); - // How many consecutive checks the pinned device has been absent. - // ALSA can temporarily fail to enumerate a device that is busy (e.g. actively - // streaming), so we require 3 consecutive misses (~9 s) before treating it - // as truly unplugged. + // macOS/Windows: consecutive polls where a pinned device is absent from cpal's list. + #[cfg(not(target_os = "linux"))] let mut pinned_miss_count: u32 = 0; loop { @@ -3060,14 +3141,31 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) { (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() { + 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. + // ALSA/cpal often omit the active HDMI/USB sink from enumeration for the + // whole session — any miss counter eventually tripped audio:device-reset. + // Clearing the pin is left to the user (Settings → System Default) or + // to a future explicit error signal from the output stream. + continue; + } + + // ── Case 2 (non-Linux): pinned device disappeared from enumeration ─ + #[cfg(not(target_os = "linux"))] if let Some(ref dev_name) = pinned { - // ── Case 2: pinned device disappeared ──────────────────────── - if !available.iter().any(|d| d == dev_name) { + if !output_enumeration_includes_pinned(&available, dev_name) { pinned_miss_count += 1; - // Only act after 3 consecutive misses (~9 s) to avoid false - // positives when ALSA temporarily hides a busy device. if pinned_miss_count < 3 { continue; } @@ -3075,7 +3173,6 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) { pinned_miss_count = 0; *selected_device.lock().unwrap() = None; - // Debounce so the OS finishes reconfiguring. tokio::time::sleep(Duration::from_millis(500)).await; let rate = stream_rate.load(Ordering::Relaxed); @@ -3093,17 +3190,13 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) { *stream_handle.lock().unwrap() = handle; if let Some(s) = current.lock().unwrap().sink.take() { s.stop(); } if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); } - // audio:device-reset tells the frontend to clear its - // audioOutputDevice setting and restart playback. app.emit("audio:device-reset", ()).ok(); } last_default = current_default; } else { - // Device is present — reset miss counter. pinned_miss_count = 0; } - // Pinned device still present (or not yet confirmed gone) — nothing to do. continue; } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 01b3df6e..c58649fb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1714,6 +1714,7 @@ pub fn run() { audio::audio_set_crossfade, audio::audio_set_gapless, audio::audio_list_devices, + audio::audio_canonicalize_selected_device, audio::audio_default_output_device_name, audio::audio_set_device, audio::audio_chain_preload, diff --git a/src/locales/de.ts b/src/locales/de.ts index 8c944268..99bcc316 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -505,6 +505,7 @@ export const deTranslation = { audioOutputDeviceRefresh: 'Geräteliste aktualisieren', audioOutputDeviceOsDefaultNow: 'aktuelle Systemausgabe', audioOutputDeviceListError: 'Audiogeräteliste konnte nicht geladen werden.', + audioOutputDeviceNotInCurrentList: 'nicht in der aktuellen Liste', hiResTitle: 'Native Hi-Res-Wiedergabe', hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren', hiResDesc: "Standardmäßig wird auf 44,1 kHz begrenzt (maximale Stabilität). Nur aktivieren, wenn Hardware und Netzwerk zuverlässig hohe Abtastraten (88,2 kHz+) unterstützen.", diff --git a/src/locales/en.ts b/src/locales/en.ts index 7eaa0ea3..7efdf545 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -507,6 +507,7 @@ export const enTranslation = { audioOutputDeviceRefresh: 'Refresh device list', audioOutputDeviceOsDefaultNow: 'current system output', audioOutputDeviceListError: 'Could not load the audio device list.', + audioOutputDeviceNotInCurrentList: 'not in current list', hiResTitle: 'Native Hi-Res Playback', hiResEnabled: 'Enable native hi-res playback', hiResDesc: "Forces 44.1 kHz output by default for maximum stability. Enable only if your hardware and network reliably support high sample rates (88.2 kHz+).", diff --git a/src/locales/es.ts b/src/locales/es.ts index c64e12df..f2ce6006 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -508,6 +508,7 @@ export const esTranslation = { audioOutputDeviceRefresh: 'Actualizar lista de dispositivos', audioOutputDeviceOsDefaultNow: 'salida actual del sistema', audioOutputDeviceListError: 'No se pudo cargar la lista de dispositivos de audio.', + audioOutputDeviceNotInCurrentList: 'no está en la lista actual', hiResTitle: 'Reproducción Nativa Hi-Res', hiResEnabled: 'Habilitar reproducción nativa hi-res', hiResDesc: "Fuerza 44.1 kHz de salida por defecto para máxima estabilidad. Habilita solo si tu hardware y red soportan confiablemente altas tasas de muestreo (88.2 kHz+).", diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 2a0e4bfc..bb4f60ba 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -505,6 +505,7 @@ export const frTranslation = { audioOutputDeviceRefresh: 'Actualiser la liste', audioOutputDeviceOsDefaultNow: 'sortie système actuelle', audioOutputDeviceListError: 'Impossible de charger la liste des périphériques audio.', + audioOutputDeviceNotInCurrentList: 'absent de la liste actuelle', hiResTitle: 'Lecture haute résolution native', hiResEnabled: 'Activer la lecture haute résolution native', hiResDesc: "Force une sortie à 44,1 kHz par défaut pour une stabilité maximale. N'activer que si le matériel et le réseau prennent en charge les hautes fréquences d'échantillonnage (88,2 kHz+).", diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 175838b7..380e3174 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -506,6 +506,7 @@ export const nbTranslation = { audioOutputDeviceRefresh: 'Oppdater enhetsliste', audioOutputDeviceOsDefaultNow: 'gjeldende systemutgang', audioOutputDeviceListError: 'Kunne ikke laste listen over lydenheter.', + audioOutputDeviceNotInCurrentList: 'ikke i gjeldende liste', hiResTitle: 'Innebygd hi-res-avspilling', hiResEnabled: 'Aktiver innebygd hi-res-avspilling', hiResDesc: "Begrenser utdata til 44,1 kHz som standard for maksimal stabilitet. Aktiver kun hvis maskinvare og nettverk støtter høye samplingsrater (88,2 kHz+) pålitelig.", diff --git a/src/locales/nl.ts b/src/locales/nl.ts index bcc1d654..7fba5494 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -504,6 +504,7 @@ export const nlTranslation = { audioOutputDeviceRefresh: 'Apparatenlijst vernieuwen', audioOutputDeviceOsDefaultNow: 'huidige systeemuitvoer', audioOutputDeviceListError: 'De lijst met audio-apparaten kon niet worden geladen.', + audioOutputDeviceNotInCurrentList: 'staat niet in de huidige lijst', hiResTitle: 'Natieve hi-res-weergave', hiResEnabled: 'Natieve hi-res-weergave inschakelen', hiResDesc: "Beperkt de uitvoer standaard tot 44,1 kHz voor maximale stabiliteit. Alleen inschakelen als hardware en netwerk hoge samplerates (88,2 kHz+) ondersteunen.", diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 7d9eb36c..2cd93ae3 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -522,6 +522,7 @@ export const ruTranslation = { audioOutputDeviceRefresh: 'Обновить список устройств', audioOutputDeviceOsDefaultNow: 'текущий системный вывод', audioOutputDeviceListError: 'Не удалось загрузить список аудиоустройств.', + audioOutputDeviceNotInCurrentList: 'нет в текущем списке', hiResTitle: 'Нативное воспроизведение Hi-Res', hiResEnabled: 'Включить нативное Hi-Res воспроизведение', hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).", diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 8255b44d..3cdd3e01 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -500,6 +500,7 @@ export const zhTranslation = { audioOutputDeviceRefresh: '刷新设备列表', audioOutputDeviceOsDefaultNow: '当前系统输出', audioOutputDeviceListError: '无法加载音频设备列表。', + audioOutputDeviceNotInCurrentList: '不在当前列表中', hiResTitle: '原生高清晰度播放', hiResEnabled: '启用原生高清晰度播放', hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。", diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index e7ef3a77..f7425e3f 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -300,17 +300,44 @@ function disambiguatedAudioDeviceLabel(raw: string, baseLabel: string, duplicate return `${baseLabel} · ${audioDeviceDuplicateHint(raw)}`; } +/** cpal order is arbitrary; sort by readable label, current OS default first. */ +function sortAudioDeviceIds(devices: string[], osDefaultDeviceId: string | null): string[] { + return [...devices].sort((a, b) => { + const aDef = osDefaultDeviceId && a === osDefaultDeviceId; + const bDef = osDefaultDeviceId && b === osDefaultDeviceId; + if (aDef !== bDef) return aDef ? -1 : 1; + const la = formatAudioDeviceLabel(a); + const lb = formatAudioDeviceLabel(b); + const byLabel = la.localeCompare(lb, undefined, { sensitivity: 'base' }); + if (byLabel !== 0) return byLabel; + return a.localeCompare(b); + }); +} + function buildAudioDeviceSelectOptions( devices: string[], defaultLabel: string, osDefaultDeviceId: string | null, osDefaultMark: string, + pinnedDevice: string | null, + notInListSuffix: string, ): { value: string; label: string }[] { const baseLabels = devices.map(formatAudioDeviceLabel); const countByBase = new Map(); for (const b of baseLabels) countByBase.set(b, (countByBase.get(b) ?? 0) + 1); + const pinned = pinnedDevice?.trim() || null; + const pinnedNotListed = !!(pinned && !devices.includes(pinned)); + const ghost: { value: string; label: string }[] = pinnedNotListed + ? (() => { + const base = formatAudioDeviceLabel(pinned); + let label = `${base} · ${notInListSuffix}`; + if (osDefaultDeviceId && pinned === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`; + return [{ value: pinned, label }]; + })() + : []; return [ { value: '', label: defaultLabel }, + ...ghost, ...devices.map((d, i) => { const base = baseLabels[i]; const dup = (countByBase.get(base) ?? 0) > 1; @@ -390,9 +417,20 @@ export default function Settings() { }); const defP = invoke('audio_default_output_device_name').catch(() => null); Promise.all([listP, defP]) - .then(([devices, osDefault]) => { - setAudioDevices(devices); - setOsDefaultAudioDeviceId(osDefault ?? null); + .then(async ([devices, osDefault]) => { + let canon: string | null = null; + try { + canon = await invoke('audio_canonicalize_selected_device'); + if (canon) useAuthStore.getState().setAudioOutputDevice(canon); + } catch { + /* ignore */ + } + const finalList = canon + ? await invoke('audio_list_devices').catch(() => devices) + : devices; + const defId = osDefault ?? null; + setAudioDevices(sortAudioDeviceIds(finalList, defId)); + setOsDefaultAudioDeviceId(defId); }) .finally(() => { if (!silent) setDevicesLoading(false); @@ -671,11 +709,13 @@ export default function Settings() { t('settings.audioOutputDeviceDefault'), osDefaultAudioDeviceId, t('settings.audioOutputDeviceOsDefaultNow'), + auth.audioOutputDevice, + t('settings.audioOutputDeviceNotInCurrentList'), )} />