diff --git a/src-tauri/src/audio/alsa_backend.rs b/src-tauri/src/audio/alsa_backend.rs index 26812dbfd..f13c8d1c4 100644 --- a/src-tauri/src/audio/alsa_backend.rs +++ b/src-tauri/src/audio/alsa_backend.rs @@ -204,6 +204,8 @@ impl AlsaBackend { is_default: false, max_sample_rate: Some(384000), // Assume high sample rate capability supported_sample_rates: None, // Can't detect without CPAL access + device_bus: None, + is_hardware: true, }); } } else { @@ -235,6 +237,8 @@ impl AlsaBackend { is_default: false, max_sample_rate, supported_sample_rates, + device_bus: None, + is_hardware: true, }); } @@ -247,6 +251,8 @@ impl AlsaBackend { is_default: false, max_sample_rate: Some(384000), supported_sample_rates: None, // plughw can convert, so all rates are "supported" + device_bus: None, + is_hardware: true, }); } @@ -326,6 +332,8 @@ impl AlsaBackend { is_default, max_sample_rate, supported_sample_rates, + device_bus: None, + is_hardware: true, }); } diff --git a/src-tauri/src/audio/backend.rs b/src-tauri/src/audio/backend.rs index 9e1471e49..d36a06991 100644 --- a/src-tauri/src/audio/backend.rs +++ b/src-tauri/src/audio/backend.rs @@ -84,6 +84,12 @@ pub struct AudioDevice { /// Supported sample rates (common audio rates that the device supports) /// Contains values like 44100, 48000, 88200, 96000, 176400, 192000, etc. pub supported_sample_rates: Option>, + + /// Device bus type (for PipeWire): "usb", "pci", "bluetooth", or None + pub device_bus: Option, + + /// Whether this is a hardware device (has HARDWARE flag in PipeWire) + pub is_hardware: bool, } /// Audio backend configuration diff --git a/src-tauri/src/audio/pipewire_backend.rs b/src-tauri/src/audio/pipewire_backend.rs index 06c06ba5b..c5f59bf35 100644 --- a/src-tauri/src/audio/pipewire_backend.rs +++ b/src-tauri/src/audio/pipewire_backend.rs @@ -59,6 +59,8 @@ impl PipeWireBackend { let mut current_name: Option = None; let mut current_description: Option = None; let mut current_max_rate: Option = None; + let mut current_is_hardware: bool = false; + let mut current_device_bus: Option = None; for line in stdout.lines() { let line = line.trim(); @@ -74,13 +76,20 @@ impl PipeWireBackend { is_default, max_sample_rate: current_max_rate.take(), supported_sample_rates: None, // PipeWire handles sample rate conversion + device_bus: current_device_bus.take(), + is_hardware: current_is_hardware, }); } current_max_rate = None; + current_is_hardware = false; + current_device_bus = None; } else if line.starts_with("Name:") { current_name = Some(line.trim_start_matches("Name:").trim().to_string()); } else if line.starts_with("Description:") { current_description = Some(line.trim_start_matches("Description:").trim().to_string()); + } else if line.starts_with("Flags:") { + // Check for HARDWARE flag + current_is_hardware = line.contains("HARDWARE"); } else if line.contains("Sample Specification:") { // Try to parse sample rate from lines like "Sample Specification: s32le 2ch 192000Hz" if let Some(hz_pos) = line.find("Hz") { @@ -91,6 +100,10 @@ impl PipeWireBackend { } } } + } else if line.starts_with("device.bus = ") { + // Parse device.bus property (e.g., "usb", "pci", "bluetooth") + let bus = line.trim_start_matches("device.bus = ").trim_matches('"').to_string(); + current_device_bus = Some(bus); } } @@ -104,13 +117,15 @@ impl PipeWireBackend { is_default, max_sample_rate: current_max_rate, supported_sample_rates: None, // PipeWire handles sample rate conversion + device_bus: current_device_bus, + is_hardware: current_is_hardware, }); } log::info!("[PipeWire Backend] Enumerated {} devices via pactl", devices.len()); for (idx, dev) in devices.iter().enumerate() { - log::info!(" [{}] {} (id: {}, max_rate: {:?})", - idx, dev.name, dev.id, dev.max_sample_rate); + log::info!(" [{}] {} (id: {}, bus: {:?}, hw: {})", + idx, dev.name, dev.id, dev.device_bus, dev.is_hardware); } Ok(devices) diff --git a/src/lib/components/DeviceDropdown.svelte b/src/lib/components/DeviceDropdown.svelte index 478e94493..56b28978a 100644 --- a/src/lib/components/DeviceDropdown.svelte +++ b/src/lib/components/DeviceDropdown.svelte @@ -13,6 +13,8 @@ id: string; // Device ID (for categorization) isDefault?: boolean; sampleRates?: number[]; // Supported sample rates in Hz (e.g., 44100, 48000, 96000, 192000) + deviceBus?: string; // "usb", "pci", "bluetooth" (PipeWire) + isHardware?: boolean; // Has HARDWARE flag (PipeWire) } interface DeviceGroup { @@ -27,9 +29,10 @@ onchange: (value: string) => void; wide?: boolean; expandLeft?: boolean; + backend?: 'alsa' | 'pipewire' | 'pulse'; } - let { value, devices, onchange, wide = false, expandLeft = false }: Props = $props(); + let { value, devices, onchange, wide = false, expandLeft = false, backend = 'alsa' }: Props = $props(); let isOpen = $state(false); let isHovering = $state(false); @@ -43,53 +46,62 @@ // Show search when many devices const showSearch = $derived(devices.length > 5); - // Group devices by category + // Group devices by category (different logic for ALSA vs PipeWire) const groupedDevices = $derived.by(() => { const groups: DeviceGroup[] = []; - // Defaults group - const defaults: DeviceOption[] = []; - // Bit-perfect group (hw: devices) - const bitPerfect: DeviceOption[] = []; - // Plugin Hardware group (plughw: devices) - const pluginHw: DeviceOption[] = []; - // Other devices - const others: DeviceOption[] = []; + if (backend === 'alsa') { + // ALSA grouping: Defaults, Bit-perfect, Plugin Hardware, Other + const defaults: DeviceOption[] = []; + const bitPerfect: DeviceOption[] = []; + const pluginHw: DeviceOption[] = []; + const others: DeviceOption[] = []; - for (const device of devices) { - // System Default is always first - if (device.value === 'System Default') { - defaults.push(device); + for (const device of devices) { + if (device.value === 'System Default') { + defaults.push(device); + } else if (device.id === 'default' || device.isDefault) { + defaults.push(device); + } else if (device.id.startsWith('hw:')) { + bitPerfect.push(device); + } else if (device.id.startsWith('plughw:')) { + pluginHw.push(device); + } else { + others.push(device); + } } - // Default ALSA Output - else if (device.id === 'default' || device.isDefault) { - defaults.push(device); - } - // Bit-perfect direct hardware (hw:X,Y) - else if (device.id.startsWith('hw:')) { - bitPerfect.push(device); - } - // Plugin hardware (plughw:X,Y) - else if (device.id.startsWith('plughw:')) { - pluginHw.push(device); - } - // Everything else - else { - others.push(device); - } - } - if (defaults.length > 0) { - groups.push({ key: 'defaults', label: 'Defaults', devices: defaults }); - } - if (bitPerfect.length > 0) { - groups.push({ key: 'bitperfect', label: 'Bit-perfect (Auto-detected)', devices: bitPerfect }); - } - if (pluginHw.length > 0) { - groups.push({ key: 'pluginhw', label: 'Plugin Hardware', devices: pluginHw }); - } - if (others.length > 0) { - groups.push({ key: 'others', label: 'Other Outputs', devices: others }); + if (defaults.length > 0) groups.push({ key: 'defaults', label: 'Defaults', devices: defaults }); + if (bitPerfect.length > 0) groups.push({ key: 'bitperfect', label: 'Bit-perfect (Auto-detected)', devices: bitPerfect }); + if (pluginHw.length > 0) groups.push({ key: 'pluginhw', label: 'Plugin Hardware', devices: pluginHw }); + if (others.length > 0) groups.push({ key: 'others', label: 'Other Outputs', devices: others }); + } else { + // PipeWire/PulseAudio grouping: USB Audio, HDMI/DisplayPort, Other Hardware, Virtual + const defaults: DeviceOption[] = []; + const usbAudio: DeviceOption[] = []; + const hdmi: DeviceOption[] = []; + const otherHw: DeviceOption[] = []; + const virtual: DeviceOption[] = []; + + for (const device of devices) { + if (device.value === 'System Default') { + defaults.push(device); + } else if (device.deviceBus === 'usb' && device.isHardware) { + usbAudio.push(device); + } else if (device.isHardware && (device.value.includes('HDMI') || device.value.includes('DisplayPort'))) { + hdmi.push(device); + } else if (device.isHardware) { + otherHw.push(device); + } else { + virtual.push(device); + } + } + + if (defaults.length > 0) groups.push({ key: 'defaults', label: 'Defaults', devices: defaults }); + if (usbAudio.length > 0) groups.push({ key: 'usb', label: 'USB Audio (Bit-perfect capable)', devices: usbAudio }); + if (hdmi.length > 0) groups.push({ key: 'hdmi', label: 'HDMI / DisplayPort', devices: hdmi }); + if (otherHw.length > 0) groups.push({ key: 'otherhw', label: 'Other Hardware', devices: otherHw }); + if (virtual.length > 0) groups.push({ key: 'virtual', label: 'Virtual Sinks', devices: virtual }); } return groups; @@ -324,7 +336,7 @@ {sampleRatesText} {/if} - {#if group.key === 'bitperfect'} + {#if group.key === 'bitperfect' || group.key === 'usb'} BP {/if} diff --git a/src/lib/components/views/SettingsView.svelte b/src/lib/components/views/SettingsView.svelte index 164dd8f30..c0cbc4325 100644 --- a/src/lib/components/views/SettingsView.svelte +++ b/src/lib/components/views/SettingsView.svelte @@ -462,10 +462,17 @@ return map; }); - // Device options for grouped dropdown (ALSA Direct only) + // Device options for grouped dropdown (works for both ALSA and PipeWire) let groupedDeviceOptions = $derived.by(() => { // System Default is always first - const options: { value: string; id: string; isDefault?: boolean; sampleRates?: number[] }[] = [ + const options: { + value: string; + id: string; + isDefault?: boolean; + sampleRates?: number[]; + deviceBus?: string; + isHardware?: boolean; + }[] = [ { value: 'System Default', id: 'system-default', isDefault: true } ]; @@ -489,7 +496,9 @@ value: displayName, id: device.id, isDefault: device.is_default, - sampleRates: device.supported_sample_rates ?? undefined + sampleRates: device.supported_sample_rates ?? undefined, + deviceBus: device.device_bus ?? undefined, + isHardware: device.is_hardware }); }); @@ -1084,6 +1093,8 @@ is_default: boolean; max_sample_rate: number | null; supported_sample_rates: number[] | null; + device_bus: string | null; // "usb", "pci", "bluetooth", etc. + is_hardware: boolean; } interface AlsaPluginInfo { @@ -1924,6 +1935,16 @@ value={outputDevice} devices={groupedDeviceOptions} onchange={handleBackendDeviceChange} + backend="alsa" + wide + expandLeft + /> + {:else if selectedBackend === 'PipeWire'} +