Add grouped device dropdown for PipeWire with USB/HDMI categorization
Backend changes: - Add device_bus and is_hardware fields to AudioDevice - Parse PipeWire Flags (HARDWARE) and device.bus property from pactl output Frontend changes: - DeviceDropdown now supports both ALSA and PipeWire backends - ALSA grouping: Defaults, Bit-perfect, Plugin Hardware, Other - PipeWire grouping: Defaults, USB Audio (BP capable), HDMI/DisplayPort, Other HW, Virtual - BP badge shown for USB Audio devices in PipeWire mode
This commit is contained in:
1 parent
0ccdd9981f
commit
f67b13e27e
5 files changed
+96
-34
No files matched your search
@@ -204,6 +204,8 @@ impl AlsaBackend {
|
|||||||
is_default: false,
|
is_default: false,
|
||||||
max_sample_rate: Some(384000), // Assume high sample rate capability
|
max_sample_rate: Some(384000), // Assume high sample rate capability
|
||||||
supported_sample_rates: None, // Can't detect without CPAL access
|
supported_sample_rates: None, // Can't detect without CPAL access
|
||||||
|
device_bus: None,
|
||||||
|
is_hardware: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -235,6 +237,8 @@ impl AlsaBackend {
|
|||||||
is_default: false,
|
is_default: false,
|
||||||
max_sample_rate,
|
max_sample_rate,
|
||||||
supported_sample_rates,
|
supported_sample_rates,
|
||||||
|
device_bus: None,
|
||||||
|
is_hardware: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +251,8 @@ impl AlsaBackend {
|
|||||||
is_default: false,
|
is_default: false,
|
||||||
max_sample_rate: Some(384000),
|
max_sample_rate: Some(384000),
|
||||||
supported_sample_rates: None, // plughw can convert, so all rates are "supported"
|
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,
|
is_default,
|
||||||
max_sample_rate,
|
max_sample_rate,
|
||||||
supported_sample_rates,
|
supported_sample_rates,
|
||||||
|
device_bus: None,
|
||||||
|
is_hardware: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ pub struct AudioDevice {
|
|||||||
/// Supported sample rates (common audio rates that the device supports)
|
/// Supported sample rates (common audio rates that the device supports)
|
||||||
/// Contains values like 44100, 48000, 88200, 96000, 176400, 192000, etc.
|
/// Contains values like 44100, 48000, 88200, 96000, 176400, 192000, etc.
|
||||||
pub supported_sample_rates: Option<Vec<u32>>,
|
pub supported_sample_rates: Option<Vec<u32>>,
|
||||||
|
|
||||||
|
/// Device bus type (for PipeWire): "usb", "pci", "bluetooth", or None
|
||||||
|
pub device_bus: Option<String>,
|
||||||
|
|
||||||
|
/// Whether this is a hardware device (has HARDWARE flag in PipeWire)
|
||||||
|
pub is_hardware: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audio backend configuration
|
/// Audio backend configuration
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ impl PipeWireBackend {
|
|||||||
let mut current_name: Option<String> = None;
|
let mut current_name: Option<String> = None;
|
||||||
let mut current_description: Option<String> = None;
|
let mut current_description: Option<String> = None;
|
||||||
let mut current_max_rate: Option<u32> = None;
|
let mut current_max_rate: Option<u32> = None;
|
||||||
|
let mut current_is_hardware: bool = false;
|
||||||
|
let mut current_device_bus: Option<String> = None;
|
||||||
|
|
||||||
for line in stdout.lines() {
|
for line in stdout.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
@@ -74,13 +76,20 @@ impl PipeWireBackend {
|
|||||||
is_default,
|
is_default,
|
||||||
max_sample_rate: current_max_rate.take(),
|
max_sample_rate: current_max_rate.take(),
|
||||||
supported_sample_rates: None, // PipeWire handles sample rate conversion
|
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_max_rate = None;
|
||||||
|
current_is_hardware = false;
|
||||||
|
current_device_bus = None;
|
||||||
} else if line.starts_with("Name:") {
|
} else if line.starts_with("Name:") {
|
||||||
current_name = Some(line.trim_start_matches("Name:").trim().to_string());
|
current_name = Some(line.trim_start_matches("Name:").trim().to_string());
|
||||||
} else if line.starts_with("Description:") {
|
} else if line.starts_with("Description:") {
|
||||||
current_description = Some(line.trim_start_matches("Description:").trim().to_string());
|
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:") {
|
} else if line.contains("Sample Specification:") {
|
||||||
// Try to parse sample rate from lines like "Sample Specification: s32le 2ch 192000Hz"
|
// Try to parse sample rate from lines like "Sample Specification: s32le 2ch 192000Hz"
|
||||||
if let Some(hz_pos) = line.find("Hz") {
|
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,
|
is_default,
|
||||||
max_sample_rate: current_max_rate,
|
max_sample_rate: current_max_rate,
|
||||||
supported_sample_rates: None, // PipeWire handles sample rate conversion
|
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());
|
log::info!("[PipeWire Backend] Enumerated {} devices via pactl", devices.len());
|
||||||
for (idx, dev) in devices.iter().enumerate() {
|
for (idx, dev) in devices.iter().enumerate() {
|
||||||
log::info!(" [{}] {} (id: {}, max_rate: {:?})",
|
log::info!(" [{}] {} (id: {}, bus: {:?}, hw: {})",
|
||||||
idx, dev.name, dev.id, dev.max_sample_rate);
|
idx, dev.name, dev.id, dev.device_bus, dev.is_hardware);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(devices)
|
Ok(devices)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
id: string; // Device ID (for categorization)
|
id: string; // Device ID (for categorization)
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
sampleRates?: number[]; // Supported sample rates in Hz (e.g., 44100, 48000, 96000, 192000)
|
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 {
|
interface DeviceGroup {
|
||||||
@@ -27,9 +29,10 @@
|
|||||||
onchange: (value: string) => void;
|
onchange: (value: string) => void;
|
||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
expandLeft?: 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 isOpen = $state(false);
|
||||||
let isHovering = $state(false);
|
let isHovering = $state(false);
|
||||||
@@ -43,53 +46,62 @@
|
|||||||
// Show search when many devices
|
// Show search when many devices
|
||||||
const showSearch = $derived(devices.length > 5);
|
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 groupedDevices = $derived.by(() => {
|
||||||
const groups: DeviceGroup[] = [];
|
const groups: DeviceGroup[] = [];
|
||||||
|
|
||||||
// Defaults group
|
if (backend === 'alsa') {
|
||||||
|
// ALSA grouping: Defaults, Bit-perfect, Plugin Hardware, Other
|
||||||
const defaults: DeviceOption[] = [];
|
const defaults: DeviceOption[] = [];
|
||||||
// Bit-perfect group (hw: devices)
|
|
||||||
const bitPerfect: DeviceOption[] = [];
|
const bitPerfect: DeviceOption[] = [];
|
||||||
// Plugin Hardware group (plughw: devices)
|
|
||||||
const pluginHw: DeviceOption[] = [];
|
const pluginHw: DeviceOption[] = [];
|
||||||
// Other devices
|
|
||||||
const others: DeviceOption[] = [];
|
const others: DeviceOption[] = [];
|
||||||
|
|
||||||
for (const device of devices) {
|
for (const device of devices) {
|
||||||
// System Default is always first
|
|
||||||
if (device.value === 'System Default') {
|
if (device.value === 'System Default') {
|
||||||
defaults.push(device);
|
defaults.push(device);
|
||||||
}
|
} else if (device.id === 'default' || device.isDefault) {
|
||||||
// Default ALSA Output
|
|
||||||
else if (device.id === 'default' || device.isDefault) {
|
|
||||||
defaults.push(device);
|
defaults.push(device);
|
||||||
}
|
} else if (device.id.startsWith('hw:')) {
|
||||||
// Bit-perfect direct hardware (hw:X,Y)
|
|
||||||
else if (device.id.startsWith('hw:')) {
|
|
||||||
bitPerfect.push(device);
|
bitPerfect.push(device);
|
||||||
}
|
} else if (device.id.startsWith('plughw:')) {
|
||||||
// Plugin hardware (plughw:X,Y)
|
|
||||||
else if (device.id.startsWith('plughw:')) {
|
|
||||||
pluginHw.push(device);
|
pluginHw.push(device);
|
||||||
}
|
} else {
|
||||||
// Everything else
|
|
||||||
else {
|
|
||||||
others.push(device);
|
others.push(device);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defaults.length > 0) {
|
if (defaults.length > 0) groups.push({ key: 'defaults', label: 'Defaults', devices: defaults });
|
||||||
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 (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 (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 (others.length > 0) {
|
if (hdmi.length > 0) groups.push({ key: 'hdmi', label: 'HDMI / DisplayPort', devices: hdmi });
|
||||||
groups.push({ key: 'others', label: 'Other Outputs', devices: others });
|
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;
|
return groups;
|
||||||
@@ -324,7 +336,7 @@
|
|||||||
<span class="option-sample-rates">{sampleRatesText}</span>
|
<span class="option-sample-rates">{sampleRatesText}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if group.key === 'bitperfect'}
|
{#if group.key === 'bitperfect' || group.key === 'usb'}
|
||||||
<span class="badge bit-perfect">BP</span>
|
<span class="badge bit-perfect">BP</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -462,10 +462,17 @@
|
|||||||
return map;
|
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(() => {
|
let groupedDeviceOptions = $derived.by(() => {
|
||||||
// System Default is always first
|
// 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 }
|
{ value: 'System Default', id: 'system-default', isDefault: true }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -489,7 +496,9 @@
|
|||||||
value: displayName,
|
value: displayName,
|
||||||
id: device.id,
|
id: device.id,
|
||||||
isDefault: device.is_default,
|
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;
|
is_default: boolean;
|
||||||
max_sample_rate: number | null;
|
max_sample_rate: number | null;
|
||||||
supported_sample_rates: number[] | null;
|
supported_sample_rates: number[] | null;
|
||||||
|
device_bus: string | null; // "usb", "pci", "bluetooth", etc.
|
||||||
|
is_hardware: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AlsaPluginInfo {
|
interface AlsaPluginInfo {
|
||||||
@@ -1924,6 +1935,16 @@
|
|||||||
value={outputDevice}
|
value={outputDevice}
|
||||||
devices={groupedDeviceOptions}
|
devices={groupedDeviceOptions}
|
||||||
onchange={handleBackendDeviceChange}
|
onchange={handleBackendDeviceChange}
|
||||||
|
backend="alsa"
|
||||||
|
wide
|
||||||
|
expandLeft
|
||||||
|
/>
|
||||||
|
{:else if selectedBackend === 'PipeWire'}
|
||||||
|
<DeviceDropdown
|
||||||
|
value={outputDevice}
|
||||||
|
devices={groupedDeviceOptions}
|
||||||
|
onchange={handleBackendDeviceChange}
|
||||||
|
backend="pipewire"
|
||||||
wide
|
wide
|
||||||
expandLeft
|
expandLeft
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in new issue
Block a user