use crate::tts::{AudioFormat, Speaker, SynthesizedAudio};
use crate::{Result, VoiceConfig, VoiceError};
use async_trait::async_trait;
use car_inference::backend::mlx_kokoro::KokoroBackend;
use car_inference::backend_cache::{estimate_model_size, BackendRetention, CachedBackend};
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct KokoroSpeaker {
backend: CachedBackend<KokoroBackend>,
transient_reservation: Option<Arc<car_inference::resource_policy::LocalLoadReservation>>,
_cache_scope: car_inference::ScopedKokoroBackendCache,
voice: String,
tmp_dir: PathBuf,
}
impl std::fmt::Debug for KokoroSpeaker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KokoroSpeaker")
.field("voice", &self.voice)
.field("tmp_dir", &self.tmp_dir)
.finish()
}
}
impl KokoroSpeaker {
pub fn from_config(config: &VoiceConfig) -> Result<Self> {
let state_root = car_home::root_or_relative();
Self::from_config_in_scope(config, &state_root)
}
pub fn from_config_in_scope(config: &VoiceConfig, state_root: &Path) -> Result<Self> {
let model_dir = resolve_kokoro_model_dir(&config.local_tts_model).ok_or_else(|| {
VoiceError::Config(format!(
"Kokoro TTS: model '{}' not found in HuggingFace cache — \
run `car models pull {}` first",
config.local_tts_model, config.local_tts_model,
))
})?;
let key = config.local_tts_model.clone();
let size = estimate_model_size(&model_dir);
let cache_scope = car_inference::scoped_kokoro_backend_cache(state_root);
let admission = cache_scope.admission().clone();
let mut aliases = crate::local_admission::catalog_aliases(&key, Some(&model_dir));
aliases.push(format!("voice/kokoro/{key}"));
admission.register_model_aliases(&key, aliases);
let cache_key = admission.canonical_model_id(&key);
let mut reservation = admission
.reserve_measured_host(&cache_key, size, 128)
.map_err(|error| VoiceError::Config(error.to_string()))?;
let (backend, retention) = cache_scope
.cache()
.get_or_load_admitted(&cache_key, size, &mut reservation, || {
KokoroBackend::load(&model_dir)
})
.map_err(|e: car_inference::InferenceError| {
VoiceError::Config(format!("load Kokoro model: {e}"))
})?;
Ok(Self {
backend,
transient_reservation: (retention == BackendRetention::Transient)
.then(|| Arc::new(reservation)),
_cache_scope: cache_scope,
voice: config.local_tts_voice.clone(),
tmp_dir: std::env::temp_dir(),
})
}
}
#[async_trait]
impl Speaker for KokoroSpeaker {
async fn synth(&self, text: &str) -> Result<SynthesizedAudio> {
let wav_path = self
.tmp_dir
.join(format!("car-kokoro-{}.wav", uuid::Uuid::new_v4()));
let voice = self.voice.clone();
let text = text.to_string();
let backend = Arc::clone(&self.backend);
let transient_reservation = self.transient_reservation.clone();
let wav_path_clone = wav_path.clone();
let wav_path = tokio::task::spawn_blocking(move || -> Result<PathBuf> {
let _transient_reservation = transient_reservation;
let mut guard = backend
.lock()
.map_err(|_| VoiceError::Config("Kokoro backend mutex poisoned".into()))?;
guard
.synthesize(&text, Some(&voice), &wav_path_clone)
.map_err(|e| VoiceError::Config(format!("Kokoro synth: {e}")))
})
.await
.map_err(|e| VoiceError::Config(format!("Kokoro synth task: {e}")))??;
let bytes = tokio::fs::read(&wav_path)
.await
.map_err(|e| VoiceError::Config(format!("read Kokoro wav: {e}")))?;
let _ = tokio::fs::remove_file(&wav_path).await;
Ok(SynthesizedAudio {
bytes,
format: AudioFormat::Wav,
})
}
}
fn resolve_kokoro_model_dir(repo: &str) -> Option<PathBuf> {
let home = dirs::home_dir()?;
let hub_dir = home.join(".cache/huggingface/hub");
let normalized = repo.replace('/', "--");
let candidate = hub_dir
.join(format!("models--{normalized}"))
.join("snapshots");
let entries = std::fs::read_dir(&candidate).ok()?;
entries
.flatten()
.filter_map(|e| {
let p = e.path();
let meta = e.metadata().ok()?;
if !meta.is_dir() {
return None;
}
let mtime = meta.modified().ok()?;
Some((mtime, p))
})
.max_by_key(|(m, _)| *m)
.map(|(_, p)| p)
}