screen_app/recp/
fps_monitor.rs1use std::collections::VecDeque;
11use std::time::Duration;
12
13pub const WARN_THRESHOLD_FPS: f64 = 24.0;
15
16pub const RECOVER_THRESHOLD_FPS: f64 = 26.0;
19
20#[derive(Debug)]
26pub struct FrameRateMonitor {
27 capacity: usize,
28 window: VecDeque<Duration>,
29 warn_threshold: f64,
30 recover_threshold: f64,
31 in_warn_state: bool,
34}
35
36impl Default for FrameRateMonitor {
37 fn default() -> Self {
38 Self::new(150, WARN_THRESHOLD_FPS, RECOVER_THRESHOLD_FPS)
40 }
41}
42
43impl FrameRateMonitor {
44 #[must_use]
46 pub fn new(capacity: usize, warn_threshold: f64, recover_threshold: f64) -> Self {
47 Self {
48 capacity,
49 window: VecDeque::with_capacity(capacity),
50 warn_threshold,
51 recover_threshold,
52 in_warn_state: false,
53 }
54 }
55
56 pub fn observe(&mut self, timestamp: Duration) -> Option<Transition> {
61 if self.window.len() == self.capacity {
62 self.window.pop_front();
63 }
64 self.window.push_back(timestamp);
65 if self.window.len() < 2 {
66 return None;
67 }
68 let fps = self.current_fps();
69 if !self.in_warn_state && fps < self.warn_threshold {
70 self.in_warn_state = true;
71 Some(Transition::DroppedBelow(fps))
72 } else if self.in_warn_state && fps >= self.recover_threshold {
73 self.in_warn_state = false;
74 Some(Transition::Recovered(fps))
75 } else {
76 None
77 }
78 }
79
80 #[must_use]
82 pub fn current_fps(&self) -> f64 {
83 if self.window.len() < 2 {
84 return 0.0;
85 }
86 let first = self.window.front().copied().unwrap_or_default();
87 let last = self.window.back().copied().unwrap_or_default();
88 let elapsed = last.saturating_sub(first).as_secs_f64();
89 if elapsed <= 0.0 {
90 return 0.0;
91 }
92 #[allow(
93 clippy::cast_precision_loss,
94 reason = "window length is well under 2^53; lossy conversion is fine"
95 )]
96 let frames = (self.window.len() - 1) as f64;
97 frames / elapsed
98 }
99}
100
101#[derive(Clone, Copy, Debug, PartialEq)]
103pub enum Transition {
104 DroppedBelow(f64),
107 Recovered(f64),
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 fn ts(ms: u64) -> Duration {
117 Duration::from_millis(ms)
118 }
119
120 #[test]
121 fn default_window_is_five_seconds_at_30_fps() {
122 let m = FrameRateMonitor::default();
123 assert_eq!(m.capacity, 150);
124 }
125
126 #[test]
127 fn single_frame_has_no_fps() {
128 let mut m = FrameRateMonitor::default();
129 assert_eq!(m.observe(ts(0)), None);
130 assert!(m.current_fps().abs() < 1e-6);
131 }
132
133 #[test]
134 fn thirty_fps_steady_state_no_transition() {
135 let mut m = FrameRateMonitor::default();
136 for i in 0..60 {
137 let t = u64::try_from(i).unwrap() * 33;
139 assert_eq!(m.observe(ts(t)), None);
140 }
141 let fps = m.current_fps();
142 assert!((fps - 30.0).abs() < 1.0, "got {fps}");
143 }
144
145 #[test]
146 fn fps_drop_below_threshold_emits_transition() {
147 let mut m = FrameRateMonitor::default();
148 let mut transition = None;
150 for i in 0..30 {
151 let t = u64::try_from(i).unwrap() * 100;
152 if let Some(tr) = m.observe(ts(t)) {
153 transition = Some(tr);
154 }
155 }
156 assert!(
157 matches!(transition, Some(Transition::DroppedBelow(_))),
158 "expected DroppedBelow, got {transition:?}"
159 );
160 }
161
162 #[test]
163 fn recovery_emits_transition_only_after_warn() {
164 let mut m = FrameRateMonitor::new(30, WARN_THRESHOLD_FPS, RECOVER_THRESHOLD_FPS);
167 for i in 0..30 {
170 let t = u64::try_from(i).unwrap() * 100;
171 m.observe(ts(t));
172 }
173 assert!(m.in_warn_state);
174
175 let mut recovered = None;
179 for i in 0..200 {
180 let t = 3000 + u64::try_from(i).unwrap() * 16;
181 if let Some(tr) = m.observe(ts(t)) {
182 recovered = Some(tr);
183 }
184 }
185 assert!(
186 matches!(recovered, Some(Transition::Recovered(_))),
187 "expected Recovered, got {recovered:?}; final fps = {}",
188 m.current_fps()
189 );
190 }
191
192 #[test]
193 fn hysteresis_prevents_repeat_warns() {
194 let mut m = FrameRateMonitor::default();
195 let mut warn_count = 0;
197 for i in 0..200 {
198 let t = u64::try_from(i).unwrap() * 100;
199 if let Some(Transition::DroppedBelow(_)) = m.observe(ts(t)) {
200 warn_count += 1;
201 }
202 }
203 assert_eq!(warn_count, 1);
206 }
207}