1use 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
16static ROUTE_NATIVE_LOGS: Once = Once::new();
25
26#[derive(Debug, Clone, Default, PartialEq)]
31pub struct Stages {
32 pub model_load: Duration,
35 pub resample: Duration,
37 pub mel: Duration,
39 pub encode: Duration,
41 pub decode: Duration,
43 pub normalize: Option<Duration>,
48 pub audio: Duration,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct WindowProgress {
61 pub done: usize,
62 pub total: Option<usize>,
63}
64
65#[derive(Debug, Clone, PartialEq)]
67pub struct Transcript {
68 pub text: String,
77 pub asr: String,
81 pub stages: Stages,
83 pub truncated: bool,
96}
97
98#[derive(Debug, Clone, PartialEq)]
100pub enum Outcome {
101 Transcript(Transcript),
103 NoAudio {
109 peak: f32,
133 audio: Duration,
136 },
137}
138
139fn 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
169fn audio_duration(samples: usize) -> Duration {
172 Duration::from_micros((samples as u64).saturating_mul(1_000_000) / u64::from(SAMPLE_RATE))
173}
174
175fn 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
181const WINDOW_TARGET: usize = 60 * SAMPLE_RATE as usize;
189const WINDOW_MIN: usize = 30 * SAMPLE_RATE as usize;
193const ENERGY_FRAME: usize = SAMPLE_RATE as usize / 50;
195
196fn 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
216fn 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
236fn 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
253fn 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
271fn 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
287fn join_window_texts(parts: &[String]) -> String {
291 parts.iter().map(|p| p.trim()).filter(|p| !p.is_empty()).collect::<Vec<_>>().join(" ")
292}
293
294fn 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
308struct Job {
310 pcm: Vec<f32>,
311 resample: Duration,
312 audio: Duration,
313 truncated: bool,
314 normalize: Option<NormalizeOptions>,
318 prejoined: Option<String>,
321 diagnose: bool,
324 cancel: CancelToken,
325 reply: Sender<Result<Outcome, Error>>,
326 queued_at: Instant,
327}
328
329#[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
362pub struct Engine {
367 max_capture: Duration,
368 normalize_options: NormalizeOptions,
369 silence_floor: f32,
370 diagnostics_dir: Option<PathBuf>,
374 models: Arc<diagnostics::TakeModels>,
378 stopping: Arc<AtomicBool>,
382 in_flight: Arc<Mutex<Option<CancelToken>>>,
385 jobs: Option<Sender<Job>>,
387 worker: Option<std::thread::JoinHandle<()>>,
388 readiness: Arc<Readiness>,
390}
391
392impl Drop for Engine {
393 fn drop(&mut self) {
394 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 if let Some(worker) = self.worker.take() {
414 let _ = worker.join();
415 }
416 }
417}
418
419impl Engine {
420 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 pub fn wait_ready(&self) -> Result<(), Error> {
477 self.readiness.wait()
478 }
479
480 pub fn silence_floor(&self) -> f32 {
485 self.silence_floor
486 }
487
488 pub fn transcribe(&self, source: impl AudioSource) -> Result<Ticket, Error> {
495 self.transcribe_with(source, self.normalize_options)
496 }
497
498 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 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 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 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 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 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
635pub struct StreamCapture {
643 pub sink: FrameSink,
644 pub capture: Capture,
645}
646
647pub struct FrameSink {
652 recording: Arc<crate::capture::Recording>,
653 limit: usize,
654}
655
656impl FrameSink {
657 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
678pub struct CaptureMeter {
682 recording: Arc<crate::capture::Recording>,
683}
684
685impl CaptureMeter {
686 pub fn level(&self) -> f32 {
690 self.recording.peak_dbfs()
691 }
692
693 pub fn was_truncated(&self) -> bool {
696 self.recording.was_truncated()
697 }
698
699 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
713pub struct Capture {
719 engine: Arc<Engine>,
720 recording: Arc<crate::capture::Recording>,
721 max_capture: Duration,
722 segmenter: Option<std::thread::JoinHandle<()>>,
725 finish_tx: Option<Sender<FinishTake>>,
728 answer_rx: Option<Receiver<Result<Outcome, Error>>>,
731 progress_rx: Option<Receiver<WindowProgress>>,
735 cancel: CancelToken,
739}
740
741impl Capture {
742 pub fn level(&self) -> f32 {
752 self.recording.peak_dbfs()
753 }
754
755 pub fn meter(&self) -> CaptureMeter {
761 CaptureMeter { recording: Arc::clone(&self.recording) }
762 }
763
764 pub fn take_progress(&mut self) -> Option<Receiver<WindowProgress>> {
769 self.progress_rx.take()
770 }
771
772 pub fn finish(self) -> Result<Ticket, Error> {
774 let options = self.engine.normalize_options;
775 self.finish_with(options)
776 }
777
778 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 pub fn cancel(self) {
815 drop(self);
816 }
817
818 pub fn max_capture(&self) -> Duration {
821 self.max_capture
822 }
823
824 pub fn was_truncated(&self) -> bool {
828 self.recording.was_truncated()
829 }
830
831 fn stop_recording(&mut self) {
836 self.recording.stop();
837 }
838}
839
840impl Drop for Capture {
841 fn drop(&mut self) {
842 self.stop_recording();
846 self.finish_tx.take();
847 if let Some(segmenter) = self.segmenter.take() {
848 let _ = segmenter.join();
849 }
850 }
851}
852
853pub struct Ticket {
857 answer: Receiver<Result<Outcome, Error>>,
858 cancel: CancelToken,
859 delivered: bool,
866 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 pub fn recv(mut self) -> Result<Outcome, Error> {
888 let answer = self.answer.recv().map_err(|_| Error::EngineStopped)?;
889 self.delivered = true;
893 if let Some(segmenter) = self.segmenter.take() {
894 let _ = segmenter.join();
895 }
896 answer
897 }
898
899 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
920struct Loaded {
922 model: Model,
923 session: Session,
924 normalizer: Option<crate::normalize::Normalizer>,
925 model_load: Duration,
928}
929
930fn 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 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 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#[derive(Debug, PartialEq)]
978enum WindowAction {
979 Continue(String),
981 Truncated(String),
983 Cancelled,
985 FinalizeWithError { message: String },
989 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
1016fn failure_before_answer(cancel: &CancelToken, failure: Option<Error>) -> Option<Error> {
1026 if cancel.is_cancelled() { Some(Error::Cancelled) } else { failure }
1027}
1028
1029struct JobOutcome {
1031 answer: Result<Outcome, Error>,
1032 stages: Stages,
1035 diag: Option<(&'static str, String)>,
1037}
1038
1039fn 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 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 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
1074fn 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
1101fn keep_job_record(job: &Job, dir: &Path, record: &diagnostics::TakeRecord<'_>) {
1108 diagnostics::capture_take(dir, diagnostics::take_stamp(), record, &job.cancel);
1109}
1110
1111#[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 fn has_prior_text(&self) -> bool {
1131 self.asr_parts.iter().any(|part| !part.trim().is_empty())
1132 }
1133
1134 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
1174fn 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 ROUTE_NATIVE_LOGS.call_once(transcribe_cpp::init_logging);
1188
1189 let loaded = load_models(asr_path, cfg);
1190 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 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 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 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 if recognizing && stopping.load(Ordering::Relaxed) {
1256 job.cancel.cancel();
1257 }
1258 let mut state = TakeState::new(job.truncated);
1263 let started = Instant::now();
1264 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 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 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 let JobOutcome { answer, stages: diag_stages, diag } =
1311 finish_job(&job, &asr_parts, failure, truncated, stages, normalizer.as_ref());
1312 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 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
1352const SEGMENT_POLL: Duration = Duration::from_millis(250);
1356
1357#[derive(Clone, Copy)]
1361struct FinishTake {
1362 options: NormalizeOptions,
1363 truncated: bool,
1364}
1365
1366struct 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
1378struct TakeShared {
1380 cancel: CancelToken,
1382 answer: Sender<Result<Outcome, Error>>,
1384 progress: Sender<WindowProgress>,
1386}
1387
1388#[derive(Debug)]
1390enum Part {
1391 Transcript {
1394 raw: String,
1395 stages: Stages,
1396 truncated: bool,
1397 },
1398 NoAudio {
1400 peak: f32,
1401 audio: Duration,
1402 },
1403 Failed(Error),
1404}
1405
1406#[derive(Debug)]
1408enum Aggregate {
1409 Join {
1413 raws: Vec<String>,
1414 failed: Option<Error>,
1415 },
1416 NoAudio {
1417 peak: f32,
1418 audio: Duration,
1419 },
1420 Failed(Error),
1421}
1422
1423fn 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
1445fn take_is_truncated(folded_truncated: bool, finish_truncated: bool, failed: bool) -> bool {
1448 folded_truncated || finish_truncated || failed
1449}
1450
1451fn 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 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 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
1504struct TakeSegmenter {
1512 recording: Arc<crate::capture::Recording>,
1513 jobs: Sender<Job>,
1514 silence_floor: f32,
1515 diagnostics_dir: Option<PathBuf>,
1516 models: Arc<diagnostics::TakeModels>,
1518 take: TakeShared,
1519 last_cut: usize,
1521 cuts: Vec<(usize, usize)>,
1523 parts: Vec<Part>,
1526 pending: Vec<Receiver<Result<Outcome, Error>>>,
1529 settled: usize,
1531 first_queued_at: Option<Instant>,
1534 first_settled_at: Option<Instant>,
1535 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 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 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(®ion, 0) else { return };
1576 self.last_cut += relative;
1577 let segment = region[..relative].to_vec();
1580 self.submit_region(segment);
1581 }
1582
1583 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 self.parts.push(Part::Failed(Error::EngineStopped));
1620 self.stamp_settled();
1621 }
1622 }
1623
1624 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 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 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 fn finish(mut self, finish: FinishTake) {
1681 let started = Instant::now();
1682 self.drain_pending();
1683 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 self.drain_pending();
1696 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 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 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 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 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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[test]
2027 fn every_queued_job_is_answered_even_when_the_model_is_missing() {
2028 let (_dir, engine) = engine_without_weights();
2029 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 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 #[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 #[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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 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 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 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 #[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 #[test]
3015 fn windows_stay_within_the_sizing_bounds_and_cover_everything() {
3016 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 #[test]
3045 fn cuts_land_in_the_quietest_stretch_of_the_search_region() {
3046 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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#[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 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 drop(engine);
3748
3749 let answers: Vec<_> = queued.into_iter().map(Ticket::recv).collect();
3750 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}