forge_dictate/lib.rs
1//! Local dictation: audio in, text out.
2//!
3//! The crate owns its model files, speech recognition and text
4//! normalization, and knows nothing about the program embedding it.
5//!
6//! [`prepare`] fetches whatever models a [`Config`] names and checks
7//! each one against its recorded size and SHA-256 before anything opens
8//! it. A file whose size and modification time still match a digest
9//! already verified against that same spec is not read again. A
10//! truncated model does not fail at download time; it fails much
11//! later, inside a model runtime, as an offset error that reads like a
12//! bad build. Verifying first is what turns that into a sentence naming
13//! the file.
14//!
15//! # Audio is 16 kHz mono
16//!
17//! A take is fed mono frames at 16 kHz; the device half - opening an
18//! input, downmixing its channels, resampling - is `forge-capture`'s,
19//! wherever a host opens it. Rate conversion is not implemented, so an
20//! [`AudioSource`] declaring another rate is refused rather than
21//! silently resampled. Adding it means a real filtered resampler
22//! (`rubato`), because decimating without one folds everything above the
23//! new Nyquist back into the speech band and yields confident wrong
24//! words instead of obvious noise.
25//!
26//! # Every entry point here blocks
27//!
28//! This crate is runtime-agnostic on purpose, so nothing in it is
29//! async and nothing in it may be called from a runtime thread. An
30//! async caller wraps each entry point in `tokio::task::spawn_blocking`
31//! or its equivalent. Calling one directly from an async context
32//! panics in a debug build ("Cannot drop a runtime in a context where
33//! blocking is not allowed") and, worse, succeeds in a release build
34//! having held a runtime worker for the entire operation - so a release
35//! smoke test passes while dev crashes.
36
37mod audio;
38pub mod bench;
39mod capture;
40pub mod catalogue;
41pub mod cleanup;
42mod config;
43mod diagnostics;
44mod engine;
45mod error;
46mod fetch;
47#[cfg(all(test, target_os = "macos"))]
48mod gates;
49pub mod normalize;
50
51pub use audio::{AudioSource, SAMPLE_RATE, Samples, resample_mono};
52pub use config::{Config, ConfigBuilder, ModelFacts, ModelSpec, spec_for_download};
53pub use engine::{
54 Capture, CaptureMeter, Engine, FrameSink, Outcome, Stages, StreamCapture, Ticket, Transcript,
55 WindowProgress,
56};
57pub use error::Error;
58pub use fetch::{Progress, prepare};
59pub use normalize::{NormalizeError, NormalizeOptions, Normalizer};
60pub use transcribe_cpp::CancelToken;
61
62#[cfg(test)]
63mod tests_leaf_invariant {
64 /// This crate is a leaf: it may depend on another LEAF forge crate and
65 /// on nothing else of the workspace. The one edge it has is
66 /// `forge-capture`, which opens the microphone - the device half of a
67 /// take, shared with a client's own shell - and which is itself a leaf
68 /// with no forge-* dependencies at all.
69 ///
70 /// Depending on the workspace's core is the change this guards: a
71 /// host, a session or a view reaching into dictation would end the
72 /// standalone primitive, and nothing catches it at compile time,
73 /// because such an edge closes no cycle and the workspace builds fine.
74 ///
75 /// Asked of cargo's resolver rather than read out of the manifest,
76 /// because a rename is exactly what a manifest hides and what the
77 /// resolver has already undone. `--manifest-path` is a compile-time
78 /// constant, so the answer does not depend on where this runs from.
79 ///
80 /// Checks direct dependencies only: a forge crate reached through a
81 /// workspace crate not itself named `forge-*` is not caught.
82 #[test]
83 fn depends_on_no_forge_crate_but_a_leaf() {
84 let output = std::process::Command::new(option_env!("CARGO").unwrap_or("cargo"))
85 .args([
86 "metadata",
87 "--no-deps",
88 "--offline",
89 "--format-version",
90 "1",
91 "--manifest-path",
92 concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"),
93 ])
94 .output()
95 .expect("cargo metadata must run: a guard that cannot run is not a guard");
96 assert!(
97 output.status.success(),
98 "cargo metadata failed, so the invariant is unchecked: {}",
99 String::from_utf8_lossy(&output.stderr)
100 );
101
102 let metadata: serde_json::Value =
103 serde_json::from_slice(&output.stdout).expect("cargo metadata emits json");
104 let packages = metadata["packages"].as_array().into_iter().flatten();
105 let forge_deps = |named: &str| -> Vec<String> {
106 packages
107 .clone()
108 .find(|p| p["name"] == named)
109 .unwrap_or_else(|| panic!("{named} must appear in its own metadata"))
110 ["dependencies"]
111 .as_array()
112 .into_iter()
113 .flatten()
114 .filter_map(|dep| dep["name"].as_str())
115 .filter(|name| name.starts_with("forge-"))
116 .map(str::to_owned)
117 .collect()
118 };
119
120 let mine = forge_deps("forge-dictate");
121 assert_eq!(
122 mine,
123 vec!["forge-capture".to_owned()],
124 "forge-dictate's only forge-* dependency is the capture leaf, found {mine:?}"
125 );
126 let theirs = forge_deps("forge-capture");
127 assert!(
128 theirs.is_empty(),
129 "the crate forge-dictate leans on must itself be a leaf, but forge-capture depends on \
130 {theirs:?}"
131 );
132 }
133}