Skip to main content

aurum_core/
partial.rs

1//! Host-driven partial transcription session (JOE-1605).
2//!
3//! Aurum does not own microphone devices. Hosts push PCM, and this module
4//! decides when to decode, how to stabilize prefixes, and how to discard
5//! superseded results.
6
7use 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/// Why a partial or final emission was produced.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum FinalizationReason {
18    /// Periodic partial decode on the rolling window.
19    Interval,
20    /// Energy/VAD gate passed after enough audio.
21    EnergyGate,
22    /// Host requested a definitive final transcript.
23    Final,
24    /// Session cancelled.
25    Cancelled,
26}
27
28/// One partial emission for the host UI.
29#[derive(Debug, Clone, PartialEq)]
30pub struct PartialUpdate {
31    /// Monotonic revision (later supersedes earlier).
32    pub revision: u64,
33    /// Prefix that will not change under the documented algorithm.
34    pub stable_text: String,
35    /// Tail that may still change on subsequent partials.
36    pub unstable_text: String,
37    /// Combined display text (`stable` + `unstable`).
38    pub display_text: String,
39    /// Sample count of the window that produced this text.
40    pub window_samples: usize,
41    /// Wall latency for this decode (host fills when known).
42    pub latency_ms: Option<u64>,
43    pub reason: FinalizationReason,
44    /// True when this is the definitive session result.
45    pub is_final: bool,
46}
47
48/// Configuration for partial session v2.
49#[derive(Debug, Clone)]
50pub struct PartialSessionConfig {
51    pub window: PartialWindowPolicy,
52    /// Characters of the previous display kept as stable when the new decode
53    /// shares that prefix (simple longest-common-prefix stabilization).
54    pub stabilize_min_chars: usize,
55    /// Maximum in-flight partial decodes; further requests are dropped.
56    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
69/// Host-driven partial STT session state.
70///
71/// Typical loop:
72/// 1. `push` mic chunks
73/// 2. if `should_decode_partial`, host runs STT on `partial_window_samples()`
74/// 3. call `on_decode_result` with the transcript text
75/// 4. on release, `finalize` with a definitive full-window decode
76pub 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    /// Cancel token for the currently queued/running partial (if any).
85    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    /// Dictation-oriented defaults.
111    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    /// Push mono 16 kHz samples into the ring buffer.
136    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    /// Whether the host should start a partial decode now.
147    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    /// Contiguous PCM for the current partial window (host passes to STT).
156    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    /// Begin a partial: marks the clock, increments inflight, returns cancel token.
163    ///
164    /// Supersedes any previous inflight cancel token (cooperative cancel).
165    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    /// Host delivers a partial decode result (or error → call [`Self::end_partial`]).
183    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    /// Mark a partial as finished without a text update (cancel/error).
218    pub fn end_partial(&mut self) {
219        self.inflight = self.inflight.saturating_sub(1);
220        self.active_cancel = None;
221    }
222
223    /// Final decode path: host supplies definitive transcript for the full buffer.
224    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    /// Cancel the session and any in-flight partial.
248    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/// Owned contiguous samples for passing into STT without lifetime coupling.
269#[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
288/// Longest common prefix stabilization.
289///
290/// Once a prefix is in `prior_stable`, it is never shortened. The new stable
291/// region is the longest common prefix of `prior_display` and `new_display`
292/// that is at least `min_chars`, merged with `prior_stable`.
293fn 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    // Never retract prior stable.
303    if let Some(_tail) = new_display.strip_prefix(prior_stable) {
304        // Grow stable from LCP of previous full display and new.
305        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        // Do not rewrite stable after commitment.
331        (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/// Migration note: [`PartialWindowPolicy`] / [`PartialClock`] remain supported
351/// for simple host loops. Prefer [`PartialSession`] for production partial UX.
352#[inline]
353pub fn legacy_policy_dictation() -> PartialWindowPolicy {
354    PartialWindowPolicy::dictation()
355}
356
357/// Suggested samples for ~1 s of audio at whisper rate.
358pub 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        // Seed with enough samples for ready checks later.
370        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        // Prior stable must remain a prefix of stable after growth, or equal.
376        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}