1use crate::audio::WHISPER_SAMPLE_RATE;
8use crate::cancel::CancelFlag;
9use crate::pcm::PcmBuffer;
10use crate::window::{PartialClock, PartialWindowPolicy};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13static NEXT_REVISION: AtomicU64 = AtomicU64::new(1);
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum FinalizationReason {
18 Interval,
20 EnergyGate,
22 Final,
24 Cancelled,
26}
27
28#[derive(Debug, Clone, PartialEq)]
30pub struct PartialUpdate {
31 pub revision: u64,
33 pub stable_text: String,
35 pub unstable_text: String,
37 pub display_text: String,
39 pub window_samples: usize,
41 pub latency_ms: Option<u64>,
43 pub reason: FinalizationReason,
44 pub is_final: bool,
46}
47
48#[derive(Debug, Clone)]
50pub struct PartialSessionConfig {
51 pub window: PartialWindowPolicy,
52 pub stabilize_min_chars: usize,
55 pub max_inflight: usize,
57}
58
59impl Default for PartialSessionConfig {
60 fn default() -> Self {
61 Self {
62 window: PartialWindowPolicy::dictation(),
63 stabilize_min_chars: 8,
64 max_inflight: 1,
65 }
66 }
67}
68
69pub struct PartialSession {
77 config: PartialSessionConfig,
78 clock: PartialClock,
79 buffer: PcmBuffer,
80 stable_text: String,
81 last_display: String,
82 last_revision: u64,
83 inflight: usize,
84 active_cancel: Option<CancelFlag>,
86 closed: bool,
87}
88
89impl PartialSession {
90 pub fn new(config: PartialSessionConfig) -> Self {
91 let max_secs = config
92 .window
93 .window_secs()
94 .max(config.window.min_partial_secs())
95 * 2.0;
96 let clock = PartialClock::new(config.window);
97 Self {
98 config,
99 clock,
100 buffer: PcmBuffer::with_max_secs(max_secs.max(1.0)),
101 stable_text: String::new(),
102 last_display: String::new(),
103 last_revision: 0,
104 inflight: 0,
105 active_cancel: None,
106 closed: false,
107 }
108 }
109
110 pub fn dictation() -> Self {
112 Self::new(PartialSessionConfig::default())
113 }
114
115 pub fn config(&self) -> &PartialSessionConfig {
116 &self.config
117 }
118
119 pub fn buffer(&self) -> &PcmBuffer {
120 &self.buffer
121 }
122
123 pub fn buffer_mut(&mut self) -> &mut PcmBuffer {
124 &mut self.buffer
125 }
126
127 pub fn stable_text(&self) -> &str {
128 &self.stable_text
129 }
130
131 pub fn is_closed(&self) -> bool {
132 self.closed
133 }
134
135 pub fn push(&mut self, chunk: &[f32]) -> crate::error::Result<()> {
137 if self.closed {
138 return Err(crate::error::UserError::Other {
139 message: "partial session is closed".into(),
140 }
141 .into());
142 }
143 self.buffer.push(chunk)
144 }
145
146 pub fn should_decode_partial(&self) -> bool {
148 if self.closed || self.inflight >= self.config.max_inflight {
149 return false;
150 }
151 let samples = self.buffer.samples();
152 self.clock.ready(samples.as_slice())
153 }
154
155 pub fn partial_window_samples(&self) -> ContiguousOwned {
157 let samples = self.buffer.samples();
158 let window = self.config.window.slice_for_partial(samples.as_slice());
159 ContiguousOwned(window.to_vec())
160 }
161
162 pub fn begin_partial(&mut self) -> Option<CancelFlag> {
166 if !self.should_decode_partial() {
167 return None;
168 }
169 if let Some(prev) = self.active_cancel.take() {
170 prev.cancel();
171 }
172 let flag = CancelFlag::new();
173 self.active_cancel = Some(flag.clone());
174 self.inflight = self
175 .inflight
176 .saturating_add(1)
177 .min(self.config.max_inflight);
178 self.clock.mark();
179 Some(flag)
180 }
181
182 pub fn on_decode_result(&mut self, raw_text: &str, latency_ms: Option<u64>) -> PartialUpdate {
184 self.inflight = self.inflight.saturating_sub(1);
185 self.active_cancel = None;
186
187 let display = normalize_partial_text(raw_text);
188 let (stable, unstable) = stabilize_prefix(
189 &self.stable_text,
190 &self.last_display,
191 &display,
192 self.config.stabilize_min_chars,
193 );
194 self.stable_text = stable.clone();
195 self.last_display = display.clone();
196
197 let revision = NEXT_REVISION.fetch_add(1, Ordering::Relaxed);
198 self.last_revision = revision;
199 let window_samples = self
200 .config
201 .window
202 .slice_for_partial(self.buffer.samples().as_slice())
203 .len();
204
205 PartialUpdate {
206 revision,
207 stable_text: stable,
208 unstable_text: unstable,
209 display_text: display,
210 window_samples,
211 latency_ms,
212 reason: FinalizationReason::Interval,
213 is_final: false,
214 }
215 }
216
217 pub fn end_partial(&mut self) {
219 self.inflight = self.inflight.saturating_sub(1);
220 self.active_cancel = None;
221 }
222
223 pub fn finalize(&mut self, final_text: &str, latency_ms: Option<u64>) -> PartialUpdate {
225 if let Some(prev) = self.active_cancel.take() {
226 prev.cancel();
227 }
228 self.inflight = 0;
229 self.closed = true;
230 let display = normalize_partial_text(final_text);
231 self.stable_text = display.clone();
232 self.last_display = display.clone();
233 let revision = NEXT_REVISION.fetch_add(1, Ordering::Relaxed);
234 self.last_revision = revision;
235 PartialUpdate {
236 revision,
237 stable_text: display.clone(),
238 unstable_text: String::new(),
239 display_text: display,
240 window_samples: self.buffer.len(),
241 latency_ms,
242 reason: FinalizationReason::Final,
243 is_final: true,
244 }
245 }
246
247 pub fn cancel(&mut self) -> PartialUpdate {
249 if let Some(prev) = self.active_cancel.take() {
250 prev.cancel();
251 }
252 self.inflight = 0;
253 self.closed = true;
254 let revision = NEXT_REVISION.fetch_add(1, Ordering::Relaxed);
255 PartialUpdate {
256 revision,
257 stable_text: self.stable_text.clone(),
258 unstable_text: String::new(),
259 display_text: self.stable_text.clone(),
260 window_samples: self.buffer.len(),
261 latency_ms: None,
262 reason: FinalizationReason::Cancelled,
263 is_final: true,
264 }
265 }
266}
267
268#[derive(Debug, Clone)]
270pub struct ContiguousOwned(pub Vec<f32>);
271
272impl ContiguousOwned {
273 pub fn as_slice(&self) -> &[f32] {
274 &self.0
275 }
276}
277
278impl AsRef<[f32]> for ContiguousOwned {
279 fn as_ref(&self) -> &[f32] {
280 &self.0
281 }
282}
283
284fn normalize_partial_text(s: &str) -> String {
285 s.split_whitespace().collect::<Vec<_>>().join(" ")
286}
287
288fn stabilize_prefix(
294 prior_stable: &str,
295 prior_display: &str,
296 new_display: &str,
297 min_chars: usize,
298) -> (String, String) {
299 if new_display.is_empty() {
300 return (prior_stable.to_string(), String::new());
301 }
302 if let Some(_tail) = new_display.strip_prefix(prior_stable) {
304 let lcp = longest_common_prefix(prior_display, new_display);
306 let grow_to = if lcp.len() >= min_chars && lcp.len() > prior_stable.len() {
307 lcp
308 } else {
309 prior_stable
310 };
311 let stable = if new_display.starts_with(grow_to) && grow_to.len() >= prior_stable.len() {
312 grow_to.to_string()
313 } else {
314 prior_stable.to_string()
315 };
316 let unstable = new_display.strip_prefix(&stable).unwrap_or("").to_string();
317 return (stable, unstable);
318 }
319 if prior_stable.is_empty() {
320 let lcp = longest_common_prefix(prior_display, new_display);
321 if lcp.len() >= min_chars {
322 (
323 lcp.to_string(),
324 new_display.strip_prefix(lcp).unwrap_or("").to_string(),
325 )
326 } else {
327 (String::new(), new_display.to_string())
328 }
329 } else {
330 (prior_stable.to_string(), String::new())
332 }
333}
334
335fn longest_common_prefix<'a>(a: &'a str, b: &'a str) -> &'a str {
336 let mut end = 0;
337 let mut ait = a.char_indices();
338 let mut bit = b.chars();
339 loop {
340 match (ait.next(), bit.next()) {
341 (Some((i, ca)), Some(cb)) if ca == cb => {
342 end = i + ca.len_utf8();
343 }
344 _ => break,
345 }
346 }
347 &a[..end]
348}
349
350#[inline]
353pub fn legacy_policy_dictation() -> PartialWindowPolicy {
354 PartialWindowPolicy::dictation()
355}
356
357pub fn samples_per_second() -> usize {
359 WHISPER_SAMPLE_RATE as usize
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn stable_never_retracts() {
368 let mut s = PartialSession::dictation();
369 s.push(&vec![0.1; 16_000]).unwrap();
371 let u1 = s.on_decode_result("hello world today", None);
372 assert!(u1.display_text.contains("hello"));
373 let u2 = s.on_decode_result("hello world tomorrow", None);
374 assert!(u2.stable_text.starts_with(&u1.stable_text) || u2.stable_text == "hello world ");
375 assert!(u2.stable_text.starts_with("hello") || s.stable_text().starts_with("hello"));
377 }
378
379 #[test]
380 fn inflight_cap_blocks() {
381 let mut s = PartialSession::new(PartialSessionConfig {
382 max_inflight: 1,
383 window: PartialWindowPolicy {
384 min_partial_samples: 10,
385 window_samples: 1000,
386 interval_nanos: 0,
387 min_rms_bits: 0,
388 },
389 ..Default::default()
390 });
391 s.push(&vec![0.2; 100]).unwrap();
392 assert!(s.should_decode_partial());
393 let _c = s.begin_partial().unwrap();
394 assert!(!s.should_decode_partial());
395 s.end_partial();
396 assert!(s.should_decode_partial());
397 }
398
399 #[test]
400 fn cancel_supersedes() {
401 let mut s = PartialSession::new(PartialSessionConfig {
402 max_inflight: 1,
403 window: PartialWindowPolicy {
404 min_partial_samples: 1,
405 window_samples: 100,
406 interval_nanos: 0,
407 min_rms_bits: 0,
408 },
409 ..Default::default()
410 });
411 s.push(&[0.2; 50]).unwrap();
412 let _c1 = s.begin_partial().unwrap();
413 s.end_partial();
414 s.push(&[0.2; 10]).unwrap();
415 let _c2 = s.begin_partial();
416 let fin = s.cancel();
417 assert!(fin.is_final);
418 assert_eq!(fin.reason, FinalizationReason::Cancelled);
419 }
420
421 #[test]
422 fn finalize_commits_all_stable() {
423 let mut s = PartialSession::dictation();
424 s.push(&vec![0.1; 1600]).unwrap();
425 let u = s.finalize("final transcript here", Some(12));
426 assert!(u.is_final);
427 assert_eq!(u.stable_text, "final transcript here");
428 assert!(u.unstable_text.is_empty());
429 assert!(s.is_closed());
430 }
431
432 #[test]
433 fn lcp_unicode() {
434 assert_eq!(longest_common_prefix("café", "cafx"), "caf");
435 assert_eq!(longest_common_prefix("abc", "xyz"), "");
436 }
437}