2026-05-06 05:50:59 -03:00
|
|
|
|
impl LesavkaClientApp {
|
|
|
|
|
|
/*──────────────── mic stream ─────────────────*/
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
|
/// Keeps `voice_loop` explicit because it sits on the live uplink path, where stale media must be dropped instead of queued into latency.
|
|
|
|
|
|
/// Inputs are the typed parameters; output is the return value or side effect.
|
|
|
|
|
|
async fn voice_loop(
|
|
|
|
|
|
ep: Channel,
|
|
|
|
|
|
initial_source: Option<String>,
|
|
|
|
|
|
telemetry: crate::uplink_telemetry::UplinkTelemetryHandle,
|
|
|
|
|
|
media_controls: crate::live_media_control::LiveMediaControls,
|
|
|
|
|
|
pause_when_camera_active: bool,
|
|
|
|
|
|
) {
|
|
|
|
|
|
let mut delay = Duration::from_secs(1);
|
|
|
|
|
|
static FAIL_CNT: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
|
let state = media_controls.refresh();
|
|
|
|
|
|
if pause_when_camera_active && state.camera {
|
|
|
|
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if !state.microphone {
|
|
|
|
|
|
telemetry.record_enabled(false);
|
|
|
|
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let microphone_source_choice = state.microphone_source.clone();
|
|
|
|
|
|
let active_source = microphone_source_choice.resolve(initial_source.as_deref());
|
2026-05-10 23:14:15 -03:00
|
|
|
|
let active_audio_codec = state
|
|
|
|
|
|
.audio_codec
|
|
|
|
|
|
.resolve(lesavka_common::audio_transport::UpstreamAudioCodec::Opus);
|
|
|
|
|
|
let active_noise_suppression = state.noise_suppression.resolve(false);
|
2026-05-06 05:50:59 -03:00
|
|
|
|
let use_default_source = matches!(
|
|
|
|
|
|
microphone_source_choice,
|
|
|
|
|
|
crate::live_media_control::MediaDeviceChoice::Auto
|
|
|
|
|
|
) && active_source.is_none();
|
|
|
|
|
|
let setup_source = active_source.clone();
|
|
|
|
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
|
|
|
|
if use_default_source {
|
2026-05-10 23:14:15 -03:00
|
|
|
|
MicrophoneCapture::new_default_source_options(
|
|
|
|
|
|
active_audio_codec,
|
|
|
|
|
|
active_noise_suppression,
|
|
|
|
|
|
)
|
2026-05-06 05:50:59 -03:00
|
|
|
|
} else {
|
2026-05-10 23:14:15 -03:00
|
|
|
|
MicrophoneCapture::new_with_source_options(
|
|
|
|
|
|
setup_source.as_deref(),
|
|
|
|
|
|
active_audio_codec,
|
|
|
|
|
|
active_noise_suppression,
|
|
|
|
|
|
)
|
2026-05-06 05:50:59 -03:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let mic = match result {
|
|
|
|
|
|
Ok(Ok(mic)) => Arc::new(mic),
|
|
|
|
|
|
Ok(Err(err)) => {
|
|
|
|
|
|
telemetry.record_disconnect(format!("microphone uplink setup failed: {err:#}"));
|
|
|
|
|
|
warn!(
|
|
|
|
|
|
"🎤 microphone uplink setup failed for {:?}: {err:#}",
|
|
|
|
|
|
active_source.as_deref().unwrap_or("auto")
|
|
|
|
|
|
);
|
|
|
|
|
|
abort_if_required_media_source_failed("microphone", "🎤", active_source.as_deref(), &err);
|
|
|
|
|
|
delay = app_support::next_delay(delay);
|
|
|
|
|
|
tokio::time::sleep(delay).await;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
telemetry.record_disconnect(format!("microphone uplink setup task failed: {err}"));
|
|
|
|
|
|
warn!("🎤 microphone uplink setup task failed before StreamMicrophone could start: {err}");
|
|
|
|
|
|
abort_if_required_media_source_failed(
|
|
|
|
|
|
"microphone",
|
|
|
|
|
|
"🎤",
|
|
|
|
|
|
active_source.as_deref(),
|
|
|
|
|
|
&err,
|
|
|
|
|
|
);
|
|
|
|
|
|
delay = app_support::next_delay(delay);
|
|
|
|
|
|
tokio::time::sleep(delay).await;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
telemetry.record_reconnect_attempt();
|
|
|
|
|
|
let mut cli = RelayClient::new(ep.clone());
|
|
|
|
|
|
let queue = crate::uplink_fresh_queue::FreshPacketQueue::new(AUDIO_UPLINK_QUEUE);
|
|
|
|
|
|
let drop_log = Arc::new(std::sync::Mutex::new(UplinkDropLogLimiter::new(
|
|
|
|
|
|
"microphone",
|
|
|
|
|
|
"🎤",
|
|
|
|
|
|
)));
|
|
|
|
|
|
|
|
|
|
|
|
let queue_stream = queue.clone();
|
|
|
|
|
|
let telemetry_stream = telemetry.clone();
|
|
|
|
|
|
let drop_log_stream = Arc::clone(&drop_log);
|
|
|
|
|
|
let outbound = async_stream::stream! {
|
|
|
|
|
|
loop {
|
|
|
|
|
|
let next = queue_stream.pop_fresh().await;
|
|
|
|
|
|
if next.dropped_stale > 0 {
|
|
|
|
|
|
telemetry_stream.record_stale_drop(next.dropped_stale);
|
|
|
|
|
|
log_uplink_drop(
|
|
|
|
|
|
&drop_log_stream,
|
|
|
|
|
|
UplinkDropReason::Stale,
|
|
|
|
|
|
next.dropped_stale,
|
|
|
|
|
|
next.queue_depth,
|
|
|
|
|
|
duration_ms(next.delivery_age),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(mut packet) = next.packet {
|
|
|
|
|
|
telemetry_stream.record_streamed(
|
|
|
|
|
|
queue_depth_u32(next.queue_depth),
|
|
|
|
|
|
duration_ms(next.delivery_age),
|
|
|
|
|
|
);
|
|
|
|
|
|
attach_audio_queue_metadata(
|
|
|
|
|
|
&mut packet,
|
|
|
|
|
|
next.queue_depth,
|
|
|
|
|
|
next.delivery_age,
|
|
|
|
|
|
);
|
|
|
|
|
|
yield packet;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
match cli.stream_microphone(Request::new(outbound)).await {
|
|
|
|
|
|
Ok(mut resp) => {
|
|
|
|
|
|
let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
|
|
|
|
|
|
let mic_clone = mic.clone();
|
|
|
|
|
|
let telemetry_thread = telemetry.clone();
|
|
|
|
|
|
let queue_thread = queue.clone();
|
|
|
|
|
|
let drop_log_thread = Arc::clone(&drop_log);
|
|
|
|
|
|
let media_controls_thread = media_controls.clone();
|
|
|
|
|
|
let initial_source_thread = initial_source.clone();
|
|
|
|
|
|
let active_source_thread = active_source.clone();
|
2026-05-10 23:14:15 -03:00
|
|
|
|
let active_audio_codec_thread = active_audio_codec;
|
|
|
|
|
|
let active_noise_suppression_thread = active_noise_suppression;
|
2026-05-06 05:50:59 -03:00
|
|
|
|
let mic_worker = std::thread::spawn(move || {
|
|
|
|
|
|
let mut paused = false;
|
|
|
|
|
|
while stop_rx.try_recv().is_err() {
|
|
|
|
|
|
let state = media_controls_thread.refresh();
|
|
|
|
|
|
let desired_source = state
|
|
|
|
|
|
.microphone_source
|
|
|
|
|
|
.resolve(initial_source_thread.as_deref());
|
2026-05-10 23:14:15 -03:00
|
|
|
|
let desired_audio_codec = state
|
|
|
|
|
|
.audio_codec
|
|
|
|
|
|
.resolve(lesavka_common::audio_transport::UpstreamAudioCodec::Opus);
|
|
|
|
|
|
let desired_noise_suppression =
|
|
|
|
|
|
state.noise_suppression.resolve(false);
|
2026-05-06 05:50:59 -03:00
|
|
|
|
if pause_when_camera_active && state.camera {
|
|
|
|
|
|
tracing::info!(
|
|
|
|
|
|
"🎤 microphone-only uplink yielding to bundled webcam A/V"
|
|
|
|
|
|
);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-05-10 23:14:15 -03:00
|
|
|
|
if desired_source != active_source_thread
|
|
|
|
|
|
|| desired_audio_codec != active_audio_codec_thread
|
|
|
|
|
|
|| desired_noise_suppression != active_noise_suppression_thread
|
|
|
|
|
|
{
|
2026-05-06 05:50:59 -03:00
|
|
|
|
tracing::info!(
|
|
|
|
|
|
from = active_source_thread.as_deref().unwrap_or("auto"),
|
|
|
|
|
|
to = desired_source.as_deref().unwrap_or("auto"),
|
2026-05-10 23:14:15 -03:00
|
|
|
|
from_codec = active_audio_codec_thread.as_id(),
|
|
|
|
|
|
to_codec = desired_audio_codec.as_id(),
|
|
|
|
|
|
from_noise_suppression = active_noise_suppression_thread,
|
|
|
|
|
|
to_noise_suppression = desired_noise_suppression,
|
|
|
|
|
|
"🎤 microphone route changed; restarting live uplink pipeline"
|
2026-05-06 05:50:59 -03:00
|
|
|
|
);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
if !state.microphone {
|
|
|
|
|
|
if !paused {
|
|
|
|
|
|
telemetry_thread.record_enabled(false);
|
|
|
|
|
|
tracing::info!("🎤 microphone uplink soft-paused");
|
|
|
|
|
|
paused = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if paused {
|
|
|
|
|
|
telemetry_thread.record_enabled(true);
|
|
|
|
|
|
tracing::info!("🎤 microphone uplink resumed");
|
|
|
|
|
|
paused = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(mut pkt) = mic_clone.pull() {
|
|
|
|
|
|
trace!("🎤📤 cli {} bytes → gRPC", pkt.data.len());
|
|
|
|
|
|
let enqueue_age = stamp_audio_timing_metadata_at_enqueue(&mut pkt);
|
|
|
|
|
|
let stats = queue_thread.push(pkt, enqueue_age);
|
|
|
|
|
|
if stats.dropped_queue_full > 0 {
|
|
|
|
|
|
telemetry_thread.record_queue_full_drop(stats.dropped_queue_full);
|
|
|
|
|
|
log_uplink_drop(
|
|
|
|
|
|
&drop_log_thread,
|
|
|
|
|
|
UplinkDropReason::QueueFull,
|
|
|
|
|
|
stats.dropped_queue_full,
|
|
|
|
|
|
stats.queue_depth,
|
|
|
|
|
|
duration_ms(enqueue_age),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
telemetry_thread.record_enqueue(
|
|
|
|
|
|
queue_depth_u32(stats.queue_depth),
|
|
|
|
|
|
duration_ms(enqueue_age),
|
|
|
|
|
|
0.0,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
delay = Duration::from_secs(1);
|
|
|
|
|
|
telemetry.record_connected();
|
|
|
|
|
|
while resp.get_mut().message().await.transpose().is_some() {}
|
|
|
|
|
|
telemetry.record_disconnect("microphone uplink stream ended");
|
|
|
|
|
|
queue.close();
|
|
|
|
|
|
let _ = stop_tx.send(());
|
|
|
|
|
|
let _ = mic_worker.join();
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
telemetry.record_disconnect(format!("microphone uplink connect failed: {e}"));
|
|
|
|
|
|
if FAIL_CNT.fetch_add(1, Ordering::Relaxed) == 0 {
|
|
|
|
|
|
warn!("❌🎤 connect failed: {e}");
|
|
|
|
|
|
warn!("⚠️🎤 further microphone‑stream failures will be logged at DEBUG");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
debug!("❌🎤 reconnect failed: {e}");
|
|
|
|
|
|
}
|
|
|
|
|
|
delay = app_support::next_delay(delay);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
queue.close();
|
|
|
|
|
|
tokio::time::sleep(delay).await;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|