Skip to main content

forge_dictate/
config.rs

1//! Configuration for a dictation engine.
2
3use std::path::PathBuf;
4use std::time::Duration;
5
6use crate::normalize::NormalizeOptions;
7
8/// One model file: where it comes from and what it must hash to.
9///
10/// The size and digest are what let [`crate::prepare`] reject a
11/// truncated or corrupt file before a runtime ever opens it.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ModelSpec {
14    /// File name under the models directory.
15    pub file: String,
16    /// Source the file is downloaded from when it is absent.
17    pub url: String,
18    /// Exact byte length of the complete file.
19    pub size: u64,
20    /// Lowercase hex SHA-256 of the complete file, where one exists.
21    ///
22    /// **`None` is a file whose upstream publishes no digest** - a model
23    /// downloaded from the feed's own doc tables - and its check is the
24    /// size alone. Nothing may describe such a file as verified.
25    pub sha256: Option<String>,
26    /// Described facts about the checkpoint, for readers that draw a
27    /// model rather than run it. The normalizer is in no catalogue, so
28    /// for it this is the only place these facts exist.
29    pub facts: ModelFacts,
30}
31
32/// A spec for a file a feed names: its URL, the entry's own byte length, and
33/// the digest where the host publishes one - the Hub's blobs carry a sha256
34/// per file, and the speech feed's documents carry none.
35pub fn spec_for_download(
36    file: &str,
37    url: &str,
38    size: u64,
39    sha256: Option<String>,
40    facts: ModelFacts,
41) -> ModelSpec {
42    ModelSpec { file: file.to_owned(), url: url.to_owned(), size, sha256, facts }
43}
44
45/// Facts the pin declares about its checkpoint.
46#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
47pub struct ModelFacts {
48    /// Quantisation, as the file names it.
49    pub quant: Option<String>,
50    /// Unique parameter count of the checkpoint.
51    pub params: Option<u64>,
52    /// Licence, as the model's own source spells it.
53    pub license: Option<String>,
54    /// The runtime that loads the file.
55    pub runtime: Option<String>,
56}
57
58impl ModelSpec {
59    /// Cohere Transcribe, Q4_K_M quantisation. The default ASR model.
60    pub fn cohere_transcribe_q4_k_m() -> Self {
61        Self {
62            file: "cohere-transcribe-03-2026-Q4_K_M.gguf".into(),
63            url: "https://huggingface.co/handy-computer/cohere-transcribe-03-2026-gguf/resolve/main/cohere-transcribe-03-2026-Q4_K_M.gguf".into(),
64            size: 1_558_162_944,
65            sha256: Some("0ea56826d8bd5d74b7143a4a04e022dc1bb75452cfae49d98b6acb0c1d16a1fb".into()),
66            facts: ModelFacts {
67                quant: Some("Q4_K_M".into()),
68                params: Some(2_049_026_832),
69                license: Some("Apache-2.0".into()),
70                runtime: Some("transcribe.cpp".into()),
71            },
72        }
73    }
74
75    /// S1-mini, f16. The default normalizer.
76    pub fn s1_mini_f16() -> Self {
77        Self {
78            file: "s1-mini-f16.gguf".into(),
79            url: "https://huggingface.co/superwhisper/s1-mini-GGUF/resolve/main/s1-mini-f16.gguf"
80                .into(),
81            size: 1_509_347_232,
82            sha256: Some("0370da4f1bae19e3150bcafa33c5d396c15f97bf25519540a3e013db5cc00af4".into()),
83            facts: ModelFacts {
84                quant: Some("F16".into()),
85                params: Some(596_000_000),
86                license: Some("Apache-2.0 + naming clause".into()),
87                runtime: Some("llama.cpp".into()),
88            },
89        }
90    }
91}
92
93/// Configuration for one dictation engine.
94///
95/// Construct via [`ConfigBuilder`] rather than populating directly.
96/// Values are validated where they are used, never here: an unreadable
97/// models directory surfaces from [`crate::prepare`], not from
98/// [`ConfigBuilder::build`].
99#[derive(Debug, Clone)]
100pub struct Config {
101    /// Directory holding model files. None resolves to a subdirectory of
102    /// the platform cache directory.
103    pub models_dir: Option<PathBuf>,
104    /// Speech recognition model.
105    pub asr_model: ModelSpec,
106    /// Model that rewrites raw recognition output into clean text. None
107    /// leaves the recognition output as-is and fetches nothing for it.
108    ///
109    /// When set, the engine loads it beside the recognition model and
110    /// rewrites every transcript: [`crate::Transcript::text`] carries the
111    /// result and `asr` the recognised text it came from. When `None`
112    /// the two are equal, which is a supported configuration rather than
113    /// a degraded one.
114    pub normalizer: Option<ModelSpec>,
115    /// How the normalizer rewrites text, when one is configured.
116    ///
117    /// `styling` is the axis a person picks per recording; `k` and
118    /// `ngram` are decoder tuning and do not belong in a user-facing
119    /// picker beside it. Overridable per call via
120    /// [`crate::Engine::transcribe_with`], so this is the default rather
121    /// than a fixed choice.
122    pub normalize_options: NormalizeOptions,
123    /// Spoken language hint. None autodetects.
124    pub language: Option<String>,
125    /// Upper bound on a single capture. A capture nobody stops ends
126    /// itself here rather than holding the input device indefinitely.
127    ///
128    /// Costs memory eagerly: the whole cap is reserved when recording
129    /// starts, at 4 bytes a sample, so the 30 min default reserves about
130    /// 110 MiB and an hour reserves 219 MiB, whatever the utterance turns
131    /// out to be. Reserved rather than grown because the audio callback
132    /// must not allocate. Clamped to an hour.
133    pub max_capture: Duration,
134    /// Peak input level, in dBFS, below which a capture counts as
135    /// silence rather than as an empty transcript.
136    pub silence_floor: f32,
137    /// Directory the crate writes per-take diagnostics into: the
138    /// capture audio and every transcription stage, best-effort, under
139    /// `crate::diagnostics`. `None` writes nothing - diagnostics are
140    /// the host's choice of directory, never a requirement.
141    pub diagnostics_dir: Option<PathBuf>,
142    /// Directory the crate records verified model digests in, keyed on
143    /// each file's size and modification time, so a file unchanged
144    /// since it was last verified is not read end to end again.
145    ///
146    /// `None` disables the cache and hashes every time: a caller with no
147    /// machine-local directory of its own to put it in keeps the old
148    /// cost rather than having one invented for it.
149    pub digest_cache_dir: Option<PathBuf>,
150}
151
152impl Default for Config {
153    fn default() -> Self {
154        Self {
155            models_dir: None,
156            asr_model: ModelSpec::cohere_transcribe_q4_k_m(),
157            normalizer: Some(ModelSpec::s1_mini_f16()),
158            normalize_options: NormalizeOptions::default(),
159            language: None,
160            max_capture: Duration::from_secs(30 * 60),
161            silence_floor: -50.0,
162            diagnostics_dir: None,
163            digest_cache_dir: None,
164        }
165    }
166}
167
168/// Builder for [`Config`].
169#[derive(Debug, Clone, Default)]
170pub struct ConfigBuilder {
171    inner: Config,
172}
173
174impl ConfigBuilder {
175    /// Start from defaults.
176    pub fn new() -> Self {
177        Self::default()
178    }
179
180    /// Override the directory model files live in.
181    pub fn models_dir(mut self, dir: impl Into<PathBuf>) -> Self {
182        self.inner.models_dir = Some(dir.into());
183        self
184    }
185
186    /// Override the speech recognition model.
187    pub fn asr_model(mut self, spec: ModelSpec) -> Self {
188        self.inner.asr_model = spec;
189        self
190    }
191
192    /// Set or clear the normalizer. Passing `None` skips normalization
193    /// and its download.
194    pub fn normalizer(mut self, spec: impl Into<Option<ModelSpec>>) -> Self {
195        self.inner.normalizer = spec.into();
196        self
197    }
198
199    /// Set the default normalizer options. Per-call overrides go
200    /// through [`crate::Engine::transcribe_with`].
201    pub fn normalize_options(mut self, options: NormalizeOptions) -> Self {
202        self.inner.normalize_options = options;
203        self
204    }
205
206    /// Set the spoken language hint.
207    pub fn language(mut self, language: impl Into<String>) -> Self {
208        self.inner.language = Some(language.into());
209        self
210    }
211
212    /// Cap the length of a single capture.
213    pub fn max_capture(mut self, max: Duration) -> Self {
214        self.inner.max_capture = max;
215        self
216    }
217
218    /// Set the silence threshold in dBFS.
219    pub fn silence_floor(mut self, dbfs: f32) -> Self {
220        self.inner.silence_floor = dbfs;
221        self
222    }
223
224    /// Point per-take diagnostics at a directory. `None` (the default)
225    /// writes nothing.
226    pub fn diagnostics_dir(mut self, dir: impl Into<PathBuf>) -> Self {
227        self.inner.diagnostics_dir = Some(dir.into());
228        self
229    }
230
231    /// Record verified model digests under a directory, so a file
232    /// unchanged since its last verification is not hashed again.
233    pub fn digest_cache_dir(mut self, dir: impl Into<PathBuf>) -> Self {
234        self.inner.digest_cache_dir = Some(dir.into());
235        self
236    }
237
238    /// Finalise and return the [`Config`].
239    pub fn build(self) -> Config {
240        self.inner
241    }
242}
243
244#[cfg(test)]
245mod tests_config {
246    use super::*;
247    use crate::normalize::Styling;
248    use std::path::Path;
249
250    #[test]
251    fn defaults_match_the_documented_values() {
252        let cfg = ConfigBuilder::new().build();
253
254        assert!(cfg.models_dir.is_none(), "models_dir defaults to the platform cache directory");
255        assert_eq!(
256            cfg.asr_model.file, "cohere-transcribe-03-2026-Q4_K_M.gguf",
257            "the default asr model is Cohere Transcribe at Q4_K_M"
258        );
259        assert!(cfg.normalizer.is_some(), "normalization is on by default");
260        assert_eq!(
261            cfg.normalize_options,
262            NormalizeOptions::default(),
263            "the shipped normalizer settings are the default ones; nothing else pins this"
264        );
265        assert!(cfg.language.is_none(), "language defaults to autodetect");
266        assert_eq!(
267            cfg.max_capture,
268            Duration::from_secs(30 * 60),
269            "max_capture defaults to 30 minutes"
270        );
271        assert!(
272            (cfg.silence_floor - -50.0).abs() < f32::EPSILON,
273            "silence_floor defaults to -50 dBFS, got {}",
274            cfg.silence_floor
275        );
276    }
277
278    /// The models page draws facts no feed carries for every pin - the
279    /// normalizer is in no catalogue - so the pin is where they are
280    /// declared.
281    #[test]
282    fn the_pins_declare_the_facts_the_models_page_draws() {
283        let asr = ModelSpec::cohere_transcribe_q4_k_m();
284        assert_eq!(asr.facts.quant.as_deref(), Some("Q4_K_M"));
285        assert_eq!(
286            asr.facts.params,
287            Some(2_049_026_832),
288            "the parameter count the catalogue publishes for cohere-transcribe"
289        );
290        assert_eq!(asr.facts.license.as_deref(), Some("Apache-2.0"));
291        assert_eq!(asr.facts.runtime.as_deref(), Some("transcribe.cpp"));
292
293        let normalizer = ModelSpec::s1_mini_f16();
294        assert_eq!(normalizer.facts.quant.as_deref(), Some("F16"));
295        assert_eq!(
296            normalizer.facts.params,
297            Some(596_000_000),
298            "the model card's unique-parameter count; the GGUF's own 752M label counts the tied \
299             embedding twice"
300        );
301        assert_eq!(normalizer.facts.license.as_deref(), Some("Apache-2.0 + naming clause"));
302        assert_eq!(normalizer.facts.runtime.as_deref(), Some("llama.cpp"));
303    }
304
305    #[test]
306    fn setters_round_trip_through_build() {
307        let cfg = ConfigBuilder::new()
308            .models_dir("/models")
309            .asr_model(ModelSpec::s1_mini_f16())
310            .language("en")
311            .max_capture(Duration::from_secs(5))
312            .silence_floor(-30.0)
313            .diagnostics_dir("/diag")
314            .digest_cache_dir("/digests")
315            .normalizer(ModelSpec::cohere_transcribe_q4_k_m())
316            .normalize_options(NormalizeOptions {
317                styling: Styling::Casual,
318                ..NormalizeOptions::default()
319            })
320            .build();
321
322        assert_eq!(
323            cfg.models_dir.as_deref(),
324            Some(Path::new("/models")),
325            "an explicit models_dir must override the platform cache default"
326        );
327        assert_eq!(
328            cfg.asr_model.file,
329            ModelSpec::s1_mini_f16().file,
330            "asr_model must replace the default spec outright"
331        );
332        assert_eq!(
333            cfg.language.as_deref(),
334            Some("en"),
335            "a language hint must reach the field that autodetects when unset"
336        );
337        assert_eq!(
338            cfg.max_capture,
339            Duration::from_secs(5),
340            "max_capture must carry the caller's cap, not the default"
341        );
342        assert!(
343            (cfg.silence_floor - -30.0).abs() < f32::EPSILON,
344            "silence_floor must carry the caller's threshold, not the -50 dBFS default, got {}",
345            cfg.silence_floor
346        );
347        assert_eq!(
348            cfg.diagnostics_dir.as_deref(),
349            Some(Path::new("/diag")),
350            "diagnostics_dir must carry the caller's store location, not be dropped by the builder"
351        );
352        assert_eq!(
353            cfg.digest_cache_dir.as_deref(),
354            Some(Path::new("/digests")),
355            "digest_cache_dir must carry the caller's store location, or every boot re-hashes"
356        );
357        assert_eq!(
358            cfg.normalizer.map(|n| n.file),
359            Some(ModelSpec::cohere_transcribe_q4_k_m().file),
360            "a bare ModelSpec must reach the Option field"
361        );
362
363        assert_eq!(
364            cfg.normalize_options.styling,
365            Styling::Casual,
366            "normalize_options must reach the field the engine reads, not be dropped by the builder"
367        );
368
369        let off = ConfigBuilder::new().normalizer(None).build();
370        assert!(off.normalizer.is_none(), "the same setter must also clear the normalizer");
371    }
372}