Skip to main content

forge_workspace/
dictate.rs

1//! Dictation - the `[dictate]` section in `forge.toml`, and the
2//! preflight pass that makes the models usable before forge starts.
3//!
4//! The costs are what shape this. A first run fetches 3.07 GB; every
5//! later one re-hashes what is on disk, about 0.8 s for the pair now the
6//! two models are prepared concurrently, then loads the weights, another
7//! second warm. The hash is skipped for a model whose size and
8//! modification time still match the digest recorded for it, which is
9//! every boot but the one after a file changed. None of that can happen
10//! while somebody is dictating, so all of it happens once, at boot, on
11//! the preflight screen.
12
13use 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
26/// Cap on one recording when `[dictate] max_capture_minutes` is absent.
27/// Matches the crate's own default; a capture reserves 4 bytes a sample
28/// eagerly, so this is about 110 MiB held for the run.
29const DEFAULT_MAX_CAPTURE_MINUTES: u64 = 30;
30
31/// The push-to-talk key. Right Cmd is the default; on Linux and
32/// Windows there is no Cmd key, so the cmd equivalent is the right
33/// Control key.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub enum DictateBind {
36    #[default]
37    RightCmd,
38    LeftCmd,
39    Off,
40}
41
42/// How a press/release pair maps onto starting and stopping a take.
43/// `Auto` infers from timing; the forced modes bypass the tap window
44/// entirely.
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub enum DictateMode {
47    /// Sub-window tap toggles, hold transcribes on release.
48    #[default]
49    Auto,
50    /// Press starts, the next press stops; releases never stop.
51    Toggle,
52    /// Hold records, release stops, however brief the hold.
53    Hold,
54}
55
56impl DictateMode {
57    /// What the forge.toml key accepts.
58    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/// What a session has overridden on the normalizer's prompt axes.
83/// `None` on an axis means "the crate default"; the `/dictate` dialog
84/// derives each row's in-force marker from this plus the defaults.
85#[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    /// The per-recording options this session dictates with: crate
94    /// defaults with each overridden axis replaced.
95    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    /// `true` when nothing is overridden: the reset row is inert and
110    /// no row carries the session-set suffix.
111    pub fn is_empty(self) -> bool {
112        self.styling.is_none() && self.structure.is_none() && self.context.is_none()
113    }
114}
115
116/// The three axes decided, as a client that captures sends them: the
117/// values it read from the greeting with its own edits applied, so every
118/// axis is a value rather than a maybe. The crate defaults stand in where
119/// nothing was configured, which is what the client's own panel draws as
120/// the unset state.
121#[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    /// The options a take with these axes normalizes by.
130    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/// One edit the `/dictate` dialog asks for: set a single axis, or
148/// clear them all. Enter on an already-set row re-sets the same value;
149/// there is no per-axis clear, so the reset row is the only way back.
150#[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    /// What the forge.toml key accepts.
161    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/// The `[dictate]` section. Every field has a default, so an absent
188/// section is exactly `enabled = false`.
189///
190/// Deliberately narrower than [`forge_dictate::Config`]. A model spec
191/// carries a URL, a byte length and a digest, and hand-editing one is
192/// how a file arrives that nothing can verify. The normalizer's three
193/// prompt axes are permissions rather than instructions - they say what
194/// the model MAY change, not what it must - so exposing them means
195/// flipping one, seeing no difference, and concluding it is broken.
196#[derive(Debug, Clone, Deserialize, PartialEq)]
197#[serde(deny_unknown_fields)]
198pub struct DictateSettings {
199    /// Off unless asked for: turning it on costs a 3.07 GB download the
200    /// first time and holds about 1.8 GB of physical footprint for the
201    /// run.
202    #[serde(default)]
203    pub enabled: bool,
204    /// Where the model files live. Absent uses the platform cache
205    /// directory.
206    #[serde(default)]
207    pub models_dir: Option<String>,
208    /// Ghost of the retired `[dictate] device` pin: read only so a stale
209    /// forge.toml still carrying it warns at load instead of being refused
210    /// as an unknown key. Capture moved to the client, which picks its own
211    /// machine's input, so nothing reads this.
212    #[serde(default)]
213    pub device: Option<toml::Value>,
214    /// The model the transcribing role runs, as the feed's own variant
215    /// name. Set, it wins over every runtime pick and refuses Activate
216    /// for the role; absent leaves the role to the last pick or the
217    /// compiled pin.
218    #[serde(default)]
219    pub transcribe_model: Option<String>,
220    /// The model the cleanup role runs, by feed variant name, under the
221    /// same rule as [`Self::transcribe_model`]. Inert while
222    /// [`Self::normalizer`] is off.
223    #[serde(default)]
224    pub cleanup_model: Option<String>,
225    /// Spoken language hint. Absent autodetects.
226    #[serde(default)]
227    pub language: Option<String>,
228    /// Rewrite recognition output into clean text. Off halves the
229    /// download and skips a pass per utterance.
230    #[serde(default = "enabled_by_default")]
231    pub normalizer: bool,
232    /// Upper bound on a single recording. A capture nobody stops ends
233    /// itself here rather than holding the seat indefinitely.
234    #[serde(default = "default_max_capture_minutes")]
235    pub max_capture_minutes: u64,
236    /// The push-to-talk key.
237    #[serde(default)]
238    pub bind: DictateBind,
239    /// How press/release maps onto starting and stopping a take.
240    #[serde(default)]
241    pub mode: DictateMode,
242    /// The register a take's cleanup rewrites into, where a client has not
243    /// chosen its own. Absent means the crate's default.
244    #[serde(default)]
245    pub styling: Option<forge_dictate::normalize::Styling>,
246    /// Whether the cleanup may return a bulleted list. Absent means the
247    /// crate's default.
248    #[serde(default)]
249    pub structure: Option<forge_dictate::normalize::Structure>,
250    /// What the words are being written for. Absent means the crate's
251    /// default.
252    #[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    /// The book (`docs/book/src/configuration.md`) documents `30` as
267    /// the default; a drift here must fail a test, not the user's
268    /// expectation.
269    #[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    /// The axes in force: what `forge.toml` sets, and the crate's own
297    /// default wherever it sets nothing. This is what a client reads as the
298    /// values it starts on and resets to.
299    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    /// The engine configuration these settings describe.
309    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    /// Where the model files land. Preflight says it while fetching,
325    /// because cancelling keeps what has arrived and a reader needs to
326    /// know where.
327    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    /// The model each role runs, in role order: the pins plus the roles
335    /// they run as. The one place the pair is spelled, so preflight's
336    /// rows and the models page's IN USE rows cannot disagree about which
337    /// models forge runs.
338    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    /// The compiled pins as active models: what every role runs before any
346    /// resolution has landed, and what a role with no key and no runtime
347    /// pick keeps running.
348    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    /// The rows preflight draws before any work has started.
366    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/// What a model is for. Preflight labels the row by role and prints the
379/// file underneath, because the role is what a reader is scanning for
380/// and the file is the detail they want only when something is wrong.
381///
382/// Named for dictation, which owned the model lifecycle first; the set now
383/// spans every local role, speech out included.
384#[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/// How far one model has got.
403#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
404#[serde(rename_all = "snake_case")]
405pub enum DictateModelState {
406    /// Nothing has started on this one.
407    Pending,
408    /// Bytes are moving. `resumed_from` is what a `.part` already held,
409    /// so a bar that opens at 38% can say why.
410    Downloading { downloaded: u64, total: u64, resumed_from: Option<u64> },
411    /// Hashing what is on disk, which reads the file end to end.
412    Verifying,
413    /// On disk and verified, weights not loaded yet.
414    Fetched,
415    /// Weights going into memory.
416    Loading,
417    /// Loaded and usable.
418    Ready,
419    /// The one that stopped preflight, carrying its own reason. The
420    /// snapshot's copy is for the prose block under the rows.
421    Failed(DictateFailure),
422}
423
424/// Why preflight stopped.
425///
426/// Every variant ends the run: there is no degraded dictation to fall
427/// back to, so the screen names the way out and forge quits.
428#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
429#[serde(rename_all = "snake_case")]
430pub enum DictateFailure {
431    /// A file is the right length and the wrong bytes. Reported rather
432    /// than repaired - discarding a multi-gigabyte file somebody put
433    /// there is not forge's call, and `size` is what makes that
434    /// sentence concrete rather than abstract on screen.
435    HashMismatch { path: PathBuf, expected: String, actual: String, size: u64 },
436    /// The user asked preflight to stop. `kept` of `total` bytes are on
437    /// disk for the file that was in flight, which the screen says
438    /// before forge goes. `total` is carried so the bar can stay a
439    /// fraction: a full bar beside `cancelled` reads as finished.
440    Cancelled { kept: u64, total: u64 },
441    /// Everything else, worded as the crate worded it.
442    Other { message: String },
443}
444
445impl DictateFailure {
446    /// `true` when the user stopped preflight rather than something
447    /// going wrong. The screen wording and forge's exit differ.
448    pub fn is_cancelled(&self) -> bool {
449        matches!(self, Self::Cancelled { .. })
450    }
451}
452
453/// One model's row in preflight.
454#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
455pub struct DictateModel {
456    pub role: DictateRole,
457    /// File name as the spec records it, including the extension.
458    pub file: String,
459    pub state: DictateModelState,
460}
461
462/// Everything preflight knows about dictation right now.
463///
464/// `models` is empty when `[dictate] enabled` is false, and preflight
465/// then draws no Dictation section at all.
466#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
467pub struct DictateSnapshot {
468    pub models: Vec<DictateModel>,
469    /// Set once, and never cleared for the run: nothing re-probes a
470    /// failed model the way the account loader re-probes a bailed
471    /// account, so clearing this one takes a config edit or a fresh run.
472    /// The failing row carries its own copy; this one is the screen-wide
473    /// prose block's.
474    pub failure: Option<DictateFailure>,
475}
476
477impl DictateSnapshot {
478    /// `true` when there is nothing left to wait for. Dictation that is
479    /// switched off is trivially done; a failure never becomes done,
480    /// which is what stops forge starting over a broken model.
481    pub fn is_ready(&self) -> bool {
482        self.failure.is_none() && self.models.iter().all(|m| m.state == DictateModelState::Ready)
483    }
484}
485
486/// Preflight's own handle on the work: the snapshot a view renders, the
487/// cancellation flag, and the active models the resolution landed.
488pub(crate) struct DictateState {
489    pub(crate) snapshot: Mutex<DictateSnapshot>,
490    pub(crate) cancelled: AtomicBool,
491    /// The live engine, held for the whole run. Dropping it unloads the
492    /// weights, which is the one thing preflight exists to avoid.
493    pub(crate) engine: Mutex<Option<Arc<forge_dictate::Engine>>>,
494    /// Which model each role runs: the compiled pins until the preflight
495    /// resolves, then whatever the config, the runtime pick or the pin
496    /// answered.
497    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    /// Move the resolved models in, before the preflight fetches anything:
513    /// the rows follow the files the roles will actually run, keeping the
514    /// state a row already reached when its file is unchanged.
515    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    /// Move every row to one state: preflight's Loading and Ready points,
543    /// and the same Ready after a runtime swap, where every role's model
544    /// is loaded by the engine that just came up.
545    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
559/// The config preflight builds the engine from: the `[dictate]`
560/// settings, the resolved active models, and the always-on stores under
561/// forge's app-support dir - per-take diagnostics and the
562/// verified-digest record - machine-local and never synced.
563///
564/// A directory that cannot be resolved turns the capture off with a
565/// warning rather than failing preflight - diagnostics never break
566/// dictation, but the absence must not be silent either: a capability
567/// believed present and quietly missing is the failure nobody can see.
568pub(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    // The resolution replaces the pins the builder put in, per role.
585    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
595/// Fetch, verify and load the active models, reporting progress into
596/// `state` as it goes. The caller resolves the models and builds the
597/// config; a dictation-off run never gets here.
598///
599/// Both legs are blocking and documented as panicking on a runtime
600/// thread in a debug build, so both run under `spawn_blocking`.
601pub(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    // `Engine::new` returns in microseconds having handed the load to a
617    // worker; `wait_ready` is the part that takes the second.
618    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/// Per-file transfer bookkeeping, kept across the progress events of one
636/// `prepare` call.
637///
638/// Keyed by file throughout because the models are prepared concurrently
639/// and their events interleave: a single slot lets one model's
640/// `Verifying` clear the other's resume marker mid-transfer, and lets a
641/// model that already finished be named as the one a cancellation
642/// stopped.
643#[derive(Default)]
644struct TransferProgress {
645    /// What a `.part` held when its transfer opened. A bar that starts
646    /// at 38% with nothing said about it reads as a bug.
647    resumed_from: HashMap<String, u64>,
648    /// Bytes seen so far per file still transferring.
649    in_flight: HashMap<String, (u64, u64)>,
650    /// Which transfer a cancellation is reported against. With one model
651    /// in flight this names the same file the old single slot did.
652    cancelling: Option<String>,
653}
654
655impl TransferProgress {
656    /// Fold one event in and return the row state it implies.
657    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                // The first report of a transfer carries whatever was
665                // already on disk, so it is the only chance to learn
666                // that this is a resume rather than a fresh fetch.
667                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                // A finished model is not what a later cancellation is
679                // about. Leaving it named here reports a model that
680                // downloaded and verified as cancelled, with the empty
681                // byte counts of a transfer that is no longer running.
682                if self.cancelling.as_deref() == Some(file.as_str()) {
683                    self.cancelling = None;
684                }
685                (file, DictateModelState::Fetched)
686            }
687        }
688    }
689
690    /// The file a cancellation is about and how far it got. An empty name
691    /// when nothing was transferring, which names no row and shows no
692    /// bytes rather than blaming a model that finished.
693    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
700/// The fetch-and-verify leg. `Err(())` means `state` already carries the
701/// reason.
702fn 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
730/// The file an error is about, so the row that failed is the one that
731/// goes red.
732fn 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    // A rejected download is still a `.part`, and the row is keyed on
743    // the finished name.
744    path.file_name()?.to_str().map(|name| name.trim_end_matches(".part"))
745}
746
747/// Byte length the config records for the model at `path`, or zero when
748/// the error is about a file no spec names.
749fn 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                // A hash mismatch means the length already matched, so
765                // the spec's size is what is on disk.
766                size: spec_size(cfg, path),
767            }
768        }
769        other => DictateFailure::Other { message: other.to_string() },
770    }
771}
772
773/// How often the live recording is polled for its level. A deliberate
774/// fourth clock: coarser than the repaint gate, so every level event
775/// lands on a repaint without driving it.
776const METER_INTERVAL: Duration = Duration::from_millis(50);
777
778/// How often a submitted take's per-window progress stream is polled.
779/// A window takes seconds to decode; this cannot be felt.
780const PROGRESS_POLL: Duration = Duration::from_millis(150);
781
782/// The live side of one dictation: the channel its task polls for the
783/// stop decision (`true` on the channel means submit, `false` means
784/// abandon), and - for a take the client captures - the sink the socket
785/// pushes frames into. The seat is the map key, so the entry does not
786/// carry it again.
787pub(crate) struct LiveRecording {
788    pub(crate) stop: tokio::sync::mpsc::Sender<bool>,
789    /// `Some` on a take fed by frames the client sends; `None` on an
790    /// entry that marks a take live without a socket feeding it.
791    pub(crate) sink: Option<forge_dictate::FrameSink>,
792    /// The connection this take belongs to, as its command carried it:
793    /// every update about the take goes back there alone, and a stop or
794    /// a close from any other connection is not this take's. `None`
795    /// belongs to no connection.
796    pub(crate) initiator: Option<u64>,
797}
798
799/// A submitted take still awaiting its transcript. The recording is
800/// already stopped; the entry stays only so a stop can still abandon it.
801pub(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
807/// The read-aloud set being recorded, when one is: the audio a client's
808/// own frames have fed so far, held until the stop that keeps or drops
809/// it. It belongs to no session - the words are known, so nothing is
810/// transcribed and nothing is routed - but it is a live capture like any
811/// other, and the guards that wait on captures count it.
812///
813/// The connection that started it is the only one that can feed or stop
814/// it, the same rule every framed take follows.
815pub(crate) struct SetRecording {
816    pub(crate) initiator: Option<u64>,
817    pub(crate) samples: Vec<f32>,
818}
819
820/// Dictation state across every session. Every take is fed by a client's
821/// own frames and holds no device, so several seats can dictate at once;
822/// several takes can also be finishing, because a new recording never
823/// waits for the previous transcript.
824pub(crate) struct DictateRuntime {
825    pub(crate) recordings: HashMap<SessionSlot, LiveRecording>,
826    pub(crate) finishing: Vec<FinishingTake>,
827    pub(crate) set_recording: Option<SetRecording>,
828    /// Handed out with every take's `DictateStarted` and echoed on its
829    /// `DictateEnded`, so a resolver arriving after a newer take on the
830    /// same key is recognisably stale. Starts at 1; 0 is the "matches
831    /// nothing" value refusals carry.
832    pub(crate) next_generation: u64,
833    /// A stop that arrived before the start it answers had registered,
834    /// stamped on arrival and carrying its initiator so only a start by
835    /// the same connection honours it. A registration honours it only
836    /// while it is fresh - the race it exists for is scheduler-scale, so
837    /// a park older than the window is a stop whose take already
838    /// resolved (a refusal, a cap self-submit) and must not poison the
839    /// next attempt.
840    pub(crate) stop_pending: Option<(SessionSlot, Option<u64>, Instant)>,
841}
842
843/// How long after parking a stop is still treated as racing its
844/// start's registration. The gap it covers is the scheduler's delay
845/// between two spawned tasks, so anything older is a stop whose take
846/// is already gone.
847const 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    /// The stop channel to route a `DictateStop` for `key` to, if a
863    /// recording or a submitted take belongs to it AND to the connection
864    /// that sent the stop: only the take's own connection can stop it.
865    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    /// The seat holding a live capture, when one is: a recording still
882    /// capturing, a submitted take still transcribing, or the read-aloud
883    /// set being recorded. A model swap and a bench are refused while one
884    /// is, and the refusal names this holder.
885    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    /// The live take a socket's frame belongs to, if `key` has one that
896    /// the pushing connection started.
897    ///
898    /// **The take must be the pushing connection's own**, because a seat's
899    /// live take can be another connection's - decision 3's own scenario,
900    /// where a client whose start was refused still has its microphone open
901    /// for the frames already on the wire. Answering `None` for a foreign
902    /// take is what keeps one client's microphone out of another client's
903    /// dictation.
904    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    /// Consume `key`'s parked stop, answering whether it still races
917    /// its start's registration and should pre-load an abandon. A
918    /// stale park for the key is consumed without honour; another
919    /// key's - or another connection's - park is left alone.
920    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    /// Drop `key`'s parked stop, if any: the take it answered has
942    /// resolved some other way, and a left-behind park would poison
943    /// the session's next attempt.
944    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
955/// A take the client will feed with frames, registered and ready to run:
956/// what the socket pushes into, what the runner finishes, and what the
957/// take normalizes by.
958pub(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
968/// `Command::DictateStream`: register a take the client will feed with
969/// frames.
970///
971/// Registration is SYNCHRONOUS, and the dispatch arm performs it before
972/// the runner is spawned: the socket's connection is one ordered stream,
973/// so the frame a client sends right after its start message must find
974/// the take already there. Nothing here opens a device, and that is what
975/// makes the synchronous registration possible.
976pub(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    // One live take per connection, because a frame carries no seat: with two
995    // takes on one connection, a frame would have no stream it belonged to.
996    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    // The session can close between the press and this registration; a
1009    // take handed to a session that no longer exists is nobody's, and the
1010    // sink would hold frames for a seat no view is drawing.
1011    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    // A stop the scheduler ordered before this registration is honoured
1019    // here: pre-load the abandon so the take ends the moment its runner
1020    // starts reading the channel.
1021    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
1038/// Own a stream-fed take from its registration to its delivered
1039/// transcript: the recording lifecycle, with the axes the client sent.
1040pub(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
1060/// `Command::DictateStop`: submit (`true`) or abandon the take that
1061/// `key` started. A stop that arrives before its start has registered
1062/// is parked in `stop_pending` for the registration to honour, rather
1063/// than dropped: dropping it would orphan a take until the take's own
1064/// cap ended it. A stop whose take already resolved parks
1065/// too - the park cannot tell the two apart on arrival - but it is
1066/// only honoured while fresh, and every take-resolution path clears
1067/// it, so it never poisons the session's next attempt.
1068pub(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
1089/// Own one take from first sample to delivered transcript: stream
1090/// level events while recording, then submit and wait, honouring the
1091/// stop channel at every phase. Segments of a long take transcribe
1092/// while the recording is still running; the transcript lands once the
1093/// take's join has been normalized.
1094async 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    // Take progress spans the whole take: segments settle while the
1106    // recording is still open, and the tally gains its total after the
1107    // stop. The stream closes when the take's segmenter exits.
1108    tokio::spawn(forward_take_progress(
1109        updates.clone(),
1110        key.clone(),
1111        generation,
1112        initiator,
1113        capture.take_progress(),
1114    ));
1115    // The meter handle, not the capture, crosses the await points: the
1116    // capture holds channel receivers and is not Sync.
1117    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    // The recording is stopped; the runtime entry moves from the live
1156    // map to awaiting the transcript so Esc still routes here.
1157    move_to_finishing(&ws, &key);
1158
1159    // The token is cloned out before the ticket moves into the blocking
1160    // read, so abandoning this take touches only its own jobs - a
1161    // queued take behind it keeps its own token and aborts on its own
1162    // turn.
1163    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
1194/// Forward a take's progress steps as updates while it runs. Steps
1195/// arrive while the recording is still open (segments settling, no
1196/// total) and again after the stop (total known); the task ends when
1197/// the take's segmenter closes the stream.
1198async 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
1229/// How waiting on a submitted take ended.
1230enum TakeResolution<T> {
1231    /// The transcript arrived, or the job failed - either way the take
1232    /// has an answer to report. `Err` is the blocking task's join
1233    /// failing, which reports as any other failure.
1234    Answered(Result<T, tokio::task::JoinError>),
1235    /// Esc or the owner going away abandoned the take. The caller
1236    /// reports [`DictateOutcome::Cancelled`] and the blocking answer,
1237    /// when it eventually lands, is dropped unread.
1238    Abandoned,
1239}
1240
1241/// Wait for a submitted take's answer or a stop decision, whichever
1242/// comes first. A submit decision is a no-op (the take is already in),
1243/// and ANY other stop - `Some(false)` from Esc, or the channel closing
1244/// at teardown - fires this take's own cancel token and abandons the
1245/// wait. The abandon must end the wait rather than re-select: a closed
1246/// channel answers immediately and forever, and a loop that keeps
1247/// polling it busy-spins a core until the inference runs out on its
1248/// own.
1249///
1250/// The select is biased to the stop arm: when the answer lands in the
1251/// same poll as a queued stop, Esc wins and the answer is dropped
1252/// unread. An unbiased select picks a random ready arm, which landed
1253/// a cancelled take about half the time.
1254async 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
1273/// Stream level events until a stop decision or the capture cap stops
1274/// itself. Returns whether the take should be submitted.
1275///
1276/// `cap` is enforced HERE as well as in the capture, by wall clock: the
1277/// sample cap fires only while audio arrives, so a take whose stream stops
1278/// mid-take has nothing to notice it and would hold the seat until
1279/// teardown.
1280///
1281/// The select is biased to the stop arm: a stop queued when a meter tick
1282/// is ready in the same poll cancels the take, so the cap cannot submit
1283/// one the user already cancelled.
1284async 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                // The cap stopped the capture on its own; submitting is
1304                // what a release would have done.
1305                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
1324/// Clear the seat's live entry when the take that owns it has resolved -
1325/// a teardown may already have removed it, which this tolerates.
1326fn 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
1332/// Move this take's stop channel from the live map to the finishing
1333/// list, so a stop during transcription still routes to it.
1334fn 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
1345/// Drop this take's finishing entry, whatever took it out of routing.
1346fn 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
1351/// Abandon everything `key` has in flight: a live take goes back
1352/// (dropping the entry closes the stop channel, which is what makes its
1353/// runner abandon it) and a submitted take loses its cancel route.
1354/// Called when the session closes.
1355pub(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    // A park for the closing seat is moot whoever left it.
1360    if runtime.stop_pending.as_ref().is_some_and(|(parked, _, _)| parked == key) {
1361        runtime.stop_pending = None;
1362    }
1363}
1364
1365/// Release every session's dictation at once, for workspace shutdown.
1366pub(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
1373/// Map the crate's answer onto the wire outcome. A normalisation that
1374/// produced nothing is a valid answer, not a failure.
1375fn 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    /// The models are prepared concurrently, so a cancel can land while
1395    /// one is still transferring and the other has already finished.
1396    /// Naming the finished one puts `cancelled` in error red on a model
1397    /// that downloaded and hash-verified, under prose about a `.part`
1398    /// file it does not have.
1399    #[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        // The other model is cached, so it only ever verifies.
1406        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    /// The transfer that IS still running is the one to report, with its
1414    /// own numbers.
1415    #[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    /// Interleaved events must not let one model's `Verifying` clear the
1430    /// other's resume marker: the bar would silently drop the line
1431    /// explaining why it opened at 38%.
1432    #[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    /// An absent `[dictate]` section must not download three gigabytes
1462    /// because forge started, so the whole section defaults off.
1463    #[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    /// A mistyped key silently doing nothing is worse here than a load
1471    /// failure: `modelsdir` would fetch 3 GB to the wrong volume and
1472    /// nothing would say so.
1473    #[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    /// The preflight config always points the diagnostics store at
1484    /// forge's app-support dir. A refactor dropping that assignment
1485    /// would leave the feature shipped-dead with CI green, which is why
1486    /// the assembly is its own function and this pins it.
1487    #[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    /// The axes are `forge.toml` keys and the crate's own defaults stand
1513    /// in where they are absent - which is what a client draws as its
1514    /// unset state and resets to.
1515    #[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        // A typo fails the load the way every other `[dictate]` key does.
1533        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    /// The normalizer is the one exposed knob whose default is on, and
1539    /// turning it off is what halves the download - so it has to reach
1540    /// the config as an absent spec rather than as a present one.
1541    #[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    /// The push-to-talk keybinding is a config knob, not a constant.
1558    /// Right Cmd is the default; `left_cmd` and `off` exist so the key
1559    /// can be moved or switched off, and a typo must fail the load the
1560    /// way every other `[dictate]` key does.
1561    #[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    /// The overlay's one reset control clears every axis, and the merged
1581    /// options must equal the crate defaults when nothing is set.
1582    #[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        // **The cleared set is a PRESENT object of nulls, not silence**: a skip
1666        // on an empty set would leave a client's stale set standing on every
1667        // reset, with both stacks green.
1668        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    /// The mode rides the same parsing rules as `bind`: three values,
1686    /// default `auto`, and a typo fails the load naming the value.
1687    #[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    /// Minutes, because the value is a duration and TOML has no unit.
1707    /// Passed through as seconds, so a wrong multiplier would cap a
1708    /// recording at 30 seconds.
1709    #[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    /// Preflight gates forge's boot on `is_ready`, so a failure must
1717    /// never satisfy it - not even one that arrives after every model
1718    /// has loaded.
1719    #[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    /// Dictation that is switched off has nothing to wait for, and a
1739    /// gate that waited anyway would never open.
1740    #[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    /// `fail` is the only production writer of `Failed`, so both writes
1749    /// it makes are load-bearing: the row must carry its own reason and
1750    /// the snapshot must keep the screen-wide copy.
1751    #[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    /// The row that failed is the one that has to go red, and a rejected
1774    /// download is reported against its `.part` while the row is keyed
1775    /// on the finished name.
1776    #[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    /// The meter cadence is a decided constant - a deliberate fourth
1802    /// clock, coarser than the repaint gate so every level event lands
1803    /// on a repaint. Pinning it makes a silent retune a failing test
1804    /// instead of a note nobody re-reads.
1805    #[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    /// The words a host inserts come from the transcript text; a
1815    /// normalisation that produced nothing is its own outcome, and the
1816    /// silence answer keeps its peak so a host can split a quiet room
1817    /// from no signal at all.
1818    #[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    /// Put a live take on the runtime the way a registration does, and
1854    /// hand back its stop channel so a test can watch what arrives.
1855    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    /// An engine over an empty models directory: takes register, meter
1865    /// and cap exactly as they do in production, and every job it runs
1866    /// answers the load failure a weightless engine gives.
1867    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    /// Register a seat as live, the way a started session is.
1877    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        // Kept alive for the test's length: a dropped receiver marks the
1881        // session dead and every start would refuse.
1882        std::mem::forget(cmd_rx);
1883    }
1884
1885    /// A stream take registers SYNCHRONOUSLY: the frame a client sends
1886    /// right after its start message, on the one ordered connection,
1887    /// must find the take already there. The registration is what the
1888    /// dispatch did, so nothing is awaited between the two.
1889    #[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        // The meter reads the pushed audio.
1917        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        // And the take resolves through the same pipeline: stop submits,
1930        // and a weightless engine answers the load failure.
1931        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    /// **The readings and the submit name the take they belong to** (#839).
1960    /// A reader drops a frame that is not its live take's, so a constant or a
1961    /// stale number here leaves the meter and the phase unheard - silently,
1962    /// because every frame still arrives.
1963    #[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    /// **The surface a client names on its take reaches the ending update.**
2032    /// The record keeps it with the landed words and hands them back to that
2033    /// page alone, so a seam that drops it here sends them to the wrong one.
2034    #[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    /// **The submitted take's ending carries the surface too.** The final send
2087    /// is the one a landed take uses, and the one the composer folds into its
2088    /// record, so a seam that drops it there loses the words' page.
2089    #[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    /// One take's recognition failing is that session's own work rather
2146    /// than a warning about forge, so the record is a `debug` - rule 20's
2147    /// split - and it names the seat, which is what a reader acts on.
2148    /// Both halves are the contract: re-raising the level, or dropping
2149    /// the seat, silently undoes the call this pins.
2150    #[tokio::test]
2151    async fn a_failed_take_records_debug_with_its_seat() {
2152        use tracing_subscriber::layer::SubscriberExt as _;
2153
2154        /// One record: its level, its target, and its fields.
2155        #[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        // A take runs on a spawned task, and `#[tokio::test]` is a
2193        // current-thread runtime, so a thread-local default still catches
2194        // every record it emits.
2195        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        // Without audio the take resolves as `NoAudio` before it ever
2207        // reaches the engine, so the frame is what puts it on the
2208        // failure path this test pins.
2209        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        // The directive that keeps this record readable names a module
2248        // path, and a target one character off matches nothing, so the
2249        // path is measured from the record rather than assumed.
2250        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    /// A second take on one seat is refused by name, whichever path it
2262    /// came in on: two takes would interleave their notices into one
2263    /// draft with nothing saying why.
2264    #[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    /// Two seats may stream at once, one take per connection: a take fed by
2320    /// a client's frames holds no device, so nothing about it is exclusive.
2321    #[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    /// One live take per connection: a frame carries no seat, so a second
2348    /// take on another seat from the same connection would have no stream
2349    /// its audio could belong to. The refusal rides the same outcome a busy
2350    /// seat's does, so the client that pressed draws it by name.
2351    #[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    /// A frame belongs to the take its own connection started: a seat whose
2402    /// live take is ANOTHER connection's keeps nothing of it, which is
2403    /// decision 3's own scenario - a refused start still has its microphone
2404    /// open for the frames already on the wire.
2405    #[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    /// A frame for a seat with no live take is dropped rather than kept:
2429    /// the client may be racing a refusal it has not seen, or a take that
2430    /// already resolved.
2431    #[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    /// A stream take with no engine refuses with the way out, and the
2441    /// refusal resolves no take, so a client that never saw a start is
2442    /// told why.
2443    #[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    /// A stream take for a seat with no session is refused: the frames would
2473    /// have nowhere to land, and the refusal names the way out. The device
2474    /// path's twin of this has pinned it since the beginning; a stream take
2475    /// must not be the path without cover.
2476    #[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        // Deliberately NOT marked live.
2482        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    /// A stream take nobody stops ends itself at the cap. A stalled stream
2512    /// has no callback loop to notice the cap for it, so the runner counts
2513    /// the clock - without which the take would hold the seat until teardown.
2514    #[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        // Nothing else stops it: no release, no drop, no further frame.
2539        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    /// The connection going away DROPS the take it was streaming and frees
2559    /// the seat AT ONCE: nothing it streamed lands anywhere - its reader is
2560    /// gone - and a client that reconnects and starts again must not be
2561    /// refused by the take it left behind.
2562    #[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        // CANCELLED, not failed: a weightless engine FAILS a submitted
2597        // take, so the abandoned end is what says nothing was submitted -
2598        // the drop this path exists for.
2599        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        // The property the refusal broke: the next take registers at once.
2620        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    /// A stop belongs to the connection whose take it addresses: a stop
2634    /// from any other connection parks for its own connection rather than
2635    /// reaching this take, so a view's dictation is not killed by a stop
2636    /// that never owned it.
2637    #[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        // Awaited rather than dispatched: the dispatch arm spawns the
2655        // handler, so an assertion right after it reads before the handler
2656        // has run. The park is this property's own statement - the stop
2657        // found no take of its own and left one for a start that never
2658        // comes - and it is settled the moment the handler returns.
2659        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    /// A take already submitted is still its connection's to cancel: the
2675    /// transcript would have no reader left, so the close abandons it - and
2676    /// another connection's close is not this take's at all.
2677    #[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    /// The axes a client sends decide its take's normalizer options,
2703    /// where a `None` axis would have meant "the crate default".
2704    #[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    /// Stop routing reaches the recording that owns the key, and an
2725    /// unknown key routes nowhere rather than to some other take.
2726    #[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    /// The stop channel follows the take from recording into finishing,
2746    /// so Esc can still abandon a submitted take whose transcript is in
2747    /// flight.
2748    #[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    /// A stop channel closed by teardown ends the wait and fires the
2768    /// take's own token. Before this was handled, the closed channel
2769    /// answered `None` immediately on every select iteration and the
2770    /// wait spun on one core until the inference ran out on its own.
2771    #[tokio::test]
2772    async fn a_closed_stop_channel_abandons_the_wait_instead_of_spinning() {
2773        // An answer that never lands: the worst case, the model does not
2774        // honour cancellation and the inference runs long. The sender is
2775        // released before the test ends, or the runtime drop would wait
2776        // out the blocking read.
2777        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    /// Esc during a transcription abandons the take and fires its
2802    /// token, whatever the answer is doing.
2803    #[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    /// A submit decision during a transcription is a no-op: the take is
2822    /// already submitted, so the wait continues to the answer.
2823    #[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    /// When the answer lands in the same poll as a queued stop, the
2851    /// stop wins: Esc must cancel the take rather than land words the
2852    /// user already abandoned. An unbiased select picks a random ready
2853    /// arm, so this resolved as the answer about half the time.
2854    #[tokio::test]
2855    async fn a_queued_stop_beats_an_answer_that_is_already_ready() {
2856        // Tokio redraws the start index on every select invocation, so
2857        // rounds are independent coin flips: the mutant failed 10 of 10
2858        // runs, first fail on round 3, 0, 0, 1, 0, 1, 2, 1, 2, 2. 64
2859        // rounds put a false pass at 2^-64.
2860        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    /// A stop queued when the cap tick fires must cancel the take, not
2880    /// submit it: the tick's submit path returns what the user already
2881    /// refused. Under the paused clock the interval's first tick and a
2882    /// zero cap are both ready on the first poll, so the two arms race
2883    /// exactly as they do at the cap boundary.
2884    #[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        // The same per-invocation redraw makes each round an independent
2893        // coin flip; 64 rounds put a false pass at 2^-64.
2894        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    /// A stop the scheduler ordered before its start registered is
2907    /// parked, not dropped: dropping it orphans the take until the
2908    /// capture cap releases the microphone.
2909    #[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        // A stop that DOES find a take neither parks nor leaves a stale
2921        // park behind.
2922        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    /// A parked stop is only honoured while fresh: the race it covers
2929    /// is the scheduler's gap between two spawned tasks, so a park
2930    /// older than the window is a stop whose take resolved some other
2931    /// way, and honouring it would waste the session's next attempt.
2932    #[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        // A park for a different key is never honoured and never
2949        // disturbs this key's own state.
2950        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        // Nor is a park for another connection: a stop belongs to the
2956        // take its own connection started.
2957        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    /// Teardown drops a session's park: an inert entry for a closed
2966    /// session would otherwise sit in the Option forever.
2967    #[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    /// A refused start clears the key's park: the release that follows
2980    /// the refusal parks AFTER this clear, and that later park is
2981    /// handled by the freshness window, but a park that arrived before
2982    /// the refusal must not survive it.
2983    #[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        // No engine on the stub, so the registration refuses.
2990        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    /// Closing the session that owns a live take must release it and cut
3007    /// its channel - otherwise the take stays live and the meter keeps
3008    /// emitting for a composer that no longer exists, and every later
3009    /// start is refused naming a session the user closed.
3010    #[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}