fix(audio): expand ALSA bit-perfect detection for front:CARD devices

- Add front:CARD=X,DEV=Y device enumeration from aplay -L output
- Recognize front: devices as bit-perfect in AlsaDirectStream
- Group front:CARD and "bit-perfect" named devices correctly in UI
- Add tooltip to device dropdown showing full device name
- Add current selection indicator in device dropdown
- Extend dropdown idle timeout to 10 seconds for long device names

Addresses issue #30 for USB DACs that expose front: but not hw: devices
This commit is contained in:
vicrodh committed 2026-02-01 05:55:37 -06:00
1 parent 9680bbb9cd
commit 7a76973920
3 files changed
+83 -9

No files matched your search

+48
View File
@@ -256,6 +256,54 @@ impl AlsaBackend {
});
}
// Fifth: Add front:CARD=X,DEV=Y devices from aplay -L (issue #30)
// Some USB DACs (e.g., SMSL AD18) don't expose hw: devices but have working front: devices
// DeaDBeef and Audacious use these for bit-perfect playback
for (device_id, desc) in &description_map {
// Match front:CARD=X,DEV=Y pattern
if device_id.starts_with("front:CARD=") {
// Check if we already have this device
let already_exists = devices.iter().any(|d| d.name == *device_id);
if already_exists {
continue;
}
// Extract card name for friendly description
// front:CARD=AMP,DEV=0 -> "AMP"
let card_name = device_id
.strip_prefix("front:CARD=")
.and_then(|s| s.split(',').next())
.unwrap_or("Unknown");
// Use card_map description if available, otherwise use aplay -L description
let friendly_desc = card_map
.iter()
.find(|(_, card_desc)| {
// Match by card short name in the description
card_desc.to_lowercase().contains(&card_name.to_lowercase())
})
.map(|(_, card_desc)| card_desc.clone())
.unwrap_or_else(|| desc.clone());
log::info!(
"[ALSA Backend] Adding front: device for bit-perfect: {} ({})",
device_id,
friendly_desc
);
devices.push(AudioDevice {
id: device_id.clone(),
name: device_id.clone(),
description: Some(format!("{} (Direct Hardware - Bit-perfect)", friendly_desc)),
is_default: false,
max_sample_rate: Some(384000), // Assume high sample rate capability
supported_sample_rates: None,
device_bus: None,
is_hardware: true,
});
}
}
log::info!("[ALSA Backend] Enumerated {} ALSA devices", devices.len());
for (idx, dev) in devices.iter().enumerate() {
log::info!(
+6 -2
View File
@@ -285,9 +285,12 @@ impl AlsaDirectStream {
Err(format!("No volume control found for {}. DAC may not support hardware mixer.", self.device_id))
}
/// Check if device is hw: device
/// Check if device is a bit-perfect hardware device
/// Includes: hw:X,Y, plughw:X,Y, and front:CARD=X,DEV=Y
pub fn is_hw_device(device_id: &str) -> bool {
device_id.starts_with("hw:") || device_id.starts_with("plughw:")
device_id.starts_with("hw:")
|| device_id.starts_with("plughw:")
|| device_id.starts_with("front:CARD=")
}
}
@@ -317,6 +320,7 @@ impl AlsaDirectStream {
2
}
/// Check if device is a bit-perfect hardware device (always false on non-Linux)
pub fn is_hw_device(_device_id: &str) -> bool {
false
}
+29 -7
View File
@@ -4,10 +4,12 @@
openMenu as openGlobalMenu,
closeMenu as closeGlobalMenu,
subscribe as subscribeFloatingMenu,
getActiveMenuId,
MENU_INACTIVITY_TIMEOUT
getActiveMenuId
} from '$lib/stores/floatingMenuStore';
// Extended timeout for device dropdown (10 seconds) - devices have long names
const DEVICE_DROPDOWN_TIMEOUT = 10000;
interface DeviceOption {
value: string; // Display name
id: string; // Device ID (for categorization)
@@ -62,9 +64,15 @@
defaults.push(device);
} else if (device.id === 'default' || device.isDefault) {
defaults.push(device);
} else if (device.id.startsWith('hw:') || device.id.startsWith('iec958:')) {
} else if (
device.id.startsWith('hw:') ||
device.id.startsWith('iec958:') ||
device.id.startsWith('front:CARD=') ||
device.value.toLowerCase().includes('bit-perfect')
) {
// hw: = direct hardware access, iec958: = S/PDIF digital output
// Both are bit-perfect capable
// front:CARD= = front channel device (bit-perfect for some USB DACs)
// Also include any device with "bit-perfect" in its display name
bitPerfect.push(device);
} else if (device.id.startsWith('plughw:')) {
pluginHw.push(device);
@@ -258,7 +266,7 @@
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (isOpen && !isHovering) closeDropdown();
}, MENU_INACTIVITY_TIMEOUT);
}, DEVICE_DROPDOWN_TIMEOUT);
};
if (!isHovering) scheduleIdleClose();
@@ -283,7 +291,7 @@
</script>
<div class="dropdown" class:wide bind:this={dropdownRef}>
<button class="trigger" onclick={() => isOpen ? closeDropdown() : openDropdown()}>
<button class="trigger" onclick={() => isOpen ? closeDropdown() : openDropdown()} title={value}>
<span class="value-text">{value}</span>
<ChevronDown size={16} class="chevron" />
</button>
@@ -332,6 +340,7 @@
onclick={() => handleOptionClick(device)}
title={device.id !== device.value ? `${device.value}\n${device.id}` : device.value}
>
<span class="current-indicator" class:visible={device.value === value}>▸</span>
<div class="option-content">
<span class="option-text">{device.value}</span>
{#if sampleRatesText}
@@ -486,10 +495,11 @@
width: 100%;
min-height: 36px;
padding: 6px 12px;
padding-left: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
gap: 4px;
text-align: left;
font-size: 12px;
color: var(--text-secondary);
@@ -500,6 +510,18 @@
flex-shrink: 0;
}
.current-indicator {
width: 12px;
font-size: 10px;
color: var(--accent-color, #3b82f6);
opacity: 0;
flex-shrink: 0;
}
.current-indicator.visible {
opacity: 1;
}
.option.has-subtitle {
min-height: 44px;
padding: 4px 12px;