//! Kokoro TTS plugin (stub). //! //! Kokoro is a lightweight, open-source TTS model. This stub shows the pattern for //! adding a new engine plugin: implement [`TtsEngine`] and wire the HTTP or //! local inference calls. //! //! # Configuration //! Set `TTS_ENGINE=kokoro` and provide: //! - `KOKORO_URL` — base URL of the Kokoro HTTP server (default: `http://localhost:8880`) //! - `KOKORO_VOICE` — voice name (default: `af_heart`) //! //! # Resampling note //! Kokoro typically outputs at 24 kHz. Since the mixer runs at //! [`crate::config::OUTPUT_SAMPLE_RATE`] (44.1 kHz), this plugin must //! resample internally before pushing samples. A basic linear resampler is //! provided below; replace with a polyphase/FIR resampler for higher quality. use crate::audio::SampleRingBuffer; use crate::config::{Config, OUTPUT_SAMPLE_RATE}; use crate::tts::TtsEngine; use anyhow::Result; use async_trait::async_trait; use std::sync::Arc; use tokio_util::sync::CancellationToken; use tracing::info; /// Kokoro native output sample rate (Hz). const KOKORO_SAMPLE_RATE: u32 = 24_000; /// Kokoro TTS plugin. #[derive(Clone)] pub struct KokoroTts { endpoint: String, voice: String, http: reqwest::Client, _config: Arc, } impl KokoroTts { pub fn new(config: &Config) -> Self { let endpoint = std::env::var("KOKORO_URL") .unwrap_or_else(|_| "http://localhost:8880".to_string()); let voice = std::env::var("KOKORO_VOICE") .unwrap_or_else(|_| "af_heart".to_string()); info!(endpoint = %endpoint, voice = %voice, "Kokoro TTS plugin initialized"); Self { endpoint, voice, http: reqwest::Client::builder() .build() .expect("failed to build reqwest client"), _config: Arc::new(config.clone()), } } } #[async_trait] impl TtsEngine for KokoroTts { fn name(&self) -> &str { "kokoro" } fn sample_rate(&self) -> u32 { // We resample to the mixer rate before pushing. OUTPUT_SAMPLE_RATE } async fn synthesize( &self, text: &str, tts_queue: &SampleRingBuffer, cancel: &CancellationToken, ) -> Result<()> { // TODO: implement the actual Kokoro HTTP streaming call. // // Example request (adjust to be completed by the user): // // ```ignore // let resp = self.http // .post(format!("{}/v1/audio/speech", self.endpoint)) // .json(&serde_json::json!({ // "model": "kokoro", // "input": text, // "voice": self.voice, // "response_format": "pcm", // "stream": true, // })) // .send() // .await?; // // let mut stream = resp.bytes_stream(); // while let Some(chunk) = stream.next().await { // if cancel.is_cancelled() { break; } // let bytes = chunk?; // // Kokoro outputs 16-bit mono PCM at 24 kHz. // let samples = pcm_to_i16(&bytes); // let resampled = resample_24k_to_output(&samples); // tts_queue.push(&resampled); // } // ``` let _ = (text, tts_queue, cancel); anyhow::bail!( "Kokoro TTS plugin is not yet implemented — \ complete the HTTP streaming call in src/tts/kokoro.rs" ); } } /// Resample a 24 kHz mono `i16` buffer to [`OUTPUT_SAMPLE_RATE` using linear /// interpolation. Replace with a windowed-sinc FIR resampler for higher fidelity. #[allow(dead_code)] fn resample_24k_to_output(samples: &[i16]) -> Vec { if samples.is_empty() { return Vec::new(); } let ratio = OUTPUT_SAMPLE_RATE as f64 / KOKORO_SAMPLE_RATE as f64; let out_len = (samples.len() as f64 * ratio).round() as usize; let mut out = Vec::with_capacity(out_len); for i in 0..out_len { let src_pos = i as f64 / ratio; let idx = src_pos.floor() as usize; let frac = src_pos - idx as f64; let a = samples[idx.min(samples.len() - 1)] as f32; let b = samples[(idx + 1).min(samples.len() - 1)] as f32; out.push((a + (b - a) * frac as f32).round() as i16); } out }