Skip to main content

forge_tts/
lib.rs

1//! Speech synthesis: text in, audio chunks out.
2//!
3//! A leaf crate. It depends on no other forge crate and knows nothing about
4//! a seat, a tool or a wire: a host hands [`SpeakRequest`]s to an [`Engine`]
5//! and reads [`Chunk`]s off the returned channel. Which engine renders, and
6//! when a chunk reaches the speaker, stays with the host.
7
8pub mod bench;
9mod engine;
10mod error;
11mod model;
12mod split;
13
14pub use engine::{DEFAULT_VOICE, KokoroTts, SAMPLE_RATE, select_voice};
15pub use error::Error;
16pub use model::ModelFiles;
17pub use split::{sentences, within_bound};
18
19/// One utterance to render, with the voice it renders in.
20#[derive(Debug, Clone)]
21pub struct SpeakRequest {
22    pub text: String,
23    /// The voice id for this utterance. `None` uses [`DEFAULT_VOICE`]; an id
24    /// that is not installed is refused rather than replaced by one.
25    pub voice: Option<String>,
26}
27
28/// A rendered run of audio, in arrival order.
29#[derive(Debug, Clone)]
30pub struct Chunk {
31    pub seq: u32,
32    pub sample_rate: u32,
33    pub pcm: Vec<i16>,
34}
35
36/// Text-to-speech rendering. Channels rather than `&mut self`, so a caller
37/// can hold one engine and serve every queue without a mutex on the render
38/// path.
39pub trait Engine: Send + Sync {
40    /// Render one utterance. Chunks arrive in order and the channel closes
41    /// when the render finishes or fails; the host may stop reading at any
42    /// time.
43    fn speak(&self, request: SpeakRequest) -> tokio::sync::mpsc::Receiver<Result<Chunk, Error>>;
44}
45
46#[cfg(test)]
47mod tests {
48    use super::Error;
49
50    #[test]
51    fn an_error_names_its_stage() {
52        let error = Error::ModelMissing("/models/kokoro.onnx".to_owned());
53        assert!(error.to_string().contains("kokoro.onnx"));
54    }
55
56    /// The engine swap left nothing behind: the crate's manifest carries no
57    /// llama.cpp dependency and the FFI module it lived in is gone.
58    #[test]
59    fn the_qwen_engine_is_gone() {
60        let manifest = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"))
61            .expect("the crate's own manifest reads");
62        assert!(!manifest.contains("llama"), "forge-tts still depends on llama.cpp:\n{manifest}");
63        assert!(
64            !std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src/engine/ffi.rs"))
65                .exists(),
66            "the Qwen FFI module is still in the crate"
67        );
68    }
69
70    /// This crate is a leaf, and depending on a forge-* crate is the one
71    /// change that would quietly end that: such an edge closes no cycle, so
72    /// the workspace still builds.
73    #[test]
74    fn depends_on_no_forge_crate() {
75        let output = std::process::Command::new(option_env!("CARGO").unwrap_or("cargo"))
76            .args([
77                "metadata",
78                "--no-deps",
79                "--offline",
80                "--format-version",
81                "1",
82                "--manifest-path",
83                concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"),
84            ])
85            .output()
86            .expect("cargo metadata must run: a guard that cannot run is not a guard");
87        assert!(
88            output.status.success(),
89            "cargo metadata failed, so the invariant is unchecked: {}",
90            String::from_utf8_lossy(&output.stderr)
91        );
92
93        let metadata: serde_json::Value =
94            serde_json::from_slice(&output.stdout).expect("cargo metadata emits json");
95        let package = metadata["packages"]
96            .as_array()
97            .into_iter()
98            .flatten()
99            .find(|p| p["name"] == "forge-tts")
100            .expect("forge-tts must appear in its own metadata");
101
102        let forge: Vec<&str> = package["dependencies"]
103            .as_array()
104            .into_iter()
105            .flatten()
106            .filter_map(|dep| dep["name"].as_str())
107            .filter(|name| name.starts_with("forge-"))
108            .collect();
109        assert!(forge.is_empty(), "forge-tts must depend on no forge-* crate, found {forge:?}");
110    }
111}