Skip to main content

forge_dictate/
engine.rs

1//! The transcription engine and the tickets it hands back.
2
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
6use std::sync::{Arc, Condvar, Mutex, Once, PoisonError};
7use std::time::{Duration, Instant};
8
9use transcribe_cpp::{CancelToken, Feature, Model, RunOptions, Session};
10
11use crate::audio::{AudioSource, SAMPLE_RATE};
12use crate::diagnostics;
13use crate::normalize::NormalizeOptions;
14use crate::{Config, Error};
15
16/// Native diagnostics are routed once per process, before any model is
17/// loaded: unrouted, one load plus one inference writes 122 lines
18/// straight to stderr and corrupts any host that owns it.
19///
20/// Routed to the `log` facade rather than silenced, so the output still
21/// exists - but reaching it takes a `log`-to-tracing bridge the host
22/// installs (`tracing_log::LogTracer`). Without one these records are
23/// dropped, and this workspace does not install it today.
24static ROUTE_NATIVE_LOGS: Once = Once::new();
25
26/// Where the time went in one transcription.
27///
28/// Which stage moved is what separates "our code got slower" from "the
29/// model changed".
30#[derive(Debug, Clone, Default, PartialEq)]
31pub struct Stages {
32    /// Loading weights. Zero on every run but the first, which is why it
33    /// is separate: a cold start is not a regression.
34    pub model_load: Duration,
35    /// Turning the source into the buffer the model reads.
36    pub resample: Duration,
37    /// Mel spectrogram, as reported by the recognition runtime.
38    pub mel: Duration,
39    /// Encoder pass, as reported by the recognition runtime.
40    pub encode: Duration,
41    /// Decoder pass, as reported by the recognition runtime.
42    pub decode: Duration,
43    /// Rewriting recognition output into clean text. `None` when
44    /// normalization did not happen - either none was configured, or it
45    /// failed and the raw text was returned instead. A caller knows
46    /// which, because it knows what it configured.
47    pub normalize: Option<Duration>,
48    /// How much audio this run consumed, so a caller can derive a
49    /// realtime factor without knowing where the audio came from.
50    pub audio: Duration,
51}
52
53/// How far a take's transcription has got, for a host that shows
54/// progress while the recording runs. `done` counts the segments that
55/// have settled, in order; `total` is `None` until finish has
56/// submitted the tail, because a live recording does not know how many
57/// segments it will produce. A single-segment take reports twice at
58/// stop, at 0 of 1 and 1 of 1, and no host has to special-case it.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct WindowProgress {
61    pub done: usize,
62    pub total: Option<usize>,
63}
64
65/// Clean text, plus what it was before normalization.
66#[derive(Debug, Clone, PartialEq)]
67pub struct Transcript {
68    /// The text a caller should use.
69    ///
70    /// Not necessarily one paragraph. Long input produces paragraph
71    /// breaks even under the defaults, and
72    /// [`crate::normalize::Structure::Lists`] and
73    /// [`crate::normalize::Context::Email`] make multi-line output
74    /// likely - bullets and greeting/sign-off blocks respectively. A
75    /// fixed-height single-line surface will notice.
76    pub text: String,
77    /// Recognition output before normalization. Equal to `text` when no
78    /// normalizer ran. Deliberately not named `raw`, which the
79    /// recognition library already uses for something else.
80    pub asr: String,
81    /// Where the time went.
82    pub stages: Stages,
83    /// This is what fitted rather than what was said: the recording hit
84    /// [`Config::max_capture`], the decode ran out of its output budget,
85    /// a window stopped on a repetition loop, or a window failed after
86    /// earlier windows produced words. Carried here rather than only
87    /// logged: a host cannot tell from a log line it may not be routing
88    /// that part of the take is missing.
89    ///
90    /// Which cause it was depends on the path. Audio that arrived through
91    /// [`Engine::transcribe`] or [`Engine::transcribe_with`] was never
92    /// capped, so a cap is not among its causes; on the capture path a
93    /// host can compare [`Stages::audio`] against its configured cap to
94    /// tell the cap from the rest.
95    pub truncated: bool,
96}
97
98/// What a finished transcription produced.
99#[derive(Debug, Clone, PartialEq)]
100pub enum Outcome {
101    /// Audio was heard and recognised.
102    Transcript(Transcript),
103    /// Nothing rose above [`Config::silence_floor`]. Distinct from an
104    /// empty transcript on purpose: a muted input, an unplugged
105    /// interface, the wrong device and a second process holding the
106    /// microphone all land here, and a caller that cannot tell them from
107    /// "you said nothing" has nothing to report.
108    NoAudio {
109        /// Loudest sample seen, in dBFS. The two shapes mean different
110        /// things and a host should act on them differently.
111        ///
112        /// A finite value below the floor is a quiet room, a distant
113        /// speaker, or a muted input - ordinary, and retrying may work.
114        ///
115        /// Negative infinity means every sample was exactly zero, which
116        /// a live microphone does not produce. Measured: five captures
117        /// of an empty room read -38.2 to -27.5 dBFS and never reached
118        /// it, while a capture the operating system had refused produced
119        /// nothing else. So it says something structural sits between
120        /// the caller and the device - a denied permission, a hardware
121        /// mute switch, a disconnected interface - rather than that
122        /// nobody spoke. Which one is a host question; this crate
123        /// reports the observation.
124        ///
125        /// Also measured: that condition is sticky within a process. A
126        /// second capture behaved identically with no second prompt, so
127        /// a host should surface it rather than retry in a loop.
128        ///
129        /// None of that applies to an [`crate::AudioSource`] that yielded
130        /// no samples, which reaches the same value with no device in
131        /// play at all.
132        peak: f32,
133        /// How much audio was heard, so a caller can say "four seconds of
134        /// nothing" rather than just "nothing".
135        audio: Duration,
136    },
137}
138
139/// Rewrite recognition output, recording what it cost.
140///
141/// A normalizer that fails mid-session must not cost the speaker their
142/// words: the raw text is returned and `stages.normalize` stays `None`,
143/// which is the same shape as no normalizer being configured. A caller
144/// knows which, because it knows what it configured.
145fn normalize_text(
146    normalizer: Option<&crate::normalize::Normalizer>,
147    asr: &str,
148    options: crate::normalize::NormalizeOptions,
149    stages: &mut Stages,
150) -> String {
151    let Some(normalizer) = normalizer else { return asr.to_owned() };
152    let started = Instant::now();
153    match normalizer.normalize_with(asr, options) {
154        Ok(clean) => {
155            stages.normalize = Some(started.elapsed());
156            clean
157        }
158        Err(error) => {
159            tracing::warn!(
160                event_name = "normalization_failed",
161                %error,
162                "normalization failed; returning raw text"
163            );
164            asr.to_owned()
165        }
166    }
167}
168
169/// Samples to wall-clock at the one rate the models accept. Integer
170/// maths so a long buffer cannot drift through a float cast.
171fn audio_duration(samples: usize) -> Duration {
172    Duration::from_micros((samples as u64).saturating_mul(1_000_000) / u64::from(SAMPLE_RATE))
173}
174
175/// Loudest sample in `pcm`, in dBFS.
176fn peak_dbfs(pcm: &[f32]) -> f32 {
177    let peak = pcm.iter().fold(0.0f32, |worst, s| worst.max(s.abs()));
178    if peak <= 0.0 { f32::NEG_INFINITY } else { 20.0 * peak.log10() }
179}
180
181/// Long takes are transcribed in windows rather than as one pass: the
182/// recognition runtime decodes a whole buffer against one encoder
183/// output, and past about two minutes the decoder derails into
184/// repetition loops and skip-ahead re-syncs that drop mid-take audio
185/// with nothing flagged. The ceiling is measured, not assumed: a
186/// four-minute ten-paragraph take loses whole paragraphs at a 90 s
187/// ceiling and lands complete at 60 s, so 60 is the shipping value.
188const WINDOW_TARGET: usize = 60 * SAMPLE_RATE as usize;
189/// Shortest a sought window may be, so pause-seeking cannot shred a
190/// take into fragments too short to transcribe well. The take's final
191/// window is exempt: it is whatever audio is left.
192const WINDOW_MIN: usize = 30 * SAMPLE_RATE as usize;
193/// 20 ms energy frames for pause-seeking.
194const ENERGY_FRAME: usize = SAMPLE_RATE as usize / 50;
195
196/// Sample ranges tiling `pcm`, each at most [`WINDOW_TARGET`] long,
197/// cutting at the quietest [`ENERGY_FRAME`] block between
198/// [`WINDOW_MIN`] and [`WINDOW_TARGET`] past the previous cut. A take
199/// that fits one window comes back whole, which is the exact buffer
200/// the recognition runtime saw before windowing existed.
201///
202/// Audio with no dip anywhere still splits, at the ceiling: the
203/// windows tile exactly either way, so a word straddling such a cut
204/// may transcribe loosely across the join but no audio is dropped.
205fn window_bounds(pcm: &[f32]) -> Vec<(usize, usize)> {
206    let mut bounds = Vec::new();
207    let mut start = 0;
208    while let Some(cut) = next_cut(pcm, start) {
209        bounds.push((start, cut));
210        start = cut;
211    }
212    bounds.push((start, pcm.len()));
213    bounds
214}
215
216/// Where the next window past `start` ends, once more than
217/// [`WINDOW_TARGET`] has accrued past it: the quietest [`ENERGY_FRAME`]
218/// block between [`WINDOW_MIN`] and [`WINDOW_TARGET`] past `start`.
219/// `None` while the region is still short.
220///
221/// This one function is the whole windowing rule, and both callers
222/// share it: whole-buffer windowing loops it over the finished take,
223/// and the live segmenter calls it as the recording grows. The cut at
224/// `start` depends only on samples up to `start + WINDOW_TARGET`, so
225/// deciding it as soon as they exist lands exactly where deciding it
226/// after the take would.
227fn next_cut(pcm: &[f32], start: usize) -> Option<usize> {
228    if pcm.len() - start <= WINDOW_TARGET {
229        return None;
230    }
231    let lo = start + WINDOW_MIN;
232    let hi = start + WINDOW_TARGET;
233    Some(lo + quietest_frame(&pcm[lo..hi]))
234}
235
236/// Sample offset of the quietest [`ENERGY_FRAME`] block by summed
237/// square. Ties go to the latest block, so a window runs as long as the
238/// reliability ceiling allows and a flat pause cuts at its end. Zero
239/// for a region shorter than one block.
240fn quietest_frame(pcm: &[f32]) -> usize {
241    let mut quietest = usize::MAX;
242    let mut quietest_energy = f32::INFINITY;
243    for (frame, block) in pcm.as_chunks::<ENERGY_FRAME>().0.iter().enumerate() {
244        let energy: f32 = block.iter().map(|s| s * s).sum();
245        if energy <= quietest_energy {
246            quietest_energy = energy;
247            quietest = frame;
248        }
249    }
250    if quietest == usize::MAX { 0 } else { quietest * ENERGY_FRAME }
251}
252
253/// The store entry a job's answer earns: `None` keeps nothing.
254///
255/// An abandoned take (the user's own cancel) and a take the silence
256/// gate resolved before the weights persist nothing - there is no
257/// transcript stage to examine and nothing was recognised. Everything
258/// else keeps its audio: an `empty` or a `recognition_error` are
259/// exactly the takes worth opening files over.
260fn diagnostic_outcome(answer: &Result<Outcome, Error>) -> Option<(&'static str, String)> {
261    match answer {
262        Err(Error::Cancelled) | Ok(Outcome::NoAudio { .. }) => None,
263        Err(_) => Some(("recognition_error", String::new())),
264        Ok(Outcome::Transcript(transcript)) => {
265            let label = if transcript.text.is_empty() { "empty" } else { "transcript" };
266            Some((label, transcript.text.clone()))
267        }
268    }
269}
270
271/// The store entry a finished answer earns, read with the cancel token at
272/// the write decision.
273///
274/// The early checks catch a cancel that landed before the answer was built,
275/// and [`finish_after_join`] catches one that landed during the join's
276/// normalize pass. Neither can cover the write itself: both writers read the
277/// token here first, and [`diagnostics::capture_take`] removes the directory
278/// it claimed when the token is set by the time the write finishes. Together
279/// that is what makes an abandoned take persist nothing.
280fn kept_diagnostics(
281    cancel: &CancelToken,
282    answer: &Result<Outcome, Error>,
283) -> Option<(&'static str, String)> {
284    if cancel.is_cancelled() { None } else { diagnostic_outcome(answer) }
285}
286
287/// Window texts back into one transcript. Trims and drops empties, so
288/// a silent stretch inside a take vanishes at the join instead of
289/// leaving a doubled space.
290fn join_window_texts(parts: &[String]) -> String {
291    parts.iter().map(|p| p.trim()).filter(|p| !p.is_empty()).collect::<Vec<_>>().join(" ")
292}
293
294/// The joined raw recognition, and the rewritten text over it, in that
295/// order. The order is load-bearing: the normalizer reads the whole
296/// take at once, so a sentence crossing a window boundary is repaired
297/// with full context - normalizing per window would rewrite each
298/// fragment without the context around the cut.
299fn finalize_transcript(
300    parts: &[String],
301    normalize: impl FnOnce(&str) -> String,
302) -> (String, String) {
303    let asr = join_window_texts(parts);
304    let text = normalize(&asr);
305    (asr, text)
306}
307
308/// One queued transcription.
309struct Job {
310    pcm: Vec<f32>,
311    resample: Duration,
312    audio: Duration,
313    truncated: bool,
314    /// `Some` on a job that rewrites its own text: the direct path,
315    /// and the pipelined finalize. Pipelined segment jobs carry `None`
316    /// so their raw text joins later under one normalize pass.
317    normalize: Option<NormalizeOptions>,
318    /// Set on a pipelined finalize job: rewrite this joined text
319    /// instead of recognizing `pcm`, which is empty.
320    prejoined: Option<String>,
321    /// `false` on pipelined jobs: one take writes ONE diagnostics
322    /// record, from its segmenter at finish, not one per segment.
323    diagnose: bool,
324    cancel: CancelToken,
325    reply: Sender<Result<Outcome, Error>>,
326    queued_at: Instant,
327}
328
329/// How the weights ended up, once the worker has resolved them.
330///
331/// A failure is held as the parts of [`Error::ModelLoad`] rather than
332/// the error itself, because every waiter needs its own copy and
333/// `Error` is not `Clone`.
334#[derive(Default)]
335struct Readiness {
336    outcome: Mutex<Option<Result<(), (PathBuf, String)>>>,
337    settled: Condvar,
338}
339
340impl Readiness {
341    fn settle(&self, outcome: Result<(), (PathBuf, String)>) {
342        *self.outcome.lock().unwrap_or_else(PoisonError::into_inner) = Some(outcome);
343        self.settled.notify_all();
344    }
345
346    fn wait(&self) -> Result<(), Error> {
347        let mut outcome = self.outcome.lock().unwrap_or_else(PoisonError::into_inner);
348        loop {
349            match outcome.as_ref() {
350                Some(Ok(())) => return Ok(()),
351                Some(Err((path, message))) => {
352                    return Err(Error::ModelLoad { path: path.clone(), message: message.clone() });
353                }
354                None => {
355                    outcome = self.settled.wait(outcome).unwrap_or_else(PoisonError::into_inner);
356                }
357            }
358        }
359    }
360}
361
362/// Loads the models and runs transcriptions, one at a time.
363///
364/// The recognition session needs `&mut self` to run, so one set of
365/// weights means one caller at a time whatever the API looks like.
366pub struct Engine {
367    max_capture: Duration,
368    normalize_options: NormalizeOptions,
369    silence_floor: f32,
370    /// Where take diagnostics land, if the caller pointed them
371    /// anywhere. Held here because the worker consumes the config, and
372    /// the per-take segmenter writes the record at finish.
373    diagnostics_dir: Option<PathBuf>,
374    /// The models this engine runs, resolved from the config before the
375    /// worker consumes it: what every take's diagnostics record names
376    /// itself by.
377    models: Arc<diagnostics::TakeModels>,
378    /// Set before teardown. The worker checks it between jobs, so a
379    /// backlog is DISCARDED rather than drained: shutdown should not wait
380    /// out work whose callers are going away with it.
381    stopping: Arc<AtomicBool>,
382    /// The running job's cancel token, so teardown can abort an inference
383    /// already in progress instead of waiting for it.
384    in_flight: Arc<Mutex<Option<CancelToken>>>,
385    /// `None` only while dropping.
386    jobs: Option<Sender<Job>>,
387    worker: Option<std::thread::JoinHandle<()>>,
388    /// Answers [`Engine::wait_ready`] once the worker has the weights.
389    readiness: Arc<Readiness>,
390}
391
392impl Drop for Engine {
393    fn drop(&mut self) {
394        // Order matters. Dropping the sender alone does NOT end the loop:
395        // `recv` keeps yielding buffered jobs and only errors once the
396        // channel is empty, so a backlog would run to completion first.
397        // Flag the stop, abort whatever is mid-inference, and only then
398        // close the queue.
399        self.stopping.store(true, Ordering::Relaxed);
400        if let Some(token) =
401            self.in_flight.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref()
402        {
403            token.cancel();
404        }
405        self.jobs.take();
406
407        // The join is what releases the weights before the process tears
408        // down the native backend; without it ggml's Metal teardown trips
409        // `GGML_ASSERT([rsets->data count] == 0)`. It does NOT cover a
410        // load in progress: `Model::load` is a blocking FFI call with no
411        // cancellation, so dropping an engine that has just started waits
412        // the whole load out.
413        if let Some(worker) = self.worker.take() {
414            let _ = worker.join();
415        }
416    }
417}
418
419impl Engine {
420    /// Load the configured models and start the worker.
421    ///
422    /// Blocking, like everything else here. Weights load on the worker
423    /// thread, so this returns without waiting for them; a model that
424    /// cannot be loaded surfaces from the first [`Ticket::recv`] rather
425    /// than here, because that is where it is first used.
426    pub fn new(cfg: Config) -> Result<Arc<Engine>, Error> {
427        let dir = crate::fetch::models_dir(&cfg)?;
428        let asr_path = dir.join(&cfg.asr_model.file);
429        let max_capture = cfg.max_capture;
430        let normalize_options = cfg.normalize_options;
431        let silence_floor = cfg.silence_floor;
432        let diagnostics_dir = cfg.diagnostics_dir.clone();
433        let models = Arc::new(diagnostics::TakeModels::from_config(&cfg));
434        let stopping = Arc::new(AtomicBool::new(false));
435        let in_flight: Arc<Mutex<Option<CancelToken>>> = Arc::new(Mutex::new(None));
436        let readiness = Arc::new(Readiness::default());
437        let (jobs, queue) = channel();
438
439        let handle = std::thread::Builder::new()
440            .name("forge-dictate".into())
441            .spawn({
442                let stopping = Arc::clone(&stopping);
443                let in_flight = Arc::clone(&in_flight);
444                let readiness = Arc::clone(&readiness);
445                let models = Arc::clone(&models);
446                move || worker(&asr_path, &cfg, &models, &queue, &stopping, &in_flight, &readiness)
447            })
448            .map_err(|source| Error::WorkerSpawn { message: source.to_string() })?;
449
450        Ok(Arc::new(Engine {
451            max_capture,
452            normalize_options,
453            silence_floor,
454            diagnostics_dir,
455            models,
456            stopping,
457            in_flight,
458            jobs: Some(jobs),
459            worker: Some(handle),
460            readiness,
461        }))
462    }
463
464    /// Wait for the weights to be in memory.
465    ///
466    /// [`Engine::new`] hands the load to the worker and returns without
467    /// it, so an engine over a model that cannot be read looks healthy
468    /// until somebody speaks. This is how a caller learns which one it
469    /// has before asking anyone to.
470    ///
471    /// Blocking, like everything else here. Returns once every
472    /// configured model is loaded, or with the [`Error::ModelLoad`] a
473    /// transcription would have failed with, naming the file. Idempotent
474    /// and safe from several threads: the answer is kept and handed to
475    /// each caller.
476    pub fn wait_ready(&self) -> Result<(), Error> {
477        self.readiness.wait()
478    }
479
480    /// The configured silence floor, in dBFS. A host drawing the
481    /// capture level bar needs the same floor the silence decision
482    /// uses, so the meter and the [`Outcome::NoAudio`] verdict agree
483    /// by construction rather than by a host guessing the default.
484    pub fn silence_floor(&self) -> f32 {
485        self.silence_floor
486    }
487
488    /// Queue `source` for transcription.
489    ///
490    /// Rejects a source the models cannot read before queueing anything.
491    /// A wrong sample rate or channel count is a caller mistake, knowable
492    /// the moment the source is handed over, so it is refused here rather
493    /// than arriving later mixed in with real runtime failures.
494    pub fn transcribe(&self, source: impl AudioSource) -> Result<Ticket, Error> {
495        self.transcribe_with(source, self.normalize_options)
496    }
497
498    /// Queue `source`, overriding the configured normalizer options for
499    /// this transcription only. Styling is a per-recording choice, so it
500    /// belongs on the call rather than on the engine.
501    pub fn transcribe_with(
502        &self,
503        mut source: impl AudioSource,
504        options: NormalizeOptions,
505    ) -> Result<Ticket, Error> {
506        if source.sample_rate() != SAMPLE_RATE {
507            return Err(Error::SampleRate { expected: SAMPLE_RATE, actual: source.sample_rate() });
508        }
509        if source.channels() != 1 {
510            return Err(Error::Channels { actual: source.channels() });
511        }
512
513        // Drained into one buffer because the recognition call takes the
514        // whole signal contiguously. The chunking exists for the capture
515        // path and for sources with no hardware behind them.
516        let started = Instant::now();
517        let mut pcm = Vec::new();
518        while let Some(chunk) = source.next_chunk() {
519            pcm.extend_from_slice(&chunk);
520        }
521        let resample = started.elapsed();
522
523        self.submit(pcm, resample, false, options)
524    }
525
526    /// Start a take fed by pushed frames.
527    ///
528    /// The one refusal is an engine that is winding down, whose drain
529    /// would discard every job the take queued.
530    pub fn capture_stream(self: &Arc<Self>) -> Result<StreamCapture, Error> {
531        if self.stopping.load(Ordering::Relaxed) {
532            return Err(Error::EngineStopped);
533        }
534        let jobs = self.jobs.as_ref().ok_or(Error::EngineStopped)?.clone();
535        let parts = self.begin_take(jobs);
536        let Some(segmenter) = parts.segmenter else {
537            return Err(Error::Capture { message: "the take segmenter did not start".into() });
538        };
539        Ok(StreamCapture {
540            sink: FrameSink {
541                recording: Arc::clone(&parts.recording),
542                limit: crate::capture::sample_cap(self.max_capture),
543            },
544            capture: Capture {
545                engine: Arc::clone(self),
546                recording: parts.recording,
547                max_capture: self.max_capture,
548                segmenter: Some(segmenter),
549                finish_tx: Some(parts.finish_tx),
550                answer_rx: Some(parts.answer_rx),
551                progress_rx: Some(parts.progress_rx),
552                cancel: parts.cancel,
553            },
554        })
555    }
556
557    /// The take side: the recording `sample_cap`
558    /// samples long, the segmenter cutting windows off it as it grows,
559    /// and the channels a [`Capture`] hands on at `finish`.
560    fn begin_take(self: &Arc<Self>, jobs: Sender<Job>) -> TakeParts {
561        let recording =
562            Arc::new(crate::capture::Recording::new(crate::capture::sample_cap(self.max_capture)));
563        let (finish_tx, finish_rx) = channel::<FinishTake>();
564        let (answer_tx, answer_rx) = channel();
565        let (progress_tx, progress_rx) = channel();
566        let cancel = CancelToken::new();
567        let take = TakeShared { cancel: cancel.clone(), answer: answer_tx, progress: progress_tx };
568        let segmenter = std::thread::Builder::new()
569            .name("forge-dictate-take".into())
570            .spawn({
571                let state = TakeSegmenter {
572                    recording: Arc::clone(&recording),
573                    jobs,
574                    silence_floor: self.silence_floor,
575                    diagnostics_dir: self.diagnostics_dir.clone(),
576                    models: Arc::clone(&self.models),
577                    take,
578                    last_cut: 0,
579                    cuts: Vec::new(),
580                    parts: Vec::new(),
581                    pending: Vec::new(),
582                    settled: 0,
583                    first_queued_at: None,
584                    first_settled_at: None,
585                    failed: None,
586                };
587                move || state.run(&finish_rx)
588            })
589            .ok();
590        TakeParts { recording, segmenter, finish_tx, answer_rx, progress_rx, cancel }
591    }
592
593    /// Queue already-captured audio.
594    fn submit(
595        &self,
596        pcm: Vec<f32>,
597        resample: Duration,
598        truncated: bool,
599        options: NormalizeOptions,
600    ) -> Result<Ticket, Error> {
601        let queued_at = Instant::now();
602        let (reply, answer) = channel();
603        let cancel = CancelToken::new();
604
605        // Silence is a property of the samples, so it is decided here
606        // rather than on the worker: a quiet capture needs no weights,
607        // should not load any, and should not queue behind a backlog.
608        let peak = peak_dbfs(&pcm);
609        let audio = audio_duration(pcm.len());
610        if peak < self.silence_floor {
611            let _ = reply.send(Ok(Outcome::NoAudio { peak, audio }));
612            return Ok(Ticket { answer, cancel, delivered: false, segmenter: None });
613        }
614
615        self.jobs
616            .as_ref()
617            .ok_or(Error::EngineStopped)?
618            .send(Job {
619                pcm,
620                resample,
621                audio,
622                truncated,
623                normalize: Some(options),
624                prejoined: None,
625                diagnose: true,
626                cancel: cancel.clone(),
627                reply,
628                queued_at,
629            })
630            .map_err(|_| Error::EngineStopped)?;
631        Ok(Ticket { answer, cancel, delivered: false, segmenter: None })
632    }
633}
634
635/// A take fed by frames from somewhere other than a device: what
636/// [`Engine::capture_stream`] hands back.
637///
638/// The sink is what a transport holds and pushes into; the capture is
639/// what the host finishing the take holds. Both share one recording, so
640/// a take fed this way meters, caps, segments, cancels and finishes on
641/// one path.
642pub struct StreamCapture {
643    pub sink: FrameSink,
644    pub capture: Capture,
645}
646
647/// The write half of a stream-fed take.
648///
649/// `Send` and `Sync`, so a socket task or any other reader of frames can
650/// hold it and push from wherever it runs.
651pub struct FrameSink {
652    recording: Arc<crate::capture::Recording>,
653    limit: usize,
654}
655
656impl FrameSink {
657    /// Push mono samples at [`SAMPLE_RATE`].
658    ///
659    /// The loudest sample is folded into the meter, so a level bar reads
660    /// pushed audio. Samples past [`Config::max_capture`] are dropped and
661    /// the take flags itself truncated. Answers `false` once the take is
662    /// over, so a caller can stop feeding.
663    pub fn push_mono(&self, samples: &[f32]) -> bool {
664        if self.recording.stopped() {
665            return false;
666        }
667        self.recording.push(samples, self.limit);
668        !self.recording.stopped()
669    }
670}
671
672impl std::fmt::Debug for FrameSink {
673    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
674        f.debug_struct("FrameSink").field("limit", &self.limit).finish_non_exhaustive()
675    }
676}
677
678/// The polling view of a live capture: the level read and the cap
679/// flag, over a shared handle to the recording. `Sync` and `Send`, so
680/// an async host can hold it across await points.
681pub struct CaptureMeter {
682    recording: Arc<crate::capture::Recording>,
683}
684
685impl CaptureMeter {
686    /// Loudest input since the last read, in dBFS. Take-and-reset, as
687    /// [`Capture::level`] - the handle shares the recording's
688    /// accumulator, so exactly one poller should read it.
689    pub fn level(&self) -> f32 {
690        self.recording.peak_dbfs()
691    }
692
693    /// Whether the capture reached [`Config::max_capture`] and stopped
694    /// itself.
695    pub fn was_truncated(&self) -> bool {
696        self.recording.was_truncated()
697    }
698
699    /// Stop the take at its cap, flagging it truncated. For a take fed by
700    /// frames, whose host counts the clock: nothing else notices the cap
701    /// when the audio stops arriving.
702    pub fn cap_reached(&self) {
703        self.recording.cap_reached();
704    }
705}
706
707impl std::fmt::Debug for CaptureMeter {
708    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
709        f.debug_struct("CaptureMeter").finish_non_exhaustive()
710    }
711}
712
713/// One take in flight: the recording it grows, its segmenter, and the
714/// channels `finish` hands on.
715///
716/// Release is tied to the value rather than to a method, so a caller
717/// that panics mid-take cannot leave the take's threads running.
718pub struct Capture {
719    engine: Arc<Engine>,
720    recording: Arc<crate::capture::Recording>,
721    max_capture: Duration,
722    /// The take's segmenter. Taken by `finish`, which hands it to the
723    /// ticket; joined by `Drop` on the abandon path.
724    segmenter: Option<std::thread::JoinHandle<()>>,
725    /// Dropping it aborts the segmenter: the take is over and its
726    /// queued work should not run on.
727    finish_tx: Option<Sender<FinishTake>>,
728    /// The take's aggregate answer, once every segment and the tail
729    /// have run and the join has been normalized. Taken by `finish`.
730    answer_rx: Option<Receiver<Result<Outcome, Error>>>,
731    /// Per-segment progress while the recording is still open. Taken
732    /// by the host at recording start; the stream outlives the handoff
733    /// to the ticket because the segmenter owns its sender throughout.
734    progress_rx: Option<Receiver<WindowProgress>>,
735    /// This take's cancel token, shared by every job the segmenter
736    /// queues. The ticket carries a clone, so abandoning the take
737    /// after finish aborts its outstanding work.
738    cancel: CancelToken,
739}
740
741impl Capture {
742    /// Loudest input since the last read, in dBFS. The read is
743    /// take-and-reset - it answers "peak over the window you just
744    /// polled" and clears, which is what a level meter drawing one bar
745    /// per window wants; a read that held the all-time peak would
746    /// freeze such a meter on the first syllable.
747    ///
748    /// Still a lock-free atomic, so it is safe to call from a render
749    /// loop. The mutating read means two pollers steal windows from
750    /// each other: one reader is the assumed caller.
751    pub fn level(&self) -> f32 {
752        self.recording.peak_dbfs()
753    }
754
755    /// The shareable polling view of this capture: level and cap
756    /// truncation, the two reads a recording loop wants. `Capture`
757    /// itself is `Send` but not `Sync` - it holds channel receivers -
758    /// so a host that polls a live capture from an async task borrows
759    /// this handle across awaits instead of the capture.
760    pub fn meter(&self) -> CaptureMeter {
761        CaptureMeter { recording: Arc::clone(&self.recording) }
762    }
763
764    /// Take this take's progress stream, if it has not been taken.
765    /// Steps arrive as segments settle while the recording is still
766    /// open, and again once finish has submitted the tail and the
767    /// total is known.
768    pub fn take_progress(&mut self) -> Option<Receiver<WindowProgress>> {
769        self.progress_rx.take()
770    }
771
772    /// Stop the take and queue what was captured.
773    pub fn finish(self) -> Result<Ticket, Error> {
774        let options = self.engine.normalize_options;
775        self.finish_with(options)
776    }
777
778    /// Stop recording and queue what was captured, overriding the
779    /// configured normalizer options for this recording only.
780    ///
781    /// With a take already pipelining, this submits the tail and hands
782    /// back a ticket over the AGGREGATE: the segmenter waits for every
783    /// segment already queued, joins the raw texts, and normalizes the
784    /// join once on the worker. The returned ticket resolves when the
785    /// whole transcript does.
786    pub fn finish_with(mut self, options: NormalizeOptions) -> Result<Ticket, Error> {
787        self.stop_recording();
788        let truncated = self.recording.was_truncated();
789        if truncated {
790            tracing::debug!(
791                event_name = "capture_cap_reached",
792                cap = ?self.max_capture,
793                "capture reached its cap and stopped itself"
794            );
795        }
796        let segmenter = self.segmenter.take();
797        match (self.finish_tx.take(), self.answer_rx.take(), segmenter) {
798            (Some(finish_tx), Some(answer_rx), Some(segmenter)) => {
799                finish_tx
800                    .send(FinishTake { options, truncated })
801                    .map_err(|_| Error::EngineStopped)?;
802                Ok(Ticket {
803                    answer: answer_rx,
804                    cancel: self.cancel.clone(),
805                    delivered: false,
806                    segmenter: Some(segmenter),
807                })
808            }
809            _ => Err(Error::EngineStopped),
810        }
811    }
812
813    /// Stop recording and throw the audio away.
814    pub fn cancel(self) {
815        drop(self);
816    }
817
818    /// The cap this take is bounded by. A host feeding the take itself has
819    /// to enforce it: a take fed by frames has no loop at all.
820    pub fn max_capture(&self) -> Duration {
821        self.max_capture
822    }
823
824    /// Whether the take reached [`Config::max_capture`] and stopped
825    /// itself. A host polling the level reads this so it can submit the
826    /// take instead of holding a seat whose take is no longer running.
827    pub fn was_truncated(&self) -> bool {
828        self.recording.was_truncated()
829    }
830
831    /// Stop the take: the recording's stop flag is what tells a pusher
832    /// the take is over. The captured samples stay where they are: the
833    /// segmenter holds the recording and reads the tail out of it at
834    /// finish.
835    fn stop_recording(&mut self) {
836        self.recording.stop();
837    }
838}
839
840impl Drop for Capture {
841    fn drop(&mut self) {
842        // Stop first, then the segmenter: the closed finish channel is
843        // the abandon signal, and the join makes sure no take thread
844        // outlives the value that owns its result.
845        self.stop_recording();
846        self.finish_tx.take();
847        if let Some(segmenter) = self.segmenter.take() {
848            let _ = segmenter.join();
849        }
850    }
851}
852
853/// A transcription in flight. On the pipelined capture path this is
854/// the take's AGGREGATE: it resolves once every segment and the tail
855/// have run and the join has been normalized once.
856pub struct Ticket {
857    answer: Receiver<Result<Outcome, Error>>,
858    cancel: CancelToken,
859    /// Set once the answer is in hand. A delivered ticket's own drop is not
860    /// an abandonment: on the direct path the diagnostics record is written
861    /// after the answer goes out, and the drop's cancel would delete the
862    /// take the worker is still writing. A host's clone from
863    /// [`Ticket::cancel_token`] keeps sharing the flag, so abandoning the
864    /// take after the answer still cancels it.
865    delivered: bool,
866    /// The take's segmenter, on the capture path. Joined so no take
867    /// thread outlives the value that owns its result; it holds no
868    /// native state, so the join is tidiness and not the teardown
869    /// guarantee - that is the worker join in `Engine::drop`.
870    segmenter: Option<std::thread::JoinHandle<()>>,
871}
872
873impl std::fmt::Debug for Ticket {
874    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
875        f.debug_struct("Ticket")
876            .field("cancelled", &self.cancel.is_cancelled())
877            .finish_non_exhaustive()
878    }
879}
880
881impl Ticket {
882    /// Wait for the result.
883    ///
884    /// BLOCKING, and deliberately without an async twin: an async host
885    /// wraps this in its own `spawn_blocking`, where the reverse would
886    /// put a runtime in every consumer to serve one.
887    pub fn recv(mut self) -> Result<Outcome, Error> {
888        let answer = self.answer.recv().map_err(|_| Error::EngineStopped)?;
889        // Disarmed before the drop runs: the answer is this ticket's job,
890        // and what the worker does after it - the direct path's record
891        // write - must not read as the caller walking away.
892        self.delivered = true;
893        if let Some(segmenter) = self.segmenter.take() {
894            let _ = segmenter.join();
895        }
896        answer
897    }
898
899    /// A clone of this ticket's cancel token. Cancelling it aborts THIS
900    /// transcription - a job still queued behind another takes the
901    /// token with it and is aborted when its turn comes - which is why
902    /// a host abandoning one take among several goes through here
903    /// rather than through anything engine-wide.
904    pub fn cancel_token(&self) -> CancelToken {
905        self.cancel.clone()
906    }
907}
908
909impl Drop for Ticket {
910    fn drop(&mut self) {
911        if !self.delivered {
912            self.cancel.cancel();
913        }
914        if let Some(segmenter) = self.segmenter.take() {
915            let _ = segmenter.join();
916        }
917    }
918}
919
920/// Every configured model, in memory.
921struct Loaded {
922    model: Model,
923    session: Session,
924    normalizer: Option<crate::normalize::Normalizer>,
925    /// What the recognition weights cost, for the first transcript's
926    /// [`Stages`].
927    model_load: Duration,
928}
929
930/// Load the recognition model, and the normalizer when one is
931/// configured.
932///
933/// Failure is the parts of [`Error::ModelLoad`] rather than the error,
934/// because the queue drain and every [`Engine::wait_ready`] caller each
935/// need their own copy.
936fn load_models(asr_path: &Path, cfg: &Config) -> Result<Loaded, (PathBuf, String)> {
937    let started = Instant::now();
938    let loaded = Model::load(asr_path).and_then(|model| model.session().map(|s| (model, s)));
939    let model_load = started.elapsed();
940
941    let loaded = loaded.and_then(|(model, session)| {
942        // The rate is baked into the weights, so a model that wants a
943        // different one does not fail - it silently transcribes worse.
944        // Reading what the GGUF declares turns that into a refusal.
945        let declared = model.capabilities()?.native_sample_rate;
946        if declared == i32::try_from(SAMPLE_RATE).unwrap_or(i32::MAX) {
947            Ok((model, session))
948        } else {
949            Err(transcribe_cpp::Error::ModelLoad(format!(
950                "model expects {declared} Hz audio; this crate captures and feeds {SAMPLE_RATE} Hz"
951            )))
952        }
953    });
954    let (model, session) = loaded.map_err(|source| (asr_path.to_path_buf(), source.to_string()))?;
955
956    // Loaded here, on the worker, so a second set of weights never lands
957    // on the caller's thread.
958    let normalizer = match cfg.normalizer.as_ref() {
959        None => None,
960        Some(spec) => {
961            let path = asr_path.with_file_name(&spec.file);
962            Some(
963                crate::normalize::Normalizer::load(&path)
964                    .map_err(|source| (path, source.to_string()))?,
965            )
966        }
967    };
968
969    Ok(Loaded { model, session, normalizer, model_load })
970}
971
972/// What one window's run outcome tells the take to do next.
973///
974/// Extracted from the window loop so the decisions can be tested without a
975/// loaded model: the loop owns the timings, the records and the reply, while
976/// this owns what each outcome means.
977#[derive(Debug, PartialEq)]
978enum WindowAction {
979    /// Append this text and keep going.
980    Continue(String),
981    /// Append this partial text and keep going; the take is truncated.
982    Truncated(String),
983    /// Stop the take: the user cancelled.
984    Cancelled,
985    /// Stop the take: this window failed, but earlier windows produced words.
986    /// The take finalizes what it has, marks itself truncated, and carries
987    /// the error into diagnostics.
988    FinalizeWithError { message: String },
989    /// Stop the take: this window failed and nothing was transcribed, so the
990    /// take fails.
991    Failed { message: String },
992}
993
994fn window_action(
995    result: Result<transcribe_cpp::Transcript, transcribe_cpp::Error>,
996    has_prior_text: bool,
997) -> WindowAction {
998    match result {
999        Ok(out) => WindowAction::Continue(out.text),
1000        Err(transcribe_cpp::Error::Aborted { .. }) => WindowAction::Cancelled,
1001        Err(
1002            transcribe_cpp::Error::OutputTruncated { partial: Some(partial), .. }
1003            | transcribe_cpp::Error::OutputRepetition { partial: Some(partial), .. },
1004        ) => WindowAction::Truncated(partial.text),
1005        Err(source) => {
1006            let message = source.to_string();
1007            if has_prior_text {
1008                WindowAction::FinalizeWithError { message }
1009            } else {
1010                WindowAction::Failed { message }
1011            }
1012        }
1013    }
1014}
1015
1016/// The failure a job answers with, decided before its answer is built.
1017///
1018/// `session.run` is the only place a cancel token is observed, so a take
1019/// cancelled after its last window has no run left to abort: without this
1020/// check the worker builds a transcript for a take its caller abandoned, and
1021/// a host persists words the user chose not to keep. A cancel outranks
1022/// whatever failure the fold already carries, the same rule the take fold
1023/// applies. Pulled out of the worker so the decision is tested without a
1024/// loaded model, the way [`window_action`] is.
1025fn failure_before_answer(cancel: &CancelToken, failure: Option<Error>) -> Option<Error> {
1026    if cancel.is_cancelled() { Some(Error::Cancelled) } else { failure }
1027}
1028
1029/// What one job's answer and diagnostics record come to.
1030struct JobOutcome {
1031    answer: Result<Outcome, Error>,
1032    /// The stages the record is built from, with the normalize pass's own
1033    /// timing already folded in.
1034    stages: Stages,
1035    /// The record this answer earns, read with the job's own token.
1036    diag: Option<(&'static str, String)>,
1037}
1038
1039/// Finish one job: read its token, build the answer, decide its record.
1040///
1041/// Taking `&Job` rather than a bare token is what keeps the wiring honest:
1042/// every read in here is the job's own, so the worker cannot hand the
1043/// answer one token and the record another. Drivable by a test over a
1044/// prejoined job without a loaded model.
1045fn finish_job(
1046    job: &Job,
1047    asr_parts: &[String],
1048    failure: Option<Error>,
1049    truncated: bool,
1050    mut stages: Stages,
1051    normalizer: Option<&crate::normalize::Normalizer>,
1052) -> JobOutcome {
1053    // `session.run` is the only place the recognizer observes a cancel, so
1054    // a take cancelled after its last window has no run left to abort:
1055    // read the token before anything is built.
1056    let failure = failure_before_answer(&job.cancel, failure);
1057    if let Some(error) = failure {
1058        let answer = Err(error);
1059        let diag = kept_diagnostics(&job.cancel, &answer);
1060        return JobOutcome { answer, stages, diag };
1061    }
1062    let (asr, text) = finalize_transcript(asr_parts, |raw| match job.normalize {
1063        // A normalizer that fails mid-session must not cost the speaker
1064        // their words: fall back to the recognised text and say so, where a
1065        // load failure above is fatal. A raw-only pipelined job skips the
1066        // rewrite; its text joins the take's later, under one normalize
1067        // pass.
1068        Some(options) => normalize_text(normalizer, raw, options, &mut stages),
1069        None => raw.to_owned(),
1070    });
1071    finish_after_join(job, asr, text, stages, truncated)
1072}
1073
1074/// The answer a finished join amounts to.
1075///
1076/// Separated from [`finish_job`] because the join's normalize pass is the
1077/// one stretch no early read covers: the read below is what turns a cancel
1078/// that landed inside it into `Cancelled` rather than the words the pass
1079/// rewrote. The routing from [`finish_job`] into here is pinned by
1080/// inspection, not by a test: reaching this read past a live first read
1081/// needs a cancel inside the normalizer call, and
1082/// [`crate::normalize::Normalizer`] is a loaded GGUF behind a `LlamaModel`
1083/// with no weightless double.
1084fn finish_after_join(
1085    job: &Job,
1086    asr: String,
1087    text: String,
1088    stages: Stages,
1089    truncated: bool,
1090) -> JobOutcome {
1091    let answer = if let Some(error) = failure_before_answer(&job.cancel, None) {
1092        Err(error)
1093    } else {
1094        let transcript = Transcript { text, asr, stages: stages.clone(), truncated };
1095        Ok(Outcome::Transcript(transcript))
1096    };
1097    let diag = kept_diagnostics(&job.cancel, &answer);
1098    JobOutcome { answer, stages, diag }
1099}
1100
1101/// Keep one direct job's diagnostics record.
1102///
1103/// The token is the job's own, read here rather than passed in, so the
1104/// write cannot be wired to a different take's cancel. The caller has
1105/// already decided the job earns a record: it is a direct job, the engine
1106/// is not stopping, and its answer classified as one worth keeping.
1107fn keep_job_record(job: &Job, dir: &Path, record: &diagnostics::TakeRecord<'_>) {
1108    diagnostics::capture_take(dir, diagnostics::take_stamp(), record, &job.cancel);
1109}
1110
1111/// The take's accumulated state across its windows.
1112///
1113/// Extracted from the loop so the side effects of each outcome can be
1114/// tested: a `FinalizeWithError` keeps the take alive, a `Failed` fails it.
1115#[derive(Default)]
1116struct TakeState {
1117    asr_parts: Vec<String>,
1118    window_records: Vec<diagnostics::WindowRecord>,
1119    truncated: bool,
1120    failure: Option<Error>,
1121    recognition_error: Option<(usize, String)>,
1122}
1123
1124impl TakeState {
1125    fn new(truncated: bool) -> Self {
1126        Self { truncated, ..Self::default() }
1127    }
1128
1129    /// Whether any earlier window produced words.
1130    fn has_prior_text(&self) -> bool {
1131        self.asr_parts.iter().any(|part| !part.trim().is_empty())
1132    }
1133
1134    /// Apply one window's outcome. Answers `false` when the loop should stop.
1135    fn apply(&mut self, action: WindowAction, index: usize, start_ms: u64, end_ms: u64) -> bool {
1136        match action {
1137            WindowAction::Continue(text) => {
1138                self.window_records.push(diagnostics::WindowRecord {
1139                    start_ms,
1140                    end_ms,
1141                    raw: text.clone(),
1142                });
1143                self.asr_parts.push(text);
1144                true
1145            }
1146            WindowAction::Truncated(text) => {
1147                self.window_records.push(diagnostics::WindowRecord {
1148                    start_ms,
1149                    end_ms,
1150                    raw: text.clone(),
1151                });
1152                self.asr_parts.push(text);
1153                self.truncated = true;
1154                true
1155            }
1156            WindowAction::Cancelled => {
1157                self.failure = Some(Error::Cancelled);
1158                false
1159            }
1160            WindowAction::FinalizeWithError { message } => {
1161                self.truncated = true;
1162                self.recognition_error = Some((index, message));
1163                false
1164            }
1165            WindowAction::Failed { message } => {
1166                self.failure = Some(Error::Recognition { message: message.clone() });
1167                self.recognition_error = Some((index, message));
1168                false
1169            }
1170        }
1171    }
1172}
1173
1174/// Owns the weights and drains the queue.
1175fn worker(
1176    asr_path: &Path,
1177    cfg: &Config,
1178    models: &diagnostics::TakeModels,
1179    queue: &Receiver<Job>,
1180    stopping: &AtomicBool,
1181    in_flight: &Mutex<Option<CancelToken>>,
1182    readiness: &Readiness,
1183) {
1184    // Routed here rather than in `Engine::new` so the two cannot drift
1185    // apart: this is the only place a model is loaded, and suppression
1186    // has to precede that.
1187    ROUTE_NATIVE_LOGS.call_once(transcribe_cpp::init_logging);
1188
1189    let loaded = load_models(asr_path, cfg);
1190    // Settled before the branch below can return, so a caller waiting on
1191    // the weights hears what a queued job would have been told.
1192    readiness.settle(match &loaded {
1193        Ok(_) => Ok(()),
1194        Err(failure) => Err(failure.clone()),
1195    });
1196
1197    let Loaded { model, mut session, normalizer, model_load } = match loaded {
1198        Ok(loaded) => loaded,
1199        Err((path, message)) => {
1200            // Every waiting caller hears the same thing, rather than
1201            // blocking forever on a worker that never started.
1202            while let Ok(job) = queue.recv() {
1203                let _ = job
1204                    .reply
1205                    .send(Err(Error::ModelLoad { path: path.clone(), message: message.clone() }));
1206            }
1207            return;
1208        }
1209    };
1210
1211    let cancellable = model.supports(Feature::Cancellation);
1212    if !cancellable {
1213        // Otherwise abandoning a ticket looks like it worked and silently
1214        // does not, with nothing in the log to explain the wait.
1215        tracing::info!(
1216            event_name = "model_cancellation_unsupported",
1217            "this model does not honour cancellation; abandoning a ticket discards the result but does not stop the work"
1218        );
1219    }
1220    let mut options = RunOptions::default();
1221    options.language.clone_from(&cfg.language);
1222    let mut first = true;
1223
1224    while let Ok(job) = queue.recv() {
1225        if stopping.load(Ordering::Relaxed) {
1226            let _ = job.reply.send(Err(Error::EngineStopped));
1227            continue;
1228        }
1229        let mut stages = Stages {
1230            model_load: if first { model_load } else { Duration::ZERO },
1231            resample: job.resample,
1232            audio: job.audio,
1233            ..Stages::default()
1234        };
1235
1236        if cancellable {
1237            session.set_cancel_token(&job.cancel);
1238        }
1239        // Only a job that recognizes audio runs a cancellable inference. A
1240        // prejoined job only rewrites text the take already has, so
1241        // installing it here would let a teardown's cancel discard words
1242        // that are already in hand; `finish_after_join` still reads the
1243        // take's own token.
1244        let recognizing = job.prejoined.is_none();
1245        if recognizing {
1246            in_flight
1247                .lock()
1248                .unwrap_or_else(std::sync::PoisonError::into_inner)
1249                .replace(job.cancel.clone());
1250        }
1251        // Re-checked after installing the token, not only before: teardown
1252        // between the first check and this line would have seen `None` in
1253        // `in_flight`, cancelled nothing, and then waited out a full
1254        // inference it believed it had aborted.
1255        if recognizing && stopping.load(Ordering::Relaxed) {
1256            job.cancel.cancel();
1257        }
1258        // Each window runs on its own against the shared session; a
1259        // window that outruns the decode budget keeps what it recognised
1260        // and hands the flag up, where today the whole rest of the take
1261        // would have been lost with it.
1262        let mut state = TakeState::new(job.truncated);
1263        let started = Instant::now();
1264        // How long the take sat before its first window ran: the queue,
1265        // and for early takes the tail of the model load. The felt lag
1266        // `processing_ms` never sees.
1267        let start_lag_ms = u64::try_from(job.queued_at.elapsed().as_millis()).unwrap_or(u64::MAX);
1268        let window_ms =
1269            |samples: usize| u64::try_from(audio_duration(samples).as_millis()).unwrap_or(u64::MAX);
1270        // A pipelined finalize job carries text, not audio: it exists
1271        // so the join is normalized on the worker, where the normalizer
1272        // lives. Everything else recognizes its windows.
1273        // Cloned rather than moved so `finish_job` can still read the job's
1274        // own token and settings; a join is one string against a take.
1275        if let Some(raw) = job.prejoined.clone() {
1276            state.asr_parts.push(raw);
1277        } else {
1278            let windows = window_bounds(&job.pcm);
1279            for (k, &(start, end)) in windows.iter().enumerate() {
1280                let result = session.run(&job.pcm[start..end], &options);
1281                if let Ok(out) = &result {
1282                    stages.mel = stages.mel.saturating_add(Duration::from_secs_f64(
1283                        f64::from(out.timings.mel_ms) / 1000.0,
1284                    ));
1285                    stages.encode = stages.encode.saturating_add(Duration::from_secs_f64(
1286                        f64::from(out.timings.encode_ms) / 1000.0,
1287                    ));
1288                    stages.decode = stages.decode.saturating_add(Duration::from_secs_f64(
1289                        f64::from(out.timings.decode_ms) / 1000.0,
1290                    ));
1291                }
1292                // Discriminated on the ERROR VARIANT rather than on
1293                // `was_aborted`/`was_truncated`. Those report "the most
1294                // recent run", and `run` has early returns that never reach
1295                // native at all - an interior NUL in the language string, an
1296                // oversized buffer, a busy session - on which the flags still
1297                // hold the PREVIOUS job's value. Per-error state cannot go
1298                // stale.
1299                let action = window_action(result, state.has_prior_text());
1300                if !state.apply(action, k, window_ms(start), window_ms(end)) {
1301                    break;
1302                }
1303            }
1304        }
1305        let TakeState { asr_parts, window_records, truncated, failure, recognition_error } = state;
1306        // The diagnostics record's copy of the stages is taken beside the
1307        // answer rather than before it: the cleanup pass sets
1308        // `Stages::normalize` on the way, so a clone made first would
1309        // report `normalizer_ran` false for a take that ran it.
1310        let JobOutcome { answer, stages: diag_stages, diag } =
1311            finish_job(&job, &asr_parts, failure, truncated, stages, normalizer.as_ref());
1312        // Consumed on a transcript rather than where `stages` is built: an
1313        // error or a cancel discards the stages, and taking the load cost
1314        // there would lose it for the process.
1315        if matches!(&answer, Ok(Outcome::Transcript(_))) {
1316            first = false;
1317        }
1318        in_flight.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take();
1319        let _ = job.reply.send(answer);
1320
1321        // Best-effort diagnostics, after the take has landed: the reply
1322        // is never held back by a write. An abandoned take persists
1323        // nothing (the classifier's None). Teardown skips the write:
1324        // quitting fast beats blocking shutdown on a capture-sized
1325        // write, and that is a stated trade, not a side effect. A
1326        // queued take starts after the write, which is the one cost a
1327        // 30-minute capture's wav ever asks anyone to pay. Pipelined
1328        // jobs never write at all - the take writes ONE record, from
1329        // its segmenter at finish.
1330        if let (Some(dir), Some((outcome, text))) = (&cfg.diagnostics_dir, diag)
1331            && job.diagnose
1332            && !stopping.load(Ordering::Relaxed)
1333        {
1334            let record = diagnostics::TakeRecord {
1335                audio: &job.pcm,
1336                windows: &window_records,
1337                joined: &join_window_texts(&asr_parts),
1338                text: &text,
1339                stages: &diag_stages,
1340                models,
1341                start_lag_ms,
1342                processing_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
1343                truncated,
1344                outcome,
1345                recognition_error,
1346            };
1347            keep_job_record(&job, dir, &record);
1348        }
1349    }
1350}
1351
1352/// How often the segmenter looks for the next window boundary. The cut
1353/// needs no samples past `WINDOW_TARGET` after the previous cut, so
1354/// this cadence bounds how long a segment can run past its ideal cut.
1355const SEGMENT_POLL: Duration = Duration::from_millis(250);
1356
1357/// What the host hands a pipelined take at finish: the normalizer
1358/// options for the one join pass, and whether the recording stopped
1359/// itself at the cap.
1360#[derive(Clone, Copy)]
1361struct FinishTake {
1362    options: NormalizeOptions,
1363    truncated: bool,
1364}
1365
1366/// The take side both capture paths share, from [`Engine::begin_take`]:
1367/// the recording samples land in, the segmenter cutting it, and the
1368/// channels a capture hands on at finish.
1369struct TakeParts {
1370    recording: Arc<crate::capture::Recording>,
1371    segmenter: Option<std::thread::JoinHandle<()>>,
1372    finish_tx: Sender<FinishTake>,
1373    answer_rx: Receiver<Result<Outcome, Error>>,
1374    progress_rx: Receiver<WindowProgress>,
1375    cancel: CancelToken,
1376}
1377
1378/// The channels a take's segmenter shares with its capture and ticket.
1379struct TakeShared {
1380    /// Cancels every job the segmenter queued, when the take is over.
1381    cancel: CancelToken,
1382    /// The take's aggregate answer.
1383    answer: Sender<Result<Outcome, Error>>,
1384    /// Per-segment progress.
1385    progress: Sender<WindowProgress>,
1386}
1387
1388/// One settled piece of a pipelined take, in submission order.
1389#[derive(Debug)]
1390enum Part {
1391    /// Recognition output for the join, with the stages it cost. The
1392    /// text is raw: normalization happens once, over the join.
1393    Transcript {
1394        raw: String,
1395        stages: Stages,
1396        truncated: bool,
1397    },
1398    /// Below the silence floor; contributes its span and peak only.
1399    NoAudio {
1400        peak: f32,
1401        audio: Duration,
1402    },
1403    Failed(Error),
1404}
1405
1406/// What a take's settled parts come to, before normalization.
1407#[derive(Debug)]
1408enum Aggregate {
1409    /// Raw texts to join and normalize once. `failed` is set when a segment
1410    /// failed after the take already had words: the take keeps what it
1411    /// transcribed and marks itself truncated.
1412    Join {
1413        raws: Vec<String>,
1414        failed: Option<Error>,
1415    },
1416    NoAudio {
1417        peak: f32,
1418        audio: Duration,
1419    },
1420    Failed(Error),
1421}
1422
1423/// Sum of the parts' recognition work, for the take's own stages and
1424/// the diagnostics record.
1425fn fold_stages(parts: &[Part]) -> (Stages, Duration, bool) {
1426    let mut stages = Stages::default();
1427    let mut audio = Duration::ZERO;
1428    let mut truncated = false;
1429    for part in parts {
1430        let Part::Transcript { stages: s, truncated: t, .. } = part else { continue };
1431        stages.mel += s.mel;
1432        stages.encode += s.encode;
1433        stages.decode += s.decode;
1434        stages.model_load += s.model_load;
1435        stages.resample += s.resample;
1436        if s.normalize.is_some() {
1437            stages.normalize = s.normalize;
1438        }
1439        truncated |= *t;
1440        audio += s.audio;
1441    }
1442    (stages, audio, truncated)
1443}
1444
1445/// Whether a take that kept some words is still incomplete: a segment's own
1446/// truncation, the capture cap, or a failed segment.
1447fn take_is_truncated(folded_truncated: bool, finish_truncated: bool, failed: bool) -> bool {
1448    folded_truncated || finish_truncated || failed
1449}
1450
1451/// Fold a take's parts in submission order. A failure fails the take only
1452/// when no part produced words; when any part did, the take keeps them and
1453/// carries the first failure to the caller, which marks the transcript
1454/// truncated. A cancel is the one exception: an abandoned take keeps
1455/// nothing. Any recognized part sends the take down the join path, and only
1456/// a take with nothing above the floor anywhere reads as silence.
1457fn aggregate_parts(parts: Vec<Part>) -> Aggregate {
1458    let mut raws = Vec::new();
1459    let mut peak = f32::NEG_INFINITY;
1460    let mut audio = Duration::ZERO;
1461    let mut failed = None;
1462    let mut cancelled = false;
1463    let mut saw_transcript = false;
1464    for part in parts {
1465        match part {
1466            Part::Transcript { raw, .. } => {
1467                saw_transcript = true;
1468                raws.push(raw);
1469            }
1470            Part::NoAudio { peak: p, audio: a } => {
1471                peak = peak.max(p);
1472                audio += a;
1473            }
1474            Part::Failed(error) => {
1475                if matches!(&error, Error::Cancelled) {
1476                    cancelled = true;
1477                }
1478                if failed.is_none() {
1479                    failed = Some(error);
1480                }
1481            }
1482        }
1483    }
1484    if cancelled {
1485        // A cancel anywhere in the take outranks every other failure: the
1486        // user abandoned it, and an abandoned take persists nothing.
1487        return Aggregate::Failed(Error::Cancelled);
1488    }
1489    let has_text = raws.iter().any(|raw| !raw.trim().is_empty());
1490    if let Some(error) = failed {
1491        // Any other failure keeps the words already transcribed, including
1492        // a stopped engine: the person did not ask to abandon the take.
1493        if !has_text {
1494            return Aggregate::Failed(error);
1495        }
1496        return Aggregate::Join { raws, failed: Some(error) };
1497    }
1498    if !saw_transcript {
1499        return Aggregate::NoAudio { peak, audio };
1500    }
1501    Aggregate::Join { raws, failed: None }
1502}
1503
1504/// One capture's segmenter: cuts windows off the growing recording as
1505/// they pass the reliability ceiling and queues each for recognition
1506/// while the microphone keeps running; at finish it submits the tail,
1507/// waits out every segment, and normalizes the join exactly once, on
1508/// the worker where the normalizer lives. No partial text is ever
1509/// produced - the only thing that leaves this thread before the answer
1510/// is the progress count.
1511struct TakeSegmenter {
1512    recording: Arc<crate::capture::Recording>,
1513    jobs: Sender<Job>,
1514    silence_floor: f32,
1515    diagnostics_dir: Option<PathBuf>,
1516    /// The models the take ran under, named in its diagnostics record.
1517    models: Arc<diagnostics::TakeModels>,
1518    take: TakeShared,
1519    /// First sample not yet handed to a segment.
1520    last_cut: usize,
1521    /// Segment spans in submission order, for the diagnostics record.
1522    cuts: Vec<(usize, usize)>,
1523    /// Settled pieces, in submission order. Aligned with `cuts`: every
1524    /// cut contributes exactly one part, even a silence-gated one.
1525    parts: Vec<Part>,
1526    /// Answers still awaited, in submission order. The worker is
1527    /// serial, so they complete in this order too.
1528    pending: Vec<Receiver<Result<Outcome, Error>>>,
1529    /// How many parts have settled. The progress stream's `done`.
1530    settled: usize,
1531    /// When the take's first job was queued, and when its first part
1532    /// settled: the diagnostics `start_lag_ms` spans the two.
1533    first_queued_at: Option<Instant>,
1534    first_settled_at: Option<Instant>,
1535    /// The first recognition failure and the segment it hit, for the
1536    /// diagnostics record. The take answers with the same error.
1537    failed: Option<(usize, String)>,
1538}
1539
1540impl TakeSegmenter {
1541    fn run(mut self, finish: &Receiver<FinishTake>) {
1542        loop {
1543            match finish.recv_timeout(SEGMENT_POLL) {
1544                Ok(finish) => {
1545                    self.finish(finish);
1546                    return;
1547                }
1548                Err(RecvTimeoutError::Disconnected) => {
1549                    // Abandoned without finishing: the take is over,
1550                    // and its queued work should not run on.
1551                    self.take.cancel.cancel();
1552                    return;
1553                }
1554                Err(RecvTimeoutError::Timeout) => {
1555                    self.poll_cut();
1556                    self.collect_ready();
1557                }
1558            }
1559        }
1560    }
1561
1562    /// Cut the next window once enough audio has accrued past the last
1563    /// one - the only poll that copies, since a boundary arrives every
1564    /// 30 to 60 seconds against a 250 ms poll, and the early return is
1565    /// exactly `next_cut`'s own short-circuit. The decision runs the
1566    /// same function whole-buffer windowing does, over a copy of the
1567    /// uncut region, so a live cut lands exactly where the finished
1568    /// take's would.
1569    fn poll_cut(&mut self) {
1570        let len = self.recording.sample_len();
1571        if len.saturating_sub(self.last_cut) <= WINDOW_TARGET {
1572            return;
1573        }
1574        let region = self.recording.copy_region(self.last_cut, len);
1575        let Some(relative) = next_cut(&region, 0) else { return };
1576        self.last_cut += relative;
1577        // `region[..relative]` is exactly the segment; the copy in front
1578        // of it doubles as the quietest-frame search space.
1579        let segment = region[..relative].to_vec();
1580        self.submit_region(segment);
1581    }
1582
1583    /// Silence-gate a region and queue it, recording whichever part it
1584    /// became. `cuts` and `parts` advance together, so the diagnostics
1585    /// record can name every segment's span and raw text.
1586    fn submit_region(&mut self, pcm: Vec<f32>) {
1587        let start = self.cuts.last().map_or(0, |&(_, end)| end);
1588        let end = start + pcm.len();
1589        self.cuts.push((start, end));
1590        let peak = peak_dbfs(&pcm);
1591        let audio = audio_duration(pcm.len());
1592        if peak < self.silence_floor {
1593            self.parts.push(Part::NoAudio { peak, audio });
1594            self.stamp_settled();
1595            let _ = self.take.progress.send(WindowProgress { done: self.settled, total: None });
1596            return;
1597        }
1598        let queued_at = Instant::now();
1599        if self.first_queued_at.is_none() {
1600            self.first_queued_at = Some(queued_at);
1601        }
1602        let (reply, answer) = channel();
1603        let sent = self.jobs.send(Job {
1604            pcm,
1605            resample: Duration::ZERO,
1606            audio,
1607            truncated: false,
1608            normalize: None,
1609            prejoined: None,
1610            diagnose: false,
1611            cancel: self.take.cancel.clone(),
1612            reply,
1613            queued_at,
1614        });
1615        if sent.is_ok() {
1616            self.pending.push(answer);
1617        } else {
1618            // The engine is going away; the take fails with it.
1619            self.parts.push(Part::Failed(Error::EngineStopped));
1620            self.stamp_settled();
1621        }
1622    }
1623
1624    /// Take every answer already waiting, in submission order.
1625    fn collect_ready(&mut self) {
1626        while !self.pending.is_empty() {
1627            let answer = match self.pending[0].try_recv() {
1628                Ok(answer) => answer,
1629                Err(std::sync::mpsc::TryRecvError::Empty) => return,
1630                // The worker died without replying; the take fails
1631                // with it rather than waiting forever.
1632                Err(std::sync::mpsc::TryRecvError::Disconnected) => Err(Error::EngineStopped),
1633            };
1634            self.pending.remove(0);
1635            self.record(answer);
1636            let _ = self.take.progress.send(WindowProgress { done: self.settled, total: None });
1637        }
1638    }
1639
1640    /// Wait out every answer still owed, in submission order. Only
1641    /// finish calls this, and the worker answers every job it takes,
1642    /// so this terminates.
1643    fn drain_pending(&mut self) {
1644        while !self.pending.is_empty() {
1645            let answer = self.pending[0].recv().unwrap_or(Err(Error::EngineStopped));
1646            self.pending.remove(0);
1647            self.record(answer);
1648        }
1649    }
1650
1651    fn record(&mut self, answer: Result<Outcome, Error>) {
1652        let part = match answer {
1653            Ok(Outcome::Transcript(t)) => {
1654                Part::Transcript { raw: t.asr, stages: t.stages, truncated: t.truncated }
1655            }
1656            Ok(Outcome::NoAudio { peak, audio }) => Part::NoAudio { peak, audio },
1657            Err(error) => {
1658                if self.failed.is_none() {
1659                    let index = self.parts.len();
1660                    self.failed = Some((index, error.to_string()));
1661                }
1662                Part::Failed(error)
1663            }
1664        };
1665        self.parts.push(part);
1666        self.stamp_settled();
1667    }
1668
1669    fn stamp_settled(&mut self) {
1670        self.settled += 1;
1671        if self.first_settled_at.is_none() {
1672            self.first_settled_at = Some(Instant::now());
1673        }
1674    }
1675
1676    /// Complete the take: wait out the remaining segments, submit the
1677    /// tail, and normalize the join once. The answer lands on the
1678    /// take's channel as its last act; the only earlier output is the
1679    /// progress tally gaining its total.
1680    fn finish(mut self, finish: FinishTake) {
1681        let started = Instant::now();
1682        self.drain_pending();
1683        // The tail: whatever grew past the last cut. The recording
1684        // stopped before the finish message was sent, so the span is
1685        // stable.
1686        let len = self.recording.sample_len();
1687        if len > self.last_cut {
1688            let pcm = self.recording.copy_region(self.last_cut, len);
1689            self.submit_region(pcm);
1690        }
1691        let total = self.cuts.len();
1692        let _ = self.take.progress.send(WindowProgress { done: self.settled, total: Some(total) });
1693        // The tail's raw text feeds the join, so it settles before the
1694        // normalize pass queues.
1695        self.drain_pending();
1696        // The raw text per segment, aligned with `self.cuts`; silence
1697        // and failure contribute nothing to the join.
1698        let raws: Vec<String> = self
1699            .parts
1700            .iter()
1701            .map(|part| match part {
1702                Part::Transcript { raw, .. } => raw.clone(),
1703                Part::NoAudio { .. } | Part::Failed(_) => String::new(),
1704            })
1705            .collect();
1706        let (folded_stages, audio, folded_truncated) = fold_stages(&self.parts);
1707        let aggregate = aggregate_parts(std::mem::take(&mut self.parts));
1708        let failed_before_join = matches!(&aggregate, Aggregate::Join { failed: Some(_), .. });
1709        // The final drain may have settled the last window, so no
1710        // `session.run` is left to observe a cancel. An abandoned take
1711        // answers cancelled here rather than queueing a join whose words it
1712        // would persist.
1713        let answer = if self.take.cancel.is_cancelled() {
1714            Err(Error::Cancelled)
1715        } else {
1716            match aggregate {
1717                Aggregate::Failed(error) => Err(error),
1718                Aggregate::NoAudio { peak, audio } => Ok(Outcome::NoAudio { peak, audio }),
1719                Aggregate::Join { raws: join_raws, failed } => {
1720                    let joined = join_window_texts(&join_raws);
1721                    match self.normalize_join(&joined, finish.options) {
1722                        Ok(Outcome::Transcript(mut transcript)) => {
1723                            // The take's stages: every segment's recognition
1724                            // work plus the finalize's normalize pass, over
1725                            // the span the take actually covers.
1726                            let normalize = transcript.stages.normalize;
1727                            transcript.stages = folded_stages.clone();
1728                            transcript.stages.normalize = normalize;
1729                            transcript.stages.audio = audio;
1730                            transcript.truncated |= take_is_truncated(
1731                                folded_truncated,
1732                                finish.truncated,
1733                                failed.is_some(),
1734                            );
1735                            Ok(Outcome::Transcript(transcript))
1736                        }
1737                        Ok(other) => Ok(other),
1738                        Err(error) => Err(error),
1739                    }
1740                }
1741            }
1742        };
1743        // Read at the write, not only before the join: the token can be set
1744        // between that read and this one, and an abandoned take keeps no
1745        // record.
1746        let diag = kept_diagnostics(&self.take.cancel, &answer);
1747        let diag_stages = match &answer {
1748            Ok(Outcome::Transcript(transcript)) => transcript.stages.clone(),
1749            _ => folded_stages,
1750        };
1751        // Teardown reports itself as a stopped engine, and skips its
1752        // write for the same trade the worker makes: quitting fast
1753        // beats blocking shutdown on a capture-sized write.
1754        let teardown = matches!(&answer, Err(Error::EngineStopped));
1755        let _ = self.take.answer.send(answer);
1756        let _ = self.take.progress.send(WindowProgress { done: total, total: Some(total) });
1757        if let (Some(dir), Some((outcome, text))) = (&self.diagnostics_dir, diag)
1758            && !teardown
1759        {
1760            let ms_of = |samples: usize| {
1761                u64::try_from(audio_duration(samples).as_millis()).unwrap_or(u64::MAX)
1762            };
1763            let windows: Vec<diagnostics::WindowRecord> = self
1764                .cuts
1765                .iter()
1766                .zip(raws.iter())
1767                .map(|((start, end), raw)| diagnostics::WindowRecord {
1768                    start_ms: ms_of(*start),
1769                    end_ms: ms_of(*end),
1770                    raw: raw.clone(),
1771                })
1772                .collect();
1773            let audio = self.recording.take();
1774            let start_lag_ms = match (self.first_queued_at, self.first_settled_at) {
1775                (Some(queued), Some(settled)) => {
1776                    u64::try_from(settled.saturating_duration_since(queued).as_millis())
1777                        .unwrap_or(u64::MAX)
1778                }
1779                _ => 0,
1780            };
1781            let record = diagnostics::TakeRecord {
1782                audio: &audio,
1783                windows: &windows,
1784                joined: &join_window_texts(&raws),
1785                text: &text,
1786                stages: &diag_stages,
1787                models: &self.models,
1788                start_lag_ms,
1789                processing_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
1790                truncated: take_is_truncated(
1791                    folded_truncated,
1792                    finish.truncated,
1793                    failed_before_join,
1794                ),
1795                outcome,
1796                recognition_error: self.failed,
1797            };
1798            diagnostics::capture_take(dir, diagnostics::take_stamp(), &record, &self.take.cancel);
1799        }
1800    }
1801
1802    /// Queue the join for normalization on the worker and wait it out.
1803    /// No silence gate: the job carries text, not audio, and the gate
1804    /// reads samples.
1805    fn normalize_join(&self, joined: &str, options: NormalizeOptions) -> Result<Outcome, Error> {
1806        let (reply, answer) = channel();
1807        self.jobs
1808            .send(Job {
1809                pcm: Vec::new(),
1810                resample: Duration::ZERO,
1811                audio: Duration::ZERO,
1812                truncated: false,
1813                normalize: Some(options),
1814                prejoined: Some(joined.to_owned()),
1815                diagnose: false,
1816                cancel: self.take.cancel.clone(),
1817                reply,
1818                queued_at: Instant::now(),
1819            })
1820            .map_err(|_| Error::EngineStopped)?;
1821        answer.recv().map_err(|_| Error::EngineStopped)?
1822    }
1823}
1824
1825#[cfg(test)]
1826mod tests_engine {
1827    use super::*;
1828    use crate::{ConfigBuilder, ModelFacts, ModelSpec, Samples};
1829
1830    /// An engine whose weights live in an empty directory. Enough for
1831    /// anything decided before a job is queued; a job would fail at
1832    /// `recv`, and these tests never queue one.
1833    fn engine_without_weights() -> (tempfile::TempDir, Arc<Engine>) {
1834        let dir = tempfile::tempdir().unwrap();
1835        let cfg = ConfigBuilder::new().models_dir(dir.path()).normalizer(None).build();
1836        let engine = Engine::new(cfg).expect("engine must start");
1837        (dir, engine)
1838    }
1839
1840    /// What the store keeps, decided weightless. The abandoned take
1841    /// persisting NOTHING is privacy-relevant: a take the user chose
1842    /// not to submit must not leave audio on disk.
1843    #[test]
1844    fn the_diagnostics_classifier_keeps_what_is_worth_debugging() {
1845        assert_eq!(
1846            diagnostic_outcome(&Err(Error::Recognition { message: "x".into() })),
1847            Some(("recognition_error", String::new())),
1848            "a recognition failure is exactly a take worth opening files over"
1849        );
1850        assert_eq!(
1851            diagnostic_outcome(&Err(Error::Cancelled)),
1852            None,
1853            "an abandoned take must persist nothing: no audio, no directory"
1854        );
1855        // The silence gate resolves before the worker ever sees a job,
1856        // so this arm is defensive; the assert documents the intent so
1857        // a future edit cannot quietly start storing silence.
1858        assert_eq!(
1859            diagnostic_outcome(&Ok(Outcome::NoAudio {
1860                peak: f32::NEG_INFINITY,
1861                audio: Duration::ZERO
1862            })),
1863            None,
1864            "silence has no transcript stage to examine and no words to lose"
1865        );
1866        let empty = Outcome::Transcript(Transcript {
1867            text: String::new(),
1868            asr: String::new(),
1869            stages: Stages::default(),
1870            truncated: false,
1871        });
1872        assert_eq!(
1873            diagnostic_outcome(&Ok(empty)),
1874            Some(("empty", String::new())),
1875            "a take that normalized to nothing still keeps its audio"
1876        );
1877    }
1878
1879    #[test]
1880    fn a_source_at_the_wrong_rate_is_refused_before_it_is_queued() {
1881        let (_dir, engine) = engine_without_weights();
1882        let err = engine
1883            .transcribe(Samples::new(vec![0.0; 16], 44_100, 1))
1884            .expect_err("44.1 kHz is not something the models can read");
1885        assert!(
1886            matches!(err, Error::SampleRate { expected: SAMPLE_RATE, actual: 44_100 }),
1887            "a wrong rate must be refused synchronously rather than resampled, got: {err:?}"
1888        );
1889    }
1890
1891    #[test]
1892    fn a_stereo_source_is_refused_before_it_is_queued() {
1893        let (_dir, engine) = engine_without_weights();
1894        let err = engine
1895            .transcribe(Samples::new(vec![0.0; 16], SAMPLE_RATE, 2))
1896            .expect_err("interleaved stereo is not mono");
1897        assert!(
1898            matches!(err, Error::Channels { actual: 2 }),
1899            "stereo must be refused, not read as a doubled-rate mono signal, got: {err:?}"
1900        );
1901    }
1902
1903    /// The floor a host meters against is the floor the silence verdict
1904    /// uses. A getter that drifted from the config would draw a bar and
1905    /// an outcome that disagree about where "nothing" starts.
1906    #[test]
1907    fn silence_floor_reads_the_configured_threshold() {
1908        let dir = tempfile::tempdir().unwrap();
1909        let cfg = ConfigBuilder::new()
1910            .models_dir(dir.path())
1911            .normalizer(None)
1912            .silence_floor(-3.0)
1913            .build();
1914        let engine = Engine::new(cfg).expect("engine must start");
1915        assert!(
1916            (engine.silence_floor() + 3.0).abs() < f32::EPSILON,
1917            "the getter must carry the configured floor, got {}",
1918            engine.silence_floor()
1919        );
1920    }
1921
1922    /// Cancelling a ticket must never wedge its own answer. With no
1923    /// weights every job resolves as a load failure, which is exactly
1924    /// the drain a caller abandoned mid-queue would have raced into.
1925    /// A no-op cancel would survive this pin in CI - the real-weights
1926    /// test below is what kills that mutation, where a cancelled job
1927    /// comes back cancelled instead of with words.
1928    #[test]
1929    fn cancelling_a_ticket_still_resolves_its_answer() {
1930        let (_dir, engine) = engine_without_weights();
1931        let ticket = engine.transcribe(Samples::mono(vec![0.6; 512])).expect("queued");
1932        let token = ticket.cancel_token();
1933        token.cancel();
1934        let answer =
1935            ticket.recv().expect_err("no weights means the job answers with the load failure");
1936        assert!(
1937            matches!(answer, Error::ModelLoad { .. }),
1938            "the cancelled job must still resolve, got: {answer:?}"
1939        );
1940    }
1941
1942    /// A ticket that delivered its answer is not an abandonment. The direct
1943    /// path writes its diagnostics record after the answer goes out, so the
1944    /// ticket's own drop cancelling the token would delete the take the
1945    /// worker is still writing. A ticket dropped without its answer is an
1946    /// abandonment and still cancels.
1947    #[test]
1948    fn a_delivered_ticket_does_not_cancel_its_take() {
1949        let (reply, answer) = channel();
1950        let cancel = CancelToken::new();
1951        let ticket = Ticket { answer, cancel: cancel.clone(), delivered: false, segmenter: None };
1952        reply
1953            .send(Err(Error::Recognition { message: "x".into() }))
1954            .expect("the answer is buffered");
1955        let _ = ticket.recv();
1956        assert!(
1957            !cancel.is_cancelled(),
1958            "a ticket whose answer was received must not cancel the take under its write"
1959        );
1960
1961        let (_reply, answer) = channel::<Result<Outcome, Error>>();
1962        let cancel = CancelToken::new();
1963        drop(Ticket { answer, cancel: cancel.clone(), delivered: false, segmenter: None });
1964        assert!(
1965            cancel.is_cancelled(),
1966            "a ticket dropped without its answer still abandons the take"
1967        );
1968    }
1969
1970    /// A load failure has to reach a waiter, and it has to keep reaching
1971    /// them. Both halves are load-bearing for a host that gates its
1972    /// startup on this: settling `Ok` regardless of the load would let it
1973    /// proceed over a model that will fail on the first word, and an
1974    /// answer consumed by whoever asked first would hang the second
1975    /// caller forever.
1976    #[test]
1977    fn wait_ready_reports_the_model_that_would_not_load_to_every_caller() {
1978        let (dir, engine) = engine_without_weights();
1979
1980        let first = engine.wait_ready().expect_err("an empty directory holds no weights");
1981        assert!(
1982            matches!(&first, Error::ModelLoad { path, .. } if path.starts_with(dir.path())),
1983            "readiness must carry the load failure and name the file it tried, got: {first:?}"
1984        );
1985
1986        let again = engine.wait_ready().expect_err("the answer must outlive the first caller");
1987        assert!(
1988            matches!(again, Error::ModelLoad { .. }),
1989            "the outcome must be kept rather than consumed, got: {again:?}"
1990        );
1991    }
1992
1993    /// The silence branch answers before the weights are touched, so it
1994    /// is reachable with no model on disk.
1995    #[test]
1996    fn a_silent_job_returns_no_audio_rather_than_an_empty_transcript() {
1997        let (_dir, engine) = engine_without_weights();
1998        let outcome = engine
1999            .transcribe(Samples::mono(vec![0.0; SAMPLE_RATE as usize * 2]))
2000            .expect("silence is still a valid 16 kHz mono source")
2001            .recv()
2002            .expect("a silent job must be answered, not fail");
2003
2004        match outcome {
2005            Outcome::NoAudio { peak, audio } => {
2006                assert!(
2007                    peak.is_infinite() && peak.is_sign_negative(),
2008                    "digital silence must report no signal at all, got {peak}"
2009                );
2010                assert_eq!(
2011                    audio.as_secs(),
2012                    2,
2013                    "NoAudio must say how much nothing was heard, or it cannot be diagnosed"
2014                );
2015            }
2016            Outcome::Transcript(t) => {
2017                panic!("silence must not come back as a transcript: {t:?}")
2018            }
2019        }
2020    }
2021
2022    /// Loud audio, so the silence short-circuit is not taken and the
2023    /// jobs genuinely reach the worker's queue. With no model on disk the
2024    /// load-failure drain answers both, which pins that every job
2025    /// resolves on its OWN reply channel and that neither hangs.
2026    #[test]
2027    fn every_queued_job_is_answered_even_when_the_model_is_missing() {
2028        let (_dir, engine) = engine_without_weights();
2029        // Loud, so neither is short-circuited as silence.
2030        let first = engine.transcribe(Samples::mono(vec![0.6; 512])).expect("queued");
2031        let second = engine.transcribe(Samples::mono(vec![0.6; 512])).expect("queued");
2032
2033        for (nth, ticket) in [first, second].into_iter().enumerate() {
2034            let answer = ticket.recv();
2035            assert!(
2036                matches!(answer, Err(Error::ModelLoad { .. })),
2037                "queued job {nth} must be answered rather than left hanging, got: {answer:?}"
2038            );
2039        }
2040    }
2041
2042    #[test]
2043    fn the_silence_floor_comes_from_the_config_not_a_constant() {
2044        let dir = tempfile::tempdir().unwrap();
2045        // A floor below digital silence can never be met, so even a loud
2046        // signal must be judged against the configured value.
2047        let cfg = ConfigBuilder::new()
2048            .models_dir(dir.path())
2049            .normalizer(None)
2050            .silence_floor(-3.0)
2051            .build();
2052        let engine = Engine::new(cfg).expect("engine must start");
2053
2054        let outcome = engine
2055            .transcribe(Samples::mono(vec![0.25; SAMPLE_RATE as usize]))
2056            .expect("a valid source")
2057            .recv()
2058            .expect("must be answered");
2059        assert!(
2060            matches!(outcome, Outcome::NoAudio { .. }),
2061            "a -12 dBFS signal must fall below a -3 dBFS floor, so the config is what decides"
2062        );
2063    }
2064
2065    #[test]
2066    fn the_take_aggregate_joins_raw_texts_in_submission_order() {
2067        let parts = vec![
2068            Part::Transcript { raw: "first".into(), stages: Stages::default(), truncated: false },
2069            Part::NoAudio { peak: -40.0, audio: Duration::from_millis(500) },
2070            Part::Transcript { raw: "second".into(), stages: Stages::default(), truncated: true },
2071        ];
2072        match aggregate_parts(parts) {
2073            Aggregate::Join { raws, failed } => {
2074                assert!(failed.is_none(), "no part failed");
2075                assert_eq!(
2076                    raws,
2077                    ["first", "second"],
2078                    "silence contributes nothing to the join; order is the take's order"
2079                );
2080                assert_eq!(
2081                    join_window_texts(&raws),
2082                    "first second",
2083                    "the join, not the pieces, is what the normalizer sees"
2084                );
2085            }
2086            other => panic!("a take with recognized parts must join, got {other:?}"),
2087        }
2088    }
2089
2090    #[test]
2091    fn a_take_with_nothing_above_the_floor_reads_as_silence_over_the_whole_span() {
2092        let parts = vec![
2093            Part::NoAudio { peak: -40.0, audio: Duration::from_millis(500) },
2094            Part::NoAudio { peak: -30.0, audio: Duration::from_millis(1500) },
2095        ];
2096        match aggregate_parts(parts) {
2097            Aggregate::NoAudio { peak, audio } => {
2098                // Exact, not approximated: the aggregate copies the
2099                // input peak, it computes nothing.
2100                #[allow(clippy::float_cmp)]
2101                {
2102                    assert_eq!(peak, -30.0, "the take's peak is the loudest segment's");
2103                }
2104                assert_eq!(audio, Duration::from_millis(2000), "the span is the whole take");
2105            }
2106            other => panic!("a take with no recognized part must read as silence, got {other:?}"),
2107        }
2108    }
2109
2110    #[test]
2111    fn a_failure_after_a_segment_with_words_keeps_the_words() {
2112        let parts = vec![
2113            Part::Transcript { raw: "early".into(), stages: Stages::default(), truncated: false },
2114            Part::Failed(Error::Recognition { message: "one".into() }),
2115            Part::Failed(Error::Recognition { message: "two".into() }),
2116        ];
2117        match aggregate_parts(parts) {
2118            Aggregate::Join { raws, failed: Some(Error::Recognition { message }) } => {
2119                assert_eq!(raws, ["early"], "the words before the failure are kept");
2120                assert_eq!(
2121                    message, "one",
2122                    "the first failure in submission order is the one carried"
2123                );
2124            }
2125            other => {
2126                panic!("a failure after words must keep them and carry the error, got {other:?}")
2127            }
2128        }
2129    }
2130
2131    #[test]
2132    fn a_failure_before_any_words_fails_the_take() {
2133        let parts = vec![
2134            Part::NoAudio { peak: -40.0, audio: Duration::from_millis(500) },
2135            Part::Failed(Error::Recognition { message: "one".into() }),
2136            Part::Failed(Error::Recognition { message: "two".into() }),
2137        ];
2138        match aggregate_parts(parts) {
2139            Aggregate::Failed(Error::Recognition { message }) => assert_eq!(
2140                message, "one",
2141                "the first failure in submission order wins when there is nothing to keep"
2142            ),
2143            other => panic!("a failure before any words must fail the take, got {other:?}"),
2144        }
2145    }
2146
2147    #[test]
2148    fn a_failure_after_only_whitespace_still_fails_the_take() {
2149        let parts = vec![
2150            Part::Transcript { raw: " ".into(), stages: Stages::default(), truncated: false },
2151            Part::Failed(Error::Recognition { message: "one".into() }),
2152        ];
2153        match aggregate_parts(parts) {
2154            Aggregate::Failed(Error::Recognition { message }) => assert_eq!(message, "one"),
2155            other => panic!("whitespace is not words, got {other:?}"),
2156        }
2157    }
2158
2159    #[test]
2160    fn a_cancel_after_another_failure_still_discards() {
2161        let parts = vec![
2162            Part::Transcript { raw: "early".into(), stages: Stages::default(), truncated: false },
2163            Part::Failed(Error::Recognition { message: "one".into() }),
2164            Part::Failed(Error::Cancelled),
2165        ];
2166        match aggregate_parts(parts) {
2167            Aggregate::Failed(Error::Cancelled) => {}
2168            other => panic!("a cancel anywhere must discard the take, got {other:?}"),
2169        }
2170    }
2171
2172    /// The fold's contract for a stopped engine: the words already
2173    /// transcribed are kept and the stop is carried as the error. Teardown
2174    /// is a different path and differs on both sides: with no words it
2175    /// answers `Err(EngineStopped)` and `finish` skips the write, while with
2176    /// words the fold keeps them - so what is pinned here is the fold's
2177    /// input handling, not a teardown guarantee.
2178    #[test]
2179    fn the_fold_keeps_prior_words_when_the_engine_stops() {
2180        let parts = vec![
2181            Part::Transcript { raw: "early".into(), stages: Stages::default(), truncated: false },
2182            Part::Failed(Error::EngineStopped),
2183        ];
2184        match aggregate_parts(parts) {
2185            Aggregate::Join { raws, failed: Some(Error::EngineStopped) } => {
2186                assert_eq!(
2187                    raws,
2188                    ["early"],
2189                    "the fold keeps the words it has and carries the stopped engine"
2190                );
2191            }
2192            other => panic!("the fold must keep words and carry the stopped engine, got {other:?}"),
2193        }
2194    }
2195
2196    #[test]
2197    fn words_after_a_failed_segment_are_kept() {
2198        let parts = vec![
2199            Part::Failed(Error::Recognition { message: "one".into() }),
2200            Part::Transcript { raw: "later".into(), stages: Stages::default(), truncated: false },
2201        ];
2202        match aggregate_parts(parts) {
2203            Aggregate::Join { raws, failed: Some(Error::Recognition { .. }) } => {
2204                assert_eq!(raws, ["later"], "later segments' words are kept too");
2205            }
2206            other => panic!("words after a failed segment must be kept, got {other:?}"),
2207        }
2208    }
2209
2210    #[test]
2211    fn a_failed_segment_makes_the_take_truncated() {
2212        assert!(take_is_truncated(false, false, true), "a failed segment leaves a gap");
2213        assert!(take_is_truncated(true, false, false), "a segment's own truncation carries");
2214        assert!(take_is_truncated(false, true, false), "the capture cap carries");
2215        assert!(!take_is_truncated(false, false, false));
2216    }
2217
2218    /// Whether the store holds no take. An absent store and a bare root
2219    /// both hold none: only an entry named `take-*` is a take, which is
2220    /// the same shape the retention scan counts.
2221    fn no_take_was_stored(store: &Path) -> bool {
2222        match std::fs::read_dir(store) {
2223            Ok(entries) => entries
2224                .flatten()
2225                .all(|entry| !entry.file_name().to_string_lossy().starts_with("take-")),
2226            Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
2227            Err(error) => panic!("the store could not be read: {error}"),
2228        }
2229    }
2230
2231    /// A job shaped like the worker's prejoined finalize: no audio, the
2232    /// take's join as its text, and the token the test hands it.
2233    fn prejoined_job(cancel: CancelToken) -> Job {
2234        let (reply, _answer) = channel();
2235        Job {
2236            pcm: Vec::new(),
2237            resample: Duration::ZERO,
2238            audio: Duration::ZERO,
2239            truncated: false,
2240            normalize: Some(NormalizeOptions::default()),
2241            prejoined: Some("early".to_owned()),
2242            diagnose: false,
2243            cancel,
2244            reply,
2245            queued_at: Instant::now(),
2246        }
2247    }
2248
2249    /// The fake worker behind every live-take finish test: it runs the same
2250    /// per-job seam the real worker does, so a prejoined job's answer is the
2251    /// join it was handed. `cancel_on_job` fires the take's token as the
2252    /// worker takes the job, standing in for a cancel that lands after the
2253    /// last window has settled. It gives up when the segmenter drops its
2254    /// sender.
2255    fn spawn_fake_worker(
2256        answers: Receiver<Job>,
2257        worker_cancel: CancelToken,
2258        cancel_on_job: bool,
2259    ) -> std::thread::JoinHandle<()> {
2260        std::thread::spawn(move || {
2261            while let Ok(job) = answers.recv() {
2262                if cancel_on_job {
2263                    worker_cancel.cancel();
2264                }
2265                // The prejoined text is the take's join, so the seam builds
2266                // the answer from it exactly as the worker would. Echoing
2267                // rather than inventing the text is what makes a landed
2268                // transcript assert the join.
2269                let parts: Vec<String> = job.prejoined.clone().into_iter().collect();
2270                let out = finish_job(&job, &parts, None, false, Stages::default(), None);
2271                let _ = job.reply.send(out.answer);
2272            }
2273        })
2274    }
2275
2276    /// Drive one live take's finish over [`spawn_fake_worker`].
2277    fn finish_live_take(
2278        parts: Vec<Part>,
2279        cuts: Vec<(usize, usize)>,
2280        cancel: CancelToken,
2281        cancel_on_job: bool,
2282        finish: FinishTake,
2283        diagnostics_dir: Option<PathBuf>,
2284    ) -> Receiver<Result<Outcome, Error>> {
2285        let (jobs, answers) = channel::<Job>();
2286        spawn_fake_worker(answers, cancel.clone(), cancel_on_job);
2287        let (answer_tx, answer_rx) = channel();
2288        let (progress_tx, _progress_rx) = channel();
2289        let dir = tempfile::tempdir().unwrap();
2290        let cfg = ConfigBuilder::new().models_dir(dir.path()).normalizer(None).build();
2291        // The parts ARE the settled segments, so the recording holds exactly
2292        // their audio and the cut cursor sits at its end: finish sees no
2293        // tail, and a record built here names the spans it was handed rather
2294        // than ones production never produces.
2295        let recorded = cuts.last().map_or(0, |&(_, end)| end);
2296        let recording = crate::capture::Recording::new(recorded.max(1));
2297        recording.push(&vec![0.5; recorded], recorded.max(1));
2298        let settled = parts.len();
2299        let failed = parts.iter().enumerate().find_map(|(index, part)| match part {
2300            Part::Failed(error) => Some((index, error.to_string())),
2301            Part::Transcript { .. } | Part::NoAudio { .. } => None,
2302        });
2303        let segmenter = TakeSegmenter {
2304            recording: Arc::new(recording),
2305            jobs,
2306            silence_floor: -50.0,
2307            diagnostics_dir,
2308            models: Arc::new(diagnostics::TakeModels::from_config(&cfg)),
2309            take: TakeShared { cancel, answer: answer_tx, progress: progress_tx },
2310            last_cut: recorded,
2311            cuts,
2312            parts,
2313            pending: Vec::new(),
2314            settled,
2315            first_queued_at: None,
2316            first_settled_at: None,
2317            failed,
2318        };
2319        segmenter.finish(finish);
2320        answer_rx
2321    }
2322
2323    /// The live path itself must mark the take truncated when a segment
2324    /// failed after words landed: the client's "part of the take is
2325    /// missing" copy depends on it, and dropping the flag would render a
2326    /// mid-take gap as complete.
2327    ///
2328    /// Two windows of words, so the answer has to carry the join: a worker
2329    /// that ignores the joined text passes on a single window alone.
2330    #[test]
2331    fn a_failed_segment_marks_the_live_transcript_truncated() {
2332        let answer = finish_live_take(
2333            vec![
2334                Part::Transcript {
2335                    raw: "early".into(),
2336                    stages: Stages::default(),
2337                    truncated: false,
2338                },
2339                Part::Transcript {
2340                    raw: "later".into(),
2341                    stages: Stages::default(),
2342                    truncated: false,
2343                },
2344                Part::Failed(Error::Recognition { message: "one".into() }),
2345            ],
2346            vec![(0, 80), (80, 160), (160, 180)],
2347            CancelToken::new(),
2348            false,
2349            FinishTake { options: NormalizeOptions::default(), truncated: false },
2350            None,
2351        );
2352        match answer.recv().expect("the take must answer") {
2353            Ok(Outcome::Transcript(transcript)) => {
2354                assert!(transcript.truncated, "a failed segment leaves a gap");
2355                assert_eq!(transcript.text, "early later", "the take lands the join it built");
2356            }
2357            other => panic!("a take with words and a failed segment must finalize, got {other:?}"),
2358        }
2359    }
2360
2361    /// The diagnostics record's own truncated flag on a failed segment:
2362    /// `failed_before_join` is one of that field's three inputs, and the
2363    /// existing store test only asserts a false value.
2364    #[test]
2365    fn a_failed_segment_records_a_truncated_take() {
2366        let dir = tempfile::tempdir().unwrap();
2367        let store = dir.path().join("store");
2368        let answer = finish_live_take(
2369            vec![
2370                Part::Transcript {
2371                    raw: "early".into(),
2372                    stages: Stages::default(),
2373                    truncated: false,
2374                },
2375                Part::Failed(Error::Recognition { message: "one".into() }),
2376            ],
2377            vec![(0, 80), (80, 160)],
2378            CancelToken::new(),
2379            false,
2380            FinishTake { options: NormalizeOptions::default(), truncated: false },
2381            Some(store.clone()),
2382        );
2383        assert!(
2384            matches!(answer.recv().expect("the take must answer"), Ok(Outcome::Transcript(_))),
2385            "a take with words and a failed segment must finalize"
2386        );
2387        let take = std::fs::read_dir(&store)
2388            .expect("the store")
2389            .flatten()
2390            .next()
2391            .expect("the failed take still wrote its record")
2392            .path();
2393        let meta: serde_json::Value =
2394            serde_json::from_str(&std::fs::read_to_string(take.join("meta.json")).unwrap())
2395                .expect("meta.json parses");
2396        assert_eq!(meta["truncated"], true, "a failed segment leaves a gap the record has to name");
2397    }
2398
2399    /// A segment that stopped short (the decode budget, a repetition loop)
2400    /// reaches the live answer as a truncated take, not only as a per-window
2401    /// flag: the client's "part of the take is missing" copy rides this
2402    /// input to `take_is_truncated`.
2403    #[test]
2404    fn a_truncated_segment_marks_the_live_transcript_truncated() {
2405        let answer = finish_live_take(
2406            vec![Part::Transcript {
2407                raw: "early".into(),
2408                stages: Stages::default(),
2409                truncated: true,
2410            }],
2411            vec![(0, 80)],
2412            CancelToken::new(),
2413            false,
2414            FinishTake { options: NormalizeOptions::default(), truncated: false },
2415            None,
2416        );
2417        match answer.recv().expect("the take must answer") {
2418            Ok(Outcome::Transcript(transcript)) => assert!(
2419                transcript.truncated,
2420                "a segment that stopped short leaves a gap in the take"
2421            ),
2422            other => panic!("a take with words must finalize, got {other:?}"),
2423        }
2424    }
2425
2426    /// The capture cap is the other input to the same flag: a take cut by
2427    /// `max_capture` lands truncated even when every segment it did run
2428    /// came back whole.
2429    #[test]
2430    fn a_capped_take_marks_the_live_transcript_truncated() {
2431        let answer = finish_live_take(
2432            vec![Part::Transcript {
2433                raw: "early".into(),
2434                stages: Stages::default(),
2435                truncated: false,
2436            }],
2437            vec![(0, 80)],
2438            CancelToken::new(),
2439            false,
2440            FinishTake { options: NormalizeOptions::default(), truncated: true },
2441            None,
2442        );
2443        match answer.recv().expect("the take must answer") {
2444            Ok(Outcome::Transcript(transcript)) => assert!(
2445                transcript.truncated,
2446                "the capture cap cuts the take short even when no segment stopped short"
2447            ),
2448            other => panic!("a take with words must finalize, got {other:?}"),
2449        }
2450    }
2451
2452    /// The flag's false branch, on a clean landed take: every other live
2453    /// take test expects `true`, so a predicate that always reported a gap
2454    /// would pass them all.
2455    #[test]
2456    fn a_clean_take_lands_untruncated() {
2457        let answer = finish_live_take(
2458            vec![Part::Transcript {
2459                raw: "early".into(),
2460                stages: Stages::default(),
2461                truncated: false,
2462            }],
2463            vec![(0, 80)],
2464            CancelToken::new(),
2465            false,
2466            FinishTake { options: NormalizeOptions::default(), truncated: false },
2467            None,
2468        );
2469        match answer.recv().expect("the take must answer") {
2470            Ok(Outcome::Transcript(transcript)) => {
2471                assert!(!transcript.truncated, "a take that never stopped short lands whole");
2472            }
2473            other => panic!("a take with words must finalize, got {other:?}"),
2474        }
2475    }
2476
2477    /// A cancel can land after the take's last window has settled, while the
2478    /// take is still transcribing. The only work left is the join, and a
2479    /// prejoined job never calls `session.run`, the one place the token is
2480    /// observed: without a check at the boundary the abandoned take answers
2481    /// with its words and persists them.
2482    #[test]
2483    fn a_cancel_after_the_last_window_persists_nothing() {
2484        let dir = tempfile::tempdir().unwrap();
2485        let store = dir.path().join("store");
2486        let cancel = CancelToken::new();
2487        cancel.cancel();
2488        let answer = finish_live_take(
2489            vec![Part::Transcript {
2490                raw: "early".into(),
2491                stages: Stages::default(),
2492                truncated: false,
2493            }],
2494            vec![(0, 80)],
2495            cancel,
2496            false,
2497            FinishTake { options: NormalizeOptions::default(), truncated: false },
2498            Some(store.clone()),
2499        );
2500        match answer.recv().expect("the take must answer") {
2501            Err(Error::Cancelled) => {}
2502            other => panic!("an abandoned take must answer cancelled, got {other:?}"),
2503        }
2504        assert!(no_take_was_stored(&store), "an abandoned take persists nothing");
2505    }
2506
2507    /// A cancel that lands after the segmenter's own check, while the join
2508    /// is already queued: the seam reads the token again and answers
2509    /// cancelled rather than the words it was handed, and the write keeps
2510    /// nothing.
2511    #[test]
2512    fn a_cancel_during_the_join_persists_nothing() {
2513        let dir = tempfile::tempdir().unwrap();
2514        let store = dir.path().join("store");
2515        let answer = finish_live_take(
2516            vec![Part::Transcript {
2517                raw: "early".into(),
2518                stages: Stages::default(),
2519                truncated: false,
2520            }],
2521            vec![(0, 80)],
2522            CancelToken::new(),
2523            true,
2524            FinishTake { options: NormalizeOptions::default(), truncated: false },
2525            Some(store.clone()),
2526        );
2527        match answer.recv().expect("the take must answer") {
2528            Err(Error::Cancelled) => {}
2529            other => panic!("a cancelled join must answer cancelled, got {other:?}"),
2530        }
2531        assert!(no_take_was_stored(&store), "an abandoned take persists nothing");
2532    }
2533
2534    /// A cancel that lands while the take's files are being written. The
2535    /// token is live at entry, so neither the segmenter's branch read nor
2536    /// the writer's entry sees it; the writer claims its directory first,
2537    /// and the cancel lands while the files are still going out. The writer
2538    /// must take the directory back - a check at its top could not.
2539    ///
2540    /// Driven through [`TakeSegmenter::finish`] rather than the helper so
2541    /// the segmenter's own token is the one the writer sees, and the clone
2542    /// this test fires is the host's `cancel_token()` handle: a delivered
2543    /// ticket disarms its own drop, so only a host cancel reaches a take
2544    /// whose answer has already gone out.
2545    #[test]
2546    fn a_cancel_during_the_write_takes_the_directory_back() {
2547        let dir = tempfile::tempdir().unwrap();
2548        let store = dir.path().join("store");
2549        let cancel = CancelToken::new();
2550        let (jobs, answers) = channel::<Job>();
2551        let worker = spawn_fake_worker(answers, CancelToken::new(), false);
2552
2553        // Five minutes of capture and a file per window: long enough that
2554        // the claimed directory is visible while the write is still running.
2555        let recorded = 5 * 60 * SAMPLE_RATE as usize;
2556        let recording = crate::capture::Recording::new(recorded);
2557        recording.push(&vec![0.5; recorded], recorded);
2558        let window_count = 5_000;
2559        let parts: Vec<Part> = (0..window_count)
2560            .map(|i| Part::Transcript {
2561                raw: format!("window {i}"),
2562                stages: Stages::default(),
2563                truncated: false,
2564            })
2565            .collect();
2566        let cuts: Vec<(usize, usize)> =
2567            (0..window_count).map(|i| (i * 100, (i + 1) * 100)).collect();
2568        let (answer_tx, answer_rx) = channel();
2569        let (progress_tx, _progress_rx) = channel();
2570        let cfg = ConfigBuilder::new().models_dir(dir.path()).normalizer(None).build();
2571        let segmenter = TakeSegmenter {
2572            recording: Arc::new(recording),
2573            jobs,
2574            silence_floor: -50.0,
2575            diagnostics_dir: Some(store.clone()),
2576            models: Arc::new(diagnostics::TakeModels::from_config(&cfg)),
2577            take: TakeShared { cancel: cancel.clone(), answer: answer_tx, progress: progress_tx },
2578            last_cut: recorded,
2579            cuts,
2580            parts,
2581            pending: Vec::new(),
2582            settled: window_count,
2583            first_queued_at: None,
2584            first_settled_at: None,
2585            failed: None,
2586        };
2587        let finishing = std::thread::spawn(move || {
2588            segmenter.finish(FinishTake { options: NormalizeOptions::default(), truncated: false });
2589        });
2590
2591        // The writer claims its directory before it writes anything: the
2592        // moment it is there, fire the token while the files are landing.
2593        let deadline = Instant::now() + Duration::from_secs(30);
2594        loop {
2595            let claimed = std::fs::read_dir(&store).is_ok_and(|entries| {
2596                entries
2597                    .flatten()
2598                    .any(|entry| entry.file_name().to_string_lossy().starts_with("take-"))
2599            });
2600            if claimed {
2601                break;
2602            }
2603            assert!(Instant::now() < deadline, "the writer never claimed a take directory");
2604            std::thread::sleep(Duration::from_millis(1));
2605        }
2606        cancel.cancel();
2607
2608        finishing.join().expect("the finishing thread must not panic");
2609        assert!(
2610            matches!(answer_rx.recv().expect("the take must answer"), Ok(Outcome::Transcript(_))),
2611            "the answer was built while the token was still live"
2612        );
2613        assert!(no_take_was_stored(&store), "a take cancelled inside its write persists nothing");
2614        worker.join().expect("the fake worker must not panic");
2615    }
2616
2617    /// The read after the join's normalize pass: a cancel that lands inside
2618    /// it is invisible to the boundary read, and this phase is entered with
2619    /// the token already set. It must answer cancelled, not the words the
2620    /// pass rewrote.
2621    #[test]
2622    fn a_cancel_inside_the_normalize_pass_answers_cancelled() {
2623        let cancel = CancelToken::new();
2624        cancel.cancel();
2625        let job = prejoined_job(cancel);
2626        let out = finish_after_join(
2627            &job,
2628            "early".to_owned(),
2629            "early".to_owned(),
2630            Stages::default(),
2631            false,
2632        );
2633        assert!(
2634            matches!(out.answer, Err(Error::Cancelled)),
2635            "a cancel inside the normalize pass must abort the answer, got {:?}",
2636            out.answer
2637        );
2638        assert_eq!(out.diag, None, "an abandoned take keeps no record");
2639    }
2640
2641    /// The segmenter's early check on the arm only it guards: a take whose
2642    /// every segment failed has no words, so without the token read the
2643    /// answer is the recognition error and `diagnostic_outcome` keeps a
2644    /// record. An abandoned take must answer cancelled and write nothing.
2645    #[test]
2646    fn a_cancelled_take_with_no_words_persists_nothing() {
2647        let dir = tempfile::tempdir().unwrap();
2648        let store = dir.path().join("store");
2649        let cancel = CancelToken::new();
2650        cancel.cancel();
2651        let answer = finish_live_take(
2652            vec![Part::Failed(Error::Recognition { message: "one".into() })],
2653            vec![(0, 80)],
2654            cancel,
2655            false,
2656            FinishTake { options: NormalizeOptions::default(), truncated: false },
2657            Some(store.clone()),
2658        );
2659        match answer.recv().expect("the take must answer") {
2660            Err(Error::Cancelled) => {}
2661            other => panic!("an abandoned take must answer cancelled, got {other:?}"),
2662        }
2663        assert!(no_take_was_stored(&store), "an abandoned take persists nothing");
2664    }
2665
2666    /// The write's own read, for a worker job whose answer was already
2667    /// built when the token was cancelled: the classifier alone would keep
2668    /// a record, so both write sites go through `kept_diagnostics`.
2669    #[test]
2670    fn a_cancelled_take_keeps_no_record_whatever_its_answer_says() {
2671        let answer = Ok(Outcome::Transcript(Transcript {
2672            text: "early".to_owned(),
2673            asr: "early".to_owned(),
2674            stages: Stages::default(),
2675            truncated: false,
2676        }));
2677        let cancelled = CancelToken::new();
2678        cancelled.cancel();
2679        assert_eq!(
2680            kept_diagnostics(&cancelled, &answer),
2681            None,
2682            "an abandoned take's words are never kept, even after the answer is built"
2683        );
2684        let live = CancelToken::new();
2685        assert_eq!(
2686            kept_diagnostics(&live, &answer).map(|(outcome, _)| outcome),
2687            Some("transcript"),
2688            "a live take keeps its record"
2689        );
2690    }
2691
2692    /// The direct path's write, with the job's own cancelled token:
2693    /// [`keep_job_record`] derives the token from the job it is given, so a
2694    /// fresh-token re-wiring cannot pass this.
2695    #[test]
2696    fn a_cancelled_direct_job_writes_no_record() {
2697        let dir = tempfile::tempdir().unwrap();
2698        let store = dir.path().join("store");
2699        let cancel = CancelToken::new();
2700        cancel.cancel();
2701        let (reply, _answer) = channel();
2702        let job = Job {
2703            pcm: Vec::new(),
2704            resample: Duration::ZERO,
2705            audio: Duration::ZERO,
2706            truncated: false,
2707            normalize: Some(NormalizeOptions::default()),
2708            prejoined: None,
2709            diagnose: true,
2710            cancel,
2711            reply,
2712            queued_at: Instant::now(),
2713        };
2714        let audio = vec![0.5; 16];
2715        let windows = Vec::new();
2716        let stages = Stages::default();
2717        let cfg = ConfigBuilder::new().models_dir(dir.path()).normalizer(None).build();
2718        let models = diagnostics::TakeModels::from_config(&cfg);
2719        let record = diagnostics::TakeRecord {
2720            audio: &audio,
2721            windows: &windows,
2722            joined: "early",
2723            text: "early",
2724            stages: &stages,
2725            models: &models,
2726            start_lag_ms: 0,
2727            processing_ms: 0,
2728            truncated: false,
2729            outcome: "transcript",
2730            recognition_error: None,
2731        };
2732        keep_job_record(&job, &store, &record);
2733        assert!(no_take_was_stored(&store), "an abandoned direct take writes no record");
2734    }
2735
2736    /// The worker's own half of the boundary, driven over a prejoined job
2737    /// with no model: a take cancelled after its last window has no
2738    /// `session.run` left to abort, so `finish_job` must read the token and
2739    /// answer cancelled rather than build the words. It takes the job
2740    /// itself, so the token it reads is the job's own by construction and
2741    /// the worker cannot wire another.
2742    #[test]
2743    fn a_cancelled_prejoined_job_answers_cancelled() {
2744        let cancelled = CancelToken::new();
2745        cancelled.cancel();
2746        let job = prejoined_job(cancelled);
2747        let stages = Stages { audio: Duration::from_millis(1500), ..Stages::default() };
2748        let out = finish_job(&job, &["early".to_owned()], None, false, stages.clone(), None);
2749        assert!(
2750            matches!(out.answer, Err(Error::Cancelled)),
2751            "a cancelled take with a clean fold must not answer with its words, got {:?}",
2752            out.answer
2753        );
2754        assert_eq!(out.stages, stages, "a cancelled job carries the stages it was given");
2755        assert_eq!(out.diag, None, "an abandoned take keeps no record");
2756
2757        // A cancel outranks a failure the fold already carries, the same
2758        // rule the take fold applies.
2759        let cancelled = CancelToken::new();
2760        cancelled.cancel();
2761        let job = prejoined_job(cancelled);
2762        let out = finish_job(
2763            &job,
2764            &[],
2765            Some(Error::Recognition { message: "one".into() }),
2766            false,
2767            Stages::default(),
2768            None,
2769        );
2770        assert!(
2771            matches!(out.answer, Err(Error::Cancelled)),
2772            "a cancel outranks the fold's own failure, got {:?}",
2773            out.answer
2774        );
2775
2776        // A live token builds the answer from the prejoined text, which is
2777        // what the worker does with no normalizer configured.
2778        let job = prejoined_job(CancelToken::new());
2779        let out = finish_job(&job, &["early".to_owned()], None, false, Stages::default(), None);
2780        match out.answer {
2781            Ok(Outcome::Transcript(transcript)) => {
2782                assert_eq!(transcript.text, "early", "the prejoined text is the answer");
2783                assert_eq!(transcript.asr, "early", "and its raw recognition");
2784            }
2785            other => panic!("a live token must build the transcript, got {other:?}"),
2786        }
2787        assert!(out.diag.is_some(), "a landed take keeps its record");
2788    }
2789
2790    #[test]
2791    fn a_take_without_cuts_still_resolves_through_the_pipeline() {
2792        let (_dir, engine) = engine_without_weights();
2793        let stream = engine.capture_stream().expect("an idle engine must take a stream");
2794        for chunk in loud(5).chunks(SAMPLE_RATE as usize) {
2795            assert!(stream.sink.push_mono(chunk), "a push below the cap must be kept");
2796        }
2797        let ticket = stream.capture.finish().expect("the take must finish");
2798        assert!(matches!(ticket.recv(), Err(Error::ModelLoad { .. })));
2799    }
2800
2801    #[test]
2802    fn abandoning_a_stream_take_mid_recording_does_not_hang() {
2803        let (_dir, engine) = engine_without_weights();
2804        let mut stream = engine.capture_stream().expect("an idle engine must take a stream");
2805        let mut progress =
2806            stream.capture.take_progress().expect("the capture carries a progress stream");
2807        for chunk in loud(70).chunks(SAMPLE_RATE as usize) {
2808            assert!(stream.sink.push_mono(chunk), "a push below the cap must be kept");
2809        }
2810        wait_for_a_settled_segment(&mut progress);
2811        drop(stream);
2812        engine.capture_stream().expect("the abandon must leave the engine able to take another");
2813    }
2814
2815    #[test]
2816    fn dropping_the_ticket_mid_pipeline_cancels_without_hanging() {
2817        let (_dir, engine) = engine_without_weights();
2818        let mut stream = engine.capture_stream().expect("an idle engine must take a stream");
2819        let mut progress =
2820            stream.capture.take_progress().expect("the capture carries a progress stream");
2821        for chunk in loud(70).chunks(SAMPLE_RATE as usize) {
2822            assert!(stream.sink.push_mono(chunk), "a push below the cap must be kept");
2823        }
2824        wait_for_a_settled_segment(&mut progress);
2825        let ticket = stream.capture.finish().expect("the take must finish");
2826        drop(ticket);
2827        engine
2828            .capture_stream()
2829            .expect("the cancelled take must leave the engine able to take another");
2830    }
2831
2832    /// The stream-fed take, weightless: pushed samples cross the window
2833    /// ceiling, the segmenter cuts and queues while the take is still
2834    /// open, and the take answers through the pipeline.
2835    #[test]
2836    fn a_segment_settles_while_the_stream_is_still_open() {
2837        let (_dir, engine) = engine_without_weights();
2838        let mut stream = engine.capture_stream().expect("an idle engine must take a stream");
2839        let mut progress =
2840            stream.capture.take_progress().expect("the capture carries a progress stream");
2841        for chunk in loud(70).chunks(SAMPLE_RATE as usize) {
2842            assert!(stream.sink.push_mono(chunk), "a push below the cap must be kept");
2843        }
2844        let seen = wait_for_a_settled_segment(&mut progress);
2845        assert!(
2846            seen.iter().all(|step| step.total.is_none()),
2847            "a live take cannot know its total, got {seen:?}"
2848        );
2849        let ticket = stream.capture.finish().expect("the take must finish");
2850        assert!(
2851            matches!(ticket.recv(), Err(Error::ModelLoad { .. })),
2852            "the pushed audio reached the worker, so its failure is the take's answer"
2853        );
2854    }
2855
2856    /// A stream-fed take meters and caps: the peak read is the pushed
2857    /// samples' own, and the cap stops the take rather than letting the
2858    /// buffer grow without bound.
2859    #[test]
2860    fn a_stream_take_meters_and_caps_what_it_is_pushed() {
2861        let dir = tempfile::tempdir().unwrap();
2862        let cfg = ConfigBuilder::new()
2863            .models_dir(dir.path())
2864            .normalizer(None)
2865            .max_capture(Duration::from_secs(1))
2866            .build();
2867        let engine = Engine::new(cfg).expect("engine must start");
2868        let stream = engine.capture_stream().expect("an idle engine must take a stream");
2869
2870        assert!(stream.sink.push_mono(&[0.5; 160]), "a push below the cap must be kept");
2871        let peak = stream.capture.meter().level();
2872        assert!((peak + 6.02).abs() < 0.1, "the meter must read the pushed samples, got {peak}");
2873        assert!(!stream.capture.was_truncated(), "a take inside its cap is not truncated");
2874
2875        for _ in 0..3 {
2876            stream.sink.push_mono(&vec![0.5; SAMPLE_RATE as usize]);
2877        }
2878        assert!(
2879            stream.capture.was_truncated(),
2880            "reaching the cap must flag the take, or a short transcript reads as a short utterance"
2881        );
2882        assert!(!stream.sink.push_mono(&[0.5; 16]), "a stopped take keeps nothing further");
2883        let ticket = stream.capture.finish().expect("a capped take still finishes");
2884        assert!(matches!(ticket.recv(), Err(Error::ModelLoad { .. })));
2885    }
2886
2887    /// The only refusal a stream take can meet is an engine that is
2888    /// stopping, because there is no device to open and no holder to
2889    /// contend with. A take accepted during teardown would be a take
2890    /// whose every job the drain discards.
2891    #[test]
2892    fn a_stream_take_on_a_stopped_engine_is_refused() {
2893        let (_dir, engine) = engine_without_weights();
2894        let stream = engine.capture_stream().expect("an idle engine must take a stream");
2895        engine.stopping.store(true, Ordering::Relaxed);
2896        assert!(
2897            matches!(engine.capture_stream(), Err(Error::EngineStopped)),
2898            "a stopping engine must refuse a new take rather than queue work it will discard"
2899        );
2900        drop(stream);
2901    }
2902
2903    /// The wiring leg for a capture's provenance: its record names the
2904    /// models the engine was BUILT with, not a default or a stub.
2905    ///
2906    /// Weightless: the failed load answers the take's segments, and a
2907    /// take whose segments failed still writes its record - which is
2908    /// the whole path a live capture's provenance rides.
2909    #[test]
2910    fn a_live_take_records_the_models_the_engine_was_built_with() {
2911        let dir = tempfile::tempdir().unwrap();
2912        let store = dir.path().join("store");
2913        let cfg = ConfigBuilder::new()
2914            .models_dir(dir.path())
2915            .asr_model(ModelSpec {
2916                file: "asr-stub.gguf".into(),
2917                url: String::new(),
2918                size: 4242,
2919                sha256: Some("a1b2c3".into()),
2920                facts: ModelFacts::default(),
2921            })
2922            .normalizer(None)
2923            .language("en")
2924            .diagnostics_dir(&store)
2925            .build();
2926        let engine = Engine::new(cfg).expect("engine must start");
2927        let stream = engine.capture_stream().expect("an idle engine must take a stream");
2928        for chunk in loud(5).chunks(SAMPLE_RATE as usize) {
2929            assert!(stream.sink.push_mono(chunk), "a push below the cap must be kept");
2930        }
2931        let ticket = stream.capture.finish().expect("the take must finish");
2932        // `recv` joins the segmenter, which writes the record before it
2933        // returns, so the store is complete once this lands.
2934        assert!(
2935            matches!(ticket.recv(), Err(Error::ModelLoad { .. })),
2936            "the missing weights' failure is the take's answer"
2937        );
2938
2939        let take = std::fs::read_dir(&store)
2940            .expect("the store directory")
2941            .flatten()
2942            .next()
2943            .expect("a failed take still writes its record")
2944            .path();
2945        let meta: serde_json::Value =
2946            serde_json::from_str(&std::fs::read_to_string(take.join("meta.json")).unwrap())
2947                .expect("meta.json parses");
2948        assert_eq!(meta["outcome"], "recognition_error", "the take's own outcome");
2949        assert_eq!(
2950            meta["asr"]["file"], "asr-stub.gguf",
2951            "the record names the model this engine was built with"
2952        );
2953        assert_eq!(meta["asr"]["sha256"], "a1b2c3", "its declared digest");
2954        assert_eq!(meta["asr"]["size"], 4242, "its declared size");
2955        assert!(meta["normalizer"].is_null(), "no configured cleanup names no model");
2956        assert_eq!(meta["normalizer_ran"], false, "nothing normalized this take");
2957        assert_eq!(meta["language"], "en", "the language the engine was built with");
2958        assert_eq!(
2959            meta["forge_version"],
2960            env!("CARGO_PKG_VERSION"),
2961            "the release the take was made under"
2962        );
2963    }
2964
2965    /// Poll a capture's progress stream until one segment has settled,
2966    /// while the recording is still open.
2967    fn wait_for_a_settled_segment(progress: &mut Receiver<WindowProgress>) -> Vec<WindowProgress> {
2968        let mut seen = Vec::new();
2969        let deadline = Instant::now() + Duration::from_secs(10);
2970        loop {
2971            while let Ok(step) = progress.try_recv() {
2972                seen.push(step);
2973            }
2974            if seen.iter().any(|step| step.done >= 1) {
2975                return seen;
2976            }
2977            assert!(
2978                Instant::now() < deadline,
2979                "no segment settled while the recording was open: {seen:?}"
2980            );
2981            std::thread::sleep(Duration::from_millis(10));
2982        }
2983    }
2984
2985    /// Well above any sane silence floor, so fed audio becomes jobs
2986    /// rather than silence-gated skips.
2987    fn loud(seconds_long: usize) -> Vec<f32> {
2988        vec![0.5; seconds_long * SAMPLE_RATE as usize]
2989    }
2990}
2991
2992#[cfg(test)]
2993mod tests_windowing {
2994    use super::*;
2995
2996    /// A take that fits one window must come back whole: that is the
2997    /// exact buffer the recognition runtime saw before windowing
2998    /// existed, and short takes must not move.
2999    #[test]
3000    fn a_take_that_fits_one_window_is_returned_whole() {
3001        for len in [0, 1, seconds(10), WINDOW_TARGET] {
3002            let pcm = vec![0.5; len];
3003            assert_eq!(
3004                window_bounds(&pcm),
3005                vec![(0, len)],
3006                "a take of {len} samples fits one window and must be returned undivided"
3007            );
3008        }
3009    }
3010
3011    /// Every window respects the sizing bounds, the windows tile the
3012    /// take with no gap and no overlap, and only the final window may
3013    /// be shorter than the minimum.
3014    #[test]
3015    fn windows_stay_within_the_sizing_bounds_and_cover_everything() {
3016        // 200 s of speech with pauses, so both the pause-seeking and the
3017        // hard-cut paths are exercised across several windows.
3018        let pcm = with_silence(loud(200), seconds(40), seconds(44));
3019        let pcm = with_silence(pcm, seconds(85), seconds(88));
3020
3021        let bounds = window_bounds(&pcm);
3022        assert!(!bounds.is_empty(), "a non-empty take gets at least one window");
3023
3024        let mut expected_start = 0;
3025        for (i, &(start, end)) in bounds.iter().enumerate() {
3026            assert_eq!(start, expected_start, "window {i} must continue where the last ended");
3027            assert!(end > start, "window {i} must not be empty");
3028            assert!(end - start <= WINDOW_TARGET, "window {i} must not exceed the target size");
3029            let last = i + 1 == bounds.len();
3030            if !last {
3031                assert!(
3032                    end - start >= WINDOW_MIN,
3033                    "window {i} may only shrink past the minimum as the take's final window"
3034                );
3035            }
3036            expected_start = end;
3037        }
3038        assert_eq!(expected_start, pcm.len(), "the windows must cover the whole take");
3039    }
3040
3041    /// A cut goes into the quietest stretch the search region offers:
3042    /// a pause between phrases, never a decoy quiet blip that sits
3043    /// before the region, and never mid-word while a pause is in range.
3044    #[test]
3045    fn cuts_land_in_the_quietest_stretch_of_the_search_region() {
3046        // A decoy pause before the first search region, the real pause
3047        // inside it, then more speech with a second pause inside the
3048        // next window's region.
3049        let pcm = with_silence(loud(200), seconds(20), seconds(20).saturating_add(seconds(1) / 2));
3050        let pcm = with_silence(pcm, seconds(40), seconds(44));
3051        let pcm = with_silence(pcm, seconds(85), seconds(88));
3052
3053        let bounds = window_bounds(&pcm);
3054        assert!(
3055            bounds.len() >= 3,
3056            "a 200 s take over a 60 s target must split into several windows, got {bounds:?}"
3057        );
3058        let first_cut = bounds[0].1;
3059        assert!(
3060            (seconds(40)..seconds(44)).contains(&first_cut),
3061            "the first cut must land inside the pause at 40 s, got {first_cut}"
3062        );
3063        let second_cut = bounds[1].1;
3064        assert!(
3065            (seconds(85)..seconds(88)).contains(&second_cut),
3066            "the second cut must land inside the pause at 85 s, got {second_cut}"
3067        );
3068    }
3069
3070    /// Speech with no pause anywhere still splits, hard-cutting just
3071    /// under the target: a cut mid-word beats a window long enough to
3072    /// derail.
3073    #[test]
3074    fn a_take_with_no_quiet_still_splits_hard_at_the_ceiling() {
3075        let pcm = loud(200);
3076        let bounds = window_bounds(&pcm);
3077        assert_eq!(bounds.len(), 4, "200 s over a 60 s target splits into four windows");
3078        for (i, &(start, end)) in bounds.iter().enumerate() {
3079            if i + 1 == bounds.len() {
3080                continue;
3081            }
3082            assert!(
3083                end - start >= WINDOW_TARGET - ENERGY_FRAME,
3084                "with no pause to seek, window {i} must run to the ceiling"
3085            );
3086        }
3087    }
3088
3089    /// Window texts reassemble into one transcript without doubled
3090    /// spaces: a silent stretch inside a take transcribes to nothing
3091    /// and must vanish at the join rather than leave a mark.
3092    #[test]
3093    fn joining_drops_empty_windows_and_joins_the_rest_with_spaces() {
3094        let parts = vec!["alpha ".to_owned(), String::new(), " beta".to_owned()];
3095        assert_eq!(join_window_texts(&parts), "alpha beta", "empties vanish, edges trim");
3096        assert_eq!(join_window_texts(&["only".to_owned()]), "only", "one window joins to itself");
3097        assert_eq!(join_window_texts(&[String::new()]), "", "an all-empty take joins to nothing");
3098    }
3099
3100    /// The normalizer must see the JOINED take exactly once. Asserted
3101    /// through the input it receives rather than through a counter:
3102    /// normalizing per window and joining after would hand it the last
3103    /// window alone, and a sentence crossing a window boundary would be
3104    /// rewritten without the context around the cut.
3105    #[test]
3106    fn the_normalizer_reads_the_joined_take_not_the_windows() {
3107        let parts = vec!["alpha and".to_owned(), " ".to_owned(), "beta continued".to_owned()];
3108        let mut seen = Vec::new();
3109        let (asr, text) = finalize_transcript(&parts, |raw| {
3110            seen.push(raw.to_owned());
3111            raw.replace("alpha and beta continued", "one flow")
3112        });
3113        assert_eq!(asr, "alpha and beta continued", "the raw join is the recognition output");
3114        assert_eq!(
3115            seen.as_slice(),
3116            ["alpha and beta continued"],
3117            "the rewriter must receive the whole joined take, once"
3118        );
3119        assert_eq!(text, "one flow", "the rewrite runs over the joined text");
3120    }
3121
3122    fn seconds(s: usize) -> usize {
3123        s * SAMPLE_RATE as usize
3124    }
3125
3126    fn loud(seconds_long: usize) -> Vec<f32> {
3127        vec![0.5; seconds(seconds_long)]
3128    }
3129
3130    fn with_silence(mut pcm: Vec<f32>, from: usize, to: usize) -> Vec<f32> {
3131        pcm[from..to].fill(0.0);
3132        pcm
3133    }
3134
3135    /// The live cut rule and whole-buffer windowing are one function,
3136    /// [`next_cut`]; this pins the consequence, that deciding each cut
3137    /// as the buffer grows lands exactly where deciding after the take
3138    /// would. Deterministic dips - one quiet frame per second - so the
3139    /// cut is the latest quiet frame the search window sees.
3140    #[test]
3141    fn live_cuts_and_whole_buffer_cuts_are_the_same_function() {
3142        let mut pcm = Vec::new();
3143        for _ in 0..360 {
3144            pcm.extend(std::iter::repeat_n(0.4, ENERGY_FRAME * 49));
3145            pcm.extend(std::iter::repeat_n(0.0, ENERGY_FRAME));
3146        }
3147        let offline = window_bounds(&pcm);
3148        assert!(
3149            offline.len() > 2,
3150            "a six-minute take must span several windows, got {}",
3151            offline.len()
3152        );
3153
3154        let mut live: Vec<(usize, usize)> = Vec::new();
3155        let mut last_cut = 0;
3156        for end in (0..=pcm.len()).step_by(SAMPLE_RATE as usize) {
3157            while let Some(cut) = next_cut(&pcm[..end], last_cut) {
3158                live.push((last_cut, cut));
3159                last_cut = cut;
3160            }
3161        }
3162        // The tail is the finish-time submission.
3163        live.push((last_cut, pcm.len()));
3164        assert_eq!(
3165            live, offline,
3166            "deciding cuts as the buffer grows must land exactly where whole-buffer windowing does"
3167        );
3168    }
3169}
3170
3171#[cfg(test)]
3172mod tests_window_outcomes {
3173    use super::*;
3174
3175    fn words(text: &str) -> transcribe_cpp::Transcript {
3176        transcribe_cpp::Transcript { text: text.to_owned(), ..Default::default() }
3177    }
3178
3179    fn repetition(text: &str) -> transcribe_cpp::Error {
3180        transcribe_cpp::Error::OutputRepetition {
3181            message: "output began repeating".to_owned(),
3182            partial: Some(Box::new(words(text))),
3183        }
3184    }
3185
3186    fn failure() -> transcribe_cpp::Error {
3187        transcribe_cpp::Error::Other("decode failed".to_owned())
3188    }
3189
3190    /// A repetition stop still carries the words the recognizer kept. Dropping
3191    /// them into the generic error arm fails the whole take and loses every
3192    /// window already transcribed.
3193    #[test]
3194    fn an_output_repetition_with_a_partial_keeps_the_text() {
3195        assert_eq!(
3196            window_action(Err(repetition("the words so far")), false),
3197            WindowAction::Truncated("the words so far".to_owned()),
3198            "repetition with a partial must keep the text and mark the take truncated, not fail it"
3199        );
3200    }
3201
3202    /// A window that fails after earlier windows produced words must not
3203    /// discard them: the take finalizes what it has and carries the error.
3204    #[test]
3205    fn a_generic_error_after_good_windows_keeps_the_prior_text() {
3206        assert_eq!(
3207            window_action(Err(failure()), true),
3208            WindowAction::FinalizeWithError { message: "decode failed".to_owned() },
3209            "a window failure after good windows must finalize what was transcribed, not discard it"
3210        );
3211    }
3212
3213    /// With nothing to finalize, the take fails - and a failure is what the
3214    /// client draws on the status row.
3215    #[test]
3216    fn a_first_window_error_fails_visibly() {
3217        assert_eq!(
3218            window_action(Err(failure()), false),
3219            WindowAction::Failed { message: "decode failed".to_owned() },
3220            "with no text to keep, the take must fail so the client draws the failure notice"
3221        );
3222    }
3223
3224    /// The loop's side effects, not just the classification: a
3225    /// finalize-with-error must keep the take alive and mark it truncated.
3226    /// Merging its arm with `Failed` keeps the classification tests green
3227    /// while re-losing the words, so this pins the transition itself.
3228    #[test]
3229    fn a_finalize_with_error_does_not_fail_the_take() {
3230        let mut state = TakeState::new(false);
3231        let keep_going = state.apply(
3232            WindowAction::FinalizeWithError { message: "decode failed".to_owned() },
3233            1,
3234            0,
3235            1000,
3236        );
3237        assert!(!keep_going, "the loop stops after a finalize");
3238        assert!(state.failure.is_none(), "a finalize must not fail the take");
3239        assert!(state.truncated, "a finalize must mark the take truncated");
3240        assert_eq!(state.recognition_error, Some((1, "decode failed".to_owned())));
3241    }
3242
3243    #[test]
3244    fn a_failed_action_fails_the_take() {
3245        let mut state = TakeState::new(false);
3246        let keep_going =
3247            state.apply(WindowAction::Failed { message: "decode failed".to_owned() }, 0, 0, 1000);
3248        assert!(!keep_going);
3249        assert!(
3250            matches!(
3251                state.failure,
3252                Some(Error::Recognition { ref message }) if message == "decode failed"
3253            ),
3254            "a failed window must fail the take with its own error"
3255        );
3256        assert_eq!(
3257            state.recognition_error,
3258            Some((0, "decode failed".to_owned())),
3259            "the failure carries its window and message to diagnostics"
3260        );
3261        assert!(!state.truncated, "a failed first window is not truncated");
3262    }
3263
3264    #[test]
3265    fn a_truncated_window_keeps_its_partial_and_keeps_going() {
3266        let mut state = TakeState::new(false);
3267        assert!(state.apply(WindowAction::Continue("early".to_owned()), 0, 0, 1000));
3268        assert!(state.apply(WindowAction::Truncated("so far".to_owned()), 1, 1000, 2000));
3269        assert_eq!(state.asr_parts, ["early", "so far"]);
3270        assert!(state.truncated, "a truncated window marks the take truncated");
3271        assert!(state.failure.is_none(), "a truncated window does not fail the take");
3272        assert_eq!(state.window_records.len(), 2);
3273        assert_eq!(state.window_records[0].raw, "early", "each record keeps its own raw text");
3274        assert_eq!(
3275            state.window_records[1].raw, "so far",
3276            "a truncated window's partial text is the record's raw"
3277        );
3278        assert_eq!(
3279            (state.window_records[1].start_ms, state.window_records[1].end_ms),
3280            (1000, 2000),
3281            "the record carries the window's own span"
3282        );
3283    }
3284
3285    #[test]
3286    fn whitespace_is_not_prior_text() {
3287        let mut state = TakeState::new(false);
3288        state.asr_parts.push(" ".to_owned());
3289        assert!(!state.has_prior_text(), "whitespace is not words");
3290        state.asr_parts.push("word".to_owned());
3291        assert!(state.has_prior_text());
3292    }
3293
3294    #[test]
3295    fn a_cancelled_action_stops_without_words() {
3296        let mut state = TakeState::new(false);
3297        assert!(!state.apply(WindowAction::Cancelled, 0, 0, 1000));
3298        assert!(matches!(state.failure, Some(Error::Cancelled)));
3299        assert!(state.asr_parts.is_empty(), "a cancel adds no words");
3300    }
3301}
3302
3303/// Real recognition against the real weights.
3304///
3305/// Ignored by default because it needs a 1.5 GB model that CI does not
3306/// have. **A test that cannot run in CI looks like decoration right up
3307/// until it is the only thing that catches a class of defect**, and this
3308/// one already caught a process abort at exit that no unit test could
3309/// reach: only real weights hold Metal resources, and only holding them
3310/// races the teardown. Do not delete it for being unrunnable.
3311///
3312/// ```bash
3313/// cargo run -p forge-dictate --release --example fetch
3314/// cargo nextest run -p forge-dictate --release --run-ignored all -E 'test(transcribes)'
3315/// ```
3316#[cfg(test)]
3317mod tests_real_recognition {
3318    use super::*;
3319    use crate::{ConfigBuilder, ModelSpec, Samples};
3320
3321    fn read_wav(path: &std::path::Path) -> (Vec<f32>, u32) {
3322        let bytes = std::fs::read(path).expect("fixture must be readable");
3323        // Canonical 16-bit PCM: the fixtures are written that way and the
3324        // manifest's integrity gate keeps them that way.
3325        let rate = u32::from_le_bytes(bytes[24..28].try_into().unwrap());
3326        let data = bytes[44..]
3327            .as_chunks::<2>()
3328            .0
3329            .iter()
3330            .map(|p| f32::from(i16::from_le_bytes(*p)) / 32768.0)
3331            .collect();
3332        (data, rate)
3333    }
3334
3335    #[test]
3336    #[ignore = "needs the ASR weights; run with --run-ignored all after `--example fetch`"]
3337    fn transcribes_a_fixture_to_its_locked_baseline() {
3338        // Where the corpus lands with the fixtures work. Compile-time
3339        // constant, so the answer does not depend on where this runs.
3340        let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/08_009s.wav");
3341        assert!(clip.exists(), "fixture missing at {}", clip.display());
3342        let (pcm, rate) = read_wav(&clip);
3343
3344        let engine =
3345            Engine::new(ConfigBuilder::new().normalizer(None).build()).expect("engine must start");
3346        let outcome = engine
3347            .transcribe(Samples::new(pcm, rate, 1))
3348            .expect("a 16 kHz mono fixture must be accepted")
3349            .recv()
3350            .expect("recognition must succeed");
3351
3352        let Outcome::Transcript(transcript) = outcome else {
3353            panic!("a clip of real speech must not read as silence: {outcome:?}");
3354        };
3355        assert_eq!(
3356            transcript.text,
3357            "But if there is a flag that we can pass to disallow these, that would be fantastic.",
3358            "recognition must still reproduce this clip's locked baseline"
3359        );
3360        assert!(
3361            transcript.stages.audio > Duration::from_secs(9),
3362            "the reported audio duration must match the clip, got {:?}",
3363            transcript.stages.audio
3364        );
3365    }
3366
3367    /// The success arm of [`Engine::wait_ready`], which no test without
3368    /// weights can reach: with an empty models directory every waiter
3369    /// gets a failure, so settling `Err` unconditionally passes the unit
3370    /// test above and refuses to start over a perfectly good pair.
3371    #[test]
3372    #[ignore = "needs the ASR weights; run with --run-ignored all after `--example fetch`"]
3373    fn wait_ready_succeeds_before_anything_is_transcribed() {
3374        let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/08_009s.wav");
3375        let (pcm, rate) = read_wav(&clip);
3376
3377        let engine =
3378            Engine::new(ConfigBuilder::new().normalizer(None).build()).expect("engine must start");
3379        engine.wait_ready().expect("the configured weights are on disk and must load");
3380
3381        let outcome = engine
3382            .transcribe(Samples::new(pcm, rate, 1))
3383            .expect("queued")
3384            .recv()
3385            .expect("recognition must succeed");
3386        assert!(
3387            matches!(outcome, Outcome::Transcript(_)),
3388            "readiness must mean the engine can transcribe, not merely that it answered: {outcome:?}"
3389        );
3390    }
3391
3392    /// Two callers must each get their own words back.
3393    ///
3394    /// Crosstalk is structurally impossible today because every job
3395    /// carries its own reply channel - which is the reason to pin it. A
3396    /// later move to one shared channel keyed by id is a natural-looking
3397    /// optimisation and is exactly what would break this.
3398    #[test]
3399    #[ignore = "needs the ASR weights; run with --run-ignored all after `--example fetch`"]
3400    fn two_tickets_resolve_to_their_own_callers_text() {
3401        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures");
3402        let (first_pcm, rate) = read_wav(&dir.join("08_009s.wav"));
3403        let (second_pcm, _) = read_wav(&dir.join("04_005s.wav"));
3404
3405        let engine =
3406            Engine::new(ConfigBuilder::new().normalizer(None).build()).expect("engine must start");
3407        // Both queued before either is read, so the worker holds two at once.
3408        let first = engine.transcribe(Samples::new(first_pcm, rate, 1)).expect("queued");
3409        let second = engine.transcribe(Samples::new(second_pcm, rate, 1)).expect("queued");
3410
3411        let (Ok(Outcome::Transcript(a)), Ok(Outcome::Transcript(b))) =
3412            (first.recv(), second.recv())
3413        else {
3414            panic!("both jobs must produce transcripts");
3415        };
3416        assert!(
3417            a.text.contains("disallow"),
3418            "the first ticket must carry the first caller's audio, got: {}",
3419            a.text
3420        );
3421        assert!(
3422            b.text.contains("PC games"),
3423            "the second ticket must carry the second caller's audio, got: {}",
3424            b.text
3425        );
3426    }
3427
3428    /// An abandoned ticket must not wedge the worker.
3429    ///
3430    /// The failure lands on the INNOCENT call: if dropping a ticket left
3431    /// the worker blocked on a receiver nobody holds, or never cleared
3432    /// `in_flight`, it is the next transcription that never returns.
3433    #[test]
3434    #[ignore = "needs the ASR weights; run with --run-ignored all after `--example fetch`"]
3435    fn dropping_a_ticket_does_not_wedge_the_next_one() {
3436        let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/08_009s.wav");
3437        let (pcm, rate) = read_wav(&clip);
3438
3439        let engine =
3440            Engine::new(ConfigBuilder::new().normalizer(None).build()).expect("engine must start");
3441        drop(engine.transcribe(Samples::new(pcm.clone(), rate, 1)).expect("queued"));
3442
3443        let after = engine
3444            .transcribe(Samples::new(pcm, rate, 1))
3445            .expect("queued")
3446            .recv()
3447            .expect("the call after an abandoned one must still complete");
3448        assert!(
3449            matches!(after, Outcome::Transcript(_)),
3450            "an abandoned ticket must free the worker for the next caller, got: {after:?}"
3451        );
3452    }
3453
3454    /// A cancelled ticket comes back cancelled, not with words.
3455    ///
3456    /// Cancelled before the worker's turn (the first job's model load
3457    /// gives the cancel a wide window), so this pins the whole path:
3458    /// token clone, install, and the abort mapping. Assumes the model
3459    /// honours cancellation - the engine logs a notice when it does
3460    /// not, and there this assert would misfire.
3461    #[test]
3462    #[ignore = "needs the ASR weights; run with --run-ignored all after `--example fetch`"]
3463    fn cancelling_a_ticket_returns_cancelled_rather_than_the_words() {
3464        let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/08_009s.wav");
3465        let (pcm, rate) = read_wav(&clip);
3466
3467        let engine =
3468            Engine::new(ConfigBuilder::new().normalizer(None).build()).expect("engine must start");
3469        let ticket = engine.transcribe(Samples::new(pcm, rate, 1)).expect("queued");
3470        ticket.cancel_token().cancel();
3471        let answer = ticket
3472            .recv()
3473            .expect_err("a cancelled ticket is an error, never the transcript it aborted");
3474        assert!(
3475            matches!(answer, Error::Cancelled),
3476            "cancelling before the turn must abort the job, got: {answer:?}"
3477        );
3478    }
3479
3480    /// A per-call styling override must reach the model.
3481    ///
3482    /// Asserted as a DIFFERENCE against the default rather than against
3483    /// fixed text: the point is that the argument changes the output, and
3484    /// pinning exact wording would break on any model change while
3485    /// proving nothing extra.
3486    #[test]
3487    #[ignore = "needs both models; run with --run-ignored all after `--example fetch`"]
3488    fn a_styling_override_changes_the_text() {
3489        use crate::normalize::{NormalizeOptions, Styling};
3490
3491        let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/10_013s.wav");
3492        let (pcm, rate) = read_wav(&clip);
3493
3494        let engine = Engine::new(ConfigBuilder::new().build()).expect("engine must start");
3495        let default = engine
3496            .transcribe(Samples::new(pcm.clone(), rate, 1))
3497            .expect("queued")
3498            .recv()
3499            .expect("must transcribe");
3500        let casual = engine
3501            .transcribe_with(
3502                Samples::new(pcm, rate, 1),
3503                NormalizeOptions { styling: Styling::Casual, ..NormalizeOptions::default() },
3504            )
3505            .expect("queued")
3506            .recv()
3507            .expect("must transcribe");
3508
3509        let (Outcome::Transcript(a), Outcome::Transcript(b)) = (default, casual) else {
3510            panic!("both runs must produce transcripts");
3511        };
3512        assert_eq!(a.asr, b.asr, "the same audio must recognise identically; only styling differs");
3513        assert_ne!(
3514            a.text, b.text,
3515            "a styling override that changes nothing is a decorative argument: {}",
3516            a.text
3517        );
3518    }
3519
3520    /// Diagnostics must never break a take: a store directory that
3521    /// cannot be created is logged and dropped, and the words land
3522    /// anyway.
3523    #[test]
3524    #[ignore = "needs the ASR weights; run with --run-ignored all after `--example fetch`"]
3525    fn a_failed_diagnostics_store_does_not_fail_the_take() {
3526        let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/08_009s.wav");
3527        let (pcm, rate) = read_wav(&clip);
3528        let dir = tempfile::tempdir().unwrap();
3529        let blocker = dir.path().join("blocker");
3530        std::fs::write(&blocker, "a regular file, so nothing can be created under it").unwrap();
3531
3532        let cfg =
3533            ConfigBuilder::new().normalizer(None).diagnostics_dir(blocker.join("store")).build();
3534        let engine = Engine::new(cfg).expect("engine must start");
3535        let outcome = engine
3536            .transcribe(Samples::new(pcm, rate, 1))
3537            .expect("queued")
3538            .recv()
3539            .expect("recognition must succeed");
3540        assert!(
3541            matches!(outcome, Outcome::Transcript(_)),
3542            "the take must land over a dead store, got {outcome:?}"
3543        );
3544    }
3545
3546    /// The wiring leg: a real take populates the store, not just the
3547    /// store functions in isolation. The artifacts must agree with the
3548    /// answer the caller got - joined.txt is that take's exact
3549    /// normalizer input, and the capture is that take's own audio.
3550    #[test]
3551    #[ignore = "needs the ASR weights; run with --run-ignored all after `--example fetch`"]
3552    fn a_live_take_populates_the_diagnostics_store() {
3553        let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/08_009s.wav");
3554        let (pcm, rate) = read_wav(&clip);
3555        let dir = tempfile::tempdir().unwrap();
3556        let cfg = ConfigBuilder::new().normalizer(None).diagnostics_dir(dir.path()).build();
3557        let engine = Engine::new(cfg).expect("engine must start");
3558        let outcome = engine
3559            .transcribe(Samples::new(pcm.clone(), rate, 1))
3560            .expect("queued")
3561            .recv()
3562            .expect("recognition must succeed");
3563        let Outcome::Transcript(transcript) = outcome else {
3564            panic!("a spoken take must not read as silence: {outcome:?}");
3565        };
3566        // The store entry is written after the reply, by design, so
3567        // dropping the engine (which joins the worker) is the reliable
3568        // way to observe the write. Its own stopping flag is set first,
3569        // but the write check already ran on this job's answer, so the
3570        // join only ever waits out a write that started.
3571        drop(engine);
3572
3573        let entries: Vec<_> = std::fs::read_dir(dir.path()).expect("store dir").flatten().collect();
3574        assert_eq!(entries.len(), 1, "one take makes one store entry");
3575        let take = entries[0].path();
3576
3577        let meta: serde_json::Value =
3578            serde_json::from_str(&std::fs::read_to_string(take.join("meta.json")).unwrap())
3579                .expect("meta.json parses");
3580        assert_eq!(meta["outcome"], "transcript", "the take's own outcome is recorded");
3581        let duration_ms = u64::try_from(pcm.len()).unwrap_or(u64::MAX) * 1000 / 16_000;
3582        assert_eq!(
3583            meta["duration_ms"], duration_ms,
3584            "the metadata carries the capture's own length"
3585        );
3586
3587        assert_eq!(
3588            std::fs::read_to_string(take.join("joined.txt")).unwrap(),
3589            transcript.asr,
3590            "joined.txt is that take's exact normalizer input"
3591        );
3592        assert_eq!(
3593            std::fs::read_to_string(take.join("text.txt")).unwrap(),
3594            transcript.text,
3595            "text.txt is that take's final text"
3596        );
3597
3598        let windows = meta["windows"].as_array().expect("windows array");
3599        assert_eq!(windows.len(), 1, "a sub-window take records its one window");
3600        let raw = std::fs::read_to_string(take.join("raw/0.txt")).expect("window 0's transcript");
3601        assert!(!raw.is_empty(), "a spoken window produces a non-empty raw transcript");
3602
3603        let mut reader = hound::WavReader::open(take.join("output.wav")).unwrap();
3604        assert_eq!(
3605            reader.spec().sample_rate,
3606            SAMPLE_RATE,
3607            "the capture is stored at the model rate"
3608        );
3609        assert_eq!(reader.samples::<i16>().count(), pcm.len(), "the whole capture is stored");
3610    }
3611
3612    /// The direct path's wiring leg and its ordering pin: the record
3613    /// names the models the engine was BUILT with, and is built AFTER
3614    /// the cleanup pass - `Stages::normalize` is set by the pass itself,
3615    /// so a snapshot taken before it reports `normalizer_ran` false on a
3616    /// take that was cleaned.
3617    ///
3618    /// Weights-only: the failed-load path answers jobs without writing a
3619    /// record, so no weightless take reaches the worker's record site.
3620    #[test]
3621    #[ignore = "needs both models; run with --run-ignored all after `--example fetch`"]
3622    fn a_direct_take_records_the_models_and_the_cleanup_pass_it_ran() {
3623        let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/08_009s.wav");
3624        let (pcm, rate) = read_wav(&clip);
3625        let dir = tempfile::tempdir().unwrap();
3626        let cfg = ConfigBuilder::new().language("en").diagnostics_dir(dir.path()).build();
3627        let engine = Engine::new(cfg).expect("engine must start");
3628        let outcome = engine
3629            .transcribe(Samples::new(pcm, rate, 1))
3630            .expect("queued")
3631            .recv()
3632            .expect("recognition must succeed");
3633        let Outcome::Transcript(transcript) = outcome else {
3634            panic!("a spoken take must not read as silence: {outcome:?}");
3635        };
3636        assert!(
3637            transcript.stages.normalize.is_some(),
3638            "the default config runs cleanup, or this test pins nothing"
3639        );
3640        // The write lands after the reply, on the worker the engine drop
3641        // joins.
3642        drop(engine);
3643
3644        let take = std::fs::read_dir(dir.path())
3645            .expect("store dir")
3646            .flatten()
3647            .next()
3648            .expect("the take wrote its record")
3649            .path();
3650        let meta: serde_json::Value =
3651            serde_json::from_str(&std::fs::read_to_string(take.join("meta.json")).unwrap())
3652                .expect("meta.json parses");
3653        assert_eq!(
3654            meta["normalizer_ran"], true,
3655            "the record carries the pass that ran, not a pre-pass snapshot"
3656        );
3657        let asr = ModelSpec::cohere_transcribe_q4_k_m();
3658        let s1 = ModelSpec::s1_mini_f16();
3659        assert_eq!(meta["asr"]["file"], asr.file, "the transcribing model the engine ran");
3660        assert_eq!(meta["asr"]["sha256"], asr.sha256.clone().unwrap(), "its declared digest");
3661        assert_eq!(meta["asr"]["size"], asr.size, "its declared size");
3662        assert_eq!(meta["normalizer"]["file"], s1.file, "the cleanup model the engine ran");
3663        assert_eq!(meta["normalizer"]["sha256"], s1.sha256.clone().unwrap(), "its declared digest");
3664        assert_eq!(meta["normalizer"]["size"], s1.size, "its declared size");
3665        assert_eq!(
3666            meta["language"], "en",
3667            "the record carries the engine's own config, not a default"
3668        );
3669        assert_eq!(
3670            meta["forge_version"],
3671            env!("CARGO_PKG_VERSION"),
3672            "the release the take was made under"
3673        );
3674    }
3675
3676    /// The `Config` -> `Engine` settings leg, which is a DIFFERENT path
3677    /// from the per-call override above.
3678    ///
3679    /// `a_styling_override_changes_the_text` goes through
3680    /// `transcribe_with`; this one sets the styling on the config and
3681    /// calls plain `transcribe`, so it is the only thing that catches
3682    /// `Engine::new` dropping `cfg.normalize_options` on the floor.
3683    #[test]
3684    #[ignore = "needs both models; run with --run-ignored all after `--example fetch`"]
3685    fn config_styling_reaches_the_model_without_a_per_call_override() {
3686        use crate::normalize::{NormalizeOptions, Styling};
3687
3688        let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/10_013s.wav");
3689        let (pcm, rate) = read_wav(&clip);
3690
3691        let shipped = Engine::new(ConfigBuilder::new().build()).expect("engine must start");
3692        let casual = Engine::new(
3693            ConfigBuilder::new()
3694                .normalize_options(NormalizeOptions {
3695                    styling: Styling::Casual,
3696                    ..NormalizeOptions::default()
3697                })
3698                .build(),
3699        )
3700        .expect("engine must start");
3701
3702        let a = shipped
3703            .transcribe(Samples::new(pcm.clone(), rate, 1))
3704            .expect("queued")
3705            .recv()
3706            .expect("must transcribe");
3707        let b = casual
3708            .transcribe(Samples::new(pcm, rate, 1))
3709            .expect("queued")
3710            .recv()
3711            .expect("must transcribe");
3712
3713        let (Outcome::Transcript(a), Outcome::Transcript(b)) = (a, b) else {
3714            panic!("both runs must produce transcripts");
3715        };
3716        assert_eq!(a.asr, b.asr, "the same audio must recognise identically; only styling differs");
3717        assert_ne!(
3718            a.text, b.text,
3719            "config styling that never reaches the model is a setting in name only: {}",
3720            a.text
3721        );
3722    }
3723
3724    /// Teardown must abandon queued work rather than run it.
3725    ///
3726    /// Counts jobs rather than timing the drop, so machine load cannot
3727    /// flake it and a fast machine cannot make it vacuous.
3728    #[test]
3729    #[ignore = "needs the ASR weights; run with --run-ignored all after `--example fetch`"]
3730    fn dropping_the_engine_discards_the_backlog_instead_of_draining_it() {
3731        let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/08_009s.wav");
3732        assert!(clip.exists(), "fixture missing at {}", clip.display());
3733        let (pcm, rate) = read_wav(&clip);
3734
3735        let engine =
3736            Engine::new(ConfigBuilder::new().normalizer(None).build()).expect("engine must start");
3737        let queued: Vec<_> = (0..6)
3738            .map(|_| {
3739                engine
3740                    .transcribe(Samples::new(pcm.clone(), rate, 1))
3741                    .expect("a valid source queues")
3742            })
3743            .collect();
3744
3745        // Joins the worker, so every job has resolved by the time this
3746        // returns and each `recv` below answers immediately.
3747        drop(engine);
3748
3749        let answers: Vec<_> = queued.into_iter().map(Ticket::recv).collect();
3750        // Weights load on the worker, so `Engine::new` succeeds with no
3751        // model on disk - and then every job fails, every `ok()` is None,
3752        // and the count below reads zero for the wrong reason.
3753        let unloadable =
3754            answers.iter().filter(|a| matches!(a, Err(Error::ModelLoad { .. }))).count();
3755        assert_eq!(unloadable, 0, "the weights must actually be present, or this proves nothing");
3756
3757        let completed = answers.iter().filter(|a| a.is_ok()).count();
3758        assert!(
3759            completed <= 1,
3760            "teardown must discard the backlog: at most the in-flight job may finish, {completed} of 6 did"
3761        );
3762    }
3763}