/// Open the UAC sink with retry logic. /// /// Inputs: the ALSA device string that should receive microphone audio. /// Outputs: a ready-to-use `Voice` sink. /// Why: the USB audio gadget can appear after the RPC stream has already been /// negotiated, so the server retries briefly before declaring the sink broken. #[cfg(coverage)] pub async fn open_voice_with_retry(uac_dev: &str) -> anyhow::Result { audio::Voice::new(uac_dev).await } #[cfg(not(coverage))] pub async fn open_voice_with_retry(uac_dev: &str) -> anyhow::Result { let candidates = preferred_uac_device_candidates(uac_dev); let (attempts, delay_ms) = audio_init_retry_policy(); let mut last_error: Option = None; for attempt in 1..=attempts { for candidate in &candidates { match audio::Voice::new(candidate).await { Ok(voice) => { if attempt > 1 || candidate != uac_dev { info!( requested = %uac_dev, resolved = %candidate, attempt, "🎤 microphone sink recovered" ); } return Ok(voice); } Err(error) => { warn!( requested = %uac_dev, candidate = %candidate, attempt, "⚠️ microphone sink init failed: {error:#}" ); last_error = Some(error); } } } tokio::time::sleep(Duration::from_millis(delay_ms)).await; } Err(last_error.unwrap_or_else(|| anyhow::anyhow!("microphone sink init failed"))) } /// Open the UAC capture source with retry logic. /// /// Inputs: the preferred ALSA device string plus the logical stream id. /// Outputs: a ready-to-stream AAC capture pipeline. /// Why: the USB gadget card name is not always stable, so the server should /// retry both the preferred name and any discovered aliases before failing. #[cfg(coverage)] pub async fn open_ear_with_retry(alsa_dev: &str, id: u32) -> anyhow::Result { audio::ear(alsa_dev, id).await } #[cfg(not(coverage))] pub async fn open_ear_with_retry(alsa_dev: &str, id: u32) -> anyhow::Result { let candidates = preferred_uac_device_candidates(alsa_dev); let (attempts, delay_ms) = audio_init_retry_policy(); let mut last_error: Option = None; for attempt in 1..=attempts { for candidate in &candidates { match audio::ear(candidate, id).await { Ok(stream) => { if attempt > 1 || candidate != alsa_dev { info!( requested = %alsa_dev, resolved = %candidate, attempt, "🔊 audio source recovered" ); } return Ok(stream); } Err(error) => { warn!( requested = %alsa_dev, candidate = %candidate, attempt, "⚠️ audio source init failed: {error:#}" ); last_error = Some(error); } } } tokio::time::sleep(Duration::from_millis(delay_ms)).await; } Err(last_error.unwrap_or_else(|| anyhow::anyhow!("audio source init failed"))) } #[cfg(not(coverage))] fn audio_init_retry_policy() -> (u32, u64) { let attempts = std::env::var("LESAVKA_AUDIO_INIT_ATTEMPTS") .ok() .and_then(|value| value.parse::().ok()) .or_else(|| { std::env::var("LESAVKA_MIC_INIT_ATTEMPTS") .ok() .and_then(|value| value.parse::().ok()) }) .unwrap_or(20) .max(1); let delay_ms = std::env::var("LESAVKA_AUDIO_INIT_DELAY_MS") .ok() .and_then(|value| value.parse::().ok()) .or_else(|| { std::env::var("LESAVKA_MIC_INIT_DELAY_MS") .ok() .and_then(|value| value.parse::().ok()) }) .unwrap_or(250); (attempts, delay_ms) } fn preferred_uac_device_candidates(preferred: &str) -> Vec { let mut out = Vec::new(); let mut seen = BTreeSet::new(); let auto_family = [ "hw:UAC2Gadget,0", "hw:UAC2_Gadget,0", "hw:Composite,0", "hw:Lesavka,0", ]; let allow_aliases = auto_family.contains(&preferred); if allow_aliases { for detected in detect_uac_card_candidates() { push_audio_candidate_family(&mut out, &mut seen, &detected); } for alias in auto_family { if alias != preferred { push_audio_candidate_family(&mut out, &mut seen, alias); } } } push_audio_candidate_family(&mut out, &mut seen, preferred); out } fn push_audio_candidate_family( out: &mut Vec, seen: &mut BTreeSet, candidate: &str, ) { let trimmed = candidate.trim(); if trimmed.is_empty() { return; } push_audio_candidate(out, seen, trimmed); if let Some(rest) = trimmed.strip_prefix("hw:") { push_audio_candidate(out, seen, &format!("plughw:{rest}")); } else if let Some(rest) = trimmed.strip_prefix("plughw:") { push_audio_candidate(out, seen, &format!("hw:{rest}")); } } fn push_audio_candidate(out: &mut Vec, seen: &mut BTreeSet, candidate: &str) { let trimmed = candidate.trim(); if trimmed.is_empty() { return; } if seen.insert(trimmed.to_string()) { out.push(trimmed.to_string()); } } fn detect_uac_card_candidates() -> Vec { let mut out = Vec::new(); let mut seen = BTreeSet::new(); let card_data = asound_cards_snapshot(); let numeric_card_ids = card_data .as_deref() .map(parse_uac_numeric_card_ids) .unwrap_or_default(); if let Some(pcm) = asound_pcm_snapshot() { for candidate in parse_uac_pcm_candidates(&pcm, &numeric_card_ids) { push_audio_candidate(&mut out, &mut seen, &candidate); } } if let Some(cards) = card_data.as_deref() { for candidate in parse_uac_named_card_candidates(cards) { push_audio_candidate(&mut out, &mut seen, &candidate); } } out } #[cfg(coverage)] fn asound_cards_snapshot() -> Option { std::env::var("LESAVKA_TEST_ASOUND_CARDS") .ok() .or_else(|| fs::read_to_string("/proc/asound/cards").ok()) } #[cfg(not(coverage))] fn asound_cards_snapshot() -> Option { fs::read_to_string("/proc/asound/cards").ok() } #[cfg(coverage)] fn asound_pcm_snapshot() -> Option { std::env::var("LESAVKA_TEST_ASOUND_PCM") .ok() .or_else(|| fs::read_to_string("/proc/asound/pcm").ok()) } #[cfg(not(coverage))] fn asound_pcm_snapshot() -> Option { fs::read_to_string("/proc/asound/pcm").ok() } fn parse_uac_named_card_candidates(cards: &str) -> Vec { cards .lines() .filter_map(|line| { let lower = line.to_ascii_lowercase(); if !(lower.contains("uac2") || lower.contains("gadget") || lower.contains("composite") || lower.contains("lesavka")) { return None; } let start = line.find('[')?; let end = line[start + 1..].find(']')?; let card_id = line[start + 1..start + 1 + end].trim(); (!card_id.is_empty()).then(|| format!("hw:{card_id},0")) }) .collect() } fn parse_uac_numeric_card_ids(cards: &str) -> BTreeSet { cards .lines() .filter_map(|line| { let lower = line.to_ascii_lowercase(); if !(lower.contains("uac2") || lower.contains("gadget") || lower.contains("composite") || lower.contains("lesavka")) { return None; } line.split_whitespace() .next() .filter(|candidate| candidate.chars().all(|ch| ch.is_ascii_digit())) .map(|candidate| candidate.to_string()) }) .collect() } fn parse_uac_pcm_candidates(pcm: &str, numeric_card_ids: &BTreeSet) -> Vec { pcm.lines() .filter_map(|line| { let (prefix, _) = line.split_once(':')?; let (card_id, device_id) = prefix.split_once('-')?; let normalized_card = card_id.trim_start_matches('0'); let normalized_card = if normalized_card.is_empty() { "0" } else { normalized_card }; let normalized_device = device_id.trim_start_matches('0'); let normalized_device = if normalized_device.is_empty() { "0" } else { normalized_device }; numeric_card_ids .contains(normalized_card) .then(|| format!("hw:{normalized_card},{normalized_device}")) }) .collect() }