use std::sync::Arc;
use thiserror::Error;
use crate::lang::Lang;
use crate::tts::engine::TtsEngine;
#[derive(Debug, Error)]
pub enum TtsError {
#[error("voice '{0}' not registered on this bundle")]
UnknownVoice(String),
#[error("neural synthesis requires an engine; this bundle was created without one")]
NotImplemented,
#[error("load error: {0}")]
Load(String),
#[error("engine error: {0}")]
Engine(String),
#[error("invalid request: {0}")]
InvalidRequest(String),
}
#[derive(Debug, Clone)]
pub struct Voice {
pub id: String,
pub lang: Lang,
pub display_name: String,
}
impl Voice {
pub fn new(id: impl Into<String>, lang: Lang, display_name: impl Into<String>) -> Self {
Self {
id: id.into(),
lang,
display_name: display_name.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct SynthesizeOptions {
pub speed: f32,
}
impl Default for SynthesizeOptions {
fn default() -> Self {
Self { speed: 1.0 }
}
}
pub struct TtsBundle {
voices: Vec<Voice>,
pub sample_rate: u32,
engine: Option<Arc<dyn TtsEngine>>,
}
impl TtsBundle {
pub fn scaffolding(voices: Vec<Voice>, sample_rate: u32) -> Self {
Self {
voices,
sample_rate,
engine: None,
}
}
pub fn with_engine(mut self, engine: Arc<dyn TtsEngine>) -> Self {
self.sample_rate = engine.sample_rate();
self.engine = Some(engine);
self
}
pub fn has_engine(&self) -> bool {
self.engine.is_some()
}
pub fn voices(&self) -> &[Voice] {
&self.voices
}
pub fn voice(&self, id: &str) -> Option<&Voice> {
self.voices.iter().find(|v| v.id == id)
}
pub fn synthesize(
&self,
text: &str,
voice_id: &str,
options: &SynthesizeOptions,
) -> Result<Vec<f32>, TtsError> {
match &self.engine {
Some(engine) => engine
.synthesize(text, voice_id, options.speed)
.map_err(engine_error),
None => self.scaffolding_failure(text, voice_id),
}
}
pub fn synthesize_stream(
&self,
text: &str,
voice_id: &str,
options: &SynthesizeOptions,
sink: &mut dyn FnMut(&[f32]) -> crate::error::Result<()>,
) -> Result<(), TtsError> {
match &self.engine {
Some(engine) => engine
.synthesize_stream(text, voice_id, options.speed, sink)
.map_err(engine_error),
None => self.scaffolding_failure(text, voice_id).map(|_| ()),
}
}
fn scaffolding_failure(&self, text: &str, voice_id: &str) -> Result<Vec<f32>, TtsError> {
let _ = text;
self.voice(voice_id)
.ok_or_else(|| TtsError::UnknownVoice(voice_id.to_string()))?;
Err(TtsError::NotImplemented)
}
}
fn engine_error(e: crate::error::Error) -> TtsError {
match e {
crate::error::Error::InvalidArgument { .. }
| crate::error::Error::Boostr(boostr::error::Error::InvalidArgument { .. }) => {
TtsError::InvalidRequest(e.to_string())
}
other => TtsError::Engine(other.to_string()),
}
}
pub const KOKORO_VOICES: [&str; 28] = [
"af_heart",
"af_alloy",
"af_aoede",
"af_bella",
"af_jessica",
"af_kore",
"af_nicole",
"af_nova",
"af_river",
"af_sarah",
"af_sky",
"am_adam",
"am_echo",
"am_eric",
"am_fenrir",
"am_liam",
"am_michael",
"am_onyx",
"am_puck",
"am_santa",
"bf_emma",
"bf_isabella",
"bm_george",
"bm_lewis",
"bf_alice",
"bf_lily",
"bm_daniel",
"bm_fable",
];
pub fn default_kokoro_voices() -> Vec<Voice> {
KOKORO_VOICES
.iter()
.map(|&id| {
let lang = if id.starts_with('b') {
Lang::EnGb
} else {
Lang::EnUs
};
let name = id.split_once('_').map_or(id, |(_, name)| name);
let mut chars = name.chars();
let name: String = chars
.next()
.map(|c| c.to_uppercase().chain(chars).collect())
.unwrap_or_default();
Voice::new(id, lang, name)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scaffolding_bundle_exposes_voices() {
let bundle = TtsBundle::scaffolding(default_kokoro_voices(), 24_000);
assert!(bundle.voice("af_alloy").is_some());
assert!(bundle.voice("not-a-voice").is_none());
assert_eq!(bundle.sample_rate, 24_000);
}
#[test]
fn synthesize_returns_not_implemented() {
let bundle = TtsBundle::scaffolding(default_kokoro_voices(), 24_000);
let err = bundle
.synthesize("hello", "af_alloy", &SynthesizeOptions::default())
.unwrap_err();
assert!(matches!(err, TtsError::NotImplemented));
}
#[test]
fn synthesize_validates_voice_first() {
let bundle = TtsBundle::scaffolding(default_kokoro_voices(), 24_000);
let err = bundle
.synthesize("hello", "ghost_voice", &SynthesizeOptions::default())
.unwrap_err();
assert!(matches!(err, TtsError::UnknownVoice(_)));
}
#[test]
fn default_catalog_has_known_voices() {
let voices = default_kokoro_voices();
assert_eq!(voices.len(), KOKORO_VOICES.len());
assert_eq!(voices[0].id, "af_heart");
assert_eq!(voices[0].display_name, "Heart");
assert!(voices.iter().any(|v| v.id == "af_nova"));
let george = voices.iter().find(|v| v.id == "bm_george");
assert!(george.is_some_and(|v| v.lang == Lang::EnGb && v.display_name == "George"));
}
#[test]
fn zero_shot_voice_is_found_and_not_reported_unknown() {
let mut voices = default_kokoro_voices();
voices.push(Voice::new("zero-shot", Lang::EnUs, "zero-shot"));
let bundle = TtsBundle::scaffolding(voices, 24_000);
assert!(bundle.voice("zero-shot").is_some());
let err = bundle
.synthesize("hello", "zero-shot", &SynthesizeOptions::default())
.unwrap_err();
assert!(matches!(err, TtsError::NotImplemented));
}
}