Skip to main content

aurum_core/
window.rs

1//! Rolling-window helpers for host-driven “partial-like” decode loops.
2//!
3//! Aurum does not run a background streaming decoder. Hosts (e.g. dictation apps)
4//! can push PCM into a [`crate::pcm::PcmBuffer`], then use these policies to decide
5//! when to call [`crate::LocalWhisperProvider::transcribe_pcm`] on a slice.
6//!
7//! **Migration (JOE-1605):** prefer [`crate::partial::PartialSession`] for
8//! production partial UX (stable/unstable text, revision IDs, supersession
9//! cancel). [`PartialWindowPolicy`] and [`PartialClock`] remain supported for
10//! simple loops and as the energy/interval engine inside `PartialSession`.
11
12use crate::audio::WHISPER_SAMPLE_RATE;
13
14/// Policy aligned with common hold-to-talk UX.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct PartialWindowPolicy {
17    /// Minimum samples before a partial is worth running (~1 s default).
18    pub min_partial_samples: usize,
19    /// Max samples included in a partial slice (~15 s default).
20    pub window_samples: usize,
21    /// Suggested spacing between partial attempts (nanoseconds).
22    pub interval_nanos: u64,
23    /// RMS below this → skip partial (near-silence).
24    pub min_rms_bits: u32, // store as fixed-point * 1e6 for Eq; use helpers
25}
26
27impl PartialWindowPolicy {
28    /// Defaults suited to progressive hold-to-talk decode.
29    pub fn dictation() -> Self {
30        Self {
31            min_partial_samples: WHISPER_SAMPLE_RATE as usize, // 1 s
32            window_samples: WHISPER_SAMPLE_RATE as usize * 15, // 15 s
33            interval_nanos: 1_200_000_000,                     // 1.2 s
34            min_rms_bits: 500,                                 // 0.0005
35        }
36    }
37
38    pub fn min_rms(&self) -> f32 {
39        self.min_rms_bits as f32 / 1_000_000.0
40    }
41
42    pub fn with_min_rms(mut self, rms: f32) -> Self {
43        self.min_rms_bits = (rms.clamp(0.0, 1.0) * 1_000_000.0).round() as u32;
44        self
45    }
46
47    pub fn min_partial_secs(&self) -> f64 {
48        self.min_partial_samples as f64 / f64::from(WHISPER_SAMPLE_RATE)
49    }
50
51    pub fn window_secs(&self) -> f64 {
52        self.window_samples as f64 / f64::from(WHISPER_SAMPLE_RATE)
53    }
54
55    /// Enough audio accumulated for a partial attempt?
56    pub fn can_run_partial(&self, sample_count: usize) -> bool {
57        sample_count >= self.min_partial_samples
58    }
59
60    /// Most recent `window_samples` (or all if shorter).
61    pub fn slice_for_partial<'a>(&self, samples: &'a [f32]) -> &'a [f32] {
62        if samples.len() > self.window_samples {
63            &samples[samples.len() - self.window_samples..]
64        } else {
65            samples
66        }
67    }
68
69    /// Energy gate: skip near-silent buffers.
70    pub fn passes_energy_gate(&self, samples: &[f32]) -> bool {
71        if samples.is_empty() {
72            return false;
73        }
74        let sum: f32 = samples.iter().map(|s| s * s).sum();
75        let rms = (sum / samples.len() as f32).sqrt();
76        rms >= self.min_rms()
77    }
78
79    /// Combined: enough samples + energy.
80    pub fn should_run_partial(&self, samples: &[f32]) -> bool {
81        self.can_run_partial(samples.len()) && self.passes_energy_gate(samples)
82    }
83}
84
85/// Tracks wall-clock spacing between partial attempts.
86#[derive(Debug, Clone)]
87pub struct PartialClock {
88    policy: PartialWindowPolicy,
89    last_partial_at: Option<std::time::Instant>,
90}
91
92impl PartialClock {
93    pub fn new(policy: PartialWindowPolicy) -> Self {
94        Self {
95            policy,
96            last_partial_at: None,
97        }
98    }
99
100    pub fn policy(&self) -> &PartialWindowPolicy {
101        &self.policy
102    }
103
104    /// True if enough time has elapsed since the last partial (or never ran).
105    pub fn interval_elapsed(&self) -> bool {
106        match self.last_partial_at {
107            None => true,
108            Some(t) => t.elapsed().as_nanos() as u64 >= self.policy.interval_nanos,
109        }
110    }
111
112    /// Mark that a partial was started/completed now.
113    pub fn mark(&mut self) {
114        self.last_partial_at = Some(std::time::Instant::now());
115    }
116
117    /// Ready for another partial given full buffer samples.
118    /// Energy is evaluated on the same rolling window that would be decoded.
119    pub fn ready(&self, samples: &[f32]) -> bool {
120        if !self.interval_elapsed() || !self.policy.can_run_partial(samples.len()) {
121            return false;
122        }
123        let window = self.policy.slice_for_partial(samples);
124        self.policy.passes_energy_gate(window)
125    }
126
127    /// Slice + mark if ready; returns `None` if not ready.
128    pub fn take_partial_slice<'a>(&mut self, samples: &'a [f32]) -> Option<&'a [f32]> {
129        if !self.ready(samples) {
130            return None;
131        }
132        self.mark();
133        Some(self.policy.slice_for_partial(samples))
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn slice_takes_tail() {
143        let p = PartialWindowPolicy {
144            window_samples: 4,
145            min_partial_samples: 2,
146            interval_nanos: 0,
147            min_rms_bits: 0,
148        };
149        let s = [1., 2., 3., 4., 5., 6.];
150        assert_eq!(p.slice_for_partial(&s), &[3., 4., 5., 6.]);
151    }
152
153    #[test]
154    fn energy_gate() {
155        let p = PartialWindowPolicy::dictation().with_min_rms(0.01);
156        assert!(!p.passes_energy_gate(&[0.0; 1600]));
157        assert!(p.passes_energy_gate(&[0.5; 1600]));
158    }
159
160    #[test]
161    fn clock_interval() {
162        let mut c = PartialClock::new(PartialWindowPolicy {
163            min_partial_samples: 1,
164            window_samples: 100,
165            interval_nanos: 10_000_000_000, // 10s
166            min_rms_bits: 0,
167        });
168        let s = [0.1; 10];
169        assert!(c.ready(&s));
170        c.mark();
171        assert!(!c.ready(&s));
172    }
173}