Skip to main content

polyvoice/window/
mod.rs

1//! Sliding-window utilities for batch and streaming pipelines.
2//!
3//! [`WindowIter`] iterates over `(start, end)` sample ranges.
4//! [`WindowBuffer`] buffers incoming samples and yields full windows.
5
6/// Invalid window geometry rejected by [`WindowIter::try_new`] and
7/// [`WindowBuffer::try_new`].
8#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
9pub enum WindowError {
10    /// Window size must be positive.
11    #[error("win must be > 0")]
12    ZeroWindow,
13    /// Hop size must be positive.
14    #[error("hop must be > 0")]
15    ZeroHop,
16    /// Hop must not exceed the window size.
17    #[error("hop ({hop}) must be <= win ({win})")]
18    HopExceedsWindow {
19        /// Requested hop size in samples.
20        hop: usize,
21        /// Requested window size in samples.
22        win: usize,
23    },
24}
25
26/// Iterator over fixed-size (or partial trailing) windows.
27///
28/// Yields `(start_sample, end_sample)` pairs.  `end_sample` is exclusive.
29#[derive(Debug, Clone)]
30pub struct WindowIter {
31    start: usize,
32    total: usize,
33    win: usize,
34    hop: usize,
35    include_partial: bool,
36}
37
38impl WindowIter {
39    /// { win > 0 && hop > 0 }
40    /// `pub fn new(total: usize, win: usize, hop: usize) -> Self`
41    /// { true }
42    /// Create a new iterator.
43    ///
44    /// * `total` — total number of samples in the audio region.
45    /// * `win`   — window size in samples.
46    /// * `hop`   — hop size in samples.
47    ///
48    /// # Panics
49    ///
50    /// Panics if `win == 0`, `hop == 0`, or `hop > win`.
51    /// Use [`try_new`](Self::try_new) for a fallible alternative.
52    #[allow(clippy::panic)] // Documented convenience over `try_new`.
53    pub fn new(total: usize, win: usize, hop: usize) -> Self {
54        match Self::try_new(total, win, hop) {
55            Ok(iter) => iter,
56            Err(e) => panic!("WindowIter::new: {e}"),
57        }
58    }
59
60    /// { true }
61    /// `pub fn try_new(total: usize, win: usize, hop: usize) -> Result<Self, WindowError>`
62    /// { ret.is_ok() == (win > 0 && hop > 0 && hop <= win) }
63    /// Fallible constructor: validates the window geometry and returns
64    /// [`WindowError`] instead of panicking.
65    ///
66    /// * `total` — total number of samples in the audio region.
67    /// * `win`   — window size in samples (must be > 0).
68    /// * `hop`   — hop size in samples (must be > 0 and <= `win`).
69    pub fn try_new(total: usize, win: usize, hop: usize) -> Result<Self, WindowError> {
70        if win == 0 {
71            return Err(WindowError::ZeroWindow);
72        }
73        if hop == 0 {
74            return Err(WindowError::ZeroHop);
75        }
76        if hop > win {
77            return Err(WindowError::HopExceedsWindow { hop, win });
78        }
79        Ok(Self {
80            start: 0,
81            total,
82            win,
83            hop,
84            include_partial: false,
85        })
86    }
87
88    /// { true }
89    /// `pub fn include_partial(mut self) -> Self`
90    /// { ret.include_partial }
91    /// Include a final partial window if the region does not divide evenly.
92    pub fn include_partial(mut self) -> Self {
93        self.include_partial = true;
94        self
95    }
96}
97
98impl Iterator for WindowIter {
99    type Item = (usize, usize);
100
101    fn next(&mut self) -> Option<Self::Item> {
102        if self.start >= self.total {
103            return None;
104        }
105        let end = if self.include_partial {
106            (self.start + self.win).min(self.total)
107        } else if self.start + self.win > self.total {
108            return None;
109        } else {
110            self.start + self.win
111        };
112        let item = (self.start, end);
113        self.start += self.hop;
114        Some(item)
115    }
116}
117
118/// Streaming buffer that accumulates samples and yields full windows.
119///
120/// Maintains an internal ring-like buffer.  Call [`extend`](Self::extend)
121/// with incoming chunks, then repeatedly call [`try_pop`](Self::try_pop)
122/// to consume every window that is ready.
123#[derive(Debug, Clone)]
124pub struct WindowBuffer {
125    buf: Vec<f32>,
126    win: usize,
127    hop: usize,
128    next_start: usize,
129}
130
131impl WindowBuffer {
132    /// { win > 0 && hop > 0 }
133    /// `pub fn new(win: usize, hop: usize) -> Self`
134    /// { true }
135    /// Create a new buffer.
136    ///
137    /// * `win` — window size in samples.
138    /// * `hop` — hop size in samples.
139    ///
140    /// # Panics
141    ///
142    /// Panics if `win == 0`, `hop == 0`, or `hop > win`.
143    /// Use [`try_new`](Self::try_new) for a fallible alternative.
144    #[allow(clippy::panic)] // Documented convenience over `try_new`.
145    pub fn new(win: usize, hop: usize) -> Self {
146        match Self::try_new(win, hop) {
147            Ok(buf) => buf,
148            Err(e) => panic!("WindowBuffer::new: {e}"),
149        }
150    }
151
152    /// { true }
153    /// `pub fn try_new(win: usize, hop: usize) -> Result<Self, WindowError>`
154    /// { ret.is_ok() == (win > 0 && hop > 0 && hop <= win) }
155    /// Fallible constructor: validates the window geometry and returns
156    /// [`WindowError`] instead of panicking.
157    ///
158    /// * `win` — window size in samples (must be > 0).
159    /// * `hop` — hop size in samples (must be > 0 and <= `win`).
160    pub fn try_new(win: usize, hop: usize) -> Result<Self, WindowError> {
161        if win == 0 {
162            return Err(WindowError::ZeroWindow);
163        }
164        if hop == 0 {
165            return Err(WindowError::ZeroHop);
166        }
167        if hop > win {
168            return Err(WindowError::HopExceedsWindow { hop, win });
169        }
170        Ok(Self {
171            buf: Vec::new(),
172            win,
173            hop,
174            next_start: 0,
175        })
176    }
177
178    /// { true }
179    /// `pub fn extend(&mut self, samples: &[f32])`
180    /// { true }
181    /// Append samples to the buffer.
182    pub fn extend(&mut self, samples: &[f32]) {
183        self.buf.extend_from_slice(samples);
184    }
185
186    /// { true }
187    /// `pub fn try_pop(&mut self) -> Option<(usize, Vec<f32>)>`
188    /// { ret.as_ref().map(|(_, w)| w.len() == self.win) }
189    /// Return the next full window if one is available.
190    ///
191    /// Returns `Some((global_start, buf[..win].to_vec()))` where `global_start` is the
192    /// sample offset of this window relative to the start of the stream.
193    /// The window is cloned so that the internal buffer can advance immediately.
194    pub fn try_pop(&mut self) -> Option<(usize, Vec<f32>)> {
195        if self.buf.len() < self.win {
196            return None;
197        }
198        let start = self.next_start;
199        let window = self.buf[..self.win].to_vec();
200        self.next_start += self.hop;
201        self.buf.drain(..self.hop);
202        Some((start, window))
203    }
204
205    /// { true }
206    /// `pub fn flush(&mut self) -> Option<(usize, Vec<f32>)>`
207    /// { true }
208    /// Zero-pad the remaining buffer to `win` and return the final window.
209    ///
210    /// Returns `None` if the buffer is empty.
211    pub fn flush(&mut self) -> Option<(usize, Vec<f32>)> {
212        if self.buf.is_empty() {
213            return None;
214        }
215        let start = self.next_start;
216        let mut padded = self.buf.clone();
217        if padded.len() < self.win {
218            padded.resize(self.win, 0.0f32);
219        }
220        self.buf.clear();
221        Some((start, padded))
222    }
223
224    /// { true }
225    /// `pub fn is_empty(&self) -> bool`
226    /// { ret == (self.buf.len() == 0) }
227    /// Whether the buffer is currently empty.
228    pub fn is_empty(&self) -> bool {
229        self.buf.is_empty()
230    }
231
232    /// { true }
233    /// `pub fn len(&self) -> usize`
234    /// { ret == self.buf.len() }
235    /// Current length of the buffered samples.
236    pub fn len(&self) -> usize {
237        self.buf.len()
238    }
239
240    /// { true }
241    /// `pub fn clear(&mut self)`
242    /// { self.buf.is_empty() }
243    /// Clear all buffered samples and reset the next-start offset.
244    pub fn clear(&mut self) {
245        self.buf.clear();
246    }
247
248    /// { true }
249    /// `pub fn set_next_start(&mut self, start: usize)`
250    /// { self.next_start == start }
251    /// Set the next-start offset to a specific value.
252    pub fn set_next_start(&mut self, start: usize) {
253        self.next_start = start;
254    }
255}
256
257#[allow(clippy::unwrap_used)]
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn window_iter_complete_only() {
264        let ranges: Vec<_> = WindowIter::new(10, 3, 2).collect();
265        assert_eq!(ranges, vec![(0, 3), (2, 5), (4, 7), (6, 9)]);
266    }
267
268    #[test]
269    fn window_iter_include_partial() {
270        let ranges: Vec<_> = WindowIter::new(10, 3, 2).include_partial().collect();
271        assert_eq!(ranges, vec![(0, 3), (2, 5), (4, 7), (6, 9), (8, 10)]);
272    }
273
274    #[test]
275    fn window_iter_empty() {
276        let ranges: Vec<_> = WindowIter::new(0, 3, 2).collect();
277        assert!(ranges.is_empty());
278    }
279
280    #[test]
281    fn window_iter_shorter_than_win() {
282        let ranges: Vec<_> = WindowIter::new(2, 3, 2).collect();
283        assert!(ranges.is_empty());
284        let ranges: Vec<_> = WindowIter::new(2, 3, 2).include_partial().collect();
285        assert_eq!(ranges, vec![(0, 2)]);
286    }
287
288    #[test]
289    fn window_buffer_pop_and_flush() {
290        let mut buf = WindowBuffer::new(4, 2);
291        buf.extend(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
292        let (s1, w1) = buf.try_pop().unwrap();
293        assert_eq!(s1, 0);
294        assert_eq!(w1, vec![1.0, 2.0, 3.0, 4.0]);
295        let (s2, w2) = buf.try_pop().unwrap();
296        assert_eq!(s2, 2);
297        assert_eq!(w2, vec![3.0, 4.0, 5.0, 6.0]);
298        assert!(buf.try_pop().is_none());
299
300        let (s3, w3) = buf.flush().unwrap();
301        assert_eq!(s3, 4);
302        assert_eq!(w3, vec![5.0, 6.0, 0.0, 0.0]);
303    }
304
305    #[test]
306    fn window_buffer_flush_empty() {
307        let mut buf = WindowBuffer::new(4, 2);
308        assert!(buf.flush().is_none());
309    }
310
311    #[test]
312    #[should_panic(expected = "WindowIter::new: win must be > 0")]
313    fn window_iter_rejects_zero_win() {
314        let _ = WindowIter::new(10, 0, 1);
315    }
316
317    #[test]
318    #[should_panic(expected = "WindowIter::new: hop must be > 0")]
319    fn window_iter_rejects_zero_hop() {
320        let _ = WindowIter::new(10, 2, 0);
321    }
322
323    #[test]
324    #[should_panic(expected = "WindowIter::new: hop (3) must be <= win (2)")]
325    fn window_iter_rejects_hop_greater_than_win() {
326        let _ = WindowIter::new(10, 2, 3);
327    }
328
329    #[test]
330    #[should_panic(expected = "WindowBuffer::new: win must be > 0")]
331    fn window_buffer_rejects_zero_win() {
332        let _ = WindowBuffer::new(0, 1);
333    }
334
335    #[test]
336    #[should_panic(expected = "WindowBuffer::new: hop must be > 0")]
337    fn window_buffer_rejects_zero_hop() {
338        let _ = WindowBuffer::new(4, 0);
339    }
340
341    #[test]
342    #[should_panic(expected = "WindowBuffer::new: hop (5) must be <= win (4)")]
343    fn window_buffer_rejects_hop_greater_than_win() {
344        let _ = WindowBuffer::new(4, 5);
345    }
346
347    #[test]
348    fn window_iter_try_new_reports_geometry_errors() {
349        assert_eq!(
350            WindowIter::try_new(10, 0, 1).unwrap_err(),
351            WindowError::ZeroWindow
352        );
353        assert_eq!(
354            WindowIter::try_new(10, 2, 0).unwrap_err(),
355            WindowError::ZeroHop
356        );
357        assert_eq!(
358            WindowIter::try_new(10, 2, 3).unwrap_err(),
359            WindowError::HopExceedsWindow { hop: 3, win: 2 }
360        );
361        assert!(WindowIter::try_new(10, 2, 1).is_ok());
362    }
363
364    #[test]
365    fn window_buffer_try_new_reports_geometry_errors() {
366        assert_eq!(
367            WindowBuffer::try_new(0, 1).unwrap_err(),
368            WindowError::ZeroWindow
369        );
370        assert_eq!(
371            WindowBuffer::try_new(4, 0).unwrap_err(),
372            WindowError::ZeroHop
373        );
374        assert_eq!(
375            WindowBuffer::try_new(4, 5).unwrap_err(),
376            WindowError::HopExceedsWindow { hop: 5, win: 4 }
377        );
378        assert!(WindowBuffer::try_new(4, 2).is_ok());
379    }
380
381    #[test]
382    fn window_buffer_len_empty_clear_and_set_next_start() {
383        let mut buf = WindowBuffer::new(4, 2);
384        assert!(buf.is_empty());
385        assert_eq!(buf.len(), 0);
386        buf.extend(&[1.0, 2.0]);
387        assert!(!buf.is_empty());
388        assert_eq!(buf.len(), 2);
389        buf.set_next_start(100);
390        buf.extend(&[3.0, 4.0]);
391        let (start, window) = buf.try_pop().unwrap();
392        assert_eq!(start, 100);
393        assert_eq!(window, vec![1.0, 2.0, 3.0, 4.0]);
394        buf.clear();
395        assert!(buf.is_empty());
396        assert!(buf.try_pop().is_none());
397    }
398
399    #[test]
400    fn window_buffer_flush_without_padding_when_buf_exceeds_win() {
401        let mut buf = WindowBuffer::new(4, 4);
402        buf.extend(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
403        let (start, window) = buf.flush().unwrap();
404        assert_eq!(start, 0);
405        assert_eq!(window, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
406        assert!(buf.is_empty());
407    }
408}
409
410#[allow(clippy::unwrap_used)]
411#[cfg(test)]
412mod prop_tests {
413    use super::*;
414    use proptest::prelude::*;
415
416    proptest! {
417        #![proptest_config(ProptestConfig {
418            cases: 1000,
419            ..ProptestConfig::default()
420        })]
421
422        /// All yielded (start, end) pairs satisfy start < end.
423        #[test]
424        fn window_iter_start_less_than_end(
425            total in 0usize..10000,
426            (win, hop) in (1usize..5000)
427                .prop_flat_map(|w| (Just(w), 1usize..=w)),
428            include_partial in any::<bool>(),
429        ) {
430            let iter = if include_partial {
431                WindowIter::new(total, win, hop).include_partial()
432            } else {
433                WindowIter::new(total, win, hop)
434            };
435            for (start, end) in iter {
436                prop_assert!(start < end, "start {} must be < end {}", start, end);
437            }
438        }
439
440        /// All yielded end values are <= total.
441        #[test]
442        fn window_iter_end_le_total(
443            total in 0usize..10000,
444            (win, hop) in (1usize..5000)
445                .prop_flat_map(|w| (Just(w), 1usize..=w)),
446            include_partial in any::<bool>(),
447        ) {
448            let iter = if include_partial {
449                WindowIter::new(total, win, hop).include_partial()
450            } else {
451                WindowIter::new(total, win, hop)
452            };
453            for (_, end) in iter {
454                prop_assert!(end <= total, "end {} must be <= total {}", end, total);
455            }
456        }
457
458        /// When include_partial = false, every yielded window has end - start == win.
459        #[test]
460        fn window_iter_no_partial(
461            total in 0usize..10000,
462            (win, hop) in (1usize..5000)
463                .prop_flat_map(|w| (Just(w), 1usize..=w)),
464        ) {
465            for (start, end) in WindowIter::new(total, win, hop) {
466                prop_assert_eq!(
467                    end - start,
468                    win,
469                    "window [{}..{}] must be full size {} when include_partial=false",
470                    start, end, win
471                );
472            }
473        }
474
475        /// When include_partial = true, every yielded window satisfies
476        /// end == (start + win).min(total). The final window reaches total
477        /// when total > 0.
478        #[test]
479        fn window_iter_partial_last(
480            total in 0usize..10000,
481            (win, hop) in (1usize..5000)
482                .prop_flat_map(|w| (Just(w), 1usize..=w)),
483        ) {
484            let ranges: Vec<_> = WindowIter::new(total, win, hop).include_partial().collect();
485            for (start, end) in &ranges {
486                prop_assert!(
487                    *end == (*start + win).min(total),
488                    "window [{}..{}] must equal ({} + {}).min({})",
489                    start, end, start, win, total
490                );
491            }
492        }
493    }
494}