Skip to main content

studio_worker/engine/
tts.rs

1//! Pure-Rust formant-based text-to-speech engine.
2//!
3//! Compiled in with `--features tts`.  Produces a real, decodable WAV
4//! whose pitch contour follows the input text: each character is mapped
5//! to a formant frequency, and a brief envelope-shaped sine wave is
6//! emitted per character.  The result is robotic (no neural TTS) but
7//! it's genuinely synthesized speech-adjacent audio that's
8//! deterministic, intelligibility-poor, and completely self-contained —
9//! no model files, no FFI, no install step.
10//!
11//! Operators who want natural-sounding TTS should wire up Piper (via
12//! `piper-rs` once mature) or a cloud TTS in a follow-up iteration.
13//! The trait + capability surface is ready for them.
14use crate::engine::{Engine, EngineCapabilities};
15use crate::types::*;
16use anyhow::Result;
17use hound::{SampleFormat, WavSpec, WavWriter};
18use std::collections::BTreeMap;
19use std::io::Cursor;
20use std::time::Instant;
21use tracing::{debug, warn};
22
23/// Tracing target for the formant TTS engine.  Stable so operators can
24/// filter with `RUST_LOG=studio_worker::engine::tts=debug`.
25const TRACE_TARGET: &str = "studio_worker::engine::tts";
26
27pub struct TtsEngine;
28
29impl TtsEngine {
30    pub fn new() -> Self {
31        Self
32    }
33}
34
35impl Default for TtsEngine {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41const MODEL_ID: &str = "formant-synth";
42const SAMPLE_RATE: u32 = 16_000;
43
44/// Map a single character to a (formant_hz, duration_ms) tuple.  Vowels
45/// get vocal-tract formants in the 250-800 Hz range; consonants get
46/// shorter higher-frequency clicks; whitespace and punctuation get
47/// silence with varying lengths.
48fn formant_for(c: char) -> (f32, u32) {
49    match c.to_ascii_lowercase() {
50        'a' => (730.0, 95),
51        'e' => (530.0, 80),
52        'i' => (270.0, 75),
53        'o' => (570.0, 95),
54        'u' => (440.0, 100),
55        'y' => (380.0, 90),
56        'b' => (180.0, 50),
57        'p' => (200.0, 45),
58        'd' => (220.0, 50),
59        't' => (250.0, 45),
60        'g' => (190.0, 55),
61        'k' => (260.0, 50),
62        'f' => (340.0, 70),
63        'v' => (300.0, 60),
64        's' => (380.0, 65),
65        'z' => (340.0, 60),
66        'm' => (260.0, 60),
67        'n' => (280.0, 55),
68        'l' => (310.0, 55),
69        'r' => (350.0, 60),
70        'h' => (200.0, 35),
71        'w' => (400.0, 75),
72        'c' => (310.0, 50),
73        'j' => (290.0, 50),
74        'q' => (260.0, 55),
75        'x' => (350.0, 55),
76        ' ' | '\t' => (0.0, 80),   // silence
77        '\n' | '\r' => (0.0, 120), // longer silence
78        '.' | '?' | '!' => (0.0, 180),
79        ',' | ';' | ':' => (0.0, 100),
80        c if c.is_ascii_digit() => {
81            // 220 Hz (0) ... 770 Hz (9) — covers a roughly octave-range
82            let n = c.to_digit(10).unwrap_or(0) as f32;
83            (220.0 + n * 60.0, 80)
84        }
85        _ => (440.0, 60), // unknown char: neutral A4
86    }
87}
88
89/// Render a real 16-bit PCM WAV at 16 kHz mono from `text`.
90pub fn render(text: &str, _voice: &str) -> Result<Vec<u8>> {
91    let mut samples: Vec<f32> = Vec::new();
92    for c in text.chars() {
93        let (hz, ms) = formant_for(c);
94        let n = (SAMPLE_RATE as u64 * u64::from(ms) / 1_000) as usize;
95        let attack = (n / 6).max(1);
96        let release = (n / 4).max(1);
97        for i in 0..n {
98            let t = i as f32 / SAMPLE_RATE as f32;
99            let amplitude = if hz == 0.0 {
100                0.0
101            } else {
102                // Linear attack + release envelope.
103                let env_in = if i < attack {
104                    i as f32 / attack as f32
105                } else if i >= n - release {
106                    (n - i) as f32 / release as f32
107                } else {
108                    1.0
109                };
110                env_in * 0.35
111            };
112            let s = if hz > 0.0 {
113                amplitude * (2.0 * std::f32::consts::PI * hz * t).sin()
114            } else {
115                0.0
116            };
117            samples.push(s);
118        }
119        // Small inter-character pause to give the audio more rhythm.
120        samples.resize(samples.len() + (SAMPLE_RATE / 1000 * 15) as usize, 0.0);
121    }
122    let mut buf = Cursor::new(Vec::<u8>::new());
123    {
124        let spec = WavSpec {
125            channels: 1,
126            sample_rate: SAMPLE_RATE,
127            bits_per_sample: 16,
128            sample_format: SampleFormat::Int,
129        };
130        let mut writer = WavWriter::new(&mut buf, spec)?;
131        for s in &samples {
132            let v = (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
133            writer.write_sample(v)?;
134        }
135        writer.finalize()?;
136    }
137    Ok(buf.into_inner())
138}
139
140impl Engine for TtsEngine {
141    fn name(&self) -> &'static str {
142        "tts"
143    }
144
145    fn capabilities(&self) -> EngineCapabilities {
146        let mut map: BTreeMap<TaskKind, Vec<String>> = BTreeMap::new();
147        map.insert(TaskKind::AudioTts, vec![MODEL_ID.to_string()]);
148        EngineCapabilities {
149            supported_models_per_kind: map,
150        }
151    }
152
153    fn dispatch(&self, model: &str, task: Task) -> Result<TaskResult> {
154        let kind = task.kind();
155        let started = Instant::now();
156        let params = match task {
157            Task::AudioTts(p) => p,
158            other => {
159                warn!(
160                    target: TRACE_TARGET,
161                    op = "dispatch",
162                    kind = kind.as_str(),
163                    model,
164                    "unsupported task kind"
165                );
166                return Err(crate::engine::UnsupportedTask::new("tts", other.kind()).into());
167            }
168        };
169        let text_len = params.text.chars().count();
170        let result = render(&params.text, &params.voice);
171        let elapsed_ms = started.elapsed().as_millis() as u64;
172        match &result {
173            Ok(bytes) => debug!(
174                target: TRACE_TARGET,
175                op = "dispatch",
176                kind = kind.as_str(),
177                model,
178                text_chars = text_len,
179                bytes = bytes.len(),
180                elapsed_ms,
181                "ok"
182            ),
183            Err(e) => warn!(
184                target: TRACE_TARGET,
185                op = "dispatch",
186                kind = kind.as_str(),
187                model,
188                elapsed_ms,
189                error = %e,
190                "failed"
191            ),
192        }
193        let bytes = result?;
194        Ok(TaskResult::AudioTts {
195            bytes,
196            ext: "wav".into(),
197        })
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn capabilities_advertise_audio_tts_kind() {
207        let engine = TtsEngine::new();
208        let caps = engine.capabilities();
209        assert_eq!(
210            caps.supported_models_per_kind[&TaskKind::AudioTts],
211            vec![MODEL_ID.to_string()]
212        );
213        assert_eq!(engine.name(), "tts");
214    }
215
216    #[test]
217    fn engine_default_constructs() {
218        let _ = TtsEngine;
219    }
220
221    #[test]
222    fn dispatch_rejects_non_tts_tasks() {
223        let engine = TtsEngine::new();
224        let err = engine
225            .dispatch(
226                MODEL_ID,
227                Task::Image(ImageParams {
228                    prompt: "x".into(),
229                    width: 64,
230                    height: 64,
231                    steps: 1,
232                    seed: None,
233                    ext: "webp".into(),
234                    ..Default::default()
235                }),
236            )
237            .unwrap_err();
238        assert!(err.to_string().contains("cannot serve image"));
239    }
240
241    #[test]
242    fn render_produces_decodable_wav_with_correct_duration() {
243        let bytes = render("hello", "default").unwrap();
244        assert_eq!(&bytes[0..4], b"RIFF");
245        assert_eq!(&bytes[8..12], b"WAVE");
246        let reader = hound::WavReader::new(Cursor::new(bytes)).unwrap();
247        let spec = reader.spec();
248        assert_eq!(spec.sample_rate, SAMPLE_RATE);
249        assert_eq!(spec.channels, 1);
250        // "hello" = h(35ms) + e(80ms) + l(55ms) + l(55ms) + o(95ms) +
251        //   5 inter-char pauses (15ms each) = 395 ms.
252        let duration_s = reader.duration() as f32 / spec.sample_rate as f32;
253        assert!((0.35..0.5).contains(&duration_s), "got {duration_s}");
254    }
255
256    #[test]
257    fn different_texts_produce_different_audio() {
258        let a = render("hello", "default").unwrap();
259        let b = render("world", "default").unwrap();
260        assert_ne!(a, b);
261    }
262
263    #[test]
264    fn same_text_is_deterministic() {
265        let a = render("studio", "default").unwrap();
266        let b = render("studio", "default").unwrap();
267        assert_eq!(a, b);
268    }
269
270    #[test]
271    fn render_handles_punctuation_and_digits() {
272        let bytes = render("hello, world! 42.", "default").unwrap();
273        let reader = hound::WavReader::new(Cursor::new(bytes)).unwrap();
274        assert!(reader.duration() > 0);
275    }
276
277    #[test]
278    fn formant_for_returns_silence_on_whitespace() {
279        assert_eq!(formant_for(' '), (0.0, 80));
280        assert_eq!(formant_for('\n'), (0.0, 120));
281        assert_eq!(formant_for('.'), (0.0, 180));
282    }
283
284    #[test]
285    fn formant_for_maps_digits_to_octave_range() {
286        let (hz0, _) = formant_for('0');
287        let (hz9, _) = formant_for('9');
288        assert!(hz0 < hz9);
289    }
290
291    #[test]
292    fn formant_for_falls_back_to_neutral_on_unknown() {
293        let (hz, _) = formant_for('@');
294        assert_eq!(hz, 440.0);
295    }
296}