282 lines
9.3 KiB
Rust
282 lines
9.3 KiB
Rust
|
|
/// 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> {
|
|
audio::Voice::new(uac_dev).await
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
pub async fn open_voice_with_retry(uac_dev: &str) -> anyhow::Result<audio::Voice> {
|
|
let candidates = preferred_uac_device_candidates(uac_dev);
|
|
let (attempts, delay_ms) = audio_init_retry_policy();
|
|
let mut last_error: Option<anyhow::Error> = 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::AudioStream> {
|
|
audio::ear(alsa_dev, id).await
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
pub async fn open_ear_with_retry(alsa_dev: &str, id: u32) -> anyhow::Result<audio::AudioStream> {
|
|
let candidates = preferred_uac_device_candidates(alsa_dev);
|
|
let (attempts, delay_ms) = audio_init_retry_policy();
|
|
let mut last_error: Option<anyhow::Error> = 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::<u32>().ok())
|
|
.or_else(|| {
|
|
std::env::var("LESAVKA_MIC_INIT_ATTEMPTS")
|
|
.ok()
|
|
.and_then(|value| value.parse::<u32>().ok())
|
|
})
|
|
.unwrap_or(20)
|
|
.max(1);
|
|
let delay_ms = std::env::var("LESAVKA_AUDIO_INIT_DELAY_MS")
|
|
.ok()
|
|
.and_then(|value| value.parse::<u64>().ok())
|
|
.or_else(|| {
|
|
std::env::var("LESAVKA_MIC_INIT_DELAY_MS")
|
|
.ok()
|
|
.and_then(|value| value.parse::<u64>().ok())
|
|
})
|
|
.unwrap_or(250);
|
|
(attempts, delay_ms)
|
|
}
|
|
|
|
fn preferred_uac_device_candidates(preferred: &str) -> Vec<String> {
|
|
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<String>,
|
|
seen: &mut BTreeSet<String>,
|
|
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<String>, seen: &mut BTreeSet<String>, 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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
fs::read_to_string("/proc/asound/cards").ok()
|
|
}
|
|
|
|
#[cfg(coverage)]
|
|
fn asound_pcm_snapshot() -> Option<String> {
|
|
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<String> {
|
|
fs::read_to_string("/proc/asound/pcm").ok()
|
|
}
|
|
|
|
fn parse_uac_named_card_candidates(cards: &str) -> Vec<String> {
|
|
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<String> {
|
|
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<String>) -> Vec<String> {
|
|
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()
|
|
}
|