// stats.rs — Lightweight windowed pipeline statistics for stutter diagnosis // // Tracks per-second snapshots of capture/encode/send pipeline metrics. // Designed for low overhead: only counters and timing samples are collected, // with one structured log line emitted per second when `--stats` is enabled. use std::time::Instant; /// Per-stage timing for a single encode pipeline frame. /// /// All values are in microseconds. The caller records timestamps around /// each stage and passes the deltas to [`PipelineStats::record_frame`]. #[derive(Debug, Default)] pub struct FrameTimings { /// DMA-BUF import (av_hwframe_map) pub import_us: u64, /// GPU scale (scale_vaapi filter) pub scale_us: u64, /// GPU→CPU transfer (av_hwframe_transfer_data) pub transfer_us: u64, /// sws_scale NV12→YUV420P pub sws_us: u64, /// H.264 encode (avcodec_send_frame + receive_packet) pub encode_us: u64, /// Wall-clock total for this frame (import through encode output) pub total_us: u64, /// Encoded output size in bytes pub output_bytes: usize, } /// Windowed statistics aggregator for the encode/send pipeline. /// /// Collects counters and timing samples within a one-second window, /// then computes avg/p95/max when the snapshot is taken. pub struct PipelineStats { // --- counters (reset each window) --- capture_frames: u64, encoded_frames: u64, sent_frames: u64, pipewire_dropped: u64, /// Count of frames dropped by encode thread due to Y-plane hash dedup /// (EncodeOutcome::SkippedDuplicate). Read from atomic counter set by /// encode thread, computed as delta since previous snapshot. duplicate_frames_skipped: u64, // Running total from the encode-thread atomic; NOT reset between windows. prev_duplicate_frames_skipped: u64, // --- queue depth at last observation --- capture_queue_depth: usize, encoded_queue_depth: usize, // --- timing samples --- capture_gaps_ms: Vec, encoded_gaps_ms: Vec, sent_gaps_ms: Vec, frame_age_ms: Vec, send_wait_ms: Vec, // --- per-stage timing (microseconds) --- import_us: Vec, scale_us: Vec, transfer_us: Vec, sws_us: Vec, encode_us: Vec, total_us: Vec, output_bytes: Vec, // --- timing state --- last_capture_time: Option, last_encode_time: Option, last_send_time: Option, window_start: Instant, } impl Default for PipelineStats { fn default() -> Self { Self::new() } } impl PipelineStats { pub fn new() -> Self { Self { capture_frames: 0, encoded_frames: 0, sent_frames: 0, pipewire_dropped: 0, duplicate_frames_skipped: 0, prev_duplicate_frames_skipped: 0, capture_queue_depth: 0, encoded_queue_depth: 0, capture_gaps_ms: Vec::new(), encoded_gaps_ms: Vec::new(), sent_gaps_ms: Vec::new(), frame_age_ms: Vec::new(), send_wait_ms: Vec::new(), import_us: Vec::new(), scale_us: Vec::new(), transfer_us: Vec::new(), sws_us: Vec::new(), encode_us: Vec::new(), total_us: Vec::new(), output_bytes: Vec::new(), last_capture_time: None, last_encode_time: None, last_send_time: None, window_start: Instant::now(), } } /// Record that a capture frame was received from PipeWire. pub fn record_capture(&mut self) { let now = Instant::now(); if let Some(last) = self.last_capture_time { let gap_ms = last.elapsed().as_secs_f64() * 1000.0; self.capture_gaps_ms.push(gap_ms); } self.last_capture_time = Some(now); self.capture_frames += 1; } /// Record that a frame completed encoding with the given timings. pub fn record_encode(&mut self, timings: &FrameTimings) { let now = Instant::now(); if let Some(last) = self.last_encode_time { let gap_ms = last.elapsed().as_secs_f64() * 1000.0; self.encoded_gaps_ms.push(gap_ms); } self.last_encode_time = Some(now); self.encoded_frames += 1; self.import_us.push(timings.import_us); self.scale_us.push(timings.scale_us); self.transfer_us.push(timings.transfer_us); self.sws_us.push(timings.sws_us); self.encode_us.push(timings.encode_us); self.total_us.push(timings.total_us); self.output_bytes.push(timings.output_bytes); } pub fn record_import(&mut self, import_us: u64) { self.import_us.push(import_us); } pub fn record_encode_thread(&mut self, sws_us: u64, encode_us: u64, output_bytes: usize) { let now = Instant::now(); if let Some(last) = self.last_encode_time { let gap_ms = last.elapsed().as_secs_f64() * 1000.0; self.encoded_gaps_ms.push(gap_ms); } self.last_encode_time = Some(now); self.encoded_frames += 1; self.sws_us.push(sws_us); self.encode_us.push(encode_us); self.total_us.push(sws_us.saturating_add(encode_us)); self.output_bytes.push(output_bytes); } /// Record that a frame was sent via WebRTC. /// `wait_ms` is time spent blocked waiting to send into the channel. /// `capture_time` is when the frame was originally captured (for frame age). pub fn record_send(&mut self, wait_ms: f64, capture_time: Option) { let now = Instant::now(); if let Some(last) = self.last_send_time { let gap_ms = last.elapsed().as_secs_f64() * 1000.0; self.sent_gaps_ms.push(gap_ms); } self.last_send_time = Some(now); self.sent_frames += 1; if wait_ms > 0.0 { self.send_wait_ms.push(wait_ms); } if let Some(ct) = capture_time { let age_ms = ct.elapsed().as_secs_f64() * 1000.0; self.frame_age_ms.push(age_ms); } } /// Record a frame sent from a background WebRTC thread. /// `gap_ms` is the pre-computed time since the previous send (0.0 = first frame). /// `age_ms` is the pre-computed capture-to-send latency (None if unavailable). /// Both are pre-computed on the sending thread to remain accurate when /// batch-drained at stats snapshot time on the main thread. pub fn record_send_from_thread(&mut self, gap_ms: f64, age_ms: Option) { if gap_ms > 0.0 { self.sent_gaps_ms.push(gap_ms); } self.sent_frames += 1; if let Some(age) = age_ms { self.frame_age_ms.push(age); } } /// Update PipeWire dropped counter (absolute value from AtomicU64). pub fn set_pipewire_dropped(&mut self, total_dropped: u64, prev_dropped: u64) { self.pipewire_dropped = total_dropped.saturating_sub(prev_dropped); } /// Update duplicate frames skipped counter (absolute value from atomic). /// Computes delta from previous value, like set_pipewire_dropped. pub fn set_duplicate_frames_skipped(&mut self, total_skipped: u64) { self.duplicate_frames_skipped = total_skipped.saturating_sub(self.prev_duplicate_frames_skipped); self.prev_duplicate_frames_skipped = total_skipped; } /// Update queue depth observations. pub fn set_queue_depths(&mut self, capture: usize, encoded: usize) { self.capture_queue_depth = capture; self.encoded_queue_depth = encoded; } /// Returns true if at least 1 second has elapsed since the last snapshot /// (or since creation). If true, call `snapshot_and_reset` to get the stats. pub fn should_snapshot(&self) -> bool { self.window_start.elapsed().as_secs() >= 1 } /// Compute a snapshot of the current window and reset all counters. pub fn snapshot_and_reset(&mut self) -> StatsSnapshot { let elapsed = self.window_start.elapsed().as_secs_f64(); let snap = StatsSnapshot { elapsed_secs: elapsed, capture_fps: self.capture_frames as f64 / elapsed, encoded_fps: self.encoded_frames as f64 / elapsed, sent_fps: self.sent_frames as f64 / elapsed, capture_frames: self.capture_frames, encoded_frames: self.encoded_frames, sent_frames: self.sent_frames, pipewire_dropped: self.pipewire_dropped, duplicate_frames_skipped: self.duplicate_frames_skipped, capture_queue_depth: self.capture_queue_depth, encoded_queue_depth: self.encoded_queue_depth, capture_gap_avg_ms: avg_f64(&self.capture_gaps_ms), capture_gap_p95_ms: p95_f64(&self.capture_gaps_ms), capture_gap_max_ms: max_f64(&self.capture_gaps_ms), encoded_gap_avg_ms: avg_f64(&self.encoded_gaps_ms), encoded_gap_p95_ms: p95_f64(&self.encoded_gaps_ms), encoded_gap_max_ms: max_f64(&self.encoded_gaps_ms), sent_gap_avg_ms: avg_f64(&self.sent_gaps_ms), sent_gap_p95_ms: p95_f64(&self.sent_gaps_ms), sent_gap_max_ms: max_f64(&self.sent_gaps_ms), frame_age_avg_ms: avg_f64(&self.frame_age_ms), frame_age_p95_ms: p95_f64(&self.frame_age_ms), frame_age_max_ms: max_f64(&self.frame_age_ms), send_wait_p95_ms: p95_f64(&self.send_wait_ms), import_avg_ms: avg_ms(&self.import_us), import_p95_ms: p95_ms(&self.import_us), scale_avg_ms: avg_ms(&self.scale_us), scale_p95_ms: p95_ms(&self.scale_us), transfer_avg_ms: avg_ms(&self.transfer_us), transfer_p95_ms: p95_ms(&self.transfer_us), sws_avg_ms: avg_ms(&self.sws_us), sws_p95_ms: p95_ms(&self.sws_us), encode_avg_ms: avg_ms(&self.encode_us), encode_p95_ms: p95_ms(&self.encode_us), total_avg_ms: avg_ms(&self.total_us), total_p95_ms: p95_ms(&self.total_us), output_bytes_per_sec: sum_usize(&self.output_bytes) as f64 / elapsed, output_frame_bytes_p95: p95_usize(&self.output_bytes), output_frame_bytes_max: max_usize(&self.output_bytes), }; // Reset all counters and sample buffers self.capture_frames = 0; self.encoded_frames = 0; self.sent_frames = 0; self.pipewire_dropped = 0; self.duplicate_frames_skipped = 0; self.capture_queue_depth = 0; self.encoded_queue_depth = 0; self.capture_gaps_ms.clear(); self.encoded_gaps_ms.clear(); self.sent_gaps_ms.clear(); self.frame_age_ms.clear(); self.send_wait_ms.clear(); self.import_us.clear(); self.scale_us.clear(); self.transfer_us.clear(); self.sws_us.clear(); self.encode_us.clear(); self.total_us.clear(); self.output_bytes.clear(); self.window_start = Instant::now(); snap } } /// A one-second snapshot of pipeline statistics. #[derive(Debug)] pub struct StatsSnapshot { pub elapsed_secs: f64, // FPS pub capture_fps: f64, pub encoded_fps: f64, pub sent_fps: f64, // Counters pub capture_frames: u64, pub encoded_frames: u64, pub sent_frames: u64, pub pipewire_dropped: u64, pub duplicate_frames_skipped: u64, // Queue depths pub capture_queue_depth: usize, pub encoded_queue_depth: usize, // Gap timing (ms) pub capture_gap_avg_ms: f64, pub capture_gap_p95_ms: f64, pub capture_gap_max_ms: f64, pub encoded_gap_avg_ms: f64, pub encoded_gap_p95_ms: f64, pub encoded_gap_max_ms: f64, pub sent_gap_avg_ms: f64, pub sent_gap_p95_ms: f64, pub sent_gap_max_ms: f64, // Frame age (capture → send) pub frame_age_avg_ms: f64, pub frame_age_p95_ms: f64, pub frame_age_max_ms: f64, // Send wait pub send_wait_p95_ms: f64, // Per-stage encode timing (ms) pub import_avg_ms: f64, pub import_p95_ms: f64, pub scale_avg_ms: f64, pub scale_p95_ms: f64, pub transfer_avg_ms: f64, pub transfer_p95_ms: f64, pub sws_avg_ms: f64, pub sws_p95_ms: f64, pub encode_avg_ms: f64, pub encode_p95_ms: f64, pub total_avg_ms: f64, pub total_p95_ms: f64, // Output size pub output_bytes_per_sec: f64, pub output_frame_bytes_p95: usize, pub output_frame_bytes_max: usize, } impl std::fmt::Display for StatsSnapshot { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // Layout note: each line answers one operational question. // - line 1: throughput (fps + frame counts + window length) // - line 2: drops (PipeWire backlog, encoder over-budget, frame-hash dedup) // - line 3: queue back-pressure (capture + encoded) // - line 4-6: gap timing (avg + p95 + max) — answers "is cadence stable?" // - line 7: capture-to-send age (avg + p95 + max) — answers "how stale?" // - line 8: per-stage encode timing (avg + p95) — answers "where is latency?" // - line 9: output bandwidth (bytes/sec + per-frame p95/max) // // The avg counterparts were computed but never displayed before Oracle // audit 2026-06-28; they pair with the existing p95/max to surface both // central tendency and tail behaviour in the same glance. write!( f, "elapsed={:.1}s capture_fps={:.1} encoded_fps={:.1} sent_fps={:.1} \ capture_frames={} encoded_frames={} sent_frames={} \ pw_dropped={} duplicate_frames_skipped={} \ cap_q={} enc_q={} \ cap_gap_avg={:.1}ms cap_gap_p95={:.1}ms cap_gap_max={:.1}ms \ enc_gap_avg={:.1}ms enc_gap_p95={:.1}ms enc_gap_max={:.1}ms \ sent_gap_avg={:.1}ms sent_gap_p95={:.1}ms sent_gap_max={:.1}ms \ frame_age_avg={:.1}ms frame_age_p95={:.1}ms frame_age_max={:.1}ms \ send_wait_p95={:.1}ms \ import_avg={:.1}ms import_p95={:.1}ms \ scale_avg={:.1}ms scale_p95={:.1}ms transfer_avg={:.1}ms transfer_p95={:.1}ms \ sws_avg={:.1}ms sws_p95={:.1}ms \ encode_avg={:.1}ms encode_p95={:.1}ms total_avg={:.1}ms total_p95={:.1}ms \ output_bps={:.0} frame_bytes_p95={} frame_bytes_max={}", self.elapsed_secs, self.capture_fps, self.encoded_fps, self.sent_fps, self.capture_frames, self.encoded_frames, self.sent_frames, self.pipewire_dropped, self.duplicate_frames_skipped, self.capture_queue_depth, self.encoded_queue_depth, self.capture_gap_avg_ms, self.capture_gap_p95_ms, self.capture_gap_max_ms, self.encoded_gap_avg_ms, self.encoded_gap_p95_ms, self.encoded_gap_max_ms, self.sent_gap_avg_ms, self.sent_gap_p95_ms, self.sent_gap_max_ms, self.frame_age_avg_ms, self.frame_age_p95_ms, self.frame_age_max_ms, self.send_wait_p95_ms, self.import_avg_ms, self.import_p95_ms, self.scale_avg_ms, self.scale_p95_ms, self.transfer_avg_ms, self.transfer_p95_ms, self.sws_avg_ms, self.sws_p95_ms, self.encode_avg_ms, self.encode_p95_ms, self.total_avg_ms, self.total_p95_ms, self.output_bytes_per_sec, self.output_frame_bytes_p95, self.output_frame_bytes_max, ) } } // --------------------------------------------------------------------------- // Statistics helpers // --------------------------------------------------------------------------- fn avg_f64(data: &[f64]) -> f64 { if data.is_empty() { return 0.0; } data.iter().sum::() / data.len() as f64 } fn p95_f64(data: &[f64]) -> f64 { if data.is_empty() { return 0.0; } let mut sorted: Vec = data.to_vec(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let idx = ((sorted.len() as f64) * 0.95).floor() as usize; sorted[idx.min(sorted.len() - 1)] } fn max_f64(data: &[f64]) -> f64 { data.iter().copied().fold(0.0_f64, f64::max) } fn avg_ms(data: &[u64]) -> f64 { if data.is_empty() { return 0.0; } data.iter().sum::() as f64 / data.len() as f64 / 1000.0 } fn p95_ms(data: &[u64]) -> f64 { if data.is_empty() { return 0.0; } let mut sorted = data.to_vec(); sorted.sort_unstable(); let idx = ((sorted.len() as f64) * 0.95).floor() as usize; sorted[idx.min(sorted.len() - 1)] as f64 / 1000.0 } fn sum_usize(data: &[usize]) -> usize { data.iter().sum() } fn p95_usize(data: &[usize]) -> usize { if data.is_empty() { return 0; } let mut sorted = data.to_vec(); sorted.sort_unstable(); let idx = ((sorted.len() as f64) * 0.95).floor() as usize; sorted[idx.min(sorted.len() - 1)] } fn max_usize(data: &[usize]) -> usize { data.iter().copied().max().unwrap_or(0) } #[cfg(test)] mod tests { use super::*; #[test] fn empty_stats_snapshot() { let mut stats = PipelineStats::new(); let snap = stats.snapshot_and_reset(); assert_eq!(snap.capture_frames, 0); assert_eq!(snap.encoded_frames, 0); assert_eq!(snap.sent_frames, 0); } #[test] fn record_and_snapshot_counts() { let mut stats = PipelineStats::new(); stats.record_capture(); stats.record_capture(); stats.record_encode(&FrameTimings { total_us: 5000, output_bytes: 1000, ..Default::default() }); stats.record_send(0.1, None); let snap = stats.snapshot_and_reset(); assert_eq!(snap.capture_frames, 2); assert_eq!(snap.encoded_frames, 1); assert_eq!(snap.sent_frames, 1); } #[test] fn p95_computation() { // 100 values: 0.0 through 99.0 let data: Vec = (0..100).map(|i| i as f64).collect(); let result = p95_f64(&data); assert!( (result - 95.0).abs() < 1.0, "p95 of 0..100 should be ~95, got {result}" ); } #[test] fn p95_ms_microseconds() { let data: Vec = (0..100).map(|i| i * 1000).collect(); // 0ms..99ms let result = p95_ms(&data); assert!( (result - 95.0).abs() < 1.0, "p95_ms should be ~95ms, got {result}" ); } #[test] fn snapshot_resets_counters() { let mut stats = PipelineStats::new(); stats.record_capture(); let _ = stats.snapshot_and_reset(); let snap = stats.snapshot_and_reset(); assert_eq!(snap.capture_frames, 0); } #[test] fn display_format_contains_key_fields() { let mut stats = PipelineStats::new(); stats.record_capture(); stats.record_encode(&FrameTimings { total_us: 10000, output_bytes: 5000, ..Default::default() }); stats.record_send(0.5, None); let snap = stats.snapshot_and_reset(); let text = format!("{snap}"); assert!(text.contains("capture_fps=")); assert!(text.contains("encoded_fps=")); assert!(text.contains("sent_fps=")); assert!(text.contains("total_p95=")); } }