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:
1 parent
67895aaa9b
commit
9b12f761b5
3 files changed
+138
-4
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -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]
|
||||||
|
|||||||
Reference in new issue
Block a user