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
This commit is contained in:
vicrodh committed 2026-01-17 01:29:47 -06:00
1 parent 586a0de358
commit 212aae5c9f
1 file changed
+51 -13
+51 -13
View File
@@ -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);
}