Skip to main content

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}