feat(alsa): use stable device IDs for persistent device selection

Convert hw:X,0 to front:CARD=name,DEV=0 format when saving device selection.
This makes device IDs stable across reboots and USB reconnections since they
use the card name (e.g., C20) instead of the card number which can change.

- Add normalize_device_id_to_stable() to convert hw:X,0 to front:CARD=name,DEV=0
- Add resolve_stable_to_current_hw() for reverse lookup when needed
- Apply normalization automatically in set_audio_output_device command
- Export functions from audio module for use across codebase
This commit is contained in:
vicrodh committed 2026-02-02 21:41:34 -06:00
1 parent 67895aaa9b
commit 9b12f761b5
3 files changed
+138 -4

No files matched your search

+124 -2
View File
@@ -141,8 +141,11 @@ impl AlsaBackend {
} }
} }
// Build card number -> card name map from aplay -l // Build card number -> (short_name, description) map from aplay -l
// Short name is used for stable device IDs (front:CARD=name,DEV=0)
// Description is the human-readable full name
let mut card_map = std::collections::HashMap::new(); let mut card_map = std::collections::HashMap::new();
let mut card_num_to_short_name = std::collections::HashMap::new();
let aplay_l_output = Command::new("aplay") let aplay_l_output = Command::new("aplay")
.arg("-l") .arg("-l")
.output() .output()
@@ -159,11 +162,15 @@ impl AlsaBackend {
let card_num = parts[0].trim(); let card_num = parts[0].trim();
let rest = parts[1].trim(); let rest = parts[1].trim();
// Extract description from brackets [...] // Extract short name (before bracket) and description (inside bracket)
// "C20 [Cambridge Audio USB Audio 2.0], device 0: ..."
let short_name = rest.split_whitespace().next().unwrap_or("Unknown");
if let Some(start) = rest.find('[') { if let Some(start) = rest.find('[') {
if let Some(end) = rest.find(']') { if let Some(end) = rest.find(']') {
let card_desc = &rest[start + 1..end]; let card_desc = &rest[start + 1..end];
card_map.insert(card_num.to_string(), card_desc.to_string()); card_map.insert(card_num.to_string(), card_desc.to_string());
card_num_to_short_name.insert(card_num.to_string(), short_name.to_string());
} }
} }
} }
@@ -509,6 +516,121 @@ impl AlsaBackend {
} }
} }
/// Convert an unstable hw:X,0 device ID to a stable front:CARD=name,DEV=0 format.
/// This survives reboots and USB reconnections since it uses the card name, not the number.
///
/// Examples:
/// - `hw:0,0` with card "C20" -> `front:CARD=C20,DEV=0`
/// - `hw:2,0` with card "NVidia" -> `front:CARD=NVidia,DEV=0`
/// - `front:CARD=C20,DEV=0` -> unchanged (already stable)
/// - `plughw:0,0` -> unchanged (plugin devices don't benefit from this)
/// - `default` -> unchanged (not a hardware device)
pub fn normalize_device_id_to_stable(device_id: &str) -> String {
// Already stable formats - return as-is
if device_id.starts_with("front:CARD=")
|| device_id.starts_with("plughw:")
|| !device_id.starts_with("hw:")
{
return device_id.to_string();
}
// Parse hw:X,Y format
let stripped = device_id.strip_prefix("hw:").unwrap_or(device_id);
let parts: Vec<&str> = stripped.split(',').collect();
if parts.len() < 2 {
log::warn!("[ALSA] Could not parse hw device format: {}", device_id);
return device_id.to_string();
}
let card_num = parts[0];
let device_num = parts[1];
// Get card short name from aplay -l
let output = match Command::new("aplay").arg("-l").output() {
Ok(o) if o.status.success() => o,
_ => {
log::warn!("[ALSA] aplay -l failed, cannot normalize device ID");
return device_id.to_string();
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
// Parse: "card 0: C20 [Cambridge Audio USB Audio 2.0], device 0: ..."
for line in stdout.lines() {
if let Some(card_info) = line.strip_prefix("card ") {
let line_parts: Vec<&str> = card_info.splitn(2, ':').collect();
if line_parts.len() == 2 {
let this_card_num = line_parts[0].trim();
if this_card_num == card_num {
let rest = line_parts[1].trim();
// Extract short name (first word before the bracket)
if let Some(short_name) = rest.split_whitespace().next() {
let stable_id = format!("front:CARD={},DEV={}", short_name, device_num);
log::info!(
"[ALSA] Normalized device ID: {} -> {} (stable)",
device_id,
stable_id
);
return stable_id;
}
}
}
}
}
log::warn!(
"[ALSA] Could not find card {} in aplay -l output, keeping original ID",
card_num
);
device_id.to_string()
}
/// Get the current card number for a stable device ID.
/// Used when we need to resolve front:CARD=X to hw:N,0 for certain operations.
///
/// Returns None if the card is not currently present.
pub fn resolve_stable_to_current_hw(device_id: &str) -> Option<String> {
// Only resolve front:CARD= format
if !device_id.starts_with("front:CARD=") {
return Some(device_id.to_string());
}
// Extract card name: front:CARD=C20,DEV=0 -> C20
let stripped = device_id.strip_prefix("front:CARD=")?;
let parts: Vec<&str> = stripped.split(',').collect();
let card_name = parts.first()?;
let dev_part = parts.get(1).and_then(|s| s.strip_prefix("DEV=")).unwrap_or("0");
// Find current card number for this name
let output = Command::new("aplay").arg("-l").output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if let Some(card_info) = line.strip_prefix("card ") {
let line_parts: Vec<&str> = card_info.splitn(2, ':').collect();
if line_parts.len() == 2 {
let card_num = line_parts[0].trim();
let rest = line_parts[1].trim();
if let Some(short_name) = rest.split_whitespace().next() {
if short_name == *card_name {
let hw_id = format!("hw:{},{}", card_num, dev_part);
log::debug!("[ALSA] Resolved {} -> {}", device_id, hw_id);
return Some(hw_id);
}
}
}
}
}
log::warn!("[ALSA] Card '{}' not found in current enumeration", card_name);
None
}
impl AudioBackend for AlsaBackend { impl AudioBackend for AlsaBackend {
fn backend_type(&self) -> AudioBackendType { fn backend_type(&self) -> AudioBackendType {
AudioBackendType::Alsa AudioBackendType::Alsa
+1
View File
@@ -20,3 +20,4 @@ pub use backend::{
BackendResult, BackendResult,
}; };
pub use alsa_direct::AlsaDirectStream; pub use alsa_direct::AlsaDirectStream;
pub use alsa_backend::{normalize_device_id_to_stable, resolve_stable_to_current_hw};
+13 -2
View File
@@ -292,9 +292,20 @@ pub fn set_audio_output_device(
state: tauri::State<'_, AudioSettingsState>, state: tauri::State<'_, AudioSettingsState>,
device: Option<String>, device: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
log::info!("Command: set_audio_output_device {:?}", device); // Normalize hw:X,0 to stable front:CARD=name,DEV=0 format
// This ensures the saved device ID survives reboots and USB reconnections
let normalized_device = device.as_ref().map(|d| {
crate::audio::normalize_device_id_to_stable(d)
});
log::info!(
"Command: set_audio_output_device {:?} -> {:?} (normalized)",
device,
normalized_device
);
let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
store.set_output_device(device.as_deref()) store.set_output_device(normalized_device.as_deref())
} }
#[tauri::command] #[tauri::command]