pub mod emotion;
pub mod misaki_g2p;
pub mod mock;
pub mod onnx;
pub mod phonemes;
pub use mock::MockTts;
pub use onnx::OnnxTts;
use crate::config::Config;
use crate::error::Result;
pub const TTS_SAMPLE_RATE: u32 = 24_000;
#[derive(Debug, Clone)]
pub struct TtsClip {
pub samples: Vec<f32>,
pub sample_rate: u32,
}
pub trait TtsEngine: Send {
fn synthesize(&mut self, text: &str) -> Result<TtsClip>;
}
pub(crate) fn concat_clip_samples(clips: &[TtsClip]) -> Vec<f32> {
let total: usize = clips.iter().map(|c| c.samples.len()).sum();
let mut pcm = Vec::with_capacity(total);
for clip in clips {
pcm.extend_from_slice(&clip.samples);
}
pcm
}
pub fn build_engine(cfg: &Config) -> Result<Box<dyn TtsEngine>> {
let engine: Box<dyn TtsEngine> = if !cfg.mock_tts {
match (&cfg.tts_model, &cfg.tts_voices) {
(Some(model), Some(voices)) if model.exists() && voices.exists() => {
match OnnxTts::load(model, voices, &cfg.tts_voice, cfg.tts_speed) {
Ok(engine) => Box::new(engine),
Err(e) => {
tracing::warn!(
error = %e,
"failed to load Kokoro TTS; falling back to sine-wave MockTts"
);
Box::new(MockTts::new())
}
}
}
(model, voices) => {
tracing::warn!(
tts_model = ?model,
tts_voices = ?voices,
"Kokoro TTS files absent or incomplete; falling back to sine-wave \
MockTts (run scripts/download_models.sh --with-kokoro)"
);
Box::new(MockTts::new())
}
}
} else {
Box::new(MockTts::new())
};
if cfg.tts_emotion {
tracing::info!("emotion-aware TTS enabled");
Ok(Box::new(EmotionTts::new(engine, cfg.tts_speed)))
} else {
Ok(engine)
}
}
struct EmotionTts {
inner: Box<dyn TtsEngine>,
base_speed: f32,
}
impl EmotionTts {
fn new(inner: Box<dyn TtsEngine>, base_speed: f32) -> Self {
Self { inner, base_speed }
}
}
impl TtsEngine for EmotionTts {
fn synthesize(&mut self, text: &str) -> Result<TtsClip> {
let tone = emotion::detect_tone(text);
let multiplier = tone.speed_multiplier();
tracing::debug!(
tone = ?tone,
speed = %(self.base_speed * multiplier),
"emotion-aware TTS"
);
let _ = multiplier;
self.inner.synthesize(text)
}
}