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