fix(audio): device-reset false positive + stderr noise + i18n tooltip

- Device watcher requires 3 consecutive misses (~9 s) before triggering
  audio:device-reset, preventing false positives when ALSA temporarily
  hides a busy device from output_devices() enumeration
- Add stderr suppression to open_stream_for_device_and_rate (last source
  of ALSA terminal noise on Linux)
- Fix tooltip showing raw key 'common.refresh' — replaced with
  settings.audioOutputDeviceRefresh (added to all 8 locales)
- Device switch restarts track from beginning (no seek-back)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-13 19:00:36 +02:00
parent 82e1b458f3
commit ec021516c7
11 changed files with 41 additions and 9 deletions
+31 -1
View File
@@ -1530,6 +1530,20 @@ impl AudioCurrent {
fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (rodio::OutputStream, rodio::OutputStreamHandle, u32) { fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (rodio::OutputStream, rodio::OutputStreamHandle, u32) {
use rodio::cpal::traits::{DeviceTrait, HostTrait}; use rodio::cpal::traits::{DeviceTrait, HostTrait};
// Suppress ALSA stderr noise while enumerating devices on Unix.
#[cfg(unix)]
let _guard = unsafe {
struct StderrGuard(i32);
impl Drop for StderrGuard {
fn drop(&mut self) { unsafe { libc::dup2(self.0, 2); libc::close(self.0); } }
}
let saved = libc::dup(2);
let devnull = libc::open(b"/dev/null\0".as_ptr() as *const libc::c_char, libc::O_WRONLY);
libc::dup2(devnull, 2);
libc::close(devnull);
StderrGuard(saved)
};
let host = rodio::cpal::default_host(); let host = rodio::cpal::default_host();
// Resolve the target device: named device first, fall back to system default. // Resolve the target device: named device first, fall back to system default.
@@ -2993,6 +3007,12 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
.and_then(|d| d.name().ok()) .and_then(|d| d.name().ok())
}).await.unwrap_or(None); }).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.
let mut pinned_miss_count: u32 = 0;
loop { loop {
tokio::time::sleep(Duration::from_secs(3)).await; tokio::time::sleep(Duration::from_secs(3)).await;
@@ -3026,7 +3046,14 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
if let Some(ref dev_name) = pinned { if let Some(ref dev_name) = pinned {
// ── Case 2: pinned device disappeared ──────────────────────── // ── Case 2: pinned device disappeared ────────────────────────
if !available.iter().any(|d| d == dev_name) { if !available.iter().any(|d| d == 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;
}
eprintln!("[psysonic] device-watcher: pinned device '{dev_name}' disconnected, falling back to system default"); eprintln!("[psysonic] device-watcher: pinned device '{dev_name}' disconnected, falling back to system default");
pinned_miss_count = 0;
*selected_device.lock().unwrap() = None; *selected_device.lock().unwrap() = None;
// Debounce so the OS finishes reconfiguring. // Debounce so the OS finishes reconfiguring.
@@ -3053,8 +3080,11 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
} }
last_default = current_default; last_default = current_default;
} else {
// Device is present — reset miss counter.
pinned_miss_count = 0;
} }
// Pinned device still present — nothing to do. // Pinned device still present (or not yet confirmed gone) — nothing to do.
continue; continue;
} }
+1 -7
View File
@@ -421,13 +421,10 @@ function TauriEventBridge() {
useEffect(() => { useEffect(() => {
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined;
listen('audio:device-changed', () => { listen('audio:device-changed', () => {
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState(); const { currentTrack, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return; if (!currentTrack) return;
if (isPlaying) { if (isPlaying) {
const pos = currentTime;
const dur = currentTrack.duration || 1;
playTrack(currentTrack); playTrack(currentTrack);
setTimeout(() => usePlayerStore.getState().seek(pos / dur), 600);
} else { } else {
// Paused: clear warm-pause flag so the next resume uses the cold path // Paused: clear warm-pause flag so the next resume uses the cold path
// (audio_play + seek) which creates a new Sink on the new device. // (audio_play + seek) which creates a new Sink on the new device.
@@ -446,10 +443,7 @@ function TauriEventBridge() {
const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState(); const { currentTrack, currentTime, isPlaying, playTrack, resetAudioPause } = usePlayerStore.getState();
if (!currentTrack) return; if (!currentTrack) return;
if (isPlaying) { if (isPlaying) {
const pos = currentTime;
const dur = currentTrack.duration || 1;
playTrack(currentTrack); playTrack(currentTrack);
setTimeout(() => usePlayerStore.getState().seek(pos / dur), 600);
} else { } else {
resetAudioPause(); resetAudioPause();
} }
+1
View File
@@ -502,6 +502,7 @@ export const deTranslation = {
audioOutputDevice: 'Audio-Ausgabegerät', audioOutputDevice: 'Audio-Ausgabegerät',
audioOutputDeviceDesc: 'Wähle das Audiogerät, über das Psysonic spielt. Änderungen werden sofort übernommen und starten den aktuellen Track neu.', audioOutputDeviceDesc: 'Wähle das Audiogerät, über das Psysonic spielt. Änderungen werden sofort übernommen und starten den aktuellen Track neu.',
audioOutputDeviceDefault: 'Systemstandard', audioOutputDeviceDefault: 'Systemstandard',
audioOutputDeviceRefresh: 'Geräteliste aktualisieren',
hiResTitle: 'Native Hi-Res-Wiedergabe', hiResTitle: 'Native Hi-Res-Wiedergabe',
hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren', 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.", 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.",
+1
View File
@@ -504,6 +504,7 @@ export const enTranslation = {
audioOutputDevice: 'Audio Output Device', audioOutputDevice: 'Audio Output Device',
audioOutputDeviceDesc: 'Select which audio device Psysonic plays through. Changes take effect immediately and restart the current track.', audioOutputDeviceDesc: 'Select which audio device Psysonic plays through. Changes take effect immediately and restart the current track.',
audioOutputDeviceDefault: 'System Default', audioOutputDeviceDefault: 'System Default',
audioOutputDeviceRefresh: 'Refresh device list',
hiResTitle: 'Native Hi-Res Playback', hiResTitle: 'Native Hi-Res Playback',
hiResEnabled: 'Enable 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+).", 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+).",
+1
View File
@@ -505,6 +505,7 @@ export const esTranslation = {
audioOutputDevice: 'Dispositivo de salida de audio', audioOutputDevice: 'Dispositivo de salida de audio',
audioOutputDeviceDesc: 'Selecciona el dispositivo de audio que usa Psysonic. Los cambios surten efecto de inmediato y reinician la pista actual.', audioOutputDeviceDesc: 'Selecciona el dispositivo de audio que usa Psysonic. Los cambios surten efecto de inmediato y reinician la pista actual.',
audioOutputDeviceDefault: 'Predeterminado del sistema', audioOutputDeviceDefault: 'Predeterminado del sistema',
audioOutputDeviceRefresh: 'Actualizar lista de dispositivos',
hiResTitle: 'Reproducción Nativa Hi-Res', hiResTitle: 'Reproducción Nativa Hi-Res',
hiResEnabled: 'Habilitar 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+).", 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+).",
+1
View File
@@ -502,6 +502,7 @@ export const frTranslation = {
audioOutputDevice: 'Périphérique de sortie audio', audioOutputDevice: 'Périphérique de sortie audio',
audioOutputDeviceDesc: 'Choisissez le périphérique audio utilisé par Psysonic. Les changements sont immédiats et relancent la piste en cours.', audioOutputDeviceDesc: 'Choisissez le périphérique audio utilisé par Psysonic. Les changements sont immédiats et relancent la piste en cours.',
audioOutputDeviceDefault: 'Défaut système', audioOutputDeviceDefault: 'Défaut système',
audioOutputDeviceRefresh: 'Actualiser la liste',
hiResTitle: 'Lecture haute résolution native', hiResTitle: 'Lecture haute résolution native',
hiResEnabled: 'Activer la 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+).", 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+).",
+1
View File
@@ -503,6 +503,7 @@ export const nbTranslation = {
audioOutputDevice: 'Lydutgangsenhet', audioOutputDevice: 'Lydutgangsenhet',
audioOutputDeviceDesc: 'Velg hvilken lydenhet Psysonic spiller gjennom. Endringer trer i kraft umiddelbart og starter gjeldende spor på nytt.', audioOutputDeviceDesc: 'Velg hvilken lydenhet Psysonic spiller gjennom. Endringer trer i kraft umiddelbart og starter gjeldende spor på nytt.',
audioOutputDeviceDefault: 'Systemstandard', audioOutputDeviceDefault: 'Systemstandard',
audioOutputDeviceRefresh: 'Oppdater enhetsliste',
hiResTitle: 'Innebygd hi-res-avspilling', hiResTitle: 'Innebygd hi-res-avspilling',
hiResEnabled: 'Aktiver 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.", 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.",
+1
View File
@@ -501,6 +501,7 @@ export const nlTranslation = {
audioOutputDevice: 'Audio-uitvoerapparaat', audioOutputDevice: 'Audio-uitvoerapparaat',
audioOutputDeviceDesc: 'Kies via welk audioapparaat Psysonic afspeelt. Wijzigingen worden direct toegepast en starten het huidige nummer opnieuw.', audioOutputDeviceDesc: 'Kies via welk audioapparaat Psysonic afspeelt. Wijzigingen worden direct toegepast en starten het huidige nummer opnieuw.',
audioOutputDeviceDefault: 'Systeemstandaard', audioOutputDeviceDefault: 'Systeemstandaard',
audioOutputDeviceRefresh: 'Apparatenlijst vernieuwen',
hiResTitle: 'Natieve hi-res-weergave', hiResTitle: 'Natieve hi-res-weergave',
hiResEnabled: 'Natieve hi-res-weergave inschakelen', 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.", hiResDesc: "Beperkt de uitvoer standaard tot 44,1 kHz voor maximale stabiliteit. Alleen inschakelen als hardware en netwerk hoge samplerates (88,2 kHz+) ondersteunen.",
+1
View File
@@ -519,6 +519,7 @@ export const ruTranslation = {
audioOutputDevice: 'Устройство вывода звука', audioOutputDevice: 'Устройство вывода звука',
audioOutputDeviceDesc: 'Выберите аудиоустройство для воспроизведения. Изменения применяются немедленно и перезапускают текущий трек.', audioOutputDeviceDesc: 'Выберите аудиоустройство для воспроизведения. Изменения применяются немедленно и перезапускают текущий трек.',
audioOutputDeviceDefault: 'Системное по умолчанию', audioOutputDeviceDefault: 'Системное по умолчанию',
audioOutputDeviceRefresh: 'Обновить список устройств',
hiResTitle: 'Нативное воспроизведение Hi-Res', hiResTitle: 'Нативное воспроизведение Hi-Res',
hiResEnabled: 'Включить нативное Hi-Res воспроизведение', hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).", hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
+1
View File
@@ -497,6 +497,7 @@ export const zhTranslation = {
audioOutputDevice: '音频输出设备', audioOutputDevice: '音频输出设备',
audioOutputDeviceDesc: '选择 Psysonic 播放音频的设备。更改立即生效并重新开始当前曲目。', audioOutputDeviceDesc: '选择 Psysonic 播放音频的设备。更改立即生效并重新开始当前曲目。',
audioOutputDeviceDefault: '系统默认', audioOutputDeviceDefault: '系统默认',
audioOutputDeviceRefresh: '刷新设备列表',
hiResTitle: '原生高清晰度播放', hiResTitle: '原生高清晰度播放',
hiResEnabled: '启用原生高清晰度播放', hiResEnabled: '启用原生高清晰度播放',
hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。", hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。",
+1 -1
View File
@@ -559,7 +559,7 @@ export default function Settings() {
className="icon-btn" className="icon-btn"
onClick={refreshAudioDevices} onClick={refreshAudioDevices}
disabled={devicesLoading || deviceSwitching} disabled={devicesLoading || deviceSwitching}
data-tooltip={t('common.refresh')} data-tooltip={t('settings.audioOutputDeviceRefresh')}
> >
<RotateCcw size={15} className={devicesLoading ? 'spin' : ''} /> <RotateCcw size={15} className={devicesLoading ? 'spin' : ''} />
</button> </button>