From 212aae5c9ff121de4d72d896e0a9aec8396bc78a Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 17 Jan 2026 01:29:47 -0600 Subject: [PATCH] Fix device selection: CPAL cannot open PipeWire sinks by name ROOT CAUSE: CPAL/Rodio (the audio library) cannot open PipeWire sinks by name directly. CPAL only sees ALSA device names like "default", "hw:0,0", etc., not PipeWire sink names like "alsa_output.usb-Cambridge_Audio...". When we tried to pass the PipeWire sink name to reinit_audio_device(), CPAL couldn't find it and fell back to system default, ignoring user selection. SOLUTION: 1. When user selects a device, set it as PipeWire default using pactl 2. Pass device: null to reinit_audio_device() (uses PipeWire default) 3. On app startup, restore saved device as PipeWire default This works because: - PipeWire's "default" sink can be changed programmatically - CPAL can successfully open "default" device - The default sink points to the user's selected device TRADE-OFFS: - QBZ changes the system-wide PipeWire default sink - Other apps will also use this sink (unless they specify their own) - This is unavoidable with CPAL - it's the only way to select a specific PipeWire sink from Rust without using PipeWire/ALSA APIs directly AFFECTED FUNCTIONS: - handleOutputDeviceChange() - Sets sink as default when user selects device - handleExclusiveModeChange() - Restores saved sink as default before reinit - handleDacPassthroughChange() - Restores saved sink as default before reinit - handleGaplessPlaybackChange() - Restores saved sink as default before reinit - handleCrossfadeChange() - Restores saved sink as default before reinit - loadAudioSettings() - Restores saved sink as default on app startup --- src/lib/components/views/SettingsView.svelte | 64 ++++++++++++++++---- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/src/lib/components/views/SettingsView.svelte b/src/lib/components/views/SettingsView.svelte index 5eca9471d..454c748fb 100644 --- a/src/lib/components/views/SettingsView.svelte +++ b/src/lib/components/views/SettingsView.svelte @@ -798,6 +798,15 @@ // Look up the friendly description from the sink name const description = sinkNameToDescription.get(settings.output_device); outputDevice = description ?? settings.output_device; + + // CRITICAL: Establish the saved device as PipeWire default on app start + // This ensures CPAL uses the correct device when playback begins + try { + await invoke('set_pipewire_default_sink', { sinkName: settings.output_device }); + console.log('[Audio] Restored saved device as PipeWire default:', settings.output_device); + } catch (err) { + console.error('[Audio] Failed to set saved device as default:', err); + } } else { outputDevice = 'System Default'; } @@ -831,10 +840,15 @@ // Save the preference await invoke('set_audio_output_device', { device: deviceToStore }); - // Reinitialize audio to use the selected device - // IMPORTANT: Pass the actual sink name, not null - // This opens the specific device for QBZ without changing system default - await invoke('reinit_audio_device', { device: deviceToStore }); + // CRITICAL: CPAL/Rodio cannot open PipeWire sinks by name directly + // We must set the sink as PipeWire default, then QBZ uses "default" + // This is a limitation of CPAL - it only sees ALSA devices, not PipeWire sinks + if (sinkName) { + await invoke('set_pipewire_default_sink', { sinkName }); + } + + // Reinitialize audio with default device (which now points to selected sink) + await invoke('reinit_audio_device', { device: null }); console.log('Audio output device changed:', description, '(sink:', sinkName ?? 'default', ')'); } catch (err) { @@ -846,9 +860,15 @@ exclusiveMode = enabled; try { await invoke('set_audio_exclusive_mode', { enabled }); - // Reinitialize audio to apply/release exclusive mode - // IMPORTANT: Maintain the currently selected output device - await invoke('reinit_audio_device', { device: getCurrentDeviceSinkName() }); + + // Restore the currently selected device as PipeWire default + const sinkName = getCurrentDeviceSinkName(); + if (sinkName) { + await invoke('set_pipewire_default_sink', { sinkName }); + } + + // Reinitialize audio (uses PipeWire default) + await invoke('reinit_audio_device', { device: null }); console.log('Exclusive mode changed and audio reinitialized:', enabled); } catch (err) { console.error('Failed to change exclusive mode:', err); @@ -867,9 +887,15 @@ try { await invoke('set_audio_dac_passthrough', { enabled }); + + // Restore the currently selected device as PipeWire default + const sinkName = getCurrentDeviceSinkName(); + if (sinkName) { + await invoke('set_pipewire_default_sink', { sinkName }); + } + // DAC passthrough may also require reinit for proper effect - // IMPORTANT: Maintain the currently selected output device - await invoke('reinit_audio_device', { device: getCurrentDeviceSinkName() }); + await invoke('reinit_audio_device', { device: null }); console.log('DAC passthrough changed and audio reinitialized:', enabled); } catch (err) { console.error('Failed to change DAC passthrough:', err); @@ -885,8 +911,14 @@ console.log('Gapless playback enabled: disabled DAC passthrough'); try { await invoke('set_audio_dac_passthrough', { enabled: false }); - // IMPORTANT: Maintain the currently selected output device - await invoke('reinit_audio_device', { device: getCurrentDeviceSinkName() }); + + // Restore the currently selected device as PipeWire default + const sinkName = getCurrentDeviceSinkName(); + if (sinkName) { + await invoke('set_pipewire_default_sink', { sinkName }); + } + + await invoke('reinit_audio_device', { device: null }); } catch (err) { console.error('Failed to disable DAC passthrough:', err); } @@ -902,8 +934,14 @@ console.log('Crossfade enabled: disabled DAC passthrough'); try { await invoke('set_audio_dac_passthrough', { enabled: false }); - // IMPORTANT: Maintain the currently selected output device - await invoke('reinit_audio_device', { device: getCurrentDeviceSinkName() }); + + // Restore the currently selected device as PipeWire default + const sinkName = getCurrentDeviceSinkName(); + if (sinkName) { + await invoke('set_pipewire_default_sink', { sinkName }); + } + + await invoke('reinit_audio_device', { device: null }); } catch (err) { console.error('Failed to disable DAC passthrough:', err); }