use std::path::{Path, PathBuf};
use sayd_g2p::{Dialect, Phonemizer};
use sayd_kokoro::Kokoro;
use sayd_core::config::Config;
use sayd_core::synth::Synthesizer;
pub struct KokoroSynthesizer {
models_dir: PathBuf,
model_file: String,
threads: usize,
phonemizer: Phonemizer,
session: Option<Kokoro>,
loaded_voices: Vec<String>,
}
fn model_file_for(model: &str) -> &'static str {
match model {
"fp16" => "model_fp16.onnx",
"q8" => "model_quantized.onnx",
_ => "model.onnx",
}
}
fn dialect_for(voice: &str) -> Dialect {
if voice.starts_with("bf_") || voice.starts_with("bm_") {
Dialect::British
} else {
Dialect::American
}
}
impl KokoroSynthesizer {
pub fn new(models_dir: &Path, cfg: &Config) -> Result<Self, String> {
Ok(KokoroSynthesizer {
models_dir: models_dir.to_path_buf(),
model_file: model_file_for(&cfg.model).to_string(),
threads: cfg.threads,
phonemizer: Phonemizer::new(),
session: None,
loaded_voices: Vec::new(),
})
}
fn ensure_session(&mut self) -> Result<&mut Kokoro, String> {
if self.session.is_none() {
let k = Kokoro::new(&self.models_dir, &self.model_file, self.threads)
.map_err(|e| e.to_string())?;
self.session = Some(k);
self.loaded_voices.clear();
}
self.session.as_mut().ok_or_else(|| "session missing".to_string())
}
}
impl Synthesizer for KokoroSynthesizer {
fn phonemize(&mut self, text: &str, voice: &str) -> String {
self.phonemizer.phonemize(text, dialect_for(voice))
}
fn fits(&mut self, phonemes: &str) -> bool {
match self.ensure_session() {
Ok(k) => k.tokenize(phonemes).len() < sayd_kokoro::MAX_TOKENS,
Err(_) => phonemes.chars().count() <= sayd_kokoro::MAX_TOKENS,
}
}
fn synth(&mut self, phonemes: &str, voice: &str, speed: f32) -> Result<Vec<f32>, String> {
self.ensure_session()?;
if !self.loaded_voices.contains(&voice.to_string()) {
let k = self.session.as_mut().ok_or_else(|| "session missing".to_string())?;
k.load_voice(voice).map_err(|e| e.to_string())?;
self.loaded_voices.push(voice.to_string());
}
let k = self.session.as_mut().ok_or_else(|| "session missing".to_string())?;
k.synth(phonemes, voice, speed).map_err(|e| e.to_string())
}
fn sample_rate(&self) -> u32 {
sayd_kokoro::SAMPLE_RATE
}
fn unload(&mut self) {
self.session = None;
self.loaded_voices.clear();
}
fn is_loaded(&self) -> bool {
self.session.is_some()
}
}
#[cfg(all(test, feature = "models"))]
mod models_tests {
use super::*;
use std::path::Path;
fn models_dir() -> &'static Path {
Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../models"))
}
#[test]
fn synth_produces_plausible_length_audio_from_real_text() {
let cfg = Config::default();
let mut s = KokoroSynthesizer::new(models_dir(), &cfg).expect("synthesizer constructs");
let text = "Hello there. This is sayd speaking from the engine.";
let phonemes = s.phonemize(text, "af_heart");
assert!(!phonemes.is_empty(), "expected non-empty phonemes for real text");
let audio = s.synth(&phonemes, "af_heart", 1.0).expect("synth succeeds");
assert!(!audio.is_empty(), "expected non-empty audio");
let seconds = audio.len() as f64 / sayd_kokoro::SAMPLE_RATE as f64;
assert!(
(0.5..20.0).contains(&seconds),
"synthesized audio duration {seconds}s is not plausible for this text"
);
}
#[test]
fn american_and_british_voices_produce_different_phonemes() {
let cfg = Config::default();
let mut s = KokoroSynthesizer::new(models_dir(), &cfg).expect("synthesizer constructs");
let us = s.phonemize("tomato", "af_heart");
let gb = s.phonemize("tomato", "bf_emma");
assert_ne!(
us, gb,
"British voice bf_emma must not collapse into the American phonemization"
);
}
}
#[cfg(all(test, feature = "models"))]
mod engine_models_tests {
use std::sync::{Arc, Mutex};
use sayd_core::audio::{AudioSink, VecSink};
use sayd_core::config::Config;
use sayd_core::engine::{Engine, SayOpts, State};
use super::*;
fn models_dir() -> &'static Path {
Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../models"))
}
struct DrainableSink(Arc<Mutex<VecSink>>);
impl AudioSink for DrainableSink {
fn push(&mut self, samples: &[f32]) -> usize {
self.0.lock().unwrap().push(samples)
}
fn pending(&self) -> usize {
self.0.lock().unwrap().pending()
}
fn clear(&mut self) {
self.0.lock().unwrap().clear()
}
fn set_paused(&mut self, paused: bool) {
self.0.lock().unwrap().set_paused(paused)
}
fn is_paused(&self) -> bool {
self.0.lock().unwrap().is_paused()
}
fn capacity(&self) -> usize {
self.0.lock().unwrap().capacity()
}
fn total_written(&self) -> usize {
self.0.lock().unwrap().total_written()
}
}
#[test]
fn engine_produces_non_silent_audio_of_plausible_duration() {
let cfg = Config::default();
let synth = KokoroSynthesizer::new(models_dir(), &cfg).expect("synthesizer constructs");
let sink = Arc::new(Mutex::new(VecSink::new(24_000 * 30)));
let mut e = Engine::new(cfg, Box::new(synth), Box::new(DrainableSink(sink.clone())));
let text = "Hello there. This is sayd speaking from the engine.";
e.submit(text.into(), SayOpts::default()).expect("well-formed text is accepted");
let mut finished = false;
for _ in 0..5000 {
e.tick();
let s = e.snapshot();
if s.queue_len == 0 && s.current_id == 0 {
finished = true;
break;
}
}
assert!(finished, "synthesis did not finish within the tick budget");
let s = e.snapshot();
assert_eq!(
s.state,
State::Speaking,
"engine must stay Speaking while synthesized audio is still pending in the sink"
);
let written = sink.lock().unwrap().written.clone();
assert!(!written.is_empty(), "expected some audio to have been written");
assert!(
written.iter().any(|&x| x != 0.0),
"expected non-silent audio from the real synthesizer, got all zeros"
);
let seconds = written.len() as f64 / sayd_kokoro::SAMPLE_RATE as f64;
let nonzero = written.iter().filter(|&&x| x != 0.0).count();
eprintln!(
"engine_produces_non_silent_audio_of_plausible_duration: {} samples ({seconds:.3}s), \
{nonzero} non-zero ({:.1}%)",
written.len(),
100.0 * nonzero as f64 / written.len() as f64
);
assert!(
(0.5..20.0).contains(&seconds),
"synthesized audio duration {seconds}s is not plausible for this sentence"
);
sink.lock().unwrap().drain(usize::MAX);
e.tick();
assert_eq!(
e.snapshot().state,
State::Idle,
"engine must go Idle once the sink has actually drained"
);
}
}