1use std::path::Path;
9use std::sync::Arc;
10use std::sync::atomic::Ordering;
11
12use forge_dictate::ModelSpec;
13
14use crate::dictate::SetRecording;
15use crate::{DispatchError, Workspace};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum BenchRole {
21 Transcribing,
22 Cleanup,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27pub struct BenchTarget {
28 pub file: String,
29 pub role: BenchRole,
30 pub pinned: bool,
33}
34
35#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
37#[serde(tag = "state", rename_all = "snake_case")]
38pub enum BenchState {
39 #[default]
40 Idle,
41 Running {
45 target: BenchTarget,
46 tier: forge_dictate::bench::Tier,
47 clip: usize,
48 clips: usize,
49 so_far: Option<f64>,
50 },
51 Failed { target: BenchTarget, reason: String },
53}
54
55#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
57pub struct BenchResult {
58 pub target: BenchTarget,
59 pub tier: forge_dictate::bench::Tier,
60 pub metrics: forge_dictate::bench::Metrics,
61 pub at: String,
63 pub corpus: forge_dictate::bench::CorpusId,
66}
67
68#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
70#[serde(tag = "state", rename_all = "snake_case")]
71pub enum SpeakingBenchState {
72 #[default]
73 Idle,
74 Running { clip: usize, clips: usize },
77 Failed { reason: String },
79}
80
81#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
83pub struct SpeakingBenchResult {
84 pub file: String,
86 pub metrics: forge_tts::bench::TtsBenchResult,
87 pub at: String,
89 pub corpus: forge_dictate::bench::CorpusId,
91}
92
93#[cfg(any(test, feature = "testing"))]
96#[derive(Debug)]
97pub struct LiveRoles {
98 pub models: Vec<(crate::dictate::DictateRole, crate::install::ActiveModel)>,
101 pub config: forge_dictate::Config,
103 pub models_dir: std::path::PathBuf,
106}
107
108#[cfg(any(test, feature = "testing"))]
110struct StoreCopy(std::path::PathBuf);
111
112#[cfg(any(test, feature = "testing"))]
113impl StoreCopy {
114 fn take(live: &Path) -> Result<Self, String> {
115 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
116 let path = std::env::temp_dir().join(format!(
117 "forge-store-{}-{}.redb",
118 std::process::id(),
119 NEXT.fetch_add(1, Ordering::Relaxed)
120 ));
121 std::fs::copy(live, &path).map_err(|error| {
122 format!(
123 "the store at {} could not be read ({error}); has forge run with this config dir \
124 yet?",
125 live.display()
126 )
127 })?;
128 Ok(Self(path))
129 }
130}
131
132#[cfg(any(test, feature = "testing"))]
133impl Drop for StoreCopy {
134 fn drop(&mut self) {
135 let _ = std::fs::remove_file(&self.0);
136 }
137}
138
139#[cfg(any(test, feature = "testing"))]
150pub fn resolved_live_roles(config_dir: &Path, app_support: &Path) -> Result<LiveRoles, String> {
151 let loaded = crate::config::load_from_dir(config_dir)
152 .map_err(|error| format!("forge.toml could not be read: {error}"))?;
153 let settings = &loaded.dictate;
154 if !settings.enabled {
155 return Err("[dictate] enabled = false: dictation runs no role on this machine, so the \
156 bench has nothing to measure"
157 .to_owned());
158 }
159 let Some(models_dir) = settings.models_dir() else {
160 return Err("no models directory resolves: [dictate] models_dir is unset and there is no \
161 platform cache directory"
162 .to_owned());
163 };
164
165 let live = crate::store::path_for(app_support, config_dir);
166 let copy = StoreCopy::take(&live)?;
167 let db = crate::store::Db::open(©.0).map_err(|error| {
168 format!(
169 "the store at {} could not be read through a copy at {}: {error}",
170 live.display(),
171 copy.0.display()
172 )
173 })?;
174 let installed = crate::store::dictate_models::installed(&db).map_err(|error| {
175 format!("the installed models could not be read from {}: {error}", live.display())
176 })?;
177
178 let mut models = Vec::new();
179 for (role, pin) in settings.model_specs() {
180 let configured = match role {
181 crate::dictate::DictateRole::Transcribing => settings.transcribe_model.as_deref(),
182 crate::dictate::DictateRole::Normalization => settings.cleanup_model.as_deref(),
183 crate::dictate::DictateRole::Speaking => None,
185 };
186 let pick = if configured.is_none() {
189 crate::store::dictate_models::active(&db, role).map_err(|error| {
190 format!(
191 "the {} row could not be read from {}: {error}",
192 role.label(),
193 live.display()
194 )
195 })?
196 } else {
197 None
198 };
199 match crate::install::resolve_role(role, pin, configured, &installed, pick) {
200 crate::install::RoleResolution::Chosen(model) => models.push((role, model)),
201 crate::install::RoleResolution::NeedsFeed { key, variant } => {
202 return Err(format!(
203 "[dictate] {key} names {variant}, which this machine has not installed; the \
204 bench measures what a live role runs and never fetches"
205 ));
206 }
207 }
208 }
209
210 let config = crate::dictate::preflight_config(settings, &models);
211 Ok(LiveRoles { models, config, models_dir })
212}
213
214const RESULTS_SHOWN: usize = 50;
216
217#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
220pub struct ReadAloudRecording {
221 pub id: String,
223 pub duration_ms: u64,
224 pub bytes: u64,
226 pub sha256: String,
229 pub at: String,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
239pub struct ReadAloudState {
240 pub recordings: Vec<ReadAloudRecording>,
242 pub recording: bool,
243 pub error: Option<String>,
244 pub passage: String,
245 pub terms: Vec<String>,
248}
249
250fn read_aloud_recordings(dir: &Path) -> Vec<ReadAloudRecording> {
256 let Ok(entries) = std::fs::read_dir(dir) else {
257 return Vec::new();
258 };
259 let mut names: Vec<String> = entries
260 .flatten()
261 .map(|entry| entry.file_name().to_string_lossy().into_owned())
262 .filter(|name| {
263 name.strip_prefix("take-").is_some_and(|rest| {
264 !rest.is_empty() && rest.bytes().all(|byte| byte.is_ascii_digit())
265 })
266 })
267 .collect();
268 names.sort();
269
270 names
271 .into_iter()
272 .filter_map(|id| {
273 let millis = id.strip_prefix("take-")?.parse::<i64>().ok()?;
274 let take = dir.join(&id);
275 let meta = std::fs::read_to_string(take.join("meta.json")).ok()?;
276 let meta: serde_json::Value = serde_json::from_str(&meta).ok()?;
277 let wav = take.join("output.wav");
278 let at = time::OffsetDateTime::from_unix_timestamp(millis.checked_div(1000)?)
279 .ok()?
280 .format(&time::format_description::well_known::Rfc3339)
281 .ok()?;
282 Some(ReadAloudRecording {
283 id,
284 duration_ms: meta.get("duration_ms")?.as_u64()?,
285 bytes: std::fs::metadata(&wav).ok()?.len(),
286 sha256: meta.get("sha256")?.as_str()?.to_owned(),
287 at,
288 })
289 })
290 .collect()
291}
292
293pub fn config_for(
300 base: &forge_dictate::Config,
301 models_dir: &Path,
302 target: &BenchTarget,
303) -> Result<forge_dictate::Config, forge_dictate::Error> {
304 let path = models_dir.join(&target.file);
305 let size = std::fs::metadata(&path)
306 .map_err(|source| forge_dictate::Error::Io { path: path.clone(), source })?
307 .len();
308 let spec = ModelSpec {
309 file: target.file.clone(),
310 url: String::new(),
311 size,
312 sha256: None,
313 facts: forge_dictate::ModelFacts::default(),
314 };
315
316 let mut cfg = base.clone();
317 cfg.diagnostics_dir = None;
321 match target.role {
322 BenchRole::Transcribing => cfg.asr_model = spec,
323 BenchRole::Cleanup => cfg.normalizer = Some(spec),
324 }
325 Ok(cfg)
326}
327
328impl Workspace {
329 pub fn dictate_bench(&self) -> BenchState {
331 self.dictate_bench.lock().clone()
332 }
333
334 fn read_aloud_dir(&self) -> Option<std::path::PathBuf> {
338 #[cfg(any(test, feature = "testing"))]
339 if let Some(dir) = self.test_read_aloud_dir.lock().clone() {
340 return Some(dir);
341 }
342 forge_sdk::app_support_dir().ok().map(|dir| dir.join("dictate-read-aloud"))
343 }
344
345 pub fn read_aloud_state(&self) -> ReadAloudState {
349 let dir = self.read_aloud_dir();
350 let passage = forge_dictate::bench::READ_ALOUD_PASSAGE.to_owned();
351 ReadAloudState {
352 recordings: dir.as_deref().map(read_aloud_recordings).unwrap_or_default(),
353 recording: self.dictate_runtime.lock().set_recording.is_some(),
354 error: self.read_aloud_error.lock().clone(),
355 terms: forge_dictate::bench::terms(&passage),
356 passage,
357 }
358 }
359
360 pub(crate) fn delete_read_aloud(&self, id: &str) -> Result<(), DispatchError> {
366 let Some(dir) = self.read_aloud_dir() else {
367 return Err(DispatchError::ReadAloudUnavailable {
368 reason: "no app-support directory resolves".to_owned(),
369 });
370 };
371 let keep_pattern =
372 |rest: &str| !rest.is_empty() && rest.bytes().all(|byte| byte.is_ascii_digit());
373 if !id.strip_prefix("take-").is_some_and(keep_pattern) {
374 return Err(DispatchError::ReadAloudUnavailable {
375 reason: format!("{id} is not a recording this set holds"),
376 });
377 }
378 let path = dir.join(id);
379 if let Err(error) = std::fs::remove_dir_all(&path)
380 && error.kind() != std::io::ErrorKind::NotFound
381 {
382 tracing::warn!(%error, id, "read-aloud: the recording was not deleted");
383 return Err(DispatchError::ReadAloudUnavailable { reason: error.to_string() });
384 }
385 Ok(())
386 }
387
388 pub(crate) fn start_read_aloud(&self, initiator: Option<u64>) -> Result<(), DispatchError> {
396 if !self.config_arc().dictate.enabled {
397 return Err(DispatchError::DictateOff);
398 }
399 let mut runtime = self.dictate_runtime.lock();
400 if let Some(holder) = runtime.live_holder() {
401 return Err(DispatchError::TakeLive { holder });
402 }
403 *self.read_aloud_error.lock() = None;
404 runtime.set_recording = Some(SetRecording { initiator, samples: Vec::new() });
405 Ok(())
406 }
407
408 pub fn read_aloud_push(&self, samples: &[f32], initiator: Option<u64>) -> bool {
415 let mut runtime = self.dictate_runtime.lock();
416 let Some(recording) = runtime.set_recording.as_mut() else {
417 return false;
418 };
419 if recording.initiator != initiator {
420 return false;
421 }
422 let cap = usize::try_from(self.config_arc().dictate.max_capture_minutes)
423 .unwrap_or(usize::MAX)
424 .saturating_mul(60)
425 .saturating_mul(usize::try_from(forge_dictate::SAMPLE_RATE).unwrap_or(16_000));
426 let room = cap.saturating_sub(recording.samples.len());
427 let take = room.min(samples.len());
428 recording.samples.extend_from_slice(&samples[..take]);
429 take > 0
430 }
431
432 pub(crate) fn finish_read_aloud(
437 self: &Arc<Self>,
438 keep: bool,
439 initiator: Option<u64>,
440 ) -> Result<(), DispatchError> {
441 let samples = {
442 let mut runtime = self.dictate_runtime.lock();
443 let started_by_this_connection = runtime
444 .set_recording
445 .as_ref()
446 .is_some_and(|recording| recording.initiator == initiator);
447 if !started_by_this_connection {
448 return Err(DispatchError::ReadAloudNotRecording);
449 }
450 let Some(recording) = runtime.set_recording.take() else {
451 return Err(DispatchError::ReadAloudNotRecording);
452 };
453 recording.samples
454 };
455 if !keep {
456 return Ok(());
457 }
458 let Some(dir) = self.read_aloud_dir() else {
459 *self.read_aloud_error.lock() = Some("no app-support directory resolves".to_owned());
460 return Ok(());
461 };
462 if samples.is_empty() {
463 *self.read_aloud_error.lock() = Some("nothing was captured".to_owned());
464 return Ok(());
465 }
466
467 let this = Arc::clone(self);
468 tokio::task::spawn_blocking(move || {
469 let outcome = forge_dictate::bench::store_read_aloud(&dir, &samples);
470 match outcome {
471 Ok(()) => {}
472 Err(error) => {
473 tracing::warn!(
474 event_name = "read_aloud_write_failed",
475 %error,
476 dir = %dir.display(),
477 "the read-aloud set was not written"
478 );
479 *this.read_aloud_error.lock() = Some(error.to_string());
480 }
481 }
482 this.push_models();
483 });
484 Ok(())
485 }
486
487 pub fn bench_results(&self) -> Vec<BenchResult> {
489 let db = self.db.lock();
490 db.as_ref()
491 .map(|db| {
492 let mut rows = crate::store::bench_results::results(db).unwrap_or_default();
493 rows.truncate(RESULTS_SHOWN);
494 rows
495 })
496 .unwrap_or_default()
497 }
498
499 pub(crate) fn start_bench(
507 self: &Arc<Self>,
508 target: BenchTarget,
509 tier: forge_dictate::bench::Tier,
510 ) -> Result<(), DispatchError> {
511 if !self.config_arc().dictate.enabled {
512 return Err(DispatchError::DictateOff);
513 }
514 self.claim_bench()?;
515 if let Some(holder) = self.dictate_runtime.lock().live_holder() {
516 self.release_bench();
517 return Err(DispatchError::TakeLive { holder });
518 }
519 *self.dictate_bench.lock() =
520 BenchState::Running { target: target.clone(), tier, clip: 0, clips: 0, so_far: None };
521 self.dictate_bench_cancel.store(false, Ordering::Relaxed);
522
523 let this = Arc::clone(self);
524 tokio::spawn(async move {
525 let runner = Arc::clone(&this);
526 runner.run_bench(target, tier).await;
527 this.push_models();
528 });
529 Ok(())
530 }
531
532 pub(crate) fn delete_bench_result(
534 &self,
535 target: &BenchTarget,
536 tier: forge_dictate::bench::Tier,
537 corpus: &str,
538 ) -> Result<(), DispatchError> {
539 let db = self.db.lock();
540 if let Some(db) = db.as_ref() {
541 let key =
542 crate::store::bench_results::key_parts(target.role, &target.file, tier, corpus);
543 if let Err(error) = crate::store::bench_results::remove(db, &key) {
544 tracing::warn!(
545 event_name = "dictate_bench_delete_failed",
546 %error,
547 "the saved result was not deleted"
548 );
549 }
550 }
551 Ok(())
552 }
553
554 pub(crate) fn stop_bench(&self) -> Result<(), DispatchError> {
557 if !matches!(*self.dictate_bench.lock(), BenchState::Running { .. }) {
558 return Err(DispatchError::BenchNotRunning);
559 }
560 self.dictate_bench_cancel.store(true, Ordering::Relaxed);
561 Ok(())
562 }
563
564 pub(crate) fn bench_running(&self) -> bool {
568 self.bench_claim.load(Ordering::SeqCst)
569 || matches!(*self.dictate_bench.lock(), BenchState::Running { .. })
570 || matches!(*self.speaking_bench.lock(), SpeakingBenchState::Running { .. })
571 }
572
573 fn claim_bench(&self) -> Result<(), DispatchError> {
577 if self.bench_running() {
578 return Err(DispatchError::BenchRunning);
579 }
580 self.bench_claim
581 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
582 .map(|_| ())
583 .map_err(|_| DispatchError::BenchRunning)
584 }
585
586 fn release_bench(&self) {
587 self.bench_claim.store(false, Ordering::SeqCst);
588 }
589
590 pub fn speaking_bench(&self) -> SpeakingBenchState {
592 self.speaking_bench.lock().clone()
593 }
594
595 pub fn speaking_bench_results(&self) -> Vec<SpeakingBenchResult> {
598 let db = self.db.lock();
599 db.as_ref()
600 .map(|db| {
601 let mut rows = crate::store::speaking_results::results(db).unwrap_or_default();
602 rows.truncate(RESULTS_SHOWN);
603 rows
604 })
605 .unwrap_or_default()
606 }
607
608 pub(crate) fn start_speaking_bench(self: &Arc<Self>) -> Result<(), DispatchError> {
617 let config = self.config_arc();
618 if !config.tts.enabled {
619 return Err(DispatchError::VoiceOff { reason: "[tts] enabled is false".to_owned() });
620 }
621 if !config.dictate.enabled {
622 return Err(DispatchError::VoiceOff {
623 reason: "[dictate] enabled is false, so the recognizer half cannot run".to_owned(),
624 });
625 }
626 self.claim_bench()?;
627 if let Some(holder) = self.dictate_runtime.lock().live_holder() {
628 self.release_bench();
629 return Err(DispatchError::TakeLive { holder });
630 }
631 *self.speaking_bench.lock() = SpeakingBenchState::Running { clip: 0, clips: 0 };
632 self.speaking_bench_cancel.store(false, Ordering::Relaxed);
633
634 let this = Arc::clone(self);
635 let spawned = std::thread::Builder::new().name("forge-tts-bench".into()).spawn(move || {
636 let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
637 Ok(runtime) => runtime,
638 Err(error) => {
639 this.settle_speaking_bench(Err(format!(
640 "the bench runtime did not build: {error}"
641 )));
642 this.push_models();
643 return;
644 }
645 };
646 runtime.block_on(Arc::clone(&this).run_speaking_bench());
647 this.push_models();
648 });
649 if let Err(error) = spawned {
650 self.release_bench();
651 *self.speaking_bench.lock() = SpeakingBenchState::Failed {
652 reason: format!("the bench thread did not start: {error}"),
653 };
654 }
655 Ok(())
656 }
657
658 pub(crate) fn stop_speaking_bench(&self) -> Result<(), DispatchError> {
661 if !matches!(*self.speaking_bench.lock(), SpeakingBenchState::Running { .. }) {
662 return Err(DispatchError::BenchNotRunning);
663 }
664 self.speaking_bench_cancel.store(true, Ordering::Relaxed);
665 Ok(())
666 }
667
668 async fn run_speaking_bench(self: Arc<Self>) {
670 let settings = self.config_arc().dictate.clone();
671 let active = self.active_models();
672 let cfg = crate::dictate::preflight_config(&settings, &active);
673 let Some(models_dir) = settings.models_dir() else {
674 return self.settle_speaking_bench(Err("no models directory is configured".to_owned()));
675 };
676 let takes_dir = cfg.diagnostics_dir.clone().unwrap_or_else(|| models_dir.clone());
677 let read_aloud_dir = self.read_aloud_dir().unwrap_or_else(|| takes_dir.clone());
678 let public_dir = models_dir.join("public-corpus");
681 if let Err(error) = forge_dictate::bench::install_public_corpus(&models_dir) {
682 return self.settle_speaking_bench(Err(format!(
683 "the bundled public corpus could not be installed: {error}"
684 )));
685 }
686
687 let corpus = match forge_dictate::bench::corpus(
688 forge_dictate::bench::Tier::Public,
689 &takes_dir,
690 &read_aloud_dir,
691 &public_dir,
692 ) {
693 Ok(corpus) => corpus,
694 Err(error) => return self.settle_speaking_bench(Err(error.to_string())),
695 };
696 let clips: Vec<forge_tts::bench::BenchClip> = corpus
697 .clips
698 .iter()
699 .filter_map(|clip| clip.truth.clone())
700 .map(|transcript| forge_tts::bench::BenchClip { transcript })
701 .collect();
702 if clips.is_empty() {
703 return self.settle_speaking_bench(Err(
704 "nothing to bench: the bundled public corpus holds no clips".to_owned(),
705 ));
706 }
707 let total = clips.len();
708 *self.speaking_bench.lock() = SpeakingBenchState::Running { clip: 0, clips: total };
709
710 let Some(files) = self.speaking_files() else {
711 return self.settle_speaking_bench(Err(
712 "the speaking model is not installed; install it from the models page".to_owned(),
713 ));
714 };
715 let engine = match forge_tts::KokoroTts::load(&files) {
716 Ok(engine) => engine,
717 Err(error) => return self.settle_speaking_bench(Err(error.to_string())),
718 };
719 let recognizer = match forge_dictate::Engine::new(cfg) {
720 Ok(engine) => engine,
721 Err(error) => return self.settle_speaking_bench(Err(error.to_string())),
722 };
723 if let Err(error) = recognizer.wait_ready() {
724 return self.settle_speaking_bench(Err(error.to_string()));
725 }
726
727 let heard_error: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
731 let transcribe = |pcm: &[f32], rate: u32| {
732 let record = |said: String| {
733 if let Ok(mut slot) = heard_error.lock()
734 && slot.is_none()
735 {
736 *slot = Some(said);
737 }
738 String::new()
739 };
740 let resampled =
741 match forge_dictate::resample_mono(pcm, rate, forge_dictate::SAMPLE_RATE) {
742 Ok(resampled) => resampled,
743 Err(error) => return record(error.to_string()),
744 };
745 match recognizer
746 .transcribe(forge_dictate::Samples::mono(resampled))
747 .and_then(forge_dictate::Ticket::recv)
748 {
749 Ok(forge_dictate::Outcome::Transcript(transcript)) => transcript.text,
750 Ok(other) => record(format!("a clip produced no transcript: {other:?}")),
751 Err(error) => record(error.to_string()),
752 }
753 };
754
755 let this = Arc::clone(&self);
756 let outcome = forge_tts::bench::run(&engine, &clips, transcribe, |clip| {
757 *this.speaking_bench.lock() = SpeakingBenchState::Running { clip, clips: total };
758 this.push_models();
759 if this.speaking_bench_cancel.load(Ordering::Relaxed) {
760 std::ops::ControlFlow::Break(())
761 } else {
762 std::ops::ControlFlow::Continue(())
763 }
764 })
765 .await;
766 if let Some(said) = heard_error.lock().ok().and_then(|slot| slot.clone()) {
767 return self.settle_speaking_bench(Err(said));
768 }
769 match outcome {
770 Ok(metrics) => {
771 let file = files.model.file_name().map_or_else(
772 || files.model.display().to_string(),
773 |name| name.to_string_lossy().into_owned(),
774 );
775 self.settle_speaking_bench(Ok(SpeakingBenchResult {
776 file,
777 metrics,
778 at: crate::install::rfc3339_now(),
779 corpus: corpus.id,
780 }));
781 }
782 Err(error) => self.settle_speaking_bench(Err(error.to_string())),
783 }
784 }
785
786 fn settle_speaking_bench(&self, outcome: Result<SpeakingBenchResult, String>) {
789 self.release_bench();
790 match outcome {
791 Ok(result) => {
792 self.save_speaking_result(&result);
793 *self.speaking_bench.lock() = SpeakingBenchState::Idle;
794 }
795 Err(reason) => {
796 if self.speaking_bench_cancel.load(Ordering::Relaxed) {
797 *self.speaking_bench.lock() = SpeakingBenchState::Idle;
798 } else {
799 *self.speaking_bench.lock() = SpeakingBenchState::Failed { reason };
800 }
801 }
802 }
803 }
804
805 fn save_speaking_result(&self, result: &SpeakingBenchResult) {
806 let db = self.db.lock();
807 if let Some(db) = db.as_ref()
808 && let Err(error) = crate::store::speaking_results::save(db, result)
809 {
810 tracing::warn!(
811 event_name = "speaking_bench_save_failed",
812 %error,
813 file = %result.file,
814 "the speaking bench's result was not saved; the numbers are gone with the run"
815 );
816 }
817 }
818
819 async fn run_bench(self: Arc<Self>, target: BenchTarget, tier: forge_dictate::bench::Tier) {
821 let settings = self.config_arc().dictate.clone();
822 let active = self.active_models();
823 let cfg = crate::dictate::preflight_config(&settings, &active);
824 let Some(models_dir) = settings.models_dir() else {
825 return self.fail_bench(target, "no models directory is configured".to_owned());
826 };
827 let takes_dir = cfg.diagnostics_dir.clone();
828 let read_aloud_dir = self.read_aloud_dir();
829 let public_dir = models_dir.join("public-corpus");
832 let ran = target.clone();
835
836 let this = Arc::clone(&self);
837 let outcome = tokio::task::spawn_blocking(move || {
838 let Some(takes_dir) = takes_dir else {
839 return Err("no diagnostics directory is configured".to_owned());
840 };
841 let read_aloud_dir = read_aloud_dir.unwrap_or_else(|| takes_dir.clone());
842 if tier == forge_dictate::bench::Tier::Public
843 && let Err(error) = forge_dictate::bench::install_public_corpus(&models_dir)
844 {
845 return Err(format!("the bundled public corpus could not be installed: {error}"));
846 }
847 let corpus =
848 forge_dictate::bench::corpus(tier, &takes_dir, &read_aloud_dir, &public_dir)
849 .map_err(|error| error.to_string())?;
850 if corpus.clips.is_empty() {
851 return Err(match tier {
852 forge_dictate::bench::Tier::Consensus => {
853 "nothing to bench: no takes have been saved here yet - dictate a take, \
854 or record the read-aloud passage and score that"
855 .to_owned()
856 }
857 forge_dictate::bench::Tier::ReadAloud => {
858 "nothing to bench: the read-aloud passage has not been recorded yet"
859 .to_owned()
860 }
861 forge_dictate::bench::Tier::Public => {
862 "nothing to bench: the bundled public corpus holds no clips".to_owned()
863 }
864 });
865 }
866 let run_cfg = config_for(&cfg, &models_dir, &ran).map_err(|e| e.to_string())?;
867
868 let mut compared = 0_usize;
869 let mut agreed = 0_usize;
870 let clips = corpus.clips.len();
871 let outcome = forge_dictate::bench::run(&run_cfg, &corpus, |clip| {
872 if clip.matched.is_some() {
873 compared += 1;
874 if clip.matched == Some(true) {
875 agreed += 1;
876 }
877 }
878 *this.dictate_bench.lock() = BenchState::Running {
879 target: ran.clone(),
880 tier,
881 clip: clip.index + 1,
882 clips,
883 so_far: (compared > 0).then(|| forge_dictate::bench::ratio(agreed, compared)),
884 };
885 this.push_models();
886 if this.dictate_bench_cancel.load(Ordering::Relaxed) {
887 std::ops::ControlFlow::Break(())
888 } else {
889 std::ops::ControlFlow::Continue(())
890 }
891 });
892 Ok::<_, String>((outcome, corpus.id))
893 })
894 .await;
895
896 match outcome {
897 Ok(Ok((Ok(metrics), corpus))) => {
898 let result = BenchResult {
899 target: target.clone(),
900 tier,
901 metrics,
902 at: crate::install::rfc3339_now(),
903 corpus,
904 };
905 self.settle_bench_success(&result);
906 }
907 Ok(Ok((Err(error), _))) => self.settle_bench(&target, error.to_string()),
908 Ok(Err(reason)) => self.settle_bench(&target, reason),
909 Err(join) => self.settle_bench(&target, join.to_string()),
910 }
911 }
912
913 fn settle_bench_success(&self, result: &BenchResult) {
918 self.save_bench_result(result);
919 self.release_bench();
920 *self.dictate_bench.lock() = BenchState::Idle;
921 }
922
923 fn settle_bench(&self, target: &BenchTarget, reason: String) {
927 self.release_bench();
928 if self.dictate_bench_cancel.load(Ordering::Relaxed) {
929 *self.dictate_bench.lock() = BenchState::Idle;
930 } else {
931 self.fail_bench(target.clone(), reason);
932 }
933 }
934
935 fn fail_bench(&self, target: BenchTarget, reason: String) {
936 self.release_bench();
937 *self.dictate_bench.lock() = BenchState::Failed { target, reason };
938 }
939
940 fn save_bench_result(&self, result: &BenchResult) {
941 let db = self.db.lock();
942 if let Some(db) = db.as_ref()
943 && let Err(error) = crate::store::bench_results::save(db, result)
944 {
945 tracing::warn!(
946 event_name = "dictate_bench_save_failed",
947 %error,
948 file = %result.target.file,
949 "the bench's result was not saved; the numbers are gone with the run"
950 );
951 }
952 }
953}
954
955#[cfg(test)]
956mod tests {
957 use super::*;
958 use crate::SeatUpdate;
959
960 fn present(dir: &Path, file: &str) {
962 std::fs::write(dir.join(file), b"weights").unwrap();
963 }
964
965 fn target(file: &str, role: BenchRole) -> BenchTarget {
966 BenchTarget { file: file.to_owned(), role, pinned: false }
967 }
968
969 fn metrics(clips: usize) -> forge_dictate::bench::Metrics {
970 forge_dictate::bench::Metrics {
971 clips,
972 audio_seconds: 12.5,
973 wall_seconds: 1.25,
974 xrt_wall: 10.0,
975 term_accuracy: Some(0.75),
976 wer: Some(0.05),
977 matched: Some((2, 3)),
978 stages_ms: forge_dictate::bench::StageTotals::default(),
979 }
980 }
981
982 fn corpus_id(sha: &str) -> forge_dictate::bench::CorpusId {
983 forge_dictate::bench::CorpusId { clips: 3, audio_seconds: 12, sha256: sha.to_owned() }
984 }
985
986 #[test]
990 fn a_targets_config_swaps_its_own_slot_and_leaves_the_other_alone() {
991 let dir = tempfile::tempdir().unwrap();
992 present(dir.path(), "candidate.gguf");
993 let base = forge_dictate::Config::default();
994
995 let for_it = config_for(
996 &base,
997 dir.path(),
998 &BenchTarget {
999 file: "candidate.gguf".to_owned(),
1000 role: BenchRole::Transcribing,
1001 pinned: false,
1002 },
1003 )
1004 .expect("the file is on disk");
1005
1006 assert_eq!(for_it.asr_model.file, "candidate.gguf");
1007 assert!(for_it.asr_model.url.is_empty(), "a bench never fetches");
1008 assert!(for_it.asr_model.sha256.is_none(), "no digest is published for one");
1009 assert_eq!(for_it.asr_model.size, 7, "the size is the bytes on disk");
1010 assert!(
1011 for_it.diagnostics_dir.is_none(),
1012 "a bench run must not write takes into the store it scores against"
1013 );
1014 assert_eq!(
1015 for_it.normalizer.as_ref().map(|spec| spec.file.clone()),
1016 base.normalizer.as_ref().map(|spec| spec.file.clone()),
1017 "the other role keeps its model"
1018 );
1019
1020 let for_cleanup = config_for(
1021 &base,
1022 dir.path(),
1023 &BenchTarget {
1024 file: "candidate.gguf".to_owned(),
1025 role: BenchRole::Cleanup,
1026 pinned: false,
1027 },
1028 )
1029 .expect("the file is on disk");
1030 assert_eq!(
1031 for_cleanup.normalizer.as_ref().map(|spec| spec.file.as_str()),
1032 Some("candidate.gguf")
1033 );
1034 assert_eq!(for_cleanup.asr_model.file, base.asr_model.file);
1035 }
1036
1037 #[test]
1040 fn a_target_whose_file_is_gone_fails_by_name() {
1041 let dir = tempfile::tempdir().unwrap();
1042
1043 let err = config_for(
1044 &forge_dictate::Config::default(),
1045 dir.path(),
1046 &target("gone.gguf", BenchRole::Transcribing),
1047 )
1048 .expect_err("no file, no config");
1049
1050 assert!(err.to_string().contains("gone.gguf"), "got: {err}");
1051 }
1052
1053 #[tokio::test]
1055 async fn the_two_benches_refuse_each_other_by_name() {
1056 let (ws, _updates, _config, _models) = voice_stub();
1057 *ws.dictate_bench.lock() = BenchState::Running {
1058 target: target("asr.gguf", BenchRole::Transcribing),
1059 tier: forge_dictate::bench::Tier::Consensus,
1060 clip: 0,
1061 clips: 0,
1062 so_far: None,
1063 };
1064 let err = ws.start_speaking_bench().expect_err("one bench at a time");
1065 assert!(matches!(err, crate::DispatchError::BenchRunning), "got: {err:?}");
1066
1067 *ws.dictate_bench.lock() = BenchState::Idle;
1068 *ws.speaking_bench.lock() = SpeakingBenchState::Running { clip: 0, clips: 1 };
1069 let err = ws
1070 .dispatch(crate::Command::DictateBench {
1071 target: target("asr.gguf", BenchRole::Transcribing),
1072 tier: forge_dictate::bench::Tier::Consensus,
1073 })
1074 .expect_err("the speaking bench holds the machine");
1075 assert!(matches!(err, crate::DispatchError::BenchRunning), "got: {err:?}");
1076 }
1077
1078 #[test]
1080 fn a_speaking_stop_with_nothing_running_is_refused() {
1081 let (ws, _updates, _config, _models) = voice_stub();
1082 let err = ws.dispatch(crate::Command::VoiceBenchStop).expect_err("nothing to stop");
1083 assert!(matches!(err, crate::DispatchError::BenchNotRunning), "got: {err:?}");
1084 }
1085
1086 #[test]
1089 fn a_stopped_speaking_run_settles_to_idle_and_saves_nothing() {
1090 let (ws, _updates, _config, _models) = voice_stub();
1091 let dir = tempfile::tempdir().unwrap();
1092 ws.install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).unwrap());
1093 *ws.speaking_bench.lock() = SpeakingBenchState::Running { clip: 3, clips: 12 };
1094 ws.speaking_bench_cancel.store(true, Ordering::Relaxed);
1095
1096 ws.settle_speaking_bench(Err("stopped".to_owned()));
1097
1098 assert!(matches!(*ws.speaking_bench.lock(), SpeakingBenchState::Idle));
1099 assert!(ws.speaking_bench_results().is_empty(), "a stop saves nothing");
1100 }
1101
1102 #[test]
1104 fn a_failed_speaking_run_settles_to_failed() {
1105 let (ws, _updates, _config, _models) = voice_stub();
1106 *ws.speaking_bench.lock() = SpeakingBenchState::Running { clip: 0, clips: 1 };
1107
1108 ws.settle_speaking_bench(Err("the model is missing".to_owned()));
1109
1110 assert!(
1111 matches!(&*ws.speaking_bench.lock(), SpeakingBenchState::Failed { reason } if reason.contains("missing")),
1112 "got: {:?}",
1113 *ws.speaking_bench.lock(),
1114 );
1115 }
1116
1117 #[test]
1120 fn a_speaking_bench_with_speech_off_is_refused_by_name() {
1121 let (ws, _updates) = Workspace::testing_stub();
1122 let err = ws.start_speaking_bench().expect_err("no [tts] section to run");
1123 assert!(matches!(err, crate::DispatchError::VoiceOff { .. }), "got: {err:?}");
1124 }
1125
1126 #[tokio::test]
1130 async fn a_speaking_bench_installs_the_bundled_corpus_before_scoring() {
1131 let (ws, mut updates, _config, models) = voice_stub();
1132 ws.dispatch(crate::Command::VoiceBenchStart).expect("the run starts and fails on its own");
1133
1134 let state = tokio::time::timeout(std::time::Duration::from_secs(15), async {
1135 loop {
1136 match updates.recv().await {
1137 Some(frame) => {
1138 if let crate::SessionUpdate::DictateModelsChanged { models } =
1139 std::sync::Arc::unwrap_or_clone(frame.update)
1140 && matches!(models.speaking_bench, SpeakingBenchState::Failed { .. })
1141 {
1142 break models.speaking_bench;
1143 }
1144 }
1145 None => panic!("the subscription must stay attached"),
1146 }
1147 }
1148 })
1149 .await
1150 .expect("the failure must land inside fifteen seconds");
1151 let SpeakingBenchState::Failed { reason } = state else {
1152 panic!("a run with no speaking model must land failed");
1153 };
1154 assert!(!reason.contains("public corpus"), "the bundled corpus is not a blocker: {reason}");
1155 assert!(reason.contains("speaking model"), "the missing model is named: {reason}");
1156
1157 let installed = forge_dictate::bench::public_corpus(&models.path().join("public-corpus"))
1158 .expect("the bundled slice installed");
1159 assert_eq!(installed.len(), 32, "the whole slice landed beside the models");
1160 }
1161
1162 #[tokio::test]
1166 async fn a_public_words_bench_installs_the_bundled_corpus_before_scoring() {
1167 let (ws, mut updates, _config, models) = voice_stub();
1168 ws.dispatch(crate::Command::DictateBench {
1169 target: target("asr.gguf", BenchRole::Transcribing),
1170 tier: forge_dictate::bench::Tier::Public,
1171 })
1172 .expect("the run starts and fails on its own");
1173
1174 tokio::time::timeout(std::time::Duration::from_secs(15), async {
1175 loop {
1176 match updates.recv().await {
1177 Some(frame) => {
1178 if let crate::SessionUpdate::DictateModelsChanged { models } =
1179 std::sync::Arc::unwrap_or_clone(frame.update)
1180 && matches!(models.bench, BenchState::Failed { .. })
1181 {
1182 break;
1183 }
1184 }
1185 None => panic!("the subscription must stay attached"),
1186 }
1187 }
1188 })
1189 .await
1190 .expect("the failure must land inside fifteen seconds");
1191
1192 let installed = forge_dictate::bench::public_corpus(&models.path().join("public-corpus"))
1193 .expect("the bundled slice installed");
1194 assert_eq!(installed.len(), 32, "the whole slice landed beside the models");
1195 }
1196
1197 fn voice_stub() -> (
1200 Arc<Workspace>,
1201 tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>,
1202 tempfile::TempDir,
1203 tempfile::TempDir,
1204 ) {
1205 let config_dir = tempfile::tempdir().unwrap();
1206 let models = tempfile::tempdir().unwrap();
1207 let mut config = crate::config::LoadedConfig::empty_for_test();
1208 config.dictate.enabled = true;
1209 config.dictate.models_dir = Some(models.path().to_string_lossy().into_owned());
1210 config.tts.enabled = true;
1211 config.tts.models_dir = Some(models.path().to_string_lossy().into_owned());
1212 let (ws, updates) =
1213 Workspace::testing_stub_with_config(config_dir.path().to_path_buf(), config)
1214 .expect("the stub builds");
1215 (ws, updates, config_dir, models)
1216 }
1217
1218 #[test]
1221 fn a_bench_refuses_by_name_while_a_take_is_live() {
1222 let (ws, _updates, _models) = crate::catalogue::tests_catalogue_view::enabled_stub();
1223 let (stop, _stop_rx) = tokio::sync::mpsc::channel(1);
1224 ws.dictate_runtime.lock().recordings.insert(
1225 crate::SessionSlot::new("Busytools", "forge", "worker"),
1226 crate::dictate::LiveRecording { stop, sink: None, initiator: None },
1227 );
1228
1229 let err = ws
1230 .dispatch(crate::Command::DictateBench {
1231 target: target("asr.gguf", BenchRole::Transcribing),
1232 tier: forge_dictate::bench::Tier::Consensus,
1233 })
1234 .expect_err("a live take refuses the run");
1235
1236 assert!(
1237 matches!(&err, crate::DispatchError::TakeLive { holder } if holder == "Busytools/forge/worker"),
1238 "the refusal names the seat whose take is live, got: {err:?}"
1239 );
1240 assert_eq!(ws.dictate_bench(), BenchState::Idle, "and nothing started");
1241 }
1242
1243 #[test]
1247 fn a_stopped_run_settles_to_idle_and_saves_nothing() {
1248 let (ws, _updates) = Workspace::testing_stub();
1249 let dir = tempfile::tempdir().unwrap();
1250 ws.install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).unwrap());
1251
1252 let err = ws.dispatch(crate::Command::DictateBenchStop).expect_err("nothing to stop");
1253 assert!(matches!(err, crate::DispatchError::BenchNotRunning), "got: {err:?}");
1254
1255 let target = target("asr.gguf", BenchRole::Transcribing);
1256 *ws.dictate_bench.lock() = BenchState::Running {
1257 target: target.clone(),
1258 tier: forge_dictate::bench::Tier::Consensus,
1259 clip: 3,
1260 clips: 12,
1261 so_far: Some(0.5),
1262 };
1263 ws.dispatch(crate::Command::DictateBenchStop).expect("a running bench stops");
1264
1265 ws.settle_bench(&target, forge_dictate::Error::Cancelled.to_string());
1266
1267 assert_eq!(ws.dictate_bench(), BenchState::Idle, "a stop is not a failure");
1268 assert!(ws.bench_results().is_empty(), "nothing measured part-way is kept");
1269 }
1270
1271 #[test]
1274 fn a_successful_bench_releases_the_claim() {
1275 let (ws, _updates) = Workspace::testing_stub();
1276 let dir = tempfile::tempdir().unwrap();
1277 ws.install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).unwrap());
1278 ws.claim_bench().expect("the claim is free");
1279
1280 ws.settle_bench_success(&BenchResult {
1281 target: target("asr.gguf", BenchRole::Transcribing),
1282 tier: forge_dictate::bench::Tier::Consensus,
1283 metrics: metrics(3),
1284 at: "2026-11-02T00:00:00Z".to_owned(),
1285 corpus: corpus_id("abc"),
1286 });
1287
1288 assert!(!ws.bench_running(), "the claim is released");
1289 assert_eq!(ws.dictate_bench(), BenchState::Idle);
1290 }
1291
1292 #[test]
1294 fn a_settled_speaking_run_releases_the_claim() {
1295 let (ws, _updates, _config, _models) = voice_stub();
1296 ws.claim_bench().expect("the claim is free");
1297
1298 ws.settle_speaking_bench(Err("stopped".to_owned()));
1299
1300 assert!(!ws.bench_running(), "the claim is released");
1301 }
1302
1303 #[test]
1305 fn a_failed_bench_leaves_the_slot_free() {
1306 let (ws, _updates, _config, _models) = voice_stub();
1307 ws.claim_bench().expect("the claim is free");
1308
1309 ws.fail_bench(
1310 target("asr.gguf", BenchRole::Transcribing),
1311 "the model would not load".to_owned(),
1312 );
1313
1314 ws.claim_bench().expect("the claim is free again");
1315 }
1316
1317 #[test]
1319 fn a_stopped_bench_leaves_the_slot_free() {
1320 let (ws, _updates, _config, _models) = voice_stub();
1321 ws.claim_bench().expect("the claim is free");
1322 ws.dictate_bench_cancel.store(true, Ordering::Relaxed);
1323
1324 ws.settle_bench(&target("asr.gguf", BenchRole::Transcribing), "stopped".to_owned());
1325
1326 ws.claim_bench().expect("the claim is free again");
1327 }
1328
1329 #[test]
1331 fn a_run_that_ends_without_a_result_lands_failed() {
1332 let (ws, _updates) = Workspace::testing_stub();
1333 let target = target("asr.gguf", BenchRole::Transcribing);
1334
1335 ws.settle_bench(&target, "the model would not load".to_owned());
1336
1337 let BenchState::Failed { target: failed, reason } = ws.dictate_bench() else {
1338 panic!("a failure must land as one, got {:?}", ws.dictate_bench());
1339 };
1340 assert_eq!(failed.file, "asr.gguf");
1341 assert_eq!(reason, "the model would not load");
1342 }
1343
1344 #[test]
1347 fn a_result_round_trips_through_the_store_newest_first() {
1348 let (ws, _updates) = Workspace::testing_stub();
1349 let dir = tempfile::tempdir().unwrap();
1350 ws.install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).unwrap());
1351 let target = target("asr.gguf", BenchRole::Transcribing);
1352
1353 let older = BenchResult {
1354 target: target.clone(),
1355 tier: forge_dictate::bench::Tier::Consensus,
1356 metrics: metrics(15),
1357 at: "2026-10-06T10:00:00Z".to_owned(),
1358 corpus: corpus_id("aaaa"),
1359 };
1360 let mut newer = older.clone();
1361 newer.at = "2026-10-06T11:00:00Z".to_owned();
1362 newer.corpus = corpus_id("bbbb");
1363 ws.save_bench_result(&older);
1364 ws.save_bench_result(&newer);
1365
1366 let rows = ws.bench_results();
1367
1368 assert_eq!(rows.len(), 2, "two corpora are two results");
1369 assert_eq!(rows[0].at, "2026-10-06T11:00:00Z", "newest first");
1370 assert_eq!(rows[0].metrics.clips, 15, "the metrics cross whole");
1371 assert_eq!(rows[0].metrics.term_accuracy, Some(0.75));
1372
1373 let mut rerun = older.clone();
1375 rerun.at = "2026-10-06T12:00:00Z".to_owned();
1376 ws.save_bench_result(&rerun);
1377 let rows = ws.bench_results();
1378 assert_eq!(rows.len(), 2, "a re-run replaces its own row");
1379 assert_eq!(
1380 rows.iter().filter(|row| row.corpus.sha256 == "aaaa").count(),
1381 1,
1382 "one row per (role, file, tier, corpus)"
1383 );
1384 }
1385
1386 fn drained_models(
1388 updates: &mut tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>,
1389 ) -> Option<crate::catalogue::DictateModelsSnapshot> {
1390 let mut found = None;
1391 while let Ok(frame) = updates.try_recv() {
1392 if let crate::SessionUpdate::DictateModelsChanged { models } =
1393 std::sync::Arc::unwrap_or_clone(frame.update)
1394 {
1395 found = Some(models);
1396 }
1397 }
1398 found
1399 }
1400
1401 #[test]
1406 fn starting_and_stopping_a_recording_push_the_models_read() {
1407 let (ws, mut updates, _models) = crate::catalogue::tests_catalogue_view::enabled_stub();
1408 let dir = tempfile::tempdir().unwrap();
1409 *ws.test_read_aloud_dir.lock() = Some(dir.path().to_path_buf());
1410
1411 ws.dispatch(crate::Command::DictateReadAloudStart { initiator: Some(1) })
1412 .expect("nothing else is capturing");
1413 let models = drained_models(&mut updates).expect("starting pushes the read");
1414 assert!(models.read_aloud.recording, "the frame carries the recording");
1415
1416 ws.dispatch(crate::Command::DictateReadAloudStop { keep: false, initiator: Some(1) })
1417 .expect("the recording is this connection's");
1418 let models = drained_models(&mut updates).expect("stopping pushes the read");
1419 assert!(!models.read_aloud.recording, "the frame carries it stopped");
1420 }
1421
1422 async fn await_recorded(
1426 updates: &mut tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>,
1427 ) -> crate::catalogue::DictateModelsSnapshot {
1428 tokio::time::timeout(std::time::Duration::from_secs(15), async {
1429 loop {
1430 match updates
1431 .recv()
1432 .await
1433 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
1434 {
1435 Some(crate::SessionUpdate::DictateModelsChanged { models }) => {
1436 if !models.read_aloud.recordings.is_empty() {
1437 break models;
1438 }
1439 }
1440 Some(_) => {}
1441 None => panic!("the subscription must stay attached"),
1442 }
1443 }
1444 })
1445 .await
1446 .expect("the set must land inside fifteen seconds")
1447 }
1448
1449 #[tokio::test]
1454 async fn a_kept_recording_becomes_the_set_and_a_dropped_one_writes_nothing() {
1455 let (ws, mut updates, _models) = crate::catalogue::tests_catalogue_view::enabled_stub();
1456 let dir = tempfile::tempdir().unwrap();
1457 *ws.test_read_aloud_dir.lock() = Some(dir.path().to_path_buf());
1458
1459 ws.dispatch(crate::Command::DictateReadAloudStart { initiator: Some(1) }).unwrap();
1460 assert!(ws.read_aloud_push(&vec![0.25_f32; 1_600], Some(1)), "its own frames land");
1461 assert!(!ws.read_aloud_push(&[0.0_f32; 8], Some(2)), "another connection's do not");
1462 ws.dispatch(crate::Command::DictateReadAloudStop { keep: true, initiator: Some(1) })
1463 .unwrap();
1464
1465 let models = await_recorded(&mut updates).await;
1466 assert_eq!(models.read_aloud.recordings.len(), 1, "the read says the set is here");
1467 let clips = forge_dictate::bench::read_aloud(dir.path()).expect("the set reads");
1468 assert_eq!(clips.len(), 1, "a passage and a take are there");
1469 assert_eq!(clips[0].audio.len(), 1_600, "the frames the page fed");
1470
1471 ws.dispatch(crate::Command::DictateReadAloudStart { initiator: Some(1) }).unwrap();
1474 assert!(ws.read_aloud_push(&vec![0.5_f32; 800], Some(1)));
1475 ws.dispatch(crate::Command::DictateReadAloudStop { keep: false, initiator: Some(1) })
1476 .unwrap();
1477 let clips = forge_dictate::bench::read_aloud(dir.path()).unwrap();
1478 assert_eq!(clips.len(), 1, "a dropped recording leaves the set alone");
1479 assert_eq!(clips[0].audio.len(), 1_600);
1480 }
1481
1482 #[tokio::test]
1486 async fn a_recording_is_deleted_by_its_id_and_a_foreign_one_is_refused() {
1487 let (ws, mut updates, _models) = crate::catalogue::tests_catalogue_view::enabled_stub();
1488 let dir = tempfile::tempdir().unwrap();
1489 *ws.test_read_aloud_dir.lock() = Some(dir.path().to_path_buf());
1490
1491 ws.dispatch(crate::Command::DictateReadAloudStart { initiator: Some(1) }).unwrap();
1492 assert!(ws.read_aloud_push(&vec![0.25_f32; 1_600], Some(1)));
1493 ws.dispatch(crate::Command::DictateReadAloudStop { keep: true, initiator: Some(1) })
1494 .unwrap();
1495 let models = await_recorded(&mut updates).await;
1496 let id = models.read_aloud.recordings[0].id.clone();
1497 assert_eq!(models.read_aloud.recordings[0].duration_ms, 100, "a tenth of a second");
1498
1499 let outside = dir.path().join("neighbour");
1502 std::fs::create_dir_all(&outside).unwrap();
1503 for id in ["../neighbour", "neighbour", "take-x", "take-"] {
1504 let err = ws.dispatch(crate::Command::DictateReadAloudDelete { id: id.to_owned() });
1505 assert!(err.is_err(), "accepted {id:?}");
1506 }
1507 assert!(outside.is_dir(), "a foreign directory must survive");
1508
1509 ws.dispatch(crate::Command::DictateReadAloudDelete { id: id.clone() }).unwrap();
1510 let models = drained_models(&mut updates).expect("the delete pushes the read");
1511 assert!(models.read_aloud.recordings.is_empty(), "the recording left the set");
1512 assert!(!dir.path().join(&id).exists(), "and its directory went with it");
1513 }
1514
1515 #[test]
1519 fn a_recording_and_a_take_refuse_each_other() {
1520 let (ws, _updates, _models) = crate::catalogue::tests_catalogue_view::enabled_stub();
1521 let dir = tempfile::tempdir().unwrap();
1522 *ws.test_read_aloud_dir.lock() = Some(dir.path().to_path_buf());
1523
1524 ws.dispatch(crate::Command::DictateReadAloudStart { initiator: Some(1) }).unwrap();
1525 assert_eq!(
1526 ws.dictate_runtime.lock().live_holder().as_deref(),
1527 Some("the read-aloud recording"),
1528 "a recording is a live capture"
1529 );
1530 let err = ws
1531 .dispatch(crate::Command::DictateReadAloudStart { initiator: Some(1) })
1532 .expect_err("one recording at a time");
1533 assert!(
1534 matches!(err, crate::DispatchError::TakeLive { ref holder } if holder == "the read-aloud recording"),
1535 "got: {err:?}"
1536 );
1537
1538 let (stop, _stop_rx) = tokio::sync::mpsc::channel(1);
1539 ws.dictate_runtime.lock().recordings.insert(
1540 crate::SessionSlot::new("Busytools", "forge", "worker"),
1541 crate::dictate::LiveRecording { stop, sink: None, initiator: None },
1542 );
1543 ws.dispatch(crate::Command::DictateReadAloudStop { keep: false, initiator: Some(1) })
1544 .unwrap();
1545 let err = ws
1546 .dispatch(crate::Command::DictateReadAloudStart { initiator: Some(1) })
1547 .expect_err("a take holds the capture");
1548 assert!(
1549 matches!(&err, crate::DispatchError::TakeLive { holder } if holder == "Busytools/forge/worker"),
1550 "the refusal names the seat whose take is live, got: {err:?}"
1551 );
1552 }
1553}