1use 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
23const 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
44fn 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), '\n' | '\r' => (0.0, 120), '.' | '?' | '!' => (0.0, 180),
79 ',' | ';' | ':' => (0.0, 100),
80 c if c.is_ascii_digit() => {
81 let n = c.to_digit(10).unwrap_or(0) as f32;
83 (220.0 + n * 60.0, 80)
84 }
85 _ => (440.0, 60), }
87}
88
89pub 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 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 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(¶ms.text, ¶ms.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 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}