Skip to main content

forge_tts/
model.rs

1//! The files a speaking model is made of: one ONNX model and a directory of
2//! `<voice>.bin` styles.
3
4use std::io::Read;
5use std::path::{Path, PathBuf};
6
7use sha2::{Digest as _, Sha256};
8
9use crate::Error;
10
11/// A model's files, and the digests the install declared for them.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ModelFiles {
14    /// The ONNX model file.
15    pub model: PathBuf,
16    /// The directory of `<voice>.bin` style files.
17    pub voices: PathBuf,
18    /// Digests to check before a load, keyed by path.
19    pub digests: Vec<(PathBuf, String)>,
20}
21
22impl ModelFiles {
23    /// The model file and the voice directory it renders with.
24    pub fn new(model: PathBuf, voices: PathBuf) -> Self {
25        Self { model, voices, digests: Vec::new() }
26    }
27
28    /// Carry the digests the install declared, so a load can verify them.
29    pub fn with_digests(mut self, digests: Vec<(PathBuf, String)>) -> Self {
30        self.digests = digests;
31        self
32    }
33
34    /// Check every file against its declared digest. A file with no declared
35    /// digest is left to the loader's own existence check.
36    pub fn verify(&self) -> Result<(), Error> {
37        if !self.model.is_file() {
38            return Err(Error::ModelMissing(self.model.display().to_string()));
39        }
40        if !self.voices.is_dir() {
41            return Err(Error::ModelMissing(self.voices.display().to_string()));
42        }
43        for (path, digest) in &self.digests {
44            verify_digest(path, Some(digest))?;
45        }
46        Ok(())
47    }
48
49    /// The installed voice ids: every `.bin` file stem in `voices`, sorted.
50    pub fn installed_voices(&self) -> Result<Vec<String>, Error> {
51        let entries = std::fs::read_dir(&self.voices)
52            .map_err(|error| Error::ModelMissing(format!("{}: {error}", self.voices.display())))?;
53        let mut voices = Vec::new();
54        for entry in entries {
55            let entry = entry
56                .map_err(|error| Error::Load(format!("{}: {error}", self.voices.display())))?;
57            let path = entry.path();
58            let is_style = path.extension().is_some_and(|extension| extension == "bin");
59            if !is_style {
60                continue;
61            }
62            let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
63                continue;
64            };
65            voices.push(stem.to_owned());
66        }
67        voices.sort();
68        Ok(voices)
69    }
70}
71
72/// Hash `path` and compare it with `expected`, when one is declared.
73pub fn verify_digest(path: &Path, expected: Option<&str>) -> Result<(), Error> {
74    let Some(expected) = expected else {
75        return Ok(());
76    };
77    let mut file = std::fs::File::open(path)
78        .map_err(|error| Error::ModelMissing(format!("{}: {error}", path.display())))?;
79    let mut hasher = Sha256::new();
80    let mut buffer = vec![0_u8; 1 << 20];
81    loop {
82        let read = file
83            .read(&mut buffer)
84            .map_err(|error| Error::Load(format!("{}: {error}", path.display())))?;
85        if read == 0 {
86            break;
87        }
88        hasher.update(&buffer[..read]);
89    }
90    let actual = hex::encode(hasher.finalize());
91    if actual.eq_ignore_ascii_case(expected) {
92        Ok(())
93    } else {
94        Err(Error::Load(format!(
95            "{}: the entry declares sha256 {expected}, and the file is {actual}",
96            path.display(),
97        )))
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::ModelFiles;
104
105    fn model(dir: &std::path::Path) -> ModelFiles {
106        ModelFiles::new(dir.join("model.onnx"), dir.join("voices"))
107    }
108
109    #[test]
110    fn the_installed_voices_are_the_style_file_stems_sorted() {
111        let dir = tempfile::tempdir().expect("a temp dir");
112        let voices = dir.path().join("voices");
113        std::fs::create_dir(&voices).expect("the voice dir is made");
114        for name in ["bf_emma.bin", "af_heart.bin", "am_adam.bin"] {
115            std::fs::write(voices.join(name), b"styles").expect("a style is written");
116        }
117        std::fs::write(voices.join("readme.txt"), b"not a voice").expect("a stray file");
118        let files = model(dir.path());
119        assert_eq!(
120            files.installed_voices().expect("the directory reads"),
121            vec!["af_heart".to_owned(), "am_adam".to_owned(), "bf_emma".to_owned()],
122        );
123    }
124
125    #[test]
126    fn a_file_whose_digest_does_not_match_is_refused_by_name() {
127        let dir = tempfile::tempdir().expect("a temp dir");
128        std::fs::create_dir(dir.path().join("voices")).expect("the voice dir is made");
129        let path = dir.path().join("model.onnx");
130        std::fs::write(&path, b"weights").expect("the file is written");
131        let files = model(dir.path()).with_digests(vec![(path.clone(), "00".repeat(32))]);
132        let error = files.verify().expect_err("a wrong digest is refused");
133        assert!(error.to_string().contains("model.onnx"), "got: {error}");
134    }
135
136    #[test]
137    fn a_file_whose_digest_matches_is_accepted() {
138        let dir = tempfile::tempdir().expect("a temp dir");
139        std::fs::create_dir(dir.path().join("voices")).expect("the voice dir is made");
140        let path = dir.path().join("model.onnx");
141        std::fs::write(&path, b"weights").expect("the file is written");
142        let digest = {
143            use sha2::{Digest as _, Sha256};
144            let mut hasher = Sha256::new();
145            hasher.update(b"weights");
146            hex::encode(hasher.finalize())
147        };
148        let files = model(dir.path()).with_digests(vec![(path, digest)]);
149        files.verify().expect("the declared digest matches");
150    }
151
152    #[test]
153    fn a_missing_model_file_is_refused_before_a_load() {
154        let dir = tempfile::tempdir().expect("a temp dir");
155        std::fs::create_dir(dir.path().join("voices")).expect("the voice dir is made");
156        let error = model(dir.path()).verify().expect_err("a missing model is refused");
157        assert!(error.to_string().contains("model.onnx"), "got: {error}");
158    }
159}