//! Buffer-trim helpers for streaming transcription. //! //! Brief item #25: replace the current `OVERLAP_SAMPLES`-based drain //! in `src-tauri/src/commands/live.rs` with a trim tied to the last //! commit point emitted by the `CommitPolicy`. This keeps the capture //! buffer bounded regardless of wall-clock session length (ufal #120 / //! #102) by guaranteeing that any sample already committed to the //! transcript is never kept in the working buffer. //! //! The helpers here are pure — they don't know about the live session //! loop. Integration into `live.rs` ships as a follow-up after the //! LocalAgreement wiring (#24) is dogfooded. /// Absolute sample index at the end of the given session-relative /// seconds mark, rounded to the nearest sample. `end_secs` typically /// comes from `LocalAgreement::last_committed_end_secs()`. /// /// Guards against non-finite inputs: NaN and ±infinity both return 0 /// ("nothing committed yet"). Without this, Rust's saturating /// float-to-int cast turns `f64::INFINITY` into `u64::MAX`, which /// would park the capture buffer origin at an index beyond any /// reachable sample and trim the entire buffer forever. pub fn sample_index_for_seconds(end_secs: f64, sample_rate: u32) -> u64 { if !end_secs.is_finite() || end_secs <= 0.0 { return 0; } (end_secs * sample_rate as f64).round() as u64 } /// Drain the prefix of `buffer` whose absolute sample indices fall /// below `commit_sample_index`. `buffer_start_sample` is the absolute /// index of `buffer[0]` before the trim. /// /// Returns the new `buffer_start_sample`. If the commit point is /// before or equal to `buffer_start_sample`, nothing is drained. /// If the commit point is beyond the current end of the buffer, the /// whole buffer is drained and the new start is set to the commit /// index — the buffer is still empty, but its absolute-index origin /// moves forward so subsequent samples are positioned correctly. pub fn trim_buffer_to_commit_point( buffer: &mut Vec, buffer_start_sample: u64, commit_sample_index: u64, ) -> u64 { if commit_sample_index <= buffer_start_sample { return buffer_start_sample; } let drain_count = (commit_sample_index - buffer_start_sample) as usize; if drain_count >= buffer.len() { buffer.clear(); return commit_sample_index; } buffer.drain(..drain_count); buffer_start_sample + drain_count as u64 } #[cfg(test)] mod tests { use super::*; #[test] fn sample_index_for_seconds_zero_is_zero() { assert_eq!(sample_index_for_seconds(0.0, 16_000), 0); } #[test] fn sample_index_for_seconds_negative_is_zero() { // Defensive: end_secs should never be negative, but if it is // (clock skew in a future f64 source) treat as "nothing // committed yet" rather than wrapping to a huge u64. assert_eq!(sample_index_for_seconds(-1.0, 16_000), 0); } #[test] fn sample_index_for_seconds_rejects_nan_and_infinity() { // Defensive against non-finite inputs: without the is_finite() // check, Rust's saturating float-to-int cast makes +infinity // become u64::MAX, which would park the buffer origin beyond // reach and trim the whole buffer forever. assert_eq!(sample_index_for_seconds(f64::NAN, 16_000), 0); assert_eq!(sample_index_for_seconds(f64::INFINITY, 16_000), 0); assert_eq!(sample_index_for_seconds(f64::NEG_INFINITY, 16_000), 0); } #[test] fn sample_index_for_seconds_rounds_nearest() { // 0.5 s at 16 kHz = 8000 samples exactly. assert_eq!(sample_index_for_seconds(0.5, 16_000), 8_000); // Round-nearest: 0.50003 s × 16 kHz = 8000.48 → 8000. assert_eq!(sample_index_for_seconds(0.50003, 16_000), 8_000); // 0.5001 s × 16 kHz = 8001.6 → 8002. assert_eq!(sample_index_for_seconds(0.5001, 16_000), 8_002); } #[test] fn trim_does_nothing_when_commit_is_before_buffer_start() { let mut buf = vec![1.0, 2.0, 3.0]; let new_start = trim_buffer_to_commit_point(&mut buf, 1000, 500); assert_eq!(new_start, 1000); assert_eq!(buf, vec![1.0, 2.0, 3.0]); } #[test] fn trim_does_nothing_when_commit_equals_buffer_start() { let mut buf = vec![1.0, 2.0, 3.0]; let new_start = trim_buffer_to_commit_point(&mut buf, 1000, 1000); assert_eq!(new_start, 1000); assert_eq!(buf, vec![1.0, 2.0, 3.0]); } #[test] fn trim_drains_prefix_when_commit_is_inside_buffer() { let mut buf = vec![1.0, 2.0, 3.0, 4.0, 5.0]; // buffer starts at absolute index 100, commit is at 102. // Drain 2 samples; remaining buffer starts at 102. let new_start = trim_buffer_to_commit_point(&mut buf, 100, 102); assert_eq!(new_start, 102); assert_eq!(buf, vec![3.0, 4.0, 5.0]); } #[test] fn trim_clears_buffer_when_commit_is_at_buffer_end() { let mut buf = vec![1.0, 2.0, 3.0]; // buffer is [100, 103). commit at 103 means every sample is // committed — drain all, start moves forward. let new_start = trim_buffer_to_commit_point(&mut buf, 100, 103); assert_eq!(new_start, 103); assert!(buf.is_empty()); } #[test] fn trim_clears_buffer_when_commit_is_past_buffer_end() { let mut buf = vec![1.0, 2.0, 3.0]; // Commit well beyond the buffer — this happens in rare edge // cases where the committer's notion of time outstrips the // current buffer (e.g. after a reset). Defensive: drain and // park the origin at the commit point. let new_start = trim_buffer_to_commit_point(&mut buf, 100, 200); assert_eq!(new_start, 200); assert!(buf.is_empty()); } #[test] fn trim_bounds_buffer_over_long_session() { // Simulate a committer that keeps up with capture: each cycle // feeds 16_000 samples and commits all but a 200-sample // tentative tail. Over 100 cycles the buffer must stay near // that tentative envelope — not accumulate 100 × 16_000 samples // as it would without the commit-point trim. // // The tentative tail stacks by 200 per cycle because each new // push extends the buffer BEFORE the trim runs against the // previous cycle's commit point, so the expected bound is // (tentative_per_cycle + new_push_minus_commit), not just // tentative_per_cycle. let mut buf: Vec = Vec::new(); let mut start: u64 = 0; let mut total_pushed: u64 = 0; let tentative_per_cycle: u64 = 200; for _ in 0..100 { buf.extend(std::iter::repeat_n(0.25_f32, 16_000)); total_pushed += 16_000; let commit_point = total_pushed - tentative_per_cycle; start = trim_buffer_to_commit_point(&mut buf, start, commit_point); } assert!( buf.len() as u64 <= 2 * tentative_per_cycle, "buffer outgrew the commit-bounded envelope: len = {} (bound {})", buf.len(), 2 * tentative_per_cycle ); } #[test] fn integrates_with_local_agreement_last_committed_end_secs() { use super::super::commit_policy::{LocalAgreement, Token}; let mut la = LocalAgreement::new(2); let _ = la.push(vec![Token { text: "hello".into(), start_secs: 0.0, end_secs: 0.5, }]); let _ = la.push(vec![ Token { text: "hello".into(), start_secs: 0.0, end_secs: 0.5, }, Token { text: "world".into(), start_secs: 0.5, end_secs: 1.0, }, ]); // "hello" is committed, ending at 0.5 s. let commit_idx = sample_index_for_seconds(la.last_committed_end_secs(), 16_000); assert_eq!(commit_idx, 8_000); // Simulate a capture buffer that has received 1.2 s of audio // starting at t=0. let mut buf: Vec = std::iter::repeat_n(0.1_f32, 19_200).collect(); let new_start = trim_buffer_to_commit_point(&mut buf, 0, commit_idx); assert_eq!(new_start, 8_000); assert_eq!(buf.len(), 19_200 - 8_000); } }