From aa9430a3dd12540489e1e3b2417e02f0f40956f9 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Fri, 18 Sep 2026 18:06:32 +0530 Subject: [PATCH 1/4] fix(recording): detect microphone starvation during voip calls Track keepalive silence dominance and emit stalled health events when input frames are starved without intentional muting. Signed-off-by: ManthanNimodiya --- crates/recording/src/sources/microphone.rs | 48 +++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/recording/src/sources/microphone.rs b/crates/recording/src/sources/microphone.rs index a81ea9da8f5..27ad3b39518 100644 --- a/crates/recording/src/sources/microphone.rs +++ b/crates/recording/src/sources/microphone.rs @@ -40,6 +40,17 @@ fn should_fabricate_stall_silence(stall_duration: Duration, keepalive_after: Dur stall_duration >= keepalive_after } +fn is_audio_starved(captured_delta: u64, silence_delta: u64, is_muted: bool) -> bool { + if is_muted { + return false; + } + let total = captured_delta.saturating_add(silence_delta); + if total == 0 || silence_delta == 0 { + return false; + } + (100.0 * silence_delta as f64 / total as f64) >= 80.0 +} + struct ReconnectAttemptGuard(Arc); impl Drop for ReconnectAttemptGuard { @@ -683,6 +694,7 @@ impl AudioSource for Microphone { crate::output_pipeline::spawn_capture_task({ let cancel = cancel.clone(); let health_tx = health_tx.clone(); + let recording_muted = recording_muted.clone(); async move { let frame_counter = mic_frame_counter; let drop_counter = mic_drop_counter; @@ -690,8 +702,10 @@ impl AudioSource for Microphone { let mut last_log = Instant::now(); let mut prev_captured: u64 = 0; let mut prev_dropped: u64 = 0; + let mut prev_silence: u64 = 0; let mut stale_count: u32 = 0; let mut high_drop_intervals: u32 = 0; + let mut high_silence_intervals: u32 = 0; loop { tokio::select! { biased; @@ -714,9 +728,13 @@ impl AudioSource for Microphone { let captured_delta = captured.saturating_sub(prev_captured); let dropped_delta = dropped.saturating_sub(prev_dropped); - let data_changed = captured != prev_captured || dropped != prev_dropped; + let silence_delta = silence.saturating_sub(prev_silence); + let data_changed = captured != prev_captured + || dropped != prev_dropped + || silence != prev_silence; prev_captured = captured; prev_dropped = dropped; + prev_silence = silence; // Surface a *sustained* high drop rate as a health event, like // every other source/muxer. The case that matters most is a @@ -746,6 +764,24 @@ impl AudioSource for Microphone { ); } + let is_muted = recording_muted.load(Ordering::Relaxed); + if is_audio_starved(captured_delta, silence_delta, is_muted) { + high_silence_intervals = high_silence_intervals.saturating_add(1); + } else { + high_silence_intervals = 0; + } + if high_silence_intervals >= 2 { + emit_health( + &health_tx, + PipelineHealthEvent::Stalled { + source: "microphone".to_string(), + waited_ms: (silence_delta as u64).saturating_mul( + SILENCE_CHUNK_DURATION.as_millis() as u64, + ), + }, + ); + } + if !data_changed { stale_count = stale_count.saturating_add(1); } else { @@ -1336,6 +1372,16 @@ mod tests { assert!(prev.is_some(), "resampler produced no frames"); } + + #[test] + fn audio_starvation_detects_sustained_silence_dominance() { + assert!(!is_audio_starved(100, 0, false)); + assert!(!is_audio_starved(100, 10, false)); + assert!(is_audio_starved(0, 100, false)); + assert!(is_audio_starved(5, 95, false)); + assert!(!is_audio_starved(0, 100, true)); + assert!(!is_audio_starved(0, 0, false)); + } } #[cfg(all(test, target_os = "linux"))] From fb40def2cd1232050adcf6d70f70b13835491163 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Fri, 18 Sep 2026 18:20:18 +0530 Subject: [PATCH 2/4] fix(recording): track cumulative stall duration and emit once per starvation Record stall start timestamp to compute cumulative elapsed time and prevent repeated stall health events during sustained starvation. Signed-off-by: ManthanNimodiya --- crates/recording/src/sources/microphone.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/recording/src/sources/microphone.rs b/crates/recording/src/sources/microphone.rs index 27ad3b39518..89d2bcb9cfd 100644 --- a/crates/recording/src/sources/microphone.rs +++ b/crates/recording/src/sources/microphone.rs @@ -706,6 +706,8 @@ impl AudioSource for Microphone { let mut stale_count: u32 = 0; let mut high_drop_intervals: u32 = 0; let mut high_silence_intervals: u32 = 0; + let mut silence_stall_start: Option = None; + let mut silence_stall_emitted = false; loop { tokio::select! { biased; @@ -766,18 +768,25 @@ impl AudioSource for Microphone { let is_muted = recording_muted.load(Ordering::Relaxed); if is_audio_starved(captured_delta, silence_delta, is_muted) { + if silence_stall_start.is_none() { + silence_stall_start = Some(Instant::now()); + } high_silence_intervals = high_silence_intervals.saturating_add(1); } else { high_silence_intervals = 0; + silence_stall_start = None; + silence_stall_emitted = false; } - if high_silence_intervals >= 2 { + if high_silence_intervals >= 2 && !silence_stall_emitted { + silence_stall_emitted = true; + let waited_ms = silence_stall_start + .map(|s| s.elapsed().as_millis() as u64) + .unwrap_or(0); emit_health( &health_tx, PipelineHealthEvent::Stalled { source: "microphone".to_string(), - waited_ms: (silence_delta as u64).saturating_mul( - SILENCE_CHUNK_DURATION.as_millis() as u64, - ), + waited_ms, }, ); } From 54d1d8a368e6fb02aaa4ff6227e8feab7c94e2cf Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Tue, 22 Sep 2026 22:47:02 +0530 Subject: [PATCH 3/4] fix(web): mock ImageUploads service in embed playback speed tests Provide ImageUploads service stub to satisfy embed page rendering dependencies. Signed-off-by: ManthanNimodiya --- apps/web/__tests__/unit/embed-playback-speed.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/__tests__/unit/embed-playback-speed.test.ts b/apps/web/__tests__/unit/embed-playback-speed.test.ts index ef1b41d2a6b..5f04209973a 100644 --- a/apps/web/__tests__/unit/embed-playback-speed.test.ts +++ b/apps/web/__tests__/unit/embed-playback-speed.test.ts @@ -14,6 +14,7 @@ vi.mock("@cap/env", () => ({ vi.mock("@cap/ui", () => ({ Logo: () => null })); vi.mock("@cap/utils", () => ({ userIsPro: () => true })); vi.mock("@cap/web-backend", () => ({ + ImageUploads: Context.GenericTag("ImageUploads"), VideosPolicy: Context.GenericTag("EmbedTestPolicy"), provideOptionalAuth: (effect: Effect.Effect) => effect, resolveEffectiveVideoRules: () => ({ settings: {} }), @@ -24,6 +25,9 @@ vi.mock("@/lib/server", () => ({ Effect.provideService(Context.GenericTag("EmbedTestPolicy"), { canView: () => Effect.void, }), + Effect.provideService(Context.GenericTag("ImageUploads"), { + resolveImageUrl: () => Effect.succeed(""), + }), Effect.runPromise, ), })); From 930747809d7e7838440532db1d3c4dae05d864c3 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Thu, 24 Sep 2026 23:12:17 +0530 Subject: [PATCH 4/4] chore(web): update generated MCP card bundle --- apps/web/lib/mcp-card-html.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/mcp-card-html.json b/apps/web/lib/mcp-card-html.json index 30b83d7dcaf..6d0c7f4ce84 100644 --- a/apps/web/lib/mcp-card-html.json +++ b/apps/web/lib/mcp-card-html.json @@ -1,3 +1,3 @@ { - "html": "
Cap

Recording

Loading recording context…

Transcript

" + "html": "
Cap

Recording

Loading recording context…

Transcript

" }