feat(audio): audio output device selection (closes #169)

Adds the ability to choose which audio output device Psysonic plays
through — useful for USB DACs, dedicated soundcards, and multi-device setups.

Rust (audio.rs):
- open_stream_for_device_and_rate(device_name, rate): opens a named
  device by cpal name, falls back to system default if not found
- AudioEngine.selected_device: persists the chosen device across
  stream reopens so hi-res rate switches stay on the right device
- audio_list_devices: Tauri command — returns all output device names
- audio_set_device: Tauri command — switches device immediately,
  drops old sinks, emits audio:device-changed
- start_device_watcher: now handles two cases:
    1. No pinned device + system default changed → reopen on new default
    2. Pinned device disappeared (DAC unplugged) → clear selected_device,
       fall back to system default, emit audio:device-reset

Frontend:
- authStore: audioOutputDevice (string | null), persisted
- playerStore: applies stored device on cold start
- App.tsx: listens to audio:device-reset, clears authStore device
  and restarts playback on the fallback device
- Settings → Audio tab: device dropdown at top (above Hi-Res and EQ),
  uses CustomSelect (portal-based, styled), all 8 locales
- CustomSelect: added disabled prop

Note: exclusive mode (WASAPI exclusive, CoreAudio exclusive) is out
of scope for now.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-13 18:20:23 +02:00
parent 9b22327bb0
commit 16cb4f5a6f
15 changed files with 253 additions and 66 deletions
+5
View File
@@ -77,6 +77,8 @@ interface AuthState {
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
enableHiRes: boolean;
/** Selected audio output device name. null = system default. */
audioOutputDevice: string | null;
/** Alpha: ephemeral queue prefetch cache on disk */
hotCacheEnabled: boolean;
@@ -197,6 +199,7 @@ interface AuthState {
setLastSeenChangelogVersion: (v: string) => void;
setSeekbarStyle: (v: SeekbarStyle) => void;
setEnableHiRes: (v: boolean) => void;
setAudioOutputDevice: (v: string | null) => void;
setHotCacheEnabled: (v: boolean) => void;
setHotCacheMaxMb: (v: number) => void;
setHotCacheDebounceSec: (v: number) => void;
@@ -289,6 +292,7 @@ export const useAuthStore = create<AuthState>()(
lastSeenChangelogVersion: '',
seekbarStyle: 'waveform',
enableHiRes: false,
audioOutputDevice: null,
hotCacheEnabled: false,
hotCacheMaxMb: 256,
hotCacheDebounceSec: 30,
@@ -410,6 +414,7 @@ export const useAuthStore = create<AuthState>()(
setSeekbarStyle: (v) => set({ seekbarStyle: v }),
setEnableHiRes: (v) => set({ enableHiRes: v }),
setAudioOutputDevice: (v) => set({ audioOutputDevice: v }),
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
+4 -1
View File
@@ -575,9 +575,12 @@ export function initAudioListeners(): () => void {
usePlayerStore.getState().syncLastfmLovedTracks();
// Initial sync of audio settings to Rust engine on startup.
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled } = useAuthStore.getState();
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
if (audioOutputDevice) {
invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {});
}
// Keep audio settings in sync whenever auth store changes.
const unsubAuth = useAuthStore.subscribe((state) => {