diff --git a/src-tauri/src/audio/alsa_backend.rs b/src-tauri/src/audio/alsa_backend.rs index 9b22b7b98..0b7b7224e 100644 --- a/src-tauri/src/audio/alsa_backend.rs +++ b/src-tauri/src/audio/alsa_backend.rs @@ -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_num_to_short_name = std::collections::HashMap::new(); let aplay_l_output = Command::new("aplay") .arg("-l") .output() @@ -159,11 +162,15 @@ impl AlsaBackend { let card_num = parts[0].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(end) = rest.find(']') { let card_desc = &rest[start + 1..end]; 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 { + // 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 { fn backend_type(&self) -> AudioBackendType { AudioBackendType::Alsa diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs index 628593493..80eed6de2 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -20,3 +20,4 @@ pub use backend::{ BackendResult, }; pub use alsa_direct::AlsaDirectStream; +pub use alsa_backend::{normalize_device_id_to_stable, resolve_stable_to_current_hw}; diff --git a/src-tauri/src/config/audio_settings.rs b/src-tauri/src/config/audio_settings.rs index 07e6f40e3..3a853e58d 100644 --- a/src-tauri/src/config/audio_settings.rs +++ b/src-tauri/src/config/audio_settings.rs @@ -292,9 +292,20 @@ pub fn set_audio_output_device( state: tauri::State<'_, AudioSettingsState>, device: Option, ) -> 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))?; - store.set_output_device(device.as_deref()) + store.set_output_device(normalized_device.as_deref()) } #[tauri::command]