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