1use std::collections::HashMap;
14use std::path::PathBuf;
15use std::sync::Arc;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::time::{Duration, Instant};
18
19use parking_lot::Mutex;
20use serde::Deserialize;
21use tokio::time::MissedTickBehavior;
22
23use crate::SessionSlot;
24use crate::protocol::{DictateOutcome, SessionUpdate};
25
26const DEFAULT_MAX_CAPTURE_MINUTES: u64 = 30;
30
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub enum DictateBind {
36 #[default]
37 RightCmd,
38 LeftCmd,
39 Off,
40}
41
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub enum DictateMode {
47 #[default]
49 Auto,
50 Toggle,
52 Hold,
54}
55
56impl DictateMode {
57 pub fn label(self) -> &'static str {
59 match self {
60 Self::Auto => "auto",
61 Self::Toggle => "toggle",
62 Self::Hold => "hold",
63 }
64 }
65}
66
67impl<'de> Deserialize<'de> for DictateMode {
68 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69 where
70 D: serde::Deserializer<'de>,
71 {
72 let value = String::deserialize(deserializer)?;
73 match value.as_str() {
74 "auto" => Ok(Self::Auto),
75 "toggle" => Ok(Self::Toggle),
76 "hold" => Ok(Self::Hold),
77 other => Err(serde::de::Error::unknown_variant(other, &["auto", "toggle", "hold"])),
78 }
79 }
80}
81
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
86pub struct DictateOverrides {
87 pub styling: Option<forge_dictate::normalize::Styling>,
88 pub structure: Option<forge_dictate::normalize::Structure>,
89 pub context: Option<forge_dictate::normalize::Context>,
90}
91
92impl DictateOverrides {
93 pub fn normalize_options(self) -> forge_dictate::NormalizeOptions {
96 let mut options = forge_dictate::NormalizeOptions::default();
97 if let Some(v) = self.styling {
98 options.styling = v;
99 }
100 if let Some(v) = self.structure {
101 options.structure = v;
102 }
103 if let Some(v) = self.context {
104 options.context = v;
105 }
106 options
107 }
108
109 pub fn is_empty(self) -> bool {
112 self.styling.is_none() && self.structure.is_none() && self.context.is_none()
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
122pub struct DictateAxes {
123 pub styling: forge_dictate::normalize::Styling,
124 pub structure: forge_dictate::normalize::Structure,
125 pub context: forge_dictate::normalize::Context,
126}
127
128impl DictateAxes {
129 pub fn normalize_options(self) -> forge_dictate::NormalizeOptions {
131 forge_dictate::NormalizeOptions {
132 styling: self.styling,
133 structure: self.structure,
134 context: self.context,
135 ..Default::default()
136 }
137 }
138}
139
140impl Default for DictateAxes {
141 fn default() -> Self {
142 let options = forge_dictate::NormalizeOptions::default();
143 Self { styling: options.styling, structure: options.structure, context: options.context }
144 }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum DictateOverrideUpdate {
153 Styling(forge_dictate::normalize::Styling),
154 Structure(forge_dictate::normalize::Structure),
155 Context(forge_dictate::normalize::Context),
156 Reset,
157}
158
159impl DictateBind {
160 pub fn label(self) -> &'static str {
162 match self {
163 Self::RightCmd => "right_cmd",
164 Self::LeftCmd => "left_cmd",
165 Self::Off => "off",
166 }
167 }
168}
169
170impl<'de> Deserialize<'de> for DictateBind {
171 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
172 where
173 D: serde::Deserializer<'de>,
174 {
175 let value = String::deserialize(deserializer)?;
176 match value.as_str() {
177 "right_cmd" => Ok(Self::RightCmd),
178 "left_cmd" => Ok(Self::LeftCmd),
179 "off" => Ok(Self::Off),
180 other => {
181 Err(serde::de::Error::unknown_variant(other, &["right_cmd", "left_cmd", "off"]))
182 }
183 }
184 }
185}
186
187#[derive(Debug, Clone, Deserialize, PartialEq)]
197#[serde(deny_unknown_fields)]
198pub struct DictateSettings {
199 #[serde(default)]
203 pub enabled: bool,
204 #[serde(default)]
207 pub models_dir: Option<String>,
208 #[serde(default)]
213 pub device: Option<toml::Value>,
214 #[serde(default)]
219 pub transcribe_model: Option<String>,
220 #[serde(default)]
224 pub cleanup_model: Option<String>,
225 #[serde(default)]
227 pub language: Option<String>,
228 #[serde(default = "enabled_by_default")]
231 pub normalizer: bool,
232 #[serde(default = "default_max_capture_minutes")]
235 pub max_capture_minutes: u64,
236 #[serde(default)]
238 pub bind: DictateBind,
239 #[serde(default)]
241 pub mode: DictateMode,
242 #[serde(default)]
245 pub styling: Option<forge_dictate::normalize::Styling>,
246 #[serde(default)]
249 pub structure: Option<forge_dictate::normalize::Structure>,
250 #[serde(default)]
253 pub context: Option<forge_dictate::normalize::Context>,
254}
255
256fn enabled_by_default() -> bool {
257 true
258}
259
260fn default_max_capture_minutes() -> u64 {
261 DEFAULT_MAX_CAPTURE_MINUTES
262}
263
264#[cfg(test)]
265mod default_max_capture_minutes_tests {
266 #[test]
270 fn default_matches_the_documented_thirty_minutes() {
271 assert_eq!(super::default_max_capture_minutes(), 30);
272 }
273}
274
275impl Default for DictateSettings {
276 fn default() -> Self {
277 Self {
278 enabled: false,
279 models_dir: None,
280 device: None,
281 transcribe_model: None,
282 cleanup_model: None,
283 language: None,
284 normalizer: true,
285 max_capture_minutes: DEFAULT_MAX_CAPTURE_MINUTES,
286 bind: DictateBind::default(),
287 mode: DictateMode::default(),
288 styling: None,
289 structure: None,
290 context: None,
291 }
292 }
293}
294
295impl DictateSettings {
296 pub fn axes(&self) -> DictateAxes {
300 let defaults = DictateAxes::default();
301 DictateAxes {
302 styling: self.styling.unwrap_or(defaults.styling),
303 structure: self.structure.unwrap_or(defaults.structure),
304 context: self.context.unwrap_or(defaults.context),
305 }
306 }
307
308 pub(crate) fn to_config(&self) -> forge_dictate::Config {
310 let mut builder = forge_dictate::ConfigBuilder::new()
311 .max_capture(Duration::from_secs(self.max_capture_minutes.saturating_mul(60)));
312 if let Some(dir) = self.models_dir.as_deref() {
313 builder = builder.models_dir(crate::config::expand_home(dir));
314 }
315 if let Some(language) = self.language.as_deref() {
316 builder = builder.language(language);
317 }
318 if !self.normalizer {
319 builder = builder.normalizer(None);
320 }
321 builder.build()
322 }
323
324 pub(crate) fn models_dir(&self) -> Option<PathBuf> {
328 match self.models_dir.as_deref() {
329 Some(dir) => Some(crate::config::expand_home(dir)),
330 None => dirs::cache_dir().map(|dir| dir.join("forge-dictate")),
331 }
332 }
333
334 pub(crate) fn model_specs(&self) -> Vec<(DictateRole, forge_dictate::ModelSpec)> {
339 let cfg = self.to_config();
340 std::iter::once((DictateRole::Transcribing, cfg.asr_model))
341 .chain(cfg.normalizer.map(|spec| (DictateRole::Normalization, spec)))
342 .collect()
343 }
344
345 pub(crate) fn pin_active(&self) -> Vec<(DictateRole, crate::install::ActiveModel)> {
349 self.model_specs()
350 .into_iter()
351 .map(|(role, spec)| {
352 (
353 role,
354 crate::install::ActiveModel {
355 role,
356 spec,
357 from: crate::install::ActiveFrom::Pin,
358 at: None,
359 },
360 )
361 })
362 .collect()
363 }
364
365 fn initial_models(&self) -> Vec<DictateModel> {
367 self.model_specs()
368 .into_iter()
369 .map(|(role, spec)| DictateModel {
370 role,
371 file: spec.file,
372 state: DictateModelState::Pending,
373 })
374 .collect()
375 }
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
385#[serde(rename_all = "snake_case")]
386pub enum DictateRole {
387 Transcribing,
388 Normalization,
389 Speaking,
390}
391
392impl DictateRole {
393 pub fn label(self) -> &'static str {
394 match self {
395 Self::Transcribing => "transcribing model",
396 Self::Normalization => "normalization model",
397 Self::Speaking => "speaking model",
398 }
399 }
400}
401
402#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
404#[serde(rename_all = "snake_case")]
405pub enum DictateModelState {
406 Pending,
408 Downloading { downloaded: u64, total: u64, resumed_from: Option<u64> },
411 Verifying,
413 Fetched,
415 Loading,
417 Ready,
419 Failed(DictateFailure),
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
429#[serde(rename_all = "snake_case")]
430pub enum DictateFailure {
431 HashMismatch { path: PathBuf, expected: String, actual: String, size: u64 },
436 Cancelled { kept: u64, total: u64 },
441 Other { message: String },
443}
444
445impl DictateFailure {
446 pub fn is_cancelled(&self) -> bool {
449 matches!(self, Self::Cancelled { .. })
450 }
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
455pub struct DictateModel {
456 pub role: DictateRole,
457 pub file: String,
459 pub state: DictateModelState,
460}
461
462#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
467pub struct DictateSnapshot {
468 pub models: Vec<DictateModel>,
469 pub failure: Option<DictateFailure>,
475}
476
477impl DictateSnapshot {
478 pub fn is_ready(&self) -> bool {
482 self.failure.is_none() && self.models.iter().all(|m| m.state == DictateModelState::Ready)
483 }
484}
485
486pub(crate) struct DictateState {
489 pub(crate) snapshot: Mutex<DictateSnapshot>,
490 pub(crate) cancelled: AtomicBool,
491 pub(crate) engine: Mutex<Option<Arc<forge_dictate::Engine>>>,
494 pub(crate) active: Mutex<Vec<(DictateRole, crate::install::ActiveModel)>>,
498}
499
500impl DictateState {
501 pub(crate) fn new(settings: &DictateSettings) -> Self {
502 let models = if settings.enabled { settings.initial_models() } else { Vec::new() };
503 let active = if settings.enabled { settings.pin_active() } else { Vec::new() };
504 Self {
505 snapshot: Mutex::new(DictateSnapshot { models, failure: None }),
506 cancelled: AtomicBool::new(false),
507 engine: Mutex::new(None),
508 active: Mutex::new(active),
509 }
510 }
511
512 pub(crate) fn set_active(&self, models: &[(DictateRole, crate::install::ActiveModel)]) {
516 let rows = {
517 let snapshot = self.snapshot.lock();
518 models
519 .iter()
520 .map(|(role, model)| DictateModel {
521 role: *role,
522 file: model.spec.file.clone(),
523 state: snapshot
524 .models
525 .iter()
526 .find(|row| row.file == model.spec.file)
527 .map_or(DictateModelState::Pending, |row| row.state.clone()),
528 })
529 .collect()
530 };
531 self.snapshot.lock().models = rows;
532 *self.active.lock() = models.to_vec();
533 }
534
535 fn set_state(&self, file: &str, state: DictateModelState) {
536 let mut snapshot = self.snapshot.lock();
537 if let Some(model) = snapshot.models.iter_mut().find(|m| m.file == file) {
538 model.state = state;
539 }
540 }
541
542 pub(crate) fn mark_all(&self, state: &DictateModelState) {
546 for model in &mut self.snapshot.lock().models {
547 model.state = state.clone();
548 }
549 }
550
551 pub(crate) fn fail(&self, failure: DictateFailure, file: Option<&str>) {
552 if let Some(file) = file {
553 self.set_state(file, DictateModelState::Failed(failure.clone()));
554 }
555 self.snapshot.lock().failure = Some(failure);
556 }
557}
558
559pub(crate) fn preflight_config(
569 settings: &DictateSettings,
570 active: &[(DictateRole, crate::install::ActiveModel)],
571) -> forge_dictate::Config {
572 let mut cfg = settings.to_config();
573 match forge_sdk::app_support_dir() {
574 Ok(dir) => {
575 cfg.diagnostics_dir = Some(dir.join("dictate-diagnostics"));
576 cfg.digest_cache_dir = Some(dir.join("dictate-digests"));
577 }
578 Err(error) => tracing::warn!(
579 event_name = "dictate_state_dir_unresolved",
580 %error,
581 "no app-support dir: dictate per-take diagnostics and the model-digest record are off"
582 ),
583 }
584 if let Some((_, model)) = active.iter().find(|(role, _)| *role == DictateRole::Transcribing) {
586 cfg.asr_model = model.spec.clone();
587 }
588 cfg.normalizer = active
589 .iter()
590 .find(|(role, _)| *role == DictateRole::Normalization)
591 .map(|(_, model)| model.spec.clone());
592 cfg
593}
594
595pub(crate) async fn run_dictate_preflight(cfg: forge_dictate::Config, state: Arc<DictateState>) {
602 let prepare_state = Arc::clone(&state);
603 let prepare_cfg = cfg.clone();
604 let prepared = tokio::task::spawn_blocking(move || prepare(&prepare_cfg, &prepare_state)).await;
605 match prepared {
606 Ok(Ok(())) => {}
607 Ok(Err(())) => return,
608 Err(source) => {
609 state.fail(DictateFailure::Other { message: source.to_string() }, None);
610 return;
611 }
612 }
613
614 state.mark_all(&DictateModelState::Loading);
615
616 let load_state = Arc::clone(&state);
619 let load_cfg = cfg.clone();
620 let loaded = tokio::task::spawn_blocking(move || {
621 let engine = forge_dictate::Engine::new(cfg)?;
622 engine.wait_ready()?;
623 load_state.engine.lock().replace(engine);
624 Ok::<(), forge_dictate::Error>(())
625 })
626 .await;
627
628 match loaded {
629 Ok(Ok(())) => state.mark_all(&DictateModelState::Ready),
630 Ok(Err(error)) => state.fail(failure_for(&load_cfg, &error), failing_file(&error)),
631 Err(source) => state.fail(DictateFailure::Other { message: source.to_string() }, None),
632 }
633}
634
635#[derive(Default)]
644struct TransferProgress {
645 resumed_from: HashMap<String, u64>,
648 in_flight: HashMap<String, (u64, u64)>,
650 cancelling: Option<String>,
653}
654
655impl TransferProgress {
656 fn apply(&mut self, progress: forge_dictate::Progress) -> (String, DictateModelState) {
658 match progress {
659 forge_dictate::Progress::Verifying { file } => {
660 self.resumed_from.remove(&file);
661 (file, DictateModelState::Verifying)
662 }
663 forge_dictate::Progress::Downloading { file, downloaded, total } => {
664 if !self.in_flight.contains_key(&file) && downloaded > 0 {
668 self.resumed_from.insert(file.clone(), downloaded);
669 }
670 self.in_flight.insert(file.clone(), (downloaded, total));
671 self.cancelling = Some(file.clone());
672 let resumed_from = self.resumed_from.get(&file).copied();
673 (file, DictateModelState::Downloading { downloaded, total, resumed_from })
674 }
675 forge_dictate::Progress::Ready { file } => {
676 self.resumed_from.remove(&file);
677 self.in_flight.remove(&file);
678 if self.cancelling.as_deref() == Some(file.as_str()) {
683 self.cancelling = None;
684 }
685 (file, DictateModelState::Fetched)
686 }
687 }
688 }
689
690 fn cancellation(&self) -> (String, u64, u64) {
694 let file = self.cancelling.clone().unwrap_or_default();
695 let (kept, total) = self.in_flight.get(&file).copied().unwrap_or_default();
696 (file, kept, total)
697 }
698}
699
700fn prepare(cfg: &forge_dictate::Config, state: &DictateState) -> Result<(), ()> {
703 use std::ops::ControlFlow;
704
705 let mut transfers = TransferProgress::default();
706
707 let outcome = forge_dictate::prepare(cfg, |progress| {
708 if state.cancelled.load(Ordering::Relaxed) {
709 return ControlFlow::Break(());
710 }
711 let (file, next) = transfers.apply(progress);
712 state.set_state(&file, next);
713 ControlFlow::Continue(())
714 });
715
716 match outcome {
717 Ok(()) => Ok(()),
718 Err(forge_dictate::Error::Cancelled) => {
719 let (file, kept, total) = transfers.cancellation();
720 state.fail(DictateFailure::Cancelled { kept, total }, Some(&file));
721 Err(())
722 }
723 Err(error) => {
724 state.fail(failure_for(cfg, &error), failing_file(&error));
725 Err(())
726 }
727 }
728}
729
730fn failing_file(error: &forge_dictate::Error) -> Option<&str> {
733 use forge_dictate::Error as E;
734 let (E::HashMismatch { path, .. }
735 | E::SizeMismatch { path, .. }
736 | E::StalePartial { path, .. }
737 | E::ModelLoad { path, .. }
738 | E::Io { path, .. }) = error
739 else {
740 return None;
741 };
742 path.file_name()?.to_str().map(|name| name.trim_end_matches(".part"))
745}
746
747fn spec_size(cfg: &forge_dictate::Config, path: &std::path::Path) -> u64 {
750 let name = path.file_name().and_then(|n| n.to_str()).map(|n| n.trim_end_matches(".part"));
751 std::iter::once(&cfg.asr_model)
752 .chain(cfg.normalizer.as_ref())
753 .find(|spec| Some(spec.file.as_str()) == name)
754 .map_or(0, |spec| spec.size)
755}
756
757fn failure_for(cfg: &forge_dictate::Config, error: &forge_dictate::Error) -> DictateFailure {
758 match error {
759 forge_dictate::Error::HashMismatch { path, expected, actual } => {
760 DictateFailure::HashMismatch {
761 path: path.clone(),
762 expected: expected.clone(),
763 actual: actual.clone(),
764 size: spec_size(cfg, path),
767 }
768 }
769 other => DictateFailure::Other { message: other.to_string() },
770 }
771}
772
773const METER_INTERVAL: Duration = Duration::from_millis(50);
777
778const PROGRESS_POLL: Duration = Duration::from_millis(150);
781
782pub(crate) struct LiveRecording {
788 pub(crate) stop: tokio::sync::mpsc::Sender<bool>,
789 pub(crate) sink: Option<forge_dictate::FrameSink>,
792 pub(crate) initiator: Option<u64>,
797}
798
799pub(crate) struct FinishingTake {
802 pub(crate) key: SessionSlot,
803 pub(crate) stop: tokio::sync::mpsc::Sender<bool>,
804 pub(crate) initiator: Option<u64>,
805}
806
807pub(crate) struct SetRecording {
816 pub(crate) initiator: Option<u64>,
817 pub(crate) samples: Vec<f32>,
818}
819
820pub(crate) struct DictateRuntime {
825 pub(crate) recordings: HashMap<SessionSlot, LiveRecording>,
826 pub(crate) finishing: Vec<FinishingTake>,
827 pub(crate) set_recording: Option<SetRecording>,
828 pub(crate) next_generation: u64,
833 pub(crate) stop_pending: Option<(SessionSlot, Option<u64>, Instant)>,
841}
842
843const STOP_PARK_WINDOW: Duration = Duration::from_millis(200);
848
849impl Default for DictateRuntime {
850 fn default() -> Self {
851 Self {
852 recordings: HashMap::new(),
853 finishing: Vec::new(),
854 set_recording: None,
855 next_generation: 1,
856 stop_pending: None,
857 }
858 }
859}
860
861impl DictateRuntime {
862 fn stop_channel_for(
866 &self,
867 key: &SessionSlot,
868 initiator: Option<u64>,
869 ) -> Option<tokio::sync::mpsc::Sender<bool>> {
870 if let Some(recording) = self.recordings.get(key)
871 && recording.initiator == initiator
872 {
873 return Some(recording.stop.clone());
874 }
875 self.finishing
876 .iter()
877 .find(|take| &take.key == key && take.initiator == initiator)
878 .map(|take| take.stop.clone())
879 }
880
881 pub(crate) fn live_holder(&self) -> Option<String> {
886 let slot =
887 self.recordings.keys().next().or_else(|| self.finishing.first().map(|take| &take.key));
888 match slot {
889 Some(slot) => Some(slot.display()),
890 None if self.set_recording.is_some() => Some("the read-aloud recording".to_owned()),
891 None => None,
892 }
893 }
894
895 pub(crate) fn frame_sink_for(
905 &self,
906 key: &SessionSlot,
907 initiator: Option<u64>,
908 ) -> Option<&forge_dictate::FrameSink> {
909 let live = self.recordings.get(key)?;
910 if live.initiator != initiator {
911 return None;
912 }
913 live.sink.as_ref()
914 }
915
916 fn take_parked_stop(
921 &mut self,
922 key: &SessionSlot,
923 initiator: Option<u64>,
924 now: Instant,
925 ) -> bool {
926 let ours = self
927 .stop_pending
928 .as_ref()
929 .is_some_and(|(parked, who, _)| parked == key && *who == initiator);
930 if !ours {
931 return false;
932 }
933 let fresh = self
934 .stop_pending
935 .as_ref()
936 .is_some_and(|(_, _, at)| now.duration_since(*at) < STOP_PARK_WINDOW);
937 self.stop_pending = None;
938 fresh
939 }
940
941 pub(crate) fn clear_stop_pending(&mut self, key: &SessionSlot, initiator: Option<u64>) {
945 if self
946 .stop_pending
947 .as_ref()
948 .is_some_and(|(parked, who, _)| parked == key && *who == initiator)
949 {
950 self.stop_pending = None;
951 }
952 }
953}
954
955pub(crate) struct RegisteredTake {
959 pub(crate) capture: forge_dictate::Capture,
960 pub(crate) stop: tokio::sync::mpsc::Receiver<bool>,
961 pub(crate) generation: u64,
962 pub(crate) floor_db: f32,
963 pub(crate) options: forge_dictate::NormalizeOptions,
964 pub(crate) surface: Option<String>,
965 pub(crate) initiator: Option<u64>,
966}
967
968pub(crate) fn register_stream_take(
977 ws: &Arc<crate::Workspace>,
978 key: &SessionSlot,
979 axes: DictateAxes,
980 surface: Option<String>,
981 initiator: Option<u64>,
982) -> Result<RegisteredTake, String> {
983 let (stop_tx, stop_rx) = tokio::sync::mpsc::channel(1);
984 let mut runtime = ws.dictate_runtime.lock();
985 if runtime.recordings.contains_key(key) {
986 let ours = runtime.recordings.get(key).is_some_and(|held| held.initiator == initiator);
987 return Err(if ours {
988 "this client is already dictating on this session · dictation did not start".to_owned()
989 } else {
990 "another client is already dictating on this session · dictation did not start"
991 .to_owned()
992 });
993 }
994 if runtime.recordings.values().any(|live| live.sink.is_some() && live.initiator == initiator) {
997 return Err(
998 "this client is already dictating on another seat · dictation did not start".to_owned()
999 );
1000 }
1001 let engine = ws
1002 .dictate
1003 .engine
1004 .lock()
1005 .clone()
1006 .ok_or("dictation is not ready · enable [dictate] in forge.toml and restart")?;
1007 let stream = engine.capture_stream().map_err(|error| error.to_string())?;
1008 if !ws.session_is_live(key) {
1012 return Err("the session closed · dictation did not start".to_owned());
1013 }
1014 runtime.recordings.insert(
1015 key.clone(),
1016 LiveRecording { stop: stop_tx.clone(), sink: Some(stream.sink), initiator },
1017 );
1018 if runtime.take_parked_stop(key, initiator, Instant::now()) {
1022 let _ = stop_tx.try_send(false);
1023 }
1024 let floor_db = engine.silence_floor();
1025 let generation = runtime.next_generation;
1026 runtime.next_generation = runtime.next_generation.wrapping_add(1);
1027 Ok(RegisteredTake {
1028 capture: stream.capture,
1029 stop: stop_rx,
1030 generation,
1031 floor_db,
1032 options: axes.normalize_options(),
1033 surface,
1034 initiator,
1035 })
1036}
1037
1038pub(crate) async fn run_stream_take(
1041 ws: Arc<crate::Workspace>,
1042 key: SessionSlot,
1043 take: RegisteredTake,
1044 updates: crate::update_fanout::UpdateFanout,
1045) {
1046 run_recording(
1047 ws,
1048 key,
1049 take.capture,
1050 take.generation,
1051 take.stop,
1052 updates,
1053 take.options,
1054 take.surface,
1055 take.initiator,
1056 )
1057 .await;
1058}
1059
1060pub(crate) async fn handle_dictate_stop(
1069 ws: &Arc<crate::Workspace>,
1070 key: &SessionSlot,
1071 submit: bool,
1072 initiator: Option<u64>,
1073) {
1074 let stop = {
1075 let mut runtime = ws.dictate_runtime.lock();
1076 if let Some(stop) = runtime.stop_channel_for(key, initiator) {
1077 runtime.clear_stop_pending(key, initiator);
1078 Some(stop)
1079 } else {
1080 runtime.stop_pending = Some((key.clone(), initiator, Instant::now()));
1081 None
1082 }
1083 };
1084 if let Some(stop) = stop {
1085 let _ = stop.send(submit).await;
1086 }
1087}
1088
1089async fn run_recording(
1095 ws: Arc<crate::Workspace>,
1096 key: SessionSlot,
1097 mut capture: forge_dictate::Capture,
1098 generation: u64,
1099 mut stop: tokio::sync::mpsc::Receiver<bool>,
1100 updates: crate::update_fanout::UpdateFanout,
1101 options: forge_dictate::NormalizeOptions,
1102 surface: Option<String>,
1103 initiator: Option<u64>,
1104) {
1105 tokio::spawn(forward_take_progress(
1109 updates.clone(),
1110 key.clone(),
1111 generation,
1112 initiator,
1113 capture.take_progress(),
1114 ));
1115 let meter = capture.meter();
1118 let cap = capture.max_capture();
1119 let submit =
1120 record_until_stopped(&key, &meter, &mut stop, &updates, cap, generation, initiator).await;
1121 if !submit {
1122 drop(capture);
1123 clear_recording_if_ours(&ws, &key, initiator);
1124 let _ = updates.send(SessionUpdate::DictateEnded {
1125 key,
1126 outcome: DictateOutcome::Cancelled,
1127 generation,
1128 surface,
1129 initiator,
1130 });
1131 return;
1132 }
1133
1134 let _ = updates.send(SessionUpdate::DictateTranscribing {
1135 key: key.clone(),
1136 generation,
1137 initiator,
1138 });
1139 let Ok(ticket) = capture.finish_with(options) else {
1140 tracing::warn!(
1141 event_name = "dictate_submit_failed",
1142 slot = %key.display(),
1143 "dictation could not submit its take"
1144 );
1145 clear_recording_if_ours(&ws, &key, initiator);
1146 let _ = updates.send(SessionUpdate::DictateEnded {
1147 key,
1148 outcome: DictateOutcome::Failed,
1149 generation,
1150 surface,
1151 initiator,
1152 });
1153 return;
1154 };
1155 move_to_finishing(&ws, &key);
1158
1159 let cancel = ticket.cancel_token();
1164 let mut answer = tokio::task::spawn_blocking(move || ticket.recv());
1165 let outcome = match wait_for_take(&mut answer, &mut stop, &cancel).await {
1166 TakeResolution::Abandoned => DictateOutcome::Cancelled,
1167 TakeResolution::Answered(resolved) => match resolved {
1168 Ok(Ok(outcome)) => map_outcome(outcome),
1169 Ok(Err(error)) => {
1170 tracing::debug!(
1171 event_name = "dictate_failed",
1172 slot = %key.display(),
1173 %error,
1174 "dictation failed"
1175 );
1176 DictateOutcome::Failed
1177 }
1178 Err(source) => {
1179 tracing::warn!(
1180 event_name = "dictate_answer_task_unjoined",
1181 slot = %key.display(),
1182 %source,
1183 "dictation answer task failed to join"
1184 );
1185 DictateOutcome::Failed
1186 }
1187 },
1188 };
1189 remove_finishing(&ws, &key);
1190 let _ =
1191 updates.send(SessionUpdate::DictateEnded { key, outcome, generation, surface, initiator });
1192}
1193
1194async fn forward_take_progress(
1199 updates: crate::update_fanout::UpdateFanout,
1200 key: SessionSlot,
1201 generation: u64,
1202 initiator: Option<u64>,
1203 mut progress: Option<std::sync::mpsc::Receiver<forge_dictate::WindowProgress>>,
1204) {
1205 let Some(progress) = progress.as_mut() else { return };
1206 let mut tick = tokio::time::interval(PROGRESS_POLL);
1207 tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
1208 loop {
1209 tick.tick().await;
1210 match progress.try_recv() {
1211 Ok(step) => {
1212 let delivered = updates.send(SessionUpdate::DictateProgress {
1213 key: key.clone(),
1214 generation,
1215 done: step.done,
1216 total: step.total,
1217 initiator,
1218 });
1219 if !delivered {
1220 break;
1221 }
1222 }
1223 Err(std::sync::mpsc::TryRecvError::Empty) => {}
1224 Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
1225 }
1226 }
1227}
1228
1229enum TakeResolution<T> {
1231 Answered(Result<T, tokio::task::JoinError>),
1235 Abandoned,
1239}
1240
1241async fn wait_for_take<T>(
1255 answer: &mut tokio::task::JoinHandle<T>,
1256 stop: &mut tokio::sync::mpsc::Receiver<bool>,
1257 cancel: &forge_dictate::CancelToken,
1258) -> TakeResolution<T> {
1259 loop {
1260 tokio::select! {
1261 biased;
1262 decide = stop.recv() => {
1263 if decide != Some(true) {
1264 cancel.cancel();
1265 break TakeResolution::Abandoned;
1266 }
1267 }
1268 resolved = &mut *answer => break TakeResolution::Answered(resolved),
1269 }
1270 }
1271}
1272
1273async fn record_until_stopped(
1285 key: &SessionSlot,
1286 capture: &forge_dictate::CaptureMeter,
1287 stop: &mut tokio::sync::mpsc::Receiver<bool>,
1288 updates: &crate::update_fanout::UpdateFanout,
1289 cap: Duration,
1290 generation: u64,
1291 initiator: Option<u64>,
1292) -> bool {
1293 let deadline = Instant::now() + cap;
1294 let mut meter = tokio::time::interval(METER_INTERVAL);
1295 meter.set_missed_tick_behavior(MissedTickBehavior::Skip);
1296 loop {
1297 tokio::select! {
1298 biased;
1299 decide = stop.recv() => {
1300 return decide.unwrap_or(false);
1301 }
1302 _ = meter.tick() => {
1303 if capture.was_truncated() {
1306 return true;
1307 }
1308 if Instant::now() >= deadline {
1309 capture.cap_reached();
1310 return true;
1311 }
1312 let peak_db = capture.level();
1313 let _ = updates.send(SessionUpdate::DictateLevel {
1314 key: key.clone(),
1315 peak_db,
1316 generation,
1317 initiator,
1318 });
1319 }
1320 }
1321 }
1322}
1323
1324fn clear_recording_if_ours(ws: &crate::Workspace, key: &SessionSlot, initiator: Option<u64>) {
1327 let mut runtime = ws.dictate_runtime.lock();
1328 runtime.recordings.remove(key);
1329 runtime.clear_stop_pending(key, initiator);
1330}
1331
1332fn move_to_finishing(ws: &crate::Workspace, key: &SessionSlot) {
1335 let mut runtime = ws.dictate_runtime.lock();
1336 if let Some(live) = runtime.recordings.remove(key) {
1337 runtime.finishing.push(FinishingTake {
1338 key: key.clone(),
1339 stop: live.stop,
1340 initiator: live.initiator,
1341 });
1342 }
1343}
1344
1345fn remove_finishing(ws: &crate::Workspace, key: &SessionSlot) {
1347 let mut runtime = ws.dictate_runtime.lock();
1348 runtime.finishing.retain(|take| &take.key != key);
1349}
1350
1351pub(crate) fn teardown_for_closed_session(ws: &crate::Workspace, key: &SessionSlot) {
1356 let mut runtime = ws.dictate_runtime.lock();
1357 runtime.recordings.remove(key);
1358 runtime.finishing.retain(|take| &take.key != key);
1359 if runtime.stop_pending.as_ref().is_some_and(|(parked, _, _)| parked == key) {
1361 runtime.stop_pending = None;
1362 }
1363}
1364
1365pub(crate) fn teardown_all(ws: &crate::Workspace) {
1367 let mut runtime = ws.dictate_runtime.lock();
1368 runtime.recordings.clear();
1369 runtime.finishing.clear();
1370 runtime.stop_pending = None;
1371}
1372
1373fn map_outcome(outcome: forge_dictate::Outcome) -> DictateOutcome {
1376 match outcome {
1377 forge_dictate::Outcome::Transcript(transcript) if transcript.text.is_empty() => {
1378 DictateOutcome::Empty
1379 }
1380 forge_dictate::Outcome::Transcript(transcript) => {
1381 DictateOutcome::Landed { text: transcript.text, truncated: transcript.truncated }
1382 }
1383 forge_dictate::Outcome::NoAudio { peak, audio } => {
1384 DictateOutcome::NoAudio { peak_db: peak, seconds: audio.as_secs() }
1385 }
1386 }
1387}
1388
1389#[cfg(test)]
1390mod transfer_progress_tests {
1391 use super::*;
1392 use forge_dictate::Progress;
1393
1394 #[test]
1400 fn a_model_that_finished_is_not_named_by_a_later_cancellation() {
1401 let mut t = TransferProgress::default();
1402 t.apply(Progress::Downloading { file: "asr.gguf".into(), downloaded: 612, total: 1_560 });
1403 t.apply(Progress::Verifying { file: "asr.gguf".into() });
1404 t.apply(Progress::Ready { file: "asr.gguf".into() });
1405 t.apply(Progress::Verifying { file: "norm.gguf".into() });
1407
1408 let (file, kept, total) = t.cancellation();
1409 assert_eq!(file, "", "a model that reached ready must not be blamed for the cancel");
1410 assert_eq!((kept, total), (0, 0), "and it must not lend its byte counts to one");
1411 }
1412
1413 #[test]
1416 fn the_transfer_still_running_is_the_one_reported() {
1417 let mut t = TransferProgress::default();
1418 t.apply(Progress::Downloading { file: "asr.gguf".into(), downloaded: 10, total: 100 });
1419 t.apply(Progress::Ready { file: "asr.gguf".into() });
1420 t.apply(Progress::Downloading { file: "norm.gguf".into(), downloaded: 40, total: 200 });
1421
1422 assert_eq!(
1423 t.cancellation(),
1424 ("norm.gguf".to_owned(), 40, 200),
1425 "the live transfer and its own bytes, not the finished model's"
1426 );
1427 }
1428
1429 #[test]
1433 fn one_models_verifying_does_not_clear_the_others_resume_marker() {
1434 let mut t = TransferProgress::default();
1435 t.apply(Progress::Downloading { file: "asr.gguf".into(), downloaded: 592, total: 1_560 });
1436 t.apply(Progress::Verifying { file: "norm.gguf".into() });
1437 let (_, state) = t.apply(Progress::Downloading {
1438 file: "asr.gguf".into(),
1439 downloaded: 600,
1440 total: 1_560,
1441 });
1442
1443 assert_eq!(
1444 state,
1445 DictateModelState::Downloading {
1446 downloaded: 600,
1447 total: 1_560,
1448 resumed_from: Some(592)
1449 },
1450 "the resume marker belongs to asr.gguf and nothing norm.gguf does may clear it"
1451 );
1452 }
1453}
1454
1455#[cfg(test)]
1456mod tests {
1457 use super::*;
1458
1459 use crate::{Command, SessionUpdate};
1460
1461 #[test]
1464 fn an_absent_section_is_disabled() {
1465 let settings: DictateSettings = toml::from_str("").expect("an empty section parses");
1466 assert!(!settings.enabled, "dictation must be opt-in, not implicit");
1467 assert_eq!(settings, DictateSettings::default());
1468 }
1469
1470 #[test]
1474 fn a_mistyped_key_fails_the_load_rather_than_being_ignored() {
1475 let err = toml::from_str::<DictateSettings>("enabled = true\nmodelsdir = \"/vol\"\n")
1476 .expect_err("an unknown key must be refused");
1477 assert!(
1478 err.to_string().contains("modelsdir"),
1479 "the error must name the key that was not understood, got: {err}"
1480 );
1481 }
1482
1483 #[test]
1488 fn preflight_config_points_diagnostics_at_app_support() {
1489 let settings: DictateSettings = toml::from_str("enabled = true\n").expect("parse");
1490 let cfg = preflight_config(&settings, &settings.pin_active());
1491 assert_eq!(
1492 cfg.diagnostics_dir.as_deref(),
1493 forge_sdk::app_support_dir().ok().map(|dir| dir.join("dictate-diagnostics")).as_deref(),
1494 "the diagnostics store must sit beside forge's other machine-local state"
1495 );
1496 assert!(
1497 cfg.diagnostics_dir.is_some(),
1498 "on this machine an app-support dir resolves, so the store must be armed"
1499 );
1500 assert_eq!(
1501 cfg.digest_cache_dir.as_deref(),
1502 forge_sdk::app_support_dir().ok().map(|dir| dir.join("dictate-digests")).as_deref(),
1503 "the verified-digest record must sit beside forge's other machine-local state, or \
1504 every boot re-hashes 3.07 GB"
1505 );
1506 assert!(
1507 cfg.digest_cache_dir.is_some(),
1508 "the record is what keeps a warm boot off the disk; nothing else arms it"
1509 );
1510 }
1511
1512 #[test]
1516 fn the_axes_come_from_the_config_over_the_crates_own_defaults() {
1517 let defaults: DictateSettings = toml::from_str("").expect("an empty section parses");
1518 assert_eq!(
1519 defaults.axes(),
1520 DictateAxes::default(),
1521 "with nothing configured the axes are the crate's defaults"
1522 );
1523
1524 let set: DictateSettings =
1525 toml::from_str("styling = \"formal\"\nstructure = \"lists\"\ncontext = \"email\"\n")
1526 .expect("the axes parse");
1527 let axes = set.axes();
1528 assert_eq!(axes.styling, forge_dictate::normalize::Styling::Formal);
1529 assert_eq!(axes.structure, forge_dictate::normalize::Structure::Lists);
1530 assert_eq!(axes.context, forge_dictate::normalize::Context::Email);
1531
1532 let err = toml::from_str::<DictateSettings>("styling = \"chatty\"\n")
1534 .expect_err("an unknown value must be refused");
1535 assert!(err.to_string().contains("chatty"), "the error must name the value, got: {err}");
1536 }
1537
1538 #[test]
1542 fn turning_the_normalizer_off_drops_its_model() {
1543 let on: DictateSettings = toml::from_str("enabled = true\n").expect("parse");
1544 assert!(on.to_config().normalizer.is_some(), "normalization is on by default");
1545 assert_eq!(on.initial_models().len(), 2, "both models are fetched by default");
1546
1547 let off: DictateSettings =
1548 toml::from_str("enabled = true\nnormalizer = false\n").expect("parse");
1549 assert!(off.to_config().normalizer.is_none(), "normalizer = false must clear the spec");
1550 assert_eq!(
1551 off.initial_models().len(),
1552 1,
1553 "with no normalizer configured preflight has one model to fetch, not two"
1554 );
1555 }
1556
1557 #[test]
1562 fn bind_defaults_to_right_cmd_and_parses_the_three_values() {
1563 let settings: DictateSettings = toml::from_str("").expect("an empty section parses");
1564 assert_eq!(settings.bind, DictateBind::RightCmd);
1565
1566 let left: DictateSettings = toml::from_str("bind = \"left_cmd\"\n").expect("parse");
1567 assert_eq!(left.bind, DictateBind::LeftCmd);
1568
1569 let off: DictateSettings = toml::from_str("bind = \"off\"\n").expect("parse");
1570 assert_eq!(off.bind, DictateBind::Off);
1571
1572 let err = toml::from_str::<DictateSettings>("bind = \"right_command\"\n")
1573 .expect_err("an unknown value must be refused");
1574 assert!(
1575 err.to_string().contains("right_command"),
1576 "the error must name the value that was not understood, got: {err}"
1577 );
1578 }
1579
1580 #[test]
1583 fn normalize_options_merge_overridden_axes_over_the_crate_defaults() {
1584 let empty = DictateOverrides::default();
1585 let merged = empty.normalize_options();
1586 assert_eq!(merged, forge_dictate::NormalizeOptions::default());
1587 assert!(empty.is_empty(), "a fresh session has nothing to reset");
1588
1589 let overridden = DictateOverrides {
1590 styling: Some(forge_dictate::normalize::Styling::Formal),
1591 ..DictateOverrides::default()
1592 };
1593 let merged = overridden.normalize_options();
1594 assert_eq!(merged.styling, forge_dictate::normalize::Styling::Formal);
1595 assert_eq!(
1596 merged.structure,
1597 forge_dictate::NormalizeOptions::default().structure,
1598 "an unset axis keeps the crate default"
1599 );
1600 assert!(!overridden.is_empty());
1601 }
1602
1603 #[test]
1604 fn a_set_override_lands_on_its_own_session_and_echoes_back() {
1605 let (workspace, mut updates) = crate::Workspace::testing_stub();
1606 let a = crate::SessionSlot::from_str_for_test("dictate-a");
1607 let b = crate::SessionSlot::from_str_for_test("dictate-b");
1608 workspace.register_domain_session(a.clone(), None);
1609 workspace.register_domain_session(b.clone(), None);
1610
1611 workspace
1612 .dispatch(Command::SetDictateOverride {
1613 key: a.clone(),
1614 update: DictateOverrideUpdate::Styling(forge_dictate::normalize::Styling::Formal),
1615 })
1616 .expect("dispatch");
1617
1618 let a_binding = workspace.domain_session_for(&a).expect("session a");
1619 assert_eq!(
1620 a_binding.lock().dictate_overrides.styling,
1621 Some(forge_dictate::normalize::Styling::Formal),
1622 );
1623 let b_binding = workspace.domain_session_for(&b).expect("session b");
1624 assert_eq!(b_binding.lock().dictate_overrides, DictateOverrides::default());
1625
1626 let echo = updates.try_recv().expect("an echo for the session that set it");
1627 match std::sync::Arc::unwrap_or_clone(echo.update) {
1628 SessionUpdate::DictateOverrides { key, overrides } => {
1629 assert_eq!(key, a);
1630 assert_eq!(overrides.styling, Some(forge_dictate::normalize::Styling::Formal));
1631 }
1632 other => panic!("unexpected update: {other:?}"),
1633 }
1634 }
1635
1636 #[test]
1637 fn reset_clears_every_axis_at_once() {
1638 let (workspace, mut updates) = crate::Workspace::testing_stub();
1639 let key = crate::SessionSlot::from_str_for_test("dictate-reset");
1640 workspace.register_domain_session(key.clone(), None);
1641
1642 for update in [
1643 DictateOverrideUpdate::Styling(forge_dictate::normalize::Styling::Casual),
1644 DictateOverrideUpdate::Context(forge_dictate::normalize::Context::Email),
1645 ] {
1646 workspace
1647 .dispatch(Command::SetDictateOverride { key: key.clone(), update })
1648 .expect("dispatch");
1649 }
1650 workspace.dispatch(Command::ResetDictateOverrides { key: key.clone() }).expect("dispatch");
1651
1652 let binding = workspace.domain_session_for(&key).expect("session");
1653 assert_eq!(binding.lock().dictate_overrides, DictateOverrides::default());
1654
1655 let mut echoes = 0;
1656 let mut last = None;
1657 while let Ok(frame) = updates.try_recv() {
1658 if matches!(&*frame.update, SessionUpdate::DictateOverrides { .. }) {
1659 echoes += 1;
1660 last = Some(std::sync::Arc::unwrap_or_clone(frame.update));
1661 }
1662 }
1663 assert_eq!(echoes, 3, "each set echoes, and the reset does too");
1664
1665 let frame = serde_json::to_value(last.expect("the reset's own echo")).expect("serialise");
1669 assert!(
1670 frame["dictate_overrides"].get("overrides").is_some(),
1671 "the reset echo must carry its overrides key, got: {frame}"
1672 );
1673 }
1674
1675 #[test]
1676 fn an_override_for_an_unknown_session_is_refused() {
1677 let (workspace, _updates) = crate::Workspace::testing_stub();
1678 let key = crate::SessionSlot::from_str_for_test("never-registered");
1679 let err = workspace
1680 .dispatch(Command::ResetDictateOverrides { key })
1681 .expect_err("an unknown session must be refused");
1682 assert!(matches!(err, crate::DispatchError::UnknownSession(_)));
1683 }
1684
1685 #[test]
1688 fn mode_defaults_to_auto_and_parses_the_three_values() {
1689 let settings: DictateSettings = toml::from_str("").expect("an empty section parses");
1690 assert_eq!(settings.mode, DictateMode::Auto);
1691
1692 let toggle: DictateSettings = toml::from_str("mode = \"toggle\"\n").expect("parse");
1693 assert_eq!(toggle.mode, DictateMode::Toggle);
1694
1695 let hold: DictateSettings = toml::from_str("mode = \"hold\"\n").expect("parse");
1696 assert_eq!(hold.mode, DictateMode::Hold);
1697
1698 let err = toml::from_str::<DictateSettings>("mode = \"press\"\n")
1699 .expect_err("an unknown value must be refused");
1700 assert!(
1701 err.to_string().contains("press"),
1702 "the error must name the value that was not understood, got: {err}"
1703 );
1704 }
1705
1706 #[test]
1710 fn max_capture_is_read_in_minutes() {
1711 let settings: DictateSettings =
1712 toml::from_str("enabled = true\nmax_capture_minutes = 5\n").expect("parse");
1713 assert_eq!(settings.to_config().max_capture, Duration::from_secs(300));
1714 }
1715
1716 #[test]
1720 fn a_failure_is_never_ready() {
1721 let ready = DictateSnapshot {
1722 models: vec![DictateModel {
1723 role: DictateRole::Transcribing,
1724 file: "asr.gguf".to_owned(),
1725 state: DictateModelState::Ready,
1726 }],
1727 failure: None,
1728 };
1729 assert!(ready.is_ready(), "a loaded model with no failure is ready");
1730
1731 let failed = DictateSnapshot {
1732 failure: Some(DictateFailure::Other { message: "nope".to_owned() }),
1733 ..ready.clone()
1734 };
1735 assert!(!failed.is_ready(), "a failure must hold the gate shut whatever the rows say");
1736 }
1737
1738 #[test]
1741 fn a_disabled_dictation_is_immediately_ready() {
1742 let state = DictateState::new(&DictateSettings::default());
1743 let snapshot = state.snapshot.lock().clone();
1744 assert!(snapshot.models.is_empty(), "a disabled dictation draws no rows");
1745 assert!(snapshot.is_ready(), "with nothing configured there is nothing to wait for");
1746 }
1747
1748 #[test]
1752 fn fail_marks_the_row_and_keeps_the_screen_wide_copy() {
1753 let settings: DictateSettings = toml::from_str("enabled = true\n").expect("parse");
1754 let state = DictateState::new(&settings);
1755 let file = state.snapshot.lock().models[0].file.clone();
1756
1757 let failure = DictateFailure::Cancelled { kept: 100, total: 200 };
1758 state.fail(failure.clone(), Some(&file));
1759
1760 let snapshot = state.snapshot.lock().clone();
1761 assert_eq!(
1762 snapshot.models.iter().find(|m| m.file == file).map(|m| &m.state),
1763 Some(&DictateModelState::Failed(failure.clone())),
1764 "the row that stopped carries its own reason",
1765 );
1766 assert_eq!(
1767 snapshot.failure,
1768 Some(failure),
1769 "and the snapshot keeps the screen-wide copy for the prose block",
1770 );
1771 }
1772
1773 #[test]
1777 fn a_partial_rejection_is_attributed_to_its_finished_row() {
1778 let error = forge_dictate::Error::HashMismatch {
1779 path: PathBuf::from("/models/s1-mini-f16.gguf.part"),
1780 expected: "abc".to_owned(),
1781 actual: "def".to_owned(),
1782 };
1783 assert_eq!(
1784 failing_file(&error),
1785 Some("s1-mini-f16.gguf"),
1786 "a `.part` rejection must land on the row for the file it would have become",
1787 );
1788 }
1789}
1790
1791#[cfg(test)]
1792mod dictate_lifecycle_tests {
1793 use super::*;
1794
1795 use crate::Command;
1796
1797 fn key(name: &str) -> SessionSlot {
1798 SessionSlot::from_str_for_test(name.to_owned())
1799 }
1800
1801 #[test]
1806 fn the_meter_clock_is_the_decided_fifty_ms() {
1807 assert_eq!(
1808 METER_INTERVAL,
1809 Duration::from_millis(50),
1810 "the level meter runs at 20 Hz by decision, got {METER_INTERVAL:?}"
1811 );
1812 }
1813
1814 #[test]
1819 fn crate_outcomes_map_onto_the_wire_outcomes() {
1820 use forge_dictate::Outcome;
1821
1822 let landed = map_outcome(Outcome::Transcript(forge_dictate::Transcript {
1823 text: "hello there".to_owned(),
1824 asr: "hello there".to_owned(),
1825 stages: forge_dictate::Stages::default(),
1826 truncated: false,
1827 }));
1828 assert_eq!(
1829 landed,
1830 DictateOutcome::Landed { text: "hello there".to_owned(), truncated: false },
1831 "a transcript with words lands as words"
1832 );
1833
1834 let empty = map_outcome(Outcome::Transcript(forge_dictate::Transcript {
1835 text: String::new(),
1836 asr: "um".to_owned(),
1837 stages: forge_dictate::Stages::default(),
1838 truncated: false,
1839 }));
1840 assert_eq!(empty, DictateOutcome::Empty, "filler-only input normalises to nothing");
1841
1842 let silent = map_outcome(Outcome::NoAudio {
1843 peak: f32::NEG_INFINITY,
1844 audio: Duration::from_secs(4),
1845 });
1846 assert_eq!(
1847 silent,
1848 DictateOutcome::NoAudio { peak_db: f32::NEG_INFINITY, seconds: 4 },
1849 "the peak must survive so the host can tell quiet from structural silence"
1850 );
1851 }
1852
1853 fn live_take(ws: &crate::Workspace, owner: &SessionSlot) -> tokio::sync::mpsc::Receiver<bool> {
1856 let (stop_tx, stop_rx) = tokio::sync::mpsc::channel(1);
1857 ws.dictate_runtime
1858 .lock()
1859 .recordings
1860 .insert(owner.clone(), LiveRecording { stop: stop_tx, sink: None, initiator: None });
1861 stop_rx
1862 }
1863
1864 fn synthetic_engine() -> (tempfile::TempDir, Arc<forge_dictate::Engine>) {
1868 let dir = tempfile::tempdir().unwrap();
1869 let engine = forge_dictate::Engine::new(
1870 forge_dictate::ConfigBuilder::new().models_dir(dir.path()).normalizer(None).build(),
1871 )
1872 .expect("an engine starts without its weights");
1873 (dir, engine)
1874 }
1875
1876 fn live_session(ws: &crate::Workspace, session: &SessionSlot) {
1878 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1879 ws.command_senders.lock().insert(session.clone(), cmd_tx);
1880 std::mem::forget(cmd_rx);
1883 }
1884
1885 #[tokio::test]
1890 async fn a_stream_take_registers_synchronously_and_keeps_its_frames() {
1891 let (ws, mut updates) = crate::Workspace::testing_stub();
1892 let (_dir, engine) = synthetic_engine();
1893 *ws.dictate.engine.lock() = Some(Arc::clone(&engine));
1894 let session = key("streamer");
1895 live_session(&ws, &session);
1896
1897 ws.dispatch(Command::DictateStream {
1898 key: session.clone(),
1899 options: DictateAxes::default(),
1900 surface: None,
1901 initiator: Some(1),
1902 })
1903 .expect("dispatch");
1904
1905 assert!(
1906 ws.dictate_push(&session, &[0.5; 320], Some(1)),
1907 "the first frame after a start must find its take"
1908 );
1909
1910 let started = updates.recv().await.expect("the start echoes");
1911 assert!(
1912 matches!(&*started.update, SessionUpdate::DictateStarted { initiator: Some(1), .. }),
1913 "the start echo carries the connection the take belongs to, got {started:?}"
1914 );
1915
1916 let level = tokio::time::timeout(Duration::from_secs(2), updates.recv())
1918 .await
1919 .expect("a level must arrive while the take is live")
1920 .expect("the stream stays open");
1921 match std::sync::Arc::unwrap_or_clone(level.update) {
1922 SessionUpdate::DictateLevel { peak_db, .. } => assert!(
1923 (peak_db + 6.02).abs() < 0.5,
1924 "the meter must read the pushed samples, got {peak_db} dB"
1925 ),
1926 other => panic!("expected a level, got {other:?}"),
1927 }
1928
1929 ws.dispatch(Command::DictateStop {
1932 key: session.clone(),
1933 submit: true,
1934 initiator: Some(1),
1935 })
1936 .expect("dispatch");
1937 let ended = tokio::time::timeout(Duration::from_secs(10), async {
1938 loop {
1939 match updates
1940 .recv()
1941 .await
1942 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
1943 {
1944 Some(SessionUpdate::DictateEnded { outcome, .. }) => break outcome,
1945 Some(_) => {}
1946 None => panic!("the update stream closed before the take resolved"),
1947 }
1948 }
1949 })
1950 .await
1951 .expect("the take must resolve");
1952 assert_eq!(ended, DictateOutcome::Failed, "a weightless engine fails the take");
1953 assert!(
1954 ws.dictate_runtime.lock().recordings.is_empty(),
1955 "a resolved take leaves no live entry behind"
1956 );
1957 }
1958
1959 #[tokio::test]
1964 async fn the_readings_and_the_submit_carry_the_takes_generation() {
1965 let (ws, mut updates) = crate::Workspace::testing_stub();
1966 let (_dir, engine) = synthetic_engine();
1967 *ws.dictate.engine.lock() = Some(Arc::clone(&engine));
1968 let session = key("generation");
1969 live_session(&ws, &session);
1970
1971 ws.dispatch(Command::DictateStream {
1972 key: session.clone(),
1973 options: DictateAxes::default(),
1974 surface: None,
1975 initiator: Some(1),
1976 })
1977 .expect("dispatch");
1978 ws.dictate_push(&session, &[0.5; 320], Some(1));
1979
1980 let started = tokio::time::timeout(Duration::from_secs(2), async {
1981 loop {
1982 let frame = updates.recv().await.expect("the stream stays open");
1983 if let SessionUpdate::DictateStarted { generation, .. } =
1984 std::sync::Arc::unwrap_or_clone(frame.update)
1985 {
1986 break generation;
1987 }
1988 }
1989 })
1990 .await
1991 .expect("the take announces itself");
1992
1993 let level = tokio::time::timeout(Duration::from_secs(2), async {
1994 loop {
1995 let frame = updates.recv().await.expect("the stream stays open");
1996 if let SessionUpdate::DictateLevel { generation, .. } =
1997 std::sync::Arc::unwrap_or_clone(frame.update)
1998 {
1999 break generation;
2000 }
2001 }
2002 })
2003 .await
2004 .expect("a level arrives while the take is live");
2005
2006 ws.dispatch(Command::DictateStop {
2007 key: session.clone(),
2008 submit: true,
2009 initiator: Some(1),
2010 })
2011 .expect("dispatch");
2012 let transcribing = tokio::time::timeout(Duration::from_secs(5), async {
2013 loop {
2014 let frame = updates.recv().await.expect("the stream stays open");
2015 match std::sync::Arc::unwrap_or_clone(frame.update) {
2016 SessionUpdate::DictateTranscribing { generation, .. } => break generation,
2017 SessionUpdate::DictateEnded { .. } => {
2018 panic!("the take ended before it said it was transcribing")
2019 }
2020 _ => {}
2021 }
2022 }
2023 })
2024 .await
2025 .expect("the take submits");
2026
2027 assert_eq!(level, started, "a level names the take it belongs to");
2028 assert_eq!(transcribing, started, "and so does the frame that submits it");
2029 }
2030
2031 #[tokio::test]
2035 async fn a_stream_takes_surface_reaches_its_end() {
2036 let (ws, mut updates) = crate::Workspace::testing_stub();
2037 let (_dir, engine) = synthetic_engine();
2038 *ws.dictate.engine.lock() = Some(Arc::clone(&engine));
2039 let session = key("streamer");
2040 live_session(&ws, &session);
2041
2042 ws.dispatch(Command::DictateStream {
2043 key: session.clone(),
2044 options: DictateAxes::default(),
2045 surface: Some("review".to_owned()),
2046 initiator: Some(1),
2047 })
2048 .expect("dispatch");
2049 let started = tokio::time::timeout(Duration::from_secs(2), updates.recv())
2050 .await
2051 .expect("the start echoes")
2052 .expect("the stream stays open");
2053 assert!(
2054 matches!(&*started.update, SessionUpdate::DictateStarted { .. }),
2055 "the take registers, got {started:?}"
2056 );
2057
2058 ws.dispatch(Command::DictateStop {
2059 key: session.clone(),
2060 submit: false,
2061 initiator: Some(1),
2062 })
2063 .expect("dispatch");
2064 let carried = tokio::time::timeout(Duration::from_secs(10), async {
2065 loop {
2066 match updates
2067 .recv()
2068 .await
2069 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
2070 {
2071 Some(SessionUpdate::DictateEnded { surface, .. }) => break surface,
2072 Some(_) => {}
2073 None => panic!("the update stream closed before the take resolved"),
2074 }
2075 }
2076 })
2077 .await
2078 .expect("the take must resolve");
2079 assert_eq!(
2080 carried.as_deref(),
2081 Some("review"),
2082 "the ending names the surface the client started the take with"
2083 );
2084 }
2085
2086 #[tokio::test]
2090 async fn a_submitted_takes_end_carries_its_surface() {
2091 let (ws, mut updates) = crate::Workspace::testing_stub();
2092 let (_dir, engine) = synthetic_engine();
2093 *ws.dictate.engine.lock() = Some(Arc::clone(&engine));
2094 let session = key("streamer");
2095 live_session(&ws, &session);
2096
2097 ws.dispatch(Command::DictateStream {
2098 key: session.clone(),
2099 options: DictateAxes::default(),
2100 surface: Some("review".to_owned()),
2101 initiator: Some(1),
2102 })
2103 .expect("dispatch");
2104 let started = tokio::time::timeout(Duration::from_secs(2), updates.recv())
2105 .await
2106 .expect("the start echoes")
2107 .expect("the stream stays open");
2108 assert!(
2109 matches!(&*started.update, SessionUpdate::DictateStarted { .. }),
2110 "the take registers, got {started:?}"
2111 );
2112 assert!(
2113 ws.dictate_push(&session, &[0.5; 320], Some(1)),
2114 "the frame after a start must find its take"
2115 );
2116
2117 ws.dispatch(Command::DictateStop {
2118 key: session.clone(),
2119 submit: true,
2120 initiator: Some(1),
2121 })
2122 .expect("dispatch");
2123 let carried = tokio::time::timeout(Duration::from_secs(10), async {
2124 loop {
2125 match updates
2126 .recv()
2127 .await
2128 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
2129 {
2130 Some(SessionUpdate::DictateEnded { surface, .. }) => break surface,
2131 Some(_) => {}
2132 None => panic!("the update stream closed before the take resolved"),
2133 }
2134 }
2135 })
2136 .await
2137 .expect("the take must resolve");
2138 assert_eq!(
2139 carried.as_deref(),
2140 Some("review"),
2141 "the submitted ending names the surface the client started the take with"
2142 );
2143 }
2144
2145 #[tokio::test]
2151 async fn a_failed_take_records_debug_with_its_seat() {
2152 use tracing_subscriber::layer::SubscriberExt as _;
2153
2154 #[derive(Clone, Default)]
2156 struct Caught(std::sync::Arc<std::sync::Mutex<Vec<(tracing::Level, String, String)>>>);
2157
2158 #[derive(Default)]
2159 struct Fields(String);
2160
2161 impl tracing::field::Visit for Fields {
2162 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
2163 if !self.0.is_empty() {
2164 self.0.push(' ');
2165 }
2166 let _ = std::fmt::write(&mut self.0, format_args!("{}={value:?}", field.name()));
2167 }
2168 }
2169
2170 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Caught {
2171 fn on_event(
2172 &self,
2173 event: &tracing::Event<'_>,
2174 _ctx: tracing_subscriber::layer::Context<'_, S>,
2175 ) {
2176 let mut fields = Fields::default();
2177 event.record(&mut fields);
2178 self.0.lock().expect("capture").push((
2179 *event.metadata().level(),
2180 event.metadata().target().to_owned(),
2181 fields.0,
2182 ));
2183 }
2184 }
2185
2186 let (ws, mut updates) = crate::Workspace::testing_stub();
2187 let (_dir, engine) = synthetic_engine();
2188 *ws.dictate.engine.lock() = Some(Arc::clone(&engine));
2189 let session = key("solo");
2190 live_session(&ws, &session);
2191
2192 let caught = Caught::default();
2196 let subscriber = tracing_subscriber::registry().with(caught.clone());
2197 let _guard = tracing::subscriber::set_default(subscriber);
2198
2199 ws.dispatch(Command::DictateStream {
2200 key: session.clone(),
2201 options: DictateAxes::default(),
2202 surface: None,
2203 initiator: Some(1),
2204 })
2205 .expect("dispatch");
2206 assert!(
2210 ws.dictate_push(&session, &[0.5; 320], Some(1)),
2211 "the frame after a start must find its take"
2212 );
2213 ws.dispatch(Command::DictateStop {
2214 key: session.clone(),
2215 submit: true,
2216 initiator: Some(1),
2217 })
2218 .expect("dispatch");
2219
2220 let ended = tokio::time::timeout(Duration::from_secs(10), async {
2221 loop {
2222 match updates
2223 .recv()
2224 .await
2225 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
2226 {
2227 Some(SessionUpdate::DictateEnded { outcome, .. }) => break outcome,
2228 Some(_) => {}
2229 None => panic!("the update stream closed before the take resolved"),
2230 }
2231 }
2232 })
2233 .await
2234 .expect("the take must resolve");
2235 assert_eq!(ended, DictateOutcome::Failed, "a weightless engine fails the take");
2236
2237 let records = caught.0.lock().expect("capture");
2238 let (level, target, fields) = records
2239 .iter()
2240 .find(|(_, _, fields)| fields.contains("dictate_failed"))
2241 .unwrap_or_else(|| panic!("a failed take records `dictate_failed`, saw {records:?}"));
2242 assert_eq!(
2243 *level,
2244 tracing::Level::DEBUG,
2245 "a take's own failure is not a warning about forge: {fields}",
2246 );
2247 assert_eq!(
2251 target.as_str(),
2252 "forge_workspace::dictate",
2253 "the record's target is the module path the filter directive names: {fields}",
2254 );
2255 assert!(
2256 fields.contains("slot=TestOrg/test-project/solo"),
2257 "the record names the seat it belongs to: {fields}",
2258 );
2259 }
2260
2261 #[tokio::test]
2265 async fn a_second_stream_take_on_one_seat_is_refused() {
2266 let (ws, mut updates) = crate::Workspace::testing_stub();
2267 let (_dir, engine) = synthetic_engine();
2268 *ws.dictate.engine.lock() = Some(Arc::clone(&engine));
2269 let session = key("streamer");
2270 live_session(&ws, &session);
2271
2272 ws.dispatch(Command::DictateStream {
2273 key: session.clone(),
2274 options: DictateAxes::default(),
2275 surface: None,
2276 initiator: Some(1),
2277 })
2278 .expect("dispatch");
2279 ws.dispatch(Command::DictateStream {
2280 key: session.clone(),
2281 options: DictateAxes::default(),
2282 surface: None,
2283 initiator: Some(2),
2284 })
2285 .expect("dispatch");
2286
2287 let first = updates.recv().await.expect("the first start echoes");
2288 assert!(
2289 matches!(&*first.update, SessionUpdate::DictateStarted { initiator: Some(1), .. }),
2290 "got {first:?}"
2291 );
2292 let second = updates.recv().await.expect("the refusal echoes");
2293 match std::sync::Arc::unwrap_or_clone(second.update) {
2294 SessionUpdate::DictateEnded {
2295 outcome: DictateOutcome::Refused { message },
2296 initiator,
2297 ..
2298 } => {
2299 assert_eq!(
2300 initiator,
2301 Some(2),
2302 "the refusal answers the connection that was refused, not the holder"
2303 );
2304 assert_eq!(
2305 message,
2306 "another client is already dictating on this session · dictation did not start",
2307 "decision 3's whole deliverable is that wording"
2308 );
2309 }
2310 other => panic!("expected the refusal, got {other:?}"),
2311 }
2312 assert_eq!(
2313 ws.dictate_runtime.lock().recordings.len(),
2314 1,
2315 "the refused start must not displace the live take"
2316 );
2317 }
2318
2319 #[tokio::test]
2322 async fn two_seats_may_stream_at_once() {
2323 let (ws, _updates) = crate::Workspace::testing_stub();
2324 let (_dir, engine) = synthetic_engine();
2325 *ws.dictate.engine.lock() = Some(Arc::clone(&engine));
2326 let first = key("first");
2327 let second = key("second");
2328 live_session(&ws, &first);
2329 live_session(&ws, &second);
2330
2331 for (at, session) in [&first, &second].into_iter().enumerate() {
2332 ws.dispatch(Command::DictateStream {
2333 key: session.clone(),
2334 options: DictateAxes::default(),
2335 surface: None,
2336 initiator: Some(at as u64 + 1),
2337 })
2338 .expect("dispatch");
2339 assert!(
2340 ws.dictate_push(session, &[0.5; 320], Some(at as u64 + 1)),
2341 "each seat's own take must accept its frames"
2342 );
2343 }
2344 assert_eq!(ws.dictate_runtime.lock().recordings.len(), 2, "both takes are live");
2345 }
2346
2347 #[tokio::test]
2352 async fn a_second_take_from_one_connection_is_refused() {
2353 let (ws, mut updates) = crate::Workspace::testing_stub();
2354 let (_dir, engine) = synthetic_engine();
2355 *ws.dictate.engine.lock() = Some(Arc::clone(&engine));
2356 let first = key("first");
2357 let second = key("second");
2358 live_session(&ws, &first);
2359 live_session(&ws, &second);
2360
2361 ws.dispatch(Command::DictateStream {
2362 key: first.clone(),
2363 options: DictateAxes::default(),
2364 surface: None,
2365 initiator: Some(1),
2366 })
2367 .expect("dispatch");
2368 ws.dispatch(Command::DictateStream {
2369 key: second.clone(),
2370 options: DictateAxes::default(),
2371 surface: None,
2372 initiator: Some(1),
2373 })
2374 .expect("dispatch");
2375
2376 let started = updates.recv().await.expect("the first start echoes");
2377 assert!(
2378 matches!(&*started.update, SessionUpdate::DictateStarted { initiator: Some(1), .. }),
2379 "got {started:?}"
2380 );
2381 let refusal = updates.recv().await.expect("the refusal echoes");
2382 match std::sync::Arc::unwrap_or_clone(refusal.update) {
2383 SessionUpdate::DictateEnded {
2384 outcome: DictateOutcome::Refused { message },
2385 initiator,
2386 ..
2387 } => {
2388 assert_eq!(initiator, Some(1), "the refusal answers the client that asked");
2389 assert_eq!(
2390 message,
2391 "this client is already dictating on another seat · dictation did not start",
2392 "the refusal names the reason in the client's own terms"
2393 );
2394 }
2395 other => panic!("expected the refusal, got {other:?}"),
2396 }
2397 assert!(ws.dictate_push(&first, &[0.5; 320], Some(1)), "the first take keeps its frames");
2398 assert_eq!(ws.dictate_runtime.lock().recordings.len(), 1, "nothing else registered");
2399 }
2400
2401 #[tokio::test]
2406 async fn a_frame_from_another_connection_never_lands_in_this_take() {
2407 let (ws, _updates) = crate::Workspace::testing_stub();
2408 let (_dir, engine) = synthetic_engine();
2409 *ws.dictate.engine.lock() = Some(Arc::clone(&engine));
2410 let session = key("streamer");
2411 live_session(&ws, &session);
2412
2413 ws.dispatch(Command::DictateStream {
2414 key: session.clone(),
2415 options: DictateAxes::default(),
2416 surface: None,
2417 initiator: Some(1),
2418 })
2419 .expect("dispatch");
2420
2421 assert!(
2422 !ws.dictate_push(&session, &[0.5; 320], Some(2)),
2423 "another connection's frames must not land in this take"
2424 );
2425 assert!(ws.dictate_push(&session, &[0.5; 320], Some(1)), "its own connection's do");
2426 }
2427
2428 #[tokio::test]
2432 async fn frames_for_a_seat_with_no_live_take_are_dropped() {
2433 let (ws, _updates) = crate::Workspace::testing_stub();
2434 assert!(
2435 !ws.dictate_push(&key("nobody"), &[0.5; 320], Some(1)),
2436 "a frame with no take to hold it must be dropped"
2437 );
2438 }
2439
2440 #[tokio::test]
2444 async fn a_stream_take_without_an_engine_refuses_with_the_way_out() {
2445 let (ws, mut updates) = crate::Workspace::testing_stub();
2446 let session = key("streamer");
2447 live_session(&ws, &session);
2448
2449 ws.dispatch(Command::DictateStream {
2450 key: session.clone(),
2451 options: DictateAxes::default(),
2452 surface: None,
2453 initiator: Some(1),
2454 })
2455 .expect("dispatch");
2456
2457 let ended = updates.recv().await.expect("the refusal echoes");
2458 match std::sync::Arc::unwrap_or_clone(ended.update) {
2459 SessionUpdate::DictateEnded {
2460 outcome: DictateOutcome::Refused { message }, ..
2461 } => {
2462 assert!(
2463 message.contains("not ready"),
2464 "the refusal must say dictation is not ready, got: {message}"
2465 );
2466 }
2467 other => panic!("expected the refusal, got {other:?}"),
2468 }
2469 assert!(ws.dictate_runtime.lock().recordings.is_empty(), "nothing may be left live");
2470 }
2471
2472 #[tokio::test]
2477 async fn a_stream_take_for_a_seat_with_no_session_is_refused() {
2478 let (ws, mut updates) = crate::Workspace::testing_stub();
2479 let (_dir, engine) = synthetic_engine();
2480 *ws.dictate.engine.lock() = Some(Arc::clone(&engine));
2481 let session = key("ghost");
2483
2484 ws.dispatch(Command::DictateStream {
2485 key: session.clone(),
2486 options: DictateAxes::default(),
2487 surface: None,
2488 initiator: Some(1),
2489 })
2490 .expect("dispatch");
2491
2492 let ended = updates.recv().await.expect("the refusal echoes");
2493 match std::sync::Arc::unwrap_or_clone(ended.update) {
2494 SessionUpdate::DictateEnded {
2495 outcome: DictateOutcome::Refused { message }, ..
2496 } => {
2497 assert!(
2498 message.contains("closed"),
2499 "the refusal must say the seat is gone, got: {message}"
2500 );
2501 }
2502 other => panic!("expected the refusal, got {other:?}"),
2503 }
2504 assert!(ws.dictate_runtime.lock().recordings.is_empty(), "nothing may be left live");
2505 assert!(
2506 !ws.dictate_push(&session, &[0.5; 320], Some(1)),
2507 "and nothing may accept frames for a seat no take was registered on"
2508 );
2509 }
2510
2511 #[tokio::test]
2515 async fn a_stream_take_nobody_stops_ends_at_its_cap() {
2516 let (ws, mut updates) = crate::Workspace::testing_stub();
2517 let dir = tempfile::tempdir().unwrap();
2518 let cfg = forge_dictate::ConfigBuilder::new()
2519 .models_dir(dir.path())
2520 .normalizer(None)
2521 .max_capture(Duration::from_secs(1))
2522 .build();
2523 let engine = forge_dictate::Engine::new(cfg).expect("engine must start");
2524 *ws.dictate.engine.lock() = Some(Arc::clone(&engine));
2525 let session = key("capped");
2526 live_session(&ws, &session);
2527
2528 ws.dispatch(Command::DictateStream {
2529 key: session.clone(),
2530 options: DictateAxes::default(),
2531 surface: None,
2532 initiator: Some(1),
2533 })
2534 .expect("dispatch");
2535 assert!(ws.dictate_push(&session, &[0.5; 320], Some(1)), "the take is live");
2536 let _ = updates.recv().await.expect("the start echoes");
2537
2538 let ended = tokio::time::timeout(Duration::from_secs(5), async {
2540 loop {
2541 match updates
2542 .recv()
2543 .await
2544 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
2545 {
2546 Some(SessionUpdate::DictateEnded { .. }) => break true,
2547 Some(_) => {}
2548 None => break false,
2549 }
2550 }
2551 })
2552 .await
2553 .expect("the cap must end a take nobody stops");
2554 assert!(ended, "the take must resolve rather than hold the seat forever");
2555 assert!(ws.dictate_runtime.lock().recordings.is_empty(), "and the seat is free again");
2556 }
2557
2558 #[tokio::test]
2563 async fn closing_a_stream_take_drops_it_and_frees_the_seat() {
2564 let (ws, mut updates) = crate::Workspace::testing_stub();
2565 let (_dir, engine) = synthetic_engine();
2566 *ws.dictate.engine.lock() = Some(Arc::clone(&engine));
2567 let session = key("streamer");
2568 live_session(&ws, &session);
2569
2570 ws.dispatch(Command::DictateStream {
2571 key: session.clone(),
2572 options: DictateAxes::default(),
2573 surface: None,
2574 initiator: Some(1),
2575 })
2576 .expect("dispatch");
2577 assert!(ws.dictate_push(&session, &[0.5; 320], Some(1)));
2578 let _ = updates.recv().await.expect("the start echoes");
2579
2580 assert!(
2581 !ws.dictate_close(&session, 2),
2582 "a take another connection started is not this one's to close"
2583 );
2584 assert_eq!(
2585 ws.dictate_runtime.lock().recordings.len(),
2586 1,
2587 "and the foreign close leaves it recording"
2588 );
2589 assert!(ws.dictate_close(&session, 1), "the live stream take is closed");
2590 assert!(
2591 ws.dictate_runtime.lock().recordings.is_empty(),
2592 "the seat is free before the take resolves, which is what the next start needs"
2593 );
2594 assert!(!ws.dictate_close(&session, 1), "closing a second time closes nothing");
2595
2596 let resolved = tokio::time::timeout(Duration::from_secs(3), async {
2600 loop {
2601 match updates
2602 .recv()
2603 .await
2604 .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
2605 {
2606 Some(SessionUpdate::DictateEnded { outcome, .. }) => break Some(outcome),
2607 Some(_) => {}
2608 None => break None,
2609 }
2610 }
2611 })
2612 .await
2613 .expect("a closed take must resolve rather than hold the seat");
2614 assert!(
2615 matches!(resolved, Some(DictateOutcome::Cancelled)),
2616 "a dropped take must be abandoned with nothing submitted, got {resolved:?}"
2617 );
2618
2619 ws.dispatch(Command::DictateStream {
2621 key: session.clone(),
2622 options: DictateAxes::default(),
2623 surface: None,
2624 initiator: Some(1),
2625 })
2626 .expect("dispatch");
2627 assert!(
2628 ws.dictate_push(&session, &[0.5; 320], Some(1)),
2629 "a take started right after a drop must land"
2630 );
2631 }
2632
2633 #[tokio::test]
2638 async fn a_stop_from_another_connection_leaves_the_take_running() {
2639 let (ws, mut updates) = crate::Workspace::testing_stub();
2640 let (_dir, engine) = synthetic_engine();
2641 *ws.dictate.engine.lock() = Some(Arc::clone(&engine));
2642 let session = key("streamer");
2643 live_session(&ws, &session);
2644
2645 ws.dispatch(Command::DictateStream {
2646 key: session.clone(),
2647 options: DictateAxes::default(),
2648 surface: None,
2649 initiator: Some(1),
2650 })
2651 .expect("dispatch");
2652 let _ = updates.recv().await.expect("the start echoes");
2653
2654 handle_dictate_stop(&ws, &session, false, Some(2)).await;
2660 assert!(
2661 matches!(
2662 &ws.dictate_runtime.lock().stop_pending,
2663 Some((parked, who, _)) if parked == &session && *who == Some(2)
2664 ),
2665 "a foreign stop parks for its own connection rather than reaching this take"
2666 );
2667 assert!(
2668 ws.dictate_push(&session, &[0.5; 320], Some(1)),
2669 "a foreign stop must not end the take"
2670 );
2671 assert_eq!(ws.dictate_runtime.lock().recordings.len(), 1, "and the take is still live");
2672 }
2673
2674 #[tokio::test]
2678 async fn closing_a_finishing_take_abandons_its_transcript() {
2679 let (ws, _updates) = crate::Workspace::testing_stub();
2680 let session = key("finishing");
2681 let (stop_tx, mut stop_rx) = tokio::sync::mpsc::channel(1);
2682 ws.dictate_runtime.lock().finishing.push(FinishingTake {
2683 key: session.clone(),
2684 stop: stop_tx,
2685 initiator: Some(1),
2686 });
2687
2688 assert!(!ws.dictate_close(&session, 2), "another connection's close is not this take's");
2689 assert_eq!(
2690 ws.dictate_runtime.lock().finishing.len(),
2691 1,
2692 "and a foreign close leaves the take awaiting its transcript"
2693 );
2694 assert!(ws.dictate_close(&session, 1), "the submitted take is its owner's to cancel");
2695 assert_eq!(
2696 stop_rx.try_recv(),
2697 Ok(false),
2698 "the close abandons the transcript rather than submitting it"
2699 );
2700 }
2701
2702 #[test]
2705 fn the_axes_a_client_sends_decide_its_takes_options() {
2706 let axes = DictateAxes {
2707 styling: forge_dictate::normalize::Styling::Formal,
2708 structure: forge_dictate::normalize::Structure::Lists,
2709 context: forge_dictate::normalize::Context::Email,
2710 };
2711 let options = axes.normalize_options();
2712 assert_eq!(options.styling, forge_dictate::normalize::Styling::Formal);
2713 assert_eq!(options.structure, forge_dictate::normalize::Structure::Lists);
2714 assert_eq!(options.context, forge_dictate::normalize::Context::Email);
2715
2716 let defaults = DictateAxes::default().normalize_options();
2717 assert_eq!(
2718 defaults,
2719 forge_dictate::NormalizeOptions::default(),
2720 "an unset client must normalize exactly as the crate's own defaults do"
2721 );
2722 }
2723
2724 #[tokio::test]
2727 async fn a_stop_routes_to_the_take_that_started_on_the_key() {
2728 let (ws, _updates) = crate::Workspace::testing_stub();
2729 let owner = key("owner");
2730 let mut stop_rx = live_take(&ws, &owner);
2731
2732 handle_dictate_stop(&ws, &key("owner"), true, None).await;
2733 assert!(
2734 matches!(stop_rx.try_recv(), Ok(true)),
2735 "submit must reach the owning take's channel"
2736 );
2737
2738 handle_dictate_stop(&ws, &key("stranger"), false, None).await;
2739 assert!(
2740 matches!(stop_rx.try_recv(), Err(tokio::sync::mpsc::error::TryRecvError::Empty)),
2741 "a stop from a session with no take must not reach someone else's"
2742 );
2743 }
2744
2745 #[tokio::test]
2749 async fn a_submitted_take_keeps_its_stop_channel_reachable() {
2750 let (ws, _updates) = crate::Workspace::testing_stub();
2751 let take = key("take");
2752 let mut stop_rx = live_take(&ws, &take);
2753
2754 move_to_finishing(&ws, &take);
2755 {
2756 let runtime = ws.dictate_runtime.lock();
2757 assert!(runtime.recordings.is_empty(), "a submitted take no longer holds a live entry");
2758 assert_eq!(runtime.finishing.len(), 1, "the take is awaiting its transcript");
2759 }
2760 handle_dictate_stop(&ws, &take, false, None).await;
2761 assert!(
2762 matches!(stop_rx.try_recv(), Ok(false)),
2763 "abandon must still reach the take after it finished recording"
2764 );
2765 }
2766
2767 #[tokio::test]
2772 async fn a_closed_stop_channel_abandons_the_wait_instead_of_spinning() {
2773 let (answer_tx, answer_rx) =
2778 std::sync::mpsc::channel::<Result<forge_dictate::Outcome, forge_dictate::Error>>();
2779 let mut answer = tokio::task::spawn_blocking(move || {
2780 answer_rx.recv().map_err(|_| forge_dictate::Error::EngineStopped)
2781 });
2782 let (stop_tx, mut stop_rx) = tokio::sync::mpsc::channel(1);
2783 drop(stop_tx);
2784 let cancel = forge_dictate::CancelToken::new();
2785
2786 let started = std::time::Instant::now();
2787 let resolution = wait_for_take(&mut answer, &mut stop_rx, &cancel).await;
2788 drop(answer_tx);
2789
2790 assert!(
2791 matches!(resolution, TakeResolution::Abandoned),
2792 "a closed channel means the owner is gone, not that the take resolved"
2793 );
2794 assert!(cancel.is_cancelled(), "the abandon fires this take's token");
2795 assert!(
2796 started.elapsed() < Duration::from_secs(2),
2797 "the wait must end when the channel closes, not spin until the answer resolves"
2798 );
2799 }
2800
2801 #[tokio::test]
2804 async fn an_abandon_decision_fires_the_token_and_ends_the_wait() {
2805 let (answer_tx, answer_rx) =
2806 std::sync::mpsc::channel::<Result<forge_dictate::Outcome, forge_dictate::Error>>();
2807 let mut answer = tokio::task::spawn_blocking(move || {
2808 answer_rx.recv().map_err(|_| forge_dictate::Error::EngineStopped)
2809 });
2810 let (stop_tx, mut stop_rx) = tokio::sync::mpsc::channel(1);
2811 stop_tx.send(false).await.expect("the channel is open");
2812 let cancel = forge_dictate::CancelToken::new();
2813
2814 let resolution = wait_for_take(&mut answer, &mut stop_rx, &cancel).await;
2815 drop(answer_tx);
2816
2817 assert!(matches!(resolution, TakeResolution::Abandoned));
2818 assert!(cancel.is_cancelled(), "abandoning the take cancels its job");
2819 }
2820
2821 #[tokio::test]
2824 async fn a_submit_decision_during_transcription_keeps_waiting() {
2825 let (answer_tx, answer_rx) =
2826 std::sync::mpsc::channel::<Result<forge_dictate::Outcome, forge_dictate::Error>>();
2827 let mut answer = tokio::task::spawn_blocking(move || {
2828 answer_rx.recv().map_err(|_| forge_dictate::Error::EngineStopped)
2829 });
2830 let (stop_tx, mut stop_rx) = tokio::sync::mpsc::channel(1);
2831 stop_tx.send(true).await.expect("the channel is open");
2832 answer_tx
2833 .send(Err(forge_dictate::Error::EngineStopped))
2834 .expect("an unbounded channel accepts the answer");
2835 let cancel = forge_dictate::CancelToken::new();
2836
2837 let resolution = wait_for_take(&mut answer, &mut stop_rx, &cancel).await;
2838 drop(answer_tx);
2839
2840 match resolution {
2841 TakeResolution::Answered(_) => {
2842 assert!(!cancel.is_cancelled(), "a submit decision never cancels");
2843 }
2844 TakeResolution::Abandoned => {
2845 panic!("a submit decision during a transcription must keep waiting")
2846 }
2847 }
2848 }
2849
2850 #[tokio::test]
2855 async fn a_queued_stop_beats_an_answer_that_is_already_ready() {
2856 for round in 0..64u32 {
2861 let mut answer = tokio::spawn(async move { round });
2862 while !answer.is_finished() {
2863 tokio::task::yield_now().await;
2864 }
2865 let (stop_tx, mut stop_rx) = tokio::sync::mpsc::channel(1);
2866 stop_tx.send(false).await.expect("the channel is open");
2867 let cancel = forge_dictate::CancelToken::new();
2868
2869 let resolution = wait_for_take(&mut answer, &mut stop_rx, &cancel).await;
2870
2871 assert!(
2872 matches!(resolution, TakeResolution::Abandoned),
2873 "round {round}: the take answered although the user's stop was queued"
2874 );
2875 assert!(cancel.is_cancelled(), "round {round}: the abandon fires this take's token");
2876 }
2877 }
2878
2879 #[tokio::test(start_paused = true)]
2885 async fn a_queued_stop_beats_a_cap_tick_that_is_already_ready() {
2886 let (_dir, engine) = synthetic_engine();
2887 let stream = engine.capture_stream().expect("a stream take starts");
2888 let meter = stream.capture.meter();
2889 let updates = crate::update_fanout::UpdateFanout::default();
2890 let key = key("capped");
2891
2892 for round in 0..64u32 {
2895 let (stop_tx, mut stop_rx) = tokio::sync::mpsc::channel(1);
2896 stop_tx.send(false).await.expect("the channel is open");
2897
2898 let submitted =
2899 record_until_stopped(&key, &meter, &mut stop_rx, &updates, Duration::ZERO, 0, None)
2900 .await;
2901
2902 assert!(!submitted, "round {round}: the cap submitted a take the user cancelled");
2903 }
2904 }
2905
2906 #[tokio::test]
2910 async fn a_stop_before_its_start_is_parked_for_the_registration() {
2911 let (ws, _updates) = crate::Workspace::testing_stub();
2912 let owner = key("owner");
2913
2914 handle_dictate_stop(&ws, &owner, true, None).await;
2915 assert!(
2916 matches!(&ws.dictate_runtime.lock().stop_pending, Some((parked, _, _)) if *parked == owner),
2917 "a stop with nothing to route parks itself for the start"
2918 );
2919
2920 let mut stop_rx = live_take(&ws, &owner);
2923 handle_dictate_stop(&ws, &owner, true, None).await;
2924 assert_eq!(stop_rx.recv().await, Some(true));
2925 assert_eq!(ws.dictate_runtime.lock().stop_pending, None);
2926 }
2927
2928 #[test]
2933 fn a_stale_park_is_consumed_without_honour() {
2934 let mut runtime = DictateRuntime::default();
2935 let owner = key("owner");
2936 let stale = Instant::now()
2937 .checked_sub(STOP_PARK_WINDOW + Duration::from_millis(50))
2938 .expect("a process one window old can still backdate a park; boot is far older");
2939 runtime.stop_pending = Some((owner.clone(), None, stale));
2940
2941 assert!(!runtime.take_parked_stop(&owner, None, Instant::now()));
2942 assert_eq!(runtime.stop_pending, None, "the stale park is consumed either way");
2943
2944 runtime.stop_pending = Some((owner.clone(), None, Instant::now()));
2945 assert!(runtime.take_parked_stop(&owner, None, Instant::now()), "a fresh park is honoured");
2946 assert_eq!(runtime.stop_pending, None);
2947
2948 let other = key("other");
2951 runtime.stop_pending = Some((other.clone(), None, Instant::now()));
2952 assert!(!runtime.take_parked_stop(&owner, None, Instant::now()));
2953 assert!(runtime.stop_pending.is_some(), "the other key's park survives");
2954
2955 runtime.stop_pending = Some((owner.clone(), Some(9), Instant::now()));
2958 assert!(
2959 !runtime.take_parked_stop(&owner, None, Instant::now()),
2960 "another connection's park is not this start's to honour"
2961 );
2962 assert!(runtime.stop_pending.is_some(), "and it survives for its own start");
2963 }
2964
2965 #[tokio::test]
2968 async fn closing_the_session_clears_its_parked_stop() {
2969 let (ws, _updates) = crate::Workspace::testing_stub();
2970 let owner = key("owner");
2971 handle_dictate_stop(&ws, &owner, true, None).await;
2972 assert!(ws.dictate_runtime.lock().stop_pending.is_some());
2973
2974 ws.release_session(&owner);
2975
2976 assert_eq!(ws.dictate_runtime.lock().stop_pending, None);
2977 }
2978
2979 #[tokio::test]
2984 async fn a_refused_start_clears_the_park() {
2985 let (ws, mut updates) = crate::Workspace::testing_stub();
2986 let owner = key("owner");
2987 ws.dictate_runtime.lock().stop_pending = Some((owner.clone(), None, Instant::now()));
2988
2989 ws.dispatch(Command::DictateStream {
2991 key: owner.clone(),
2992 options: DictateAxes::default(),
2993 surface: None,
2994 initiator: None,
2995 })
2996 .expect("dispatch");
2997
2998 assert_eq!(ws.dictate_runtime.lock().stop_pending, None);
2999 let ended = updates.recv().await.expect("the refusal echo");
3000 assert!(matches!(
3001 std::sync::Arc::unwrap_or_clone(ended.update),
3002 SessionUpdate::DictateEnded { outcome: DictateOutcome::Refused { .. }, .. }
3003 ));
3004 }
3005
3006 #[tokio::test]
3011 async fn closing_the_session_releases_its_live_take() {
3012 let (ws, _updates) = crate::Workspace::testing_stub();
3013 let owner = key("owner");
3014 let mut stop_rx = live_take(&ws, &owner);
3015 let (finishing_tx, _finishing_rx) = tokio::sync::mpsc::channel(1);
3016 ws.dictate_runtime.lock().finishing =
3017 vec![FinishingTake { key: owner.clone(), stop: finishing_tx, initiator: None }];
3018
3019 ws.release_session(&owner);
3020
3021 {
3022 let runtime = ws.dictate_runtime.lock();
3023 assert!(runtime.recordings.is_empty(), "a closed session must not keep a live take");
3024 assert!(
3025 runtime.finishing.is_empty(),
3026 "a closed session's submitted take must not keep a cancel route"
3027 );
3028 }
3029 assert!(
3030 matches!(stop_rx.try_recv(), Err(tokio::sync::mpsc::error::TryRecvError::Disconnected)),
3031 "dropping the entry must close the channel, which is what makes the runner abandon the take"
3032 );
3033 }
3034}