feat(client): the stats overlay learns whether your mic frames arrive

NativeClient::mic_stats reaches the per-second stream window: sent and
dropped (queue-full + stale-shed) frame deltas join the tracing line and
the Stats event, and the Detailed OSD tier renders a mic line while the
uplink is live — a healthy 10 ms-frame mic reads ~100 f/s, and a drop
term means the client is shedding backlog, not the network eating audio.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 21:49:12 +02:00
co-authored by Claude Fable 5
parent d17e942db0
commit 55e01b4dcb
2 changed files with 53 additions and 0 deletions
+19
View File
@@ -121,6 +121,12 @@ pub struct Stats {
/// received+lost (%). The OSD renders the counter line only when nonzero.
pub lost: u32,
pub lost_pct: f32,
/// Mic uplink frames this window: handed to the QUIC datagram send, and shed anywhere
/// client-side (queue-full at the producer + the pump's stale-oldest backlog governor —
/// see [`NativeClient::mic_stats`]). Both stay 0 while the mic is off, so the OSD
/// renders the mic line only when the uplink is live.
pub mic_sent: u32,
pub mic_dropped: u32,
/// The decode path frames actually took this window (`"vaapi"`/`"software"`, empty
/// until the first frame) — the OSD's trailing tag; tracks a mid-session fallback.
pub decoder: &'static str,
@@ -434,6 +440,9 @@ fn pump(
let mut dec_path: &'static str = "";
// The stats window keeps its own drop cursor — the OSD shows the per-window delta.
let mut window_dropped = connector.frames_dropped();
// Mic uplink cursor (same per-window diffing): a healthy 10 ms-frame mic reads ~100
// sent/s; a nonzero drop delta is the queue shedding backlog (see NativeClient::mic_stats).
let mut window_mic = connector.mic_stats();
let mut last_kf_req: Option<Instant> = None;
// Freeze-until-reanchor: the shared post-loss gate ([`punktfunk_core::reanchor::ReanchorGate`]).
// Armed on any loss signal (frame-index gap, dropped-count climb, decoder wedge/demotion), it
@@ -789,6 +798,12 @@ fn pump(
let (pace_p50, _) = window_percentiles(&mut pace_us_win);
let lost = dropped.saturating_sub(window_dropped) as u32;
window_dropped = dropped;
let mic_now = connector.mic_stats();
let mic_sent = mic_now.sent.saturating_sub(window_mic.sent) as u32;
let mic_dropped = (mic_now.dropped_full + mic_now.dropped_stale)
.saturating_sub(window_mic.dropped_full + window_mic.dropped_stale)
as u32;
window_mic = mic_now;
tracing::debug!(
fps = frames_n,
hostnet_p50_us = hn_p50,
@@ -800,6 +815,8 @@ fn pump(
pace_p50_us = pace_p50,
decode_p50_us = dec_p50,
lost,
mic_sent,
mic_dropped,
total_frames,
"stream window"
);
@@ -822,6 +839,8 @@ fn pump(
} else {
0.0
},
mic_sent,
mic_dropped,
decoder: dec_path,
}));
window_start = Instant::now();
+34
View File
@@ -2017,6 +2017,14 @@ fn stats_text(
if s.lost > 0 {
text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct));
}
// The mic uplink line renders only while the mic is live (a healthy 10 ms-frame uplink
// reads ~100 f/s) and only in Detailed — drops here are the client shedding backlog.
if detailed && (s.mic_sent > 0 || s.mic_dropped > 0) {
text.push_str(&format!("\nmic {} f/s", s.mic_sent));
if s.mic_dropped > 0 {
text.push_str(&format!(" · dropped {}", s.mic_dropped));
}
}
text
}
@@ -2262,6 +2270,8 @@ mod tests {
decode_ms: 1.8,
lost: 3,
lost_pct: 0.4,
mic_sent: 0,
mic_dropped: 0,
decoder: "vulkan",
},
PresentedWindow {
@@ -2303,6 +2313,30 @@ mod tests {
);
}
/// The mic uplink line: Detailed-only, and only while the uplink is live.
#[test]
fn stats_text_mic_line() {
let (mut s, p) = sample();
let text = |s: &Stats, v| stats_text(v, "m", s, &p, false, false, None);
assert!(
!text(&s, StatsVerbosity::Detailed).contains("mic"),
"no mic line while the mic is off"
);
s.mic_sent = 100;
let detailed = text(&s, StatsVerbosity::Detailed);
assert!(detailed.contains("\nmic 100 f/s"));
assert!(
!detailed.contains("dropped"),
"a healthy uplink shows no drop term"
);
assert!(
!text(&s, StatsVerbosity::Normal).contains("mic"),
"mic line is Detailed-only"
);
s.mic_dropped = 7;
assert!(text(&s, StatsVerbosity::Detailed).contains("mic 100 f/s · dropped 7"));
}
/// Compact omits the latency term until the presenter's first e2e window lands.
#[test]
fn compact_waits_for_e2e() {