use crate::error::{Error, Result};
use crate::kokoro::g2p::{Dialect, KokoroG2p, KokoroG2pModels, phonemes_to_input_ids};
use crate::kokoro::voice::VoiceResolver;
use boostr::model::audio::kokoro::{
KokoroModelV2, KokoroPhonemeVocab, load_kokoro_v2, select_voice_style,
};
use numr::runtime::Runtime;
use numr::runtime::cpu::{CpuClient, CpuDevice, CpuRuntime};
use numr::tensor::Tensor;
use std::path::Path;
use std::sync::Arc;
pub struct KokoroEngine {
pub model: KokoroModelV2<CpuRuntime>,
pub vocab: KokoroPhonemeVocab,
pub resolver: VoiceResolver,
pub client: Arc<CpuClient>,
pub device: Arc<<CpuRuntime as numr::runtime::Runtime>::Device>,
pub g2p: KokoroG2p,
}
impl KokoroEngine {
pub fn from_dir(dir: &Path, g2p: Option<KokoroG2pModels>) -> Result<Self> {
let device = Arc::new(CpuDevice::new());
let client = Arc::new(CpuRuntime::default_client(&device));
let model = load_kokoro_v2::<CpuRuntime, _>(client.as_ref(), dir, device.as_ref())
.map_err(|e| Error::ModelError {
reason: format!("loading Kokoro weights from {}: {e}", dir.display()),
})?;
let vocab_json = dir.join("vocab.json");
let vocab_source = if vocab_json.exists() {
vocab_json
} else {
dir.join("config.json")
};
let vocab =
KokoroPhonemeVocab::from_json_file(&vocab_source).map_err(|e| Error::ModelError {
reason: format!(
"loading the phoneme vocab from {}: {e}",
vocab_source.display()
),
})?;
let voices_dir = dir.join("voices");
let resolver = if voices_dir.is_dir() {
VoiceResolver::new().with_asset_dir(&voices_dir)
} else {
VoiceResolver::new()
};
let g2p = match g2p {
Some(models) => models,
None => KokoroG2pModels::resolve(dir, None, None)?,
}
.load()?;
Ok(Self {
model,
vocab,
resolver,
client,
device,
g2p,
})
}
pub fn synthesize(&self, text: &str, voice_spec: &str, speed: f32) -> Result<Vec<f32>> {
if text.is_empty() {
return Err(Error::InvalidArgument {
arg: "text",
reason: "input text must not be empty".into(),
});
}
if !(speed.is_finite() && speed > 0.0) {
return Err(Error::InvalidArgument {
arg: "speed",
reason: format!("expected a finite positive pace such as 1.0, got {speed}"),
});
}
let dialect = Dialect::from_voice_id(voice_id(voice_spec))?;
let chunks = self.g2p.chunks(text, dialect)?;
let voice_pack = self.resolver.load::<CpuRuntime>(voice_spec, &self.device)?;
let mut waveform = Vec::new();
for chunk in &chunks {
let ids = phonemes_to_input_ids(&self.vocab, &chunk.phonemes);
if ids.len() <= 2 {
continue;
}
let token_ids = Tensor::<CpuRuntime>::from_slice(&ids, &[1, ids.len()], &self.device)?;
let voice_row = select_voice_style(&voice_pack, chunk.phonemes.chars().count())?;
let chunk_waveform =
self.model
.synthesize_cpu(&self.client, &token_ids, &voice_row, speed)?;
waveform.extend(chunk_waveform.contiguous()?.to_vec::<f32>());
}
if waveform.is_empty() {
return Err(Error::DataError {
reason: "no phonemes mapped to vocab — check that G2P and vocab agree".into(),
});
}
Ok(waveform)
}
pub fn sample_rate(&self) -> u32 {
self.model.config.sample_rate
}
}
fn voice_id(voice_spec: &str) -> &str {
Path::new(voice_spec)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(voice_spec)
}
impl crate::tts::TtsEngine for KokoroEngine {
fn synthesize(&self, text: &str, voice: &str, speed: f32) -> Result<Vec<f32>> {
KokoroEngine::synthesize(self, text, voice, speed)
}
fn sample_rate(&self) -> u32 {
KokoroEngine::sample_rate(self)
}
fn voices(&self) -> Vec<crate::tts::Voice> {
crate::tts::default_kokoro_voices()
}
}
#[cfg(test)]
mod tests {
use super::voice_id;
#[test]
fn voice_id_reads_bare_ids_and_path_stems() {
assert_eq!(voice_id("af_heart"), "af_heart");
assert_eq!(voice_id("./voices/af_heart.pt"), "af_heart");
assert_eq!(voice_id("/abs/path/bf_emma.safetensors"), "bf_emma");
}
}