1use super::wav::TARGET_SAMPLE_RATE;
12
13pub const WINDOW_SAMPLES: usize = 1600;
15
16pub const SILENCE_THRESHOLD_RMS: f32 = 0.01;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum WindowClass {
23 Silent,
25 Voiced,
27}
28
29pub struct IdleDetector {
39 idle_after_secs: u32,
40 pending: Vec<f32>,
41 consecutive_silent: u32,
42 any_voiced: bool,
43}
44
45impl IdleDetector {
46 #[must_use]
49 pub fn new(idle_after_secs: u32) -> Self {
50 Self {
51 idle_after_secs,
52 pending: Vec::with_capacity(WINDOW_SAMPLES * 2),
53 consecutive_silent: 0,
54 any_voiced: false,
55 }
56 }
57
58 #[must_use]
60 pub fn idle_after_secs(&self) -> u32 {
61 self.idle_after_secs
62 }
63
64 pub fn push(&mut self, samples: &[f32]) -> Vec<WindowClass> {
69 self.pending.extend_from_slice(samples);
70 let mut classifications = Vec::new();
71 while self.pending.len() >= WINDOW_SAMPLES {
72 let class = classify_window(&self.pending[..WINDOW_SAMPLES]);
73 classifications.push(class);
74 match class {
75 WindowClass::Silent => self.consecutive_silent += 1,
76 WindowClass::Voiced => {
77 self.consecutive_silent = 0;
78 self.any_voiced = true;
79 }
80 }
81 self.pending.drain(..WINDOW_SAMPLES);
82 }
83 classifications
84 }
85
86 #[must_use]
91 pub fn is_idle(&self) -> bool {
92 if self.idle_after_secs == 0 {
93 return false;
94 }
95 let needed = u64::from(self.idle_after_secs) * windows_per_second_u64();
96 u64::from(self.consecutive_silent) >= needed
97 }
98
99 #[must_use]
102 pub fn has_any_voice(&self) -> bool {
103 self.any_voiced
104 }
105
106 #[must_use]
110 pub fn trailing_silence_samples(&self) -> usize {
111 self.consecutive_silent as usize * WINDOW_SAMPLES
112 }
113}
114
115fn classify_window(window: &[f32]) -> WindowClass {
116 if rms(window) > SILENCE_THRESHOLD_RMS {
117 WindowClass::Voiced
118 } else {
119 WindowClass::Silent
120 }
121}
122
123fn rms(samples: &[f32]) -> f32 {
124 if samples.is_empty() {
125 return 0.0;
126 }
127 let sum_sq: f32 = samples.iter().map(|s| s * s).sum();
128 (sum_sq / samples.len() as f32).sqrt()
129}
130
131const fn windows_per_second_u64() -> u64 {
132 TARGET_SAMPLE_RATE as u64 / WINDOW_SAMPLES as u64
133}
134
135#[must_use]
142pub fn trim_trailing_silence(samples: &[f32], tail_samples: usize) -> &[f32] {
143 let end = samples.len().saturating_sub(tail_samples);
144 &samples[..end]
145}
146
147#[cfg(test)]
148#[allow(clippy::unwrap_used, clippy::expect_used)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn silence_only_input_fires_at_exact_window_budget() {
154 let mut det = IdleDetector::new(2); let one_window = vec![0.0_f32; WINDOW_SAMPLES];
156 for _ in 0..19 {
158 det.push(&one_window);
159 }
160 assert!(!det.is_idle(), "should not be idle at 19 silent windows");
161 det.push(&one_window);
162 assert!(det.is_idle(), "should be idle at 20 silent windows");
163 assert!(
164 !det.has_any_voice(),
165 "no voiced window should have been seen"
166 );
167 assert_eq!(det.trailing_silence_samples(), 20 * WINDOW_SAMPLES);
168 }
169
170 #[test]
171 fn voiced_window_resets_silent_streak() {
172 let mut det = IdleDetector::new(1); let silent = vec![0.0_f32; WINDOW_SAMPLES];
174 let loud = vec![0.5_f32; WINDOW_SAMPLES];
176 for _ in 0..9 {
177 det.push(&silent);
178 }
179 assert!(!det.is_idle());
180 det.push(&loud); assert!(det.has_any_voice());
182 for _ in 0..9 {
183 det.push(&silent);
184 }
185 assert!(!det.is_idle(), "9 < 10 silent windows after the reset");
186 det.push(&silent);
187 assert!(det.is_idle(), "10 silent windows after the reset");
188 }
189
190 #[test]
191 fn voiced_only_input_never_goes_idle() {
192 let mut det = IdleDetector::new(1);
193 let loud = vec![0.5_f32; WINDOW_SAMPLES];
194 for _ in 0..50 {
195 det.push(&loud);
196 }
197 assert!(!det.is_idle());
198 assert!(det.has_any_voice());
199 assert_eq!(det.trailing_silence_samples(), 0);
200 }
201
202 #[test]
203 fn partial_window_is_buffered_until_full() {
204 let mut det = IdleDetector::new(1);
205 let half = vec![0.0_f32; WINDOW_SAMPLES / 2];
206 let c1 = det.push(&half);
208 assert!(c1.is_empty(), "first half does not complete a window");
209 let c2 = det.push(&half);
210 assert_eq!(c2, vec![WindowClass::Silent]);
211 }
212
213 #[test]
214 fn rms_boundary_classification() {
215 let below = vec![0.0099_f32; WINDOW_SAMPLES];
217 let above = vec![0.0101_f32; WINDOW_SAMPLES];
218 assert_eq!(classify_window(&below), WindowClass::Silent);
219 assert_eq!(classify_window(&above), WindowClass::Voiced);
220 }
221
222 #[test]
223 fn idle_after_zero_disables_autostop() {
224 let mut det = IdleDetector::new(0);
225 let silent = vec![0.0_f32; WINDOW_SAMPLES];
226 for _ in 0..100 {
227 det.push(&silent);
228 }
229 assert!(
230 !det.is_idle(),
231 "idle_after_secs=0 should never trigger auto-stop"
232 );
233 }
234
235 #[test]
236 fn trim_trailing_silence_drops_exactly_the_tail() {
237 let samples: Vec<f32> = (0..1000).map(|i| i as f32).collect();
238 let trimmed = trim_trailing_silence(&samples, 300);
239 assert_eq!(trimmed.len(), 700);
240 assert!((trimmed[0] - 0.0).abs() < f32::EPSILON);
243 assert!((trimmed[1] - 1.0).abs() < f32::EPSILON);
244 assert!((trimmed[2] - 2.0).abs() < f32::EPSILON);
245 assert!((trimmed[699] - 699.0).abs() < f32::EPSILON);
246 }
247
248 #[test]
249 fn trim_trailing_silence_clamps_to_empty_on_overshoot() {
250 let samples = vec![1.0, 2.0, 3.0];
251 let trimmed = trim_trailing_silence(&samples, 99);
252 assert!(trimmed.is_empty());
253 }
254
255 #[test]
256 fn rms_empty_samples_returns_zero() {
257 assert!((rms(&[]) - 0.0).abs() < f32::EPSILON);
258 }
259
260 #[test]
261 fn push_consumes_multiple_windows_in_one_call() {
262 let mut det = IdleDetector::new(1);
263 let chunk = vec![0.0_f32; WINDOW_SAMPLES * 5 / 2];
266 let classifications = det.push(&chunk);
267 assert_eq!(classifications.len(), 2);
268 assert_eq!(classifications[0], WindowClass::Silent);
269 assert_eq!(classifications[1], WindowClass::Silent);
270 }
271}