1use std::path::PathBuf;
4use std::time::Duration;
5
6use crate::normalize::NormalizeOptions;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ModelSpec {
14 pub file: String,
16 pub url: String,
18 pub size: u64,
20 pub sha256: Option<String>,
26 pub facts: ModelFacts,
30}
31
32pub 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#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
47pub struct ModelFacts {
48 pub quant: Option<String>,
50 pub params: Option<u64>,
52 pub license: Option<String>,
54 pub runtime: Option<String>,
56}
57
58impl ModelSpec {
59 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 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#[derive(Debug, Clone)]
100pub struct Config {
101 pub models_dir: Option<PathBuf>,
104 pub asr_model: ModelSpec,
106 pub normalizer: Option<ModelSpec>,
115 pub normalize_options: NormalizeOptions,
123 pub language: Option<String>,
125 pub max_capture: Duration,
134 pub silence_floor: f32,
137 pub diagnostics_dir: Option<PathBuf>,
142 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#[derive(Debug, Clone, Default)]
170pub struct ConfigBuilder {
171 inner: Config,
172}
173
174impl ConfigBuilder {
175 pub fn new() -> Self {
177 Self::default()
178 }
179
180 pub fn models_dir(mut self, dir: impl Into<PathBuf>) -> Self {
182 self.inner.models_dir = Some(dir.into());
183 self
184 }
185
186 pub fn asr_model(mut self, spec: ModelSpec) -> Self {
188 self.inner.asr_model = spec;
189 self
190 }
191
192 pub fn normalizer(mut self, spec: impl Into<Option<ModelSpec>>) -> Self {
195 self.inner.normalizer = spec.into();
196 self
197 }
198
199 pub fn normalize_options(mut self, options: NormalizeOptions) -> Self {
202 self.inner.normalize_options = options;
203 self
204 }
205
206 pub fn language(mut self, language: impl Into<String>) -> Self {
208 self.inner.language = Some(language.into());
209 self
210 }
211
212 pub fn max_capture(mut self, max: Duration) -> Self {
214 self.inner.max_capture = max;
215 self
216 }
217
218 pub fn silence_floor(mut self, dbfs: f32) -> Self {
220 self.inner.silence_floor = dbfs;
221 self
222 }
223
224 pub fn diagnostics_dir(mut self, dir: impl Into<PathBuf>) -> Self {
227 self.inner.diagnostics_dir = Some(dir.into());
228 self
229 }
230
231 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 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 #[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}