Fix audio device selection to show friendly PipeWire names

The previous implementation tried to map ALSA device names (from cpal)
to PipeWire descriptions, but the naming schemes are completely different:
- cpal returns names like "plughw:1,0"
- pactl returns sink names like "alsa_output.usb-Cambridge_Audio_..."

This fix changes the approach:
1. Use PipeWire sinks directly for the device dropdown (already filtered
   to real outputs and have friendly descriptions like "DacMagic Plus")
2. Store the PipeWire sink name when user selects a device
3. Set the PipeWire default sink using pactl when device is selected
4. Use rodio's default device (which follows PipeWire's default)

This ensures users see 4-5 meaningful devices with friendly names
instead of 30+ raw ALSA device names.

Backend changes:
- Add set_pipewire_default_sink command (pactl set-default-sink)
- Register new command in lib.rs

Frontend changes:
- Use pipewireSinks directly for device dropdown
- Display sink description (friendly name)
- Store sink name (for pactl)
- Call set_pipewire_default_sink when device changes
- Remove unused AudioDevice interface and getDevicePrettyName import
This commit is contained in:
vicrodh committed 2026-01-16 19:52:51 -06:00
1 parent eebf440f84
commit 33c5e00da0
3 files changed
+74 -65

No files matched your search

+31
View File
@@ -677,3 +677,34 @@ pub fn get_pipewire_sinks() -> Result<Vec<PipewireSink>, String> {
log::info!("Found {} audio output devices", sinks.len());
Ok(sinks)
}
/// Set the default PipeWire/PulseAudio sink
/// This is used when the user selects an audio device in settings
#[cfg(target_os = "linux")]
#[tauri::command]
pub fn set_pipewire_default_sink(sink_name: String) -> Result<(), String> {
use std::process::Command;
log::info!("Command: set_pipewire_default_sink {}", sink_name);
let output = Command::new("pactl")
.args(["set-default-sink", &sink_name])
.output()
.map_err(|e| format!("Failed to run pactl: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("pactl set-default-sink failed: {}", stderr));
}
log::info!("Set default sink to: {}", sink_name);
Ok(())
}
/// Set the default sink (no-op on non-Linux)
#[cfg(not(target_os = "linux"))]
#[tauri::command]
pub fn set_pipewire_default_sink(_sink_name: String) -> Result<(), String> {
log::info!("Command: set_pipewire_default_sink (no-op on non-Linux)");
Ok(())
}
+1
View File
@@ -333,6 +333,7 @@ pub fn run() {
commands::get_audio_devices,
commands::get_audio_output_status,
commands::get_pipewire_sinks,
commands::set_pipewire_default_sink,
commands::reinit_audio_device,
// Queue commands
commands::add_to_queue,
+42 -65
View File
@@ -14,7 +14,6 @@
type DownloadCacheStats
} from '$lib/stores/downloadState';
import { clearCache as clearLyricsCache } from '$lib/stores/lyricsStore';
import { getDevicePrettyName } from '$lib/utils/audioDeviceNames';
import {
getToastsEnabled,
setToastsEnabled,
@@ -59,11 +58,6 @@
fetching_count: number;
}
interface AudioDevice {
name: string;
is_default: boolean;
}
interface PipewireSink {
name: string;
description: string;
@@ -145,12 +139,20 @@
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// Audio device state
let audioDevices = $state<AudioDevice[]>([]);
// Audio device state - use PipeWire sinks directly for friendly names
let pipewireSinks = $state<PipewireSink[]>([]);
// Map of ALSA name -> PipeWire description (for pretty names)
const pipewireNameMap = $derived.by(() => {
// Map of description -> sink name (for looking up sink name when user selects)
const sinkDescriptionToName = $derived.by(() => {
const map = new Map<string, string>();
for (const sink of pipewireSinks) {
map.set(sink.description, sink.name);
}
return map;
});
// Map of sink name -> description (for displaying current selection)
const sinkNameToDescription = $derived.by(() => {
const map = new Map<string, string>();
for (const sink of pipewireSinks) {
map.set(sink.name, sink.description);
@@ -158,28 +160,8 @@
return map;
});
// Get pretty name: prefer PipeWire description, fall back to heuristic
function getPrettyName(alsaName: string): string {
// Try PipeWire description first
const pwDesc = pipewireNameMap.get(alsaName);
if (pwDesc) return pwDesc;
// Fall back to heuristic
return getDevicePrettyName(alsaName);
}
// Map of pretty name -> raw name
const deviceNameMap = $derived.by(() => {
const map = new Map<string, string>();
map.set('System Default', 'System Default');
for (const d of audioDevices) {
const pretty = getPrettyName(d.name);
map.set(pretty, d.name);
}
return map;
});
// Options for dropdown (pretty names from PipeWire when available)
let audioDeviceOptions = $derived(['System Default', ...audioDevices.map(d => getPrettyName(d.name))]);
// Options for dropdown - use PipeWire descriptions directly (already friendly names)
let audioDeviceOptions = $derived(['System Default', ...pipewireSinks.map(s => s.description)]);
// Theme mapping: display name -> data-theme value
const themeMap: Record<string, string> = {
@@ -778,22 +760,11 @@
async function loadAudioDevices() {
try {
// Load both ALSA devices and PipeWire sinks in parallel
const [devices, sinks] = await Promise.all([
invoke<AudioDevice[]>('get_audio_devices'),
invoke<PipewireSink[]>('get_pipewire_sinks').catch(() => [] as PipewireSink[])
]);
audioDevices = devices;
// Load PipeWire sinks - these have friendly descriptions already
const sinks = await invoke<PipewireSink[]>('get_pipewire_sinks').catch(() => [] as PipewireSink[]);
pipewireSinks = sinks;
// Debug: log raw names and their pretty versions
console.log('[Audio] ALSA devices:', devices.map(d => d.name));
console.log('[Audio] PipeWire sinks:', sinks.map(s => ({ name: s.name, desc: s.description })));
console.log('[Audio] Final device options:', devices.map(d => ({
raw: d.name,
pretty: getPrettyName(d.name)
})));
} catch (err) {
console.error('Failed to load audio devices:', err);
}
@@ -802,9 +773,11 @@
async function loadAudioSettings() {
try {
const settings = await invoke<AudioSettings>('get_audio_settings');
// Convert raw name to pretty name for display (uses PipeWire when available)
// Convert stored sink name to description for display
if (settings.output_device) {
outputDevice = getPrettyName(settings.output_device);
// Look up the friendly description from the sink name
const description = sinkNameToDescription.get(settings.output_device);
outputDevice = description ?? settings.output_device;
} else {
outputDevice = 'System Default';
}
@@ -815,22 +788,26 @@
}
}
// Get current raw device name for reinit
function getCurrentRawDevice(): string | null {
const rawName = deviceNameMap.get(outputDevice) ?? outputDevice;
return rawName === 'System Default' ? null : rawName;
}
async function handleOutputDeviceChange(description: string) {
outputDevice = description;
// Convert description back to sink name for storage
const sinkName = sinkDescriptionToName.get(description);
const deviceToStore = description === 'System Default' ? null : sinkName;
async function handleOutputDeviceChange(prettyName: string) {
outputDevice = prettyName;
// Convert pretty name back to raw name for saving
const rawName = deviceNameMap.get(prettyName) ?? prettyName;
const deviceToUse = rawName === 'System Default' ? null : rawName;
try {
await invoke('set_audio_output_device', { device: deviceToUse });
// Reinitialize audio to apply the change immediately
await invoke('reinit_audio_device', { device: deviceToUse });
console.log('Audio output device changed and reinitialized:', rawName, '(displayed as:', prettyName, ')');
// Save the preference
await invoke('set_audio_output_device', { device: deviceToStore });
// If a specific device was selected, set it as the PipeWire default
if (sinkName) {
await invoke('set_pipewire_default_sink', { sinkName });
}
// Reinitialize audio to use the new default
await invoke('reinit_audio_device', { device: null });
console.log('Audio output device changed:', description, '(sink:', sinkName ?? 'default', ')');
} catch (err) {
console.error('Failed to change audio output device:', err);
}
@@ -840,8 +817,8 @@
exclusiveMode = enabled;
try {
await invoke('set_audio_exclusive_mode', { enabled });
// Reinitialize audio to apply/release exclusive mode immediately
await invoke('reinit_audio_device', { device: getCurrentRawDevice() });
// Reinitialize audio to apply/release exclusive mode (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);
@@ -852,8 +829,8 @@
dacPassthrough = enabled;
try {
await invoke('set_audio_dac_passthrough', { enabled });
// DAC passthrough may also require reinit for proper effect
await invoke('reinit_audio_device', { device: getCurrentRawDevice() });
// DAC passthrough may also require reinit for proper effect (uses PipeWire default)
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);