Use aplay -L for real ALSA device descriptions instead of hardcoding

ALSA Backend Changes:
- Parse aplay -L output to get real hardware descriptions
- Description from hardware: "Cambridge Audio Cambridge Audio USB Audio 2.0, USB Audio"
- No more hardcoded CARD_NAMES for specific hardware
- Still uses CPAL to verify device exists and get max sample rate
- Blacklist filtering preserved (null, lavrate, samplerate, etc.)

Frontend Changes:
- Prefer device.description (from aplay -L) when available
- Falls back to getDevicePrettyName() translation only if no description
- ALSA Direct shows: "Cambridge Audio ... USB Audio, Front output / input"
- Not: "Cambridge Audio USB Front Output" (hardcoded translation)

Removed Hardcoding:
- Removed CS201 → "Cambridge Audio USB" (was specific to user's DAC)
- Removed C20 → "Cambridge Audio"
- Generic CARD_NAMES remain (NVidia, Intel HD Audio, etc.)

This makes device names work for ANY hardware, not just known cards.
Each backend should use native tools:
- ALSA: aplay -L
- PipeWire: pw-cli/pactl (future)
- PulseAudio: pactl (future)
This commit is contained in:
vicrodh committed 2026-01-17 19:55:22 -06:00
1 parent 97fb8d332a
commit c871f6becd
3 files changed
+110 -59

No files matched your search

+92 -51
View File
@@ -44,64 +44,105 @@ impl AlsaBackend {
Ok(Self { host })
}
/// Enumerate ALSA devices via CPAL
/// Enumerate ALSA devices via aplay -L
fn enumerate_alsa_devices(&self) -> BackendResult<Vec<AudioDevice>> {
let mut devices = Vec::new();
// Get all output devices from ALSA host
let output_devices = self.host
.output_devices()
.map_err(|e| format!("Failed to enumerate ALSA devices: {}", e))?;
// Run aplay -L to get device list with descriptions
let output = Command::new("aplay")
.arg("-L")
.output()
.map_err(|e| format!("Failed to run aplay -L: {}", e))?;
for (idx, device) in output_devices.enumerate() {
let name = device.name().unwrap_or_else(|_| format!("ALSA Device {}", idx));
// Skip non-useful devices
if name == "null"
|| name.starts_with("lavrate")
|| name.starts_with("samplerate")
|| name.starts_with("speexrate")
|| name == "jack"
|| name == "oss"
|| name == "speex"
|| name == "upmix"
|| name == "vdownmix"
{
continue;
}
// ID is the device name
let id = name.clone();
// Check if this is the default device
let is_default = self.host
.default_output_device()
.and_then(|d| d.name().ok())
.map(|default_name| default_name == name)
.unwrap_or(false);
// Try to get max sample rate from supported configs
let max_sample_rate = device
.supported_output_configs()
.ok()
.and_then(|mut configs| {
configs
.max_by_key(|c| c.max_sample_rate().0)
.map(|c| c.max_sample_rate().0)
});
devices.push(AudioDevice {
id: id.clone(),
name: name.clone(),
description: Some(format!("ALSA: {}", name)),
is_default,
max_sample_rate,
});
if !output.status.success() {
return Err("aplay -L command failed".to_string());
}
log::info!("[ALSA Backend] Enumerated {} devices via CPAL", devices.len());
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim_end();
// Device ID lines don't start with whitespace
if !line.starts_with(' ') && !line.is_empty() {
let device_id = line.to_string();
// Skip blacklisted devices
if device_id == "null"
|| device_id.starts_with("lavrate")
|| device_id.starts_with("samplerate")
|| device_id.starts_with("speexrate")
|| device_id == "jack"
|| device_id == "oss"
|| device_id == "speex"
|| device_id == "upmix"
|| device_id == "vdownmix"
{
// Skip this device and its description lines
i += 1;
while i < lines.len() && lines[i].starts_with(' ') {
i += 1;
}
continue;
}
// Get description from next line (if it exists and is indented)
let description = if i + 1 < lines.len() && lines[i + 1].starts_with(' ') {
Some(lines[i + 1].trim().to_string())
} else {
None
};
// Check if this is the default device
let is_default = device_id == "default" || device_id.starts_with("default:");
// Try to get max sample rate by testing with CPAL
let max_sample_rate = self.host
.output_devices()
.ok()
.and_then(|mut devs| {
devs.find(|d| d.name().ok().as_deref() == Some(&device_id))
})
.and_then(|device| {
device
.supported_output_configs()
.ok()
.and_then(|mut configs| {
configs
.max_by_key(|c| c.max_sample_rate().0)
.map(|c| c.max_sample_rate().0)
})
});
devices.push(AudioDevice {
id: device_id.clone(),
name: device_id.clone(),
description,
is_default,
max_sample_rate,
});
// Skip description lines
i += 1;
while i < lines.len() && lines[i].starts_with(' ') {
i += 1;
}
} else {
i += 1;
}
}
log::info!("[ALSA Backend] Enumerated {} devices via aplay -L", devices.len());
for (idx, dev) in devices.iter().enumerate() {
log::info!(" [{}] {} (max_rate: {:?})", idx, dev.name, dev.max_sample_rate);
log::info!(
" [{}] {} - {} (max_rate: {:?})",
idx,
dev.name,
dev.description.as_deref().unwrap_or("No description"),
dev.max_sample_rate
);
}
Ok(devices)
+18 -6
View File
@@ -227,11 +227,17 @@
}
// Device options based on selected backend (derived)
// Apply pretty names only to raw ALSA device names (Auto may use ALSA or PipeWire)
// For ALSA: use description from aplay -L if available, otherwise translate
// For PipeWire/PulseAudio: names are already friendly
let deviceOptions = $derived.by(() => {
const names = backendDevices.map(d =>
needsTranslation(d.name) ? getDevicePrettyName(d.name) : d.name
);
const names = backendDevices.map(d => {
// If backend provides description (ALSA via aplay -L), use it
if (d.description && selectedBackend === 'ALSA Direct') {
return d.description;
}
// Otherwise apply translation if needed
return needsTranslation(d.name) ? getDevicePrettyName(d.name) : d.name;
});
return ['System Default', ...names];
});
@@ -239,7 +245,10 @@
let deviceByDisplayName = $derived.by(() => {
const map = new Map<string, AudioDevice>();
for (const device of backendDevices) {
const displayName = needsTranslation(device.name) ? getDevicePrettyName(device.name) : device.name;
// Same logic: prefer description for ALSA
const displayName = (device.description && selectedBackend === 'ALSA Direct')
? device.description
: (needsTranslation(device.name) ? getDevicePrettyName(device.name) : device.name);
map.set(displayName, device);
}
return map;
@@ -782,7 +791,10 @@
if (settings.output_device) {
const device = backendDevices.find(d => d.id === settings.output_device);
if (device) {
outputDevice = needsTranslation(device.name) ? getDevicePrettyName(device.name) : device.name;
// Use description from aplay -L if available (ALSA), otherwise translate
outputDevice = (device.description && settings.backend_type === 'Alsa')
? device.description
: (needsTranslation(device.name) ? getDevicePrettyName(device.name) : device.name);
} else {
outputDevice = 'System Default';
}
-2
View File
@@ -6,8 +6,6 @@
const CARD_NAMES: Record<string, string> = {
'sofhdadsp': 'Intel HD Audio',
'NVidia': 'NVIDIA GPU',
'CS201': 'Cambridge Audio USB',
'C20': 'Cambridge Audio',
'Generic': 'Generic Audio',
'PCH': 'Onboard Audio',
'HDMI': 'HDMI Audio',