1use std::sync::{Mutex, mpsc};
4
5use tokio::sync::mpsc as tokio_mpsc;
6
7use crate::model::ModelFiles;
8use crate::{Chunk, Engine, Error, SpeakRequest};
9
10pub const SAMPLE_RATE: u32 = 24_000;
13
14pub const DEFAULT_VOICE: &str = "af_heart";
16
17pub struct KokoroTts {
23 jobs: Option<tokio_mpsc::UnboundedSender<Job>>,
24 worker: Mutex<Option<std::thread::JoinHandle<()>>>,
25}
26
27struct Job {
28 request: SpeakRequest,
29 reply: tokio_mpsc::Sender<Result<Chunk, Error>>,
30}
31
32pub fn select_voice(requested: Option<&str>, installed: &[String]) -> Result<String, Error> {
37 let id = requested.unwrap_or(DEFAULT_VOICE);
38 if installed.iter().any(|voice| voice == id) {
39 Ok(id.to_owned())
40 } else {
41 Err(Error::VoiceMissing(id.to_owned()))
42 }
43}
44
45impl KokoroTts {
46 pub fn load(files: &ModelFiles) -> Result<Self, Error> {
51 files.verify()?;
52 let installed = files.installed_voices()?;
53 let (jobs, mut queue) = tokio_mpsc::unbounded_channel::<Job>();
54 let (ready, loaded) = mpsc::channel();
55 let model = files.model.clone();
56 let voices = files.voices.clone();
57 let worker_installed = installed.clone();
58 let worker = std::thread::Builder::new()
59 .name("forge-tts".to_owned())
60 .spawn(move || {
61 let runtime =
62 match tokio::runtime::Builder::new_current_thread().enable_all().build() {
63 Ok(runtime) => runtime,
64 Err(error) => {
65 let _ = ready.send(Err(Error::Load(error.to_string())));
66 return;
67 }
68 };
69 let engine = match runtime.block_on(kokoro_en::KokoroTts::new(&model, &voices)) {
70 Ok(engine) => engine,
71 Err(error) => {
72 let _ = ready.send(Err(Error::Load(error.to_string())));
73 return;
74 }
75 };
76 let mut renderer = Kokoro { runtime, engine };
77 if ready.send(Ok(())).is_err() {
78 return;
79 }
80 while let Some(job) = queue.blocking_recv() {
81 let _ = job.reply.blocking_send(chunk_for(
84 &mut renderer,
85 &worker_installed,
86 &job.request,
87 ));
88 }
89 })
90 .map_err(|error| Error::Load(error.to_string()))?;
91
92 match loaded.recv() {
93 Ok(Ok(())) => Ok(Self { jobs: Some(jobs), worker: Mutex::new(Some(worker)) }),
94 Ok(Err(error)) => Err(error),
95 Err(_) => Err(Error::Load("the render thread stopped while loading".to_owned())),
96 }
97 }
98}
99
100impl Drop for KokoroTts {
101 fn drop(&mut self) {
102 drop(self.jobs.take());
105 let worker = self.worker.get_mut().ok().and_then(Option::take);
106 if let Some(worker) = worker {
107 let _ = worker.join();
108 }
109 }
110}
111
112impl Engine for KokoroTts {
113 fn speak(&self, request: SpeakRequest) -> tokio_mpsc::Receiver<Result<Chunk, Error>> {
114 let Some(jobs) = self.jobs.as_ref() else {
115 return refused();
116 };
117 let (reply, chunks) = tokio_mpsc::channel(2);
118 if jobs.send(Job { request, reply }).is_err() {
119 return refused();
120 }
121 chunks
122 }
123}
124
125fn refused() -> tokio_mpsc::Receiver<Result<Chunk, Error>> {
127 let (reply, chunks) = tokio_mpsc::channel(1);
128 let _ = reply.try_send(Err(Error::Render("the render thread is no longer running".to_owned())));
129 chunks
130}
131
132trait Renderer: Send {
135 fn native_rate(&self) -> u32;
136 fn render(&mut self, text: &str, voice: &str) -> Result<Vec<f32>, Error>;
137}
138
139fn chunk_for(
142 renderer: &mut dyn Renderer,
143 installed: &[String],
144 request: &SpeakRequest,
145) -> Result<Chunk, Error> {
146 let voice = select_voice(request.voice.as_deref(), installed)?;
147 let samples = renderer.render(&request.text, &voice)?;
148 let samples = resample(&samples, renderer.native_rate(), SAMPLE_RATE);
149 Ok(Chunk { seq: 0, sample_rate: SAMPLE_RATE, pcm: pcm16(&samples) })
150}
151
152fn resample(samples: &[f32], from: u32, to: u32) -> Vec<f32> {
154 if from == to || from == 0 || samples.is_empty() {
155 return samples.to_vec();
156 }
157 let ratio = f64::from(from) / f64::from(to);
158 let length = ((samples.len() as f64) * f64::from(to) / f64::from(from)).round() as usize;
159 let last = samples.len() - 1;
160 let mut out = Vec::with_capacity(length);
161 for index in 0..length {
162 let position = index as f64 * ratio;
163 let left = position.floor() as usize;
164 let fraction = (position - left as f64) as f32;
165 let a = samples[left.min(last)];
166 let b = samples[(left + 1).min(last)];
167 out.push(a + (b - a) * fraction);
168 }
169 out
170}
171
172fn pcm16(samples: &[f32]) -> Vec<i16> {
174 samples
175 .iter()
176 .map(|sample| {
177 let clamped = sample.clamp(-1.0, 1.0);
178 (clamped * f32::from(i16::MAX)) as i16
179 })
180 .collect()
181}
182
183struct Kokoro {
185 runtime: tokio::runtime::Runtime,
186 engine: kokoro_en::KokoroTts,
187}
188
189impl Renderer for Kokoro {
190 fn native_rate(&self) -> u32 {
191 SAMPLE_RATE
192 }
193
194 fn render(&mut self, text: &str, voice: &str) -> Result<Vec<f32>, Error> {
195 self.runtime
196 .block_on(self.engine.synth(text, voice))
197 .map(|(audio, _)| audio)
198 .map_err(|error| Error::Render(error.to_string()))
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::{DEFAULT_VOICE, Renderer, SAMPLE_RATE, chunk_for, select_voice};
205 use crate::{Engine as _, Error, SpeakRequest};
206
207 struct Stub {
210 rate: u32,
211 samples: Vec<f32>,
212 spoken: Vec<String>,
213 }
214
215 impl Stub {
216 fn new(rate: u32, samples: Vec<f32>) -> Self {
217 Self { rate, samples, spoken: Vec::new() }
218 }
219 }
220
221 impl Renderer for Stub {
222 fn native_rate(&self) -> u32 {
223 self.rate
224 }
225
226 fn render(&mut self, _text: &str, voice: &str) -> Result<Vec<f32>, Error> {
227 self.spoken.push(voice.to_owned());
228 Ok(self.samples.clone())
229 }
230 }
231
232 fn installed() -> Vec<String> {
233 vec!["af_heart".to_owned(), "bf_emma".to_owned()]
234 }
235
236 #[test]
237 fn a_chosen_voice_is_the_one_the_render_uses() {
238 let mut stub = Stub::new(SAMPLE_RATE, vec![0.0; 8]);
239 let request = SpeakRequest { text: "hello".to_owned(), voice: Some("bf_emma".to_owned()) };
240 chunk_for(&mut stub, &installed(), &request).expect("the render lands");
241 assert_eq!(stub.spoken, vec!["bf_emma".to_owned()]);
242 }
243
244 #[test]
245 fn no_chosen_voice_renders_in_the_default() {
246 let mut stub = Stub::new(SAMPLE_RATE, vec![0.0; 8]);
247 let request = SpeakRequest { text: "hello".to_owned(), voice: None };
248 chunk_for(&mut stub, &installed(), &request).expect("the render lands");
249 assert_eq!(stub.spoken, vec![DEFAULT_VOICE.to_owned()]);
250 }
251
252 #[test]
253 fn an_unknown_voice_is_refused_by_name() {
254 let error = select_voice(Some("zf_nobody"), &installed()).expect_err("refused");
255 assert_eq!(error, Error::VoiceMissing("zf_nobody".to_owned()));
256 assert!(error.to_string().contains("zf_nobody"), "got: {error}");
257 }
258
259 #[test]
260 fn a_default_voice_that_is_not_installed_is_refused_rather_than_replaced() {
261 let error = select_voice(None, &["bf_emma".to_owned()]).expect_err("refused");
262 assert_eq!(error, Error::VoiceMissing(DEFAULT_VOICE.to_owned()));
263 }
264
265 #[test]
268 fn the_wire_chunk_is_24k_mono_sixteen_bit_whatever_the_model_rate() {
269 let mut stub = Stub::new(16_000, vec![0.5; 16]);
270 let request = SpeakRequest { text: "hello".to_owned(), voice: None };
271 let chunk = chunk_for(&mut stub, &installed(), &request).expect("the render lands");
272 assert_eq!(chunk.sample_rate, SAMPLE_RATE);
273 assert_eq!(chunk.pcm.len(), 24, "1 ms at 16 kHz is 1 ms at 24 kHz");
274 assert!(chunk.pcm.iter().all(|sample| *sample == 16_383), "0.5 scales to half the range");
275 }
276
277 #[test]
278 fn a_sample_past_full_scale_is_clamped_not_wrapped() {
279 let mut stub = Stub::new(SAMPLE_RATE, vec![2.0, -2.0]);
280 let request = SpeakRequest { text: "loud".to_owned(), voice: None };
281 let chunk = chunk_for(&mut stub, &installed(), &request).expect("the render lands");
282 assert_eq!(chunk.pcm, vec![i16::MAX, -i16::MAX]);
283 }
284
285 #[tokio::test]
289 #[ignore = "loads the real model"]
290 async fn the_engine_speaks_a_short_sentence() {
291 let dir = std::path::PathBuf::from(
292 std::env::var("FORGE_TTS_MODEL_DIR").expect("FORGE_TTS_MODEL_DIR"),
293 );
294 let files = crate::ModelFiles::new(dir.join("model_quantized.onnx"), dir.join("voices"));
295 let engine = crate::KokoroTts::load(&files).expect("loads");
296 let mut chunks =
297 engine.speak(SpeakRequest { text: "Ready when you are.".to_owned(), voice: None });
298 let first = chunks.recv().await.expect("a chunk").expect("a render");
299 assert_eq!(first.sample_rate, SAMPLE_RATE);
300 assert!(!first.pcm.is_empty());
301 }
302}