Skip to main content

sayd_core/
synth.rs

1//! The seam between the engine and actual speech synthesis.
2//!
3//! `sayd-core` never links ONNX. The binary supplies a real implementation;
4//! tests supply `StubSynthesizer`, which returns silence of a predictable
5//! length so the engine's timing, queueing and state transitions can be
6//! asserted without a model or an audio device.
7
8/// Sample rate every implementation must produce.
9pub const SAMPLE_RATE: u32 = 24_000;
10
11pub trait Synthesizer: Send {
12    /// Grapheme-to-phoneme for one chunk of text.
13    fn phonemize(&mut self, text: &str, voice: &str) -> String;
14
15    /// Whether `phonemes` is within the model's per-call token budget.
16    fn fits(&mut self, phonemes: &str) -> bool;
17
18    /// Synthesize, returning mono f32 at `sample_rate()`.
19    fn synth(&mut self, phonemes: &str, voice: &str, speed: f32) -> Result<Vec<f32>, String>;
20
21    fn sample_rate(&self) -> u32 {
22        SAMPLE_RATE
23    }
24
25    /// Drop the model, freeing its memory. The next `synth` must reload.
26    fn unload(&mut self);
27
28    fn is_loaded(&self) -> bool;
29}
30
31/// Test double. Emits silence at roughly the rate real speech occupies, so
32/// duration-dependent engine behaviour is exercised without a model.
33pub struct StubSynthesizer {
34    token_budget: usize,
35    loaded: bool,
36    pub synth_calls: usize,
37    pub unload_calls: usize,
38}
39
40impl StubSynthesizer {
41    pub fn new() -> Self {
42        Self::with_token_budget(509)
43    }
44
45    pub fn with_token_budget(token_budget: usize) -> Self {
46        StubSynthesizer { token_budget, loaded: false, synth_calls: 0, unload_calls: 0 }
47    }
48}
49
50impl Default for StubSynthesizer {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl Synthesizer for StubSynthesizer {
57    fn phonemize(&mut self, text: &str, _voice: &str) -> String {
58        text.to_lowercase()
59    }
60
61    fn fits(&mut self, phonemes: &str) -> bool {
62        phonemes.chars().count() <= self.token_budget
63    }
64
65    fn synth(&mut self, phonemes: &str, _voice: &str, speed: f32) -> Result<Vec<f32>, String> {
66        self.loaded = true;
67        self.synth_calls += 1;
68        // ~80 ms of audio per phoneme character, divided by speed.
69        let per_char = SAMPLE_RATE as f32 * 0.08;
70        let n = ((phonemes.chars().count() as f32 * per_char) / speed.max(0.1)) as usize;
71        Ok(vec![0.0; n])
72    }
73
74    fn unload(&mut self) {
75        self.loaded = false;
76        self.unload_calls += 1;
77    }
78
79    fn is_loaded(&self) -> bool {
80        self.loaded
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn stub_produces_silence_proportional_to_length() {
90        let mut s = StubSynthesizer::new();
91        let short = s.synth("ab", "af_heart", 1.0).expect("synth");
92        let long = s.synth("abcdefgh", "af_heart", 1.0).expect("synth");
93        assert!(long.len() > short.len());
94        assert!(short.iter().all(|x| *x == 0.0), "stub must emit silence");
95    }
96
97    #[test]
98    fn stub_speed_shortens_output() {
99        let mut s = StubSynthesizer::new();
100        let normal = s.synth("abcdefgh", "af_heart", 1.0).expect("synth");
101        let fast = s.synth("abcdefgh", "af_heart", 2.0).expect("synth");
102        assert!(fast.len() < normal.len());
103    }
104
105    #[test]
106    fn stub_counts_calls() {
107        let mut s = StubSynthesizer::new();
108        let _ = s.synth("a", "af_heart", 1.0);
109        let _ = s.synth("b", "af_heart", 1.0);
110        assert_eq!(s.synth_calls, 2);
111    }
112
113    #[test]
114    fn stub_tracks_load_state() {
115        let mut s = StubSynthesizer::new();
116        assert!(!s.is_loaded(), "nothing is loaded before the first synth");
117        let _ = s.synth("a", "af_heart", 1.0);
118        assert!(s.is_loaded());
119        s.unload();
120        assert!(!s.is_loaded());
121        assert_eq!(s.unload_calls, 1);
122    }
123
124    #[test]
125    fn stub_fits_respects_the_configured_budget() {
126        let mut s = StubSynthesizer::with_token_budget(4);
127        assert!(s.fits("abcd"));
128        assert!(!s.fits("abcde"));
129    }
130
131    #[test]
132    fn stub_phonemize_is_identity_lowercased() {
133        let mut s = StubSynthesizer::new();
134        assert_eq!(s.phonemize("Hello There", "af_heart"), "hello there");
135    }
136}