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::{
KokoroClient, KokoroModelV2, KokoroPhonemeVocab, KokoroSynthOptions, load_kokoro_v2,
select_voice_style,
};
use numr::dtype::DType;
use numr::runtime::Runtime;
use numr::runtime::cpu::{CpuDevice, CpuRuntime};
use numr::tensor::Tensor;
use std::path::Path;
use std::sync::Arc;
pub struct KokoroEngine<R: Runtime<DType = DType> = CpuRuntime> {
pub model: KokoroModelV2<R>,
pub vocab: KokoroPhonemeVocab,
pub resolver: VoiceResolver,
pub client: Arc<R::Client>,
pub device: Arc<R::Device>,
pub g2p: KokoroG2p,
seed: Option<u64>,
}
impl KokoroEngine<CpuRuntime> {
pub fn from_dir(dir: &Path, g2p: Option<KokoroG2pModels>) -> Result<Self> {
Self::from_dir_on(dir, g2p, &CpuDevice::new())
}
}
impl<R: Runtime<DType = DType>> KokoroEngine<R>
where
R::Client: KokoroClient<R>,
{
pub fn from_dir_on(
dir: &Path,
g2p: Option<KokoroG2pModels>,
device: &R::Device,
) -> Result<Self> {
let device = Arc::new(device.clone());
let client = Arc::new(R::default_client(&device));
let model = load_kokoro_v2::<R, _>(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,
seed: None,
})
}
pub fn with_seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
pub fn synthesize(&self, text: &str, voice_spec: &str, speed: f32) -> Result<Vec<f32>> {
self.synthesize_seeded(text, voice_spec, speed, self.seed)
}
pub fn synthesize_seeded(
&self,
text: &str,
voice_spec: &str,
speed: f32,
seed: Option<u64>,
) -> 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::<R>(voice_spec, &self.device)?;
let mut waveform = Vec::new();
for (index, chunk) in chunks.iter().enumerate() {
let ids = phonemes_to_input_ids(&self.vocab, &chunk.phonemes);
if ids.len() <= 2 {
continue;
}
let token_ids = Tensor::<R>::from_slice(&ids, &[1, ids.len()], &self.device)?;
let voice_row = select_voice_style(&voice_pack, chunk.phonemes.chars().count())?;
let opts = KokoroSynthOptions {
speed,
seed: seed.map(|s| chunk_seed(s, index)),
};
let chunk_waveform =
self.model
.synthesize(self.client.as_ref(), &token_ids, &voice_row, &opts)?;
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 chunk_seed(seed: u64, index: usize) -> u64 {
seed ^ (index as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
}
fn voice_id(voice_spec: &str) -> &str {
Path::new(voice_spec)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(voice_spec)
}
impl<R: Runtime<DType = DType>> crate::tts::TtsEngine for KokoroEngine<R>
where
R::Client: KokoroClient<R>,
KokoroModelV2<R>: Send + Sync,
{
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::{chunk_seed, 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");
}
#[test]
fn chunk_seed_keeps_the_first_chunk_and_spreads_the_rest() {
assert_eq!(chunk_seed(7, 0), 7);
let seeds: Vec<u64> = (0..4).map(|i| chunk_seed(7, i)).collect();
for (i, a) in seeds.iter().enumerate() {
for b in &seeds[i + 1..] {
assert!(
a.abs_diff(*b) > 1,
"chunk seeds {a} and {b} collide or touch"
);
}
}
}
}