1pub mod audio;
20#[cfg(feature = "wgpu")]
21pub mod gguf;
22pub mod models;
23pub mod tokenizer;
24pub mod tts;
25
26use anyhow::{bail, Context, Result};
27use burn::backend::wgpu::WgpuDevice;
28use burn::backend::Wgpu;
29use burn::tensor::Tensor;
30use std::path::{Path, PathBuf};
31
32use tokenizer::TekkenEncoder;
34
35pub struct TtsEngine {
37 backbone: gguf::tts_model::Q4TtsBackbone,
38 fm: gguf::tts_model::Q4FmTransformer,
39 codec: tts::codec::CodecDecoder<Wgpu>,
40 tokenizer: TekkenEncoder,
41 voices_dir: PathBuf,
42 device: WgpuDevice,
43 max_frames: usize,
44}
45
46impl TtsEngine {
47 pub async fn new<P: AsRef<Path>>(gguf_path: P) -> Result<Self> {
64 Self::with_options(gguf_path, None, None).await
65 }
66
67 pub async fn with_options<P: AsRef<Path>>(
74 gguf_path: P,
75 tokenizer_path: Option<P>,
76 voices_dir: Option<P>,
77 ) -> Result<Self> {
78 let start = std::time::Instant::now();
79 let gguf_path = gguf_path.as_ref();
80 if !gguf_path.exists() {
81 bail!("GGUF model not found at {}", gguf_path.display());
82 }
83
84 let tokenizer_path = match tokenizer_path {
86 Some(p) => p.as_ref().to_path_buf(),
87 None => {
88 let gguf_dir = gguf_path
89 .parent()
90 .unwrap_or(&PathBuf::from("."))
91 .to_path_buf();
92 let candidates = [
93 gguf_dir.join("tekken.json"),
94 PathBuf::from("models/tekken.json"),
95 PathBuf::from("models/voxtral-tts/tekken.json"),
96 ];
97 candidates
98 .into_iter()
99 .find(|p| p.exists())
100 .ok_or_else(|| {
101 anyhow::anyhow!(
102 "Tokenizer not found. Provide tokenizer_path or place tekken.json alongside the GGUF file"
103 )
104 })?
105 }
106 };
107
108 if !tokenizer_path.exists() {
109 bail!("Tokenizer not found at {}", tokenizer_path.display());
110 }
111
112 let tokenizer =
113 TekkenEncoder::from_file(&tokenizer_path).context("Failed to load tokenizer")?;
114
115 let voices_dir = match voices_dir {
117 Some(d) => d.as_ref().to_path_buf(),
118 None => PathBuf::from("models/voice_embedding"),
119 };
120
121 let device = WgpuDevice::default();
123 tracing::info!("Loading Q4 TTS model from {}", gguf_path.display());
124 let load_start = std::time::Instant::now();
125 let mut loader = gguf::Q4TtsModelLoader::from_file(gguf_path)
126 .context("Failed to open GGUF")?;
127 let (backbone, fm, codec) = loader.load(&device).context("Failed to load Q4 model")?;
128 tracing::info!("Model loaded in {:.2}s", load_start.elapsed().as_secs_f32());
129
130 let total_time = start.elapsed().as_secs_f32();
131 tracing::info!("TTS engine initialized in {:.2}s", total_time);
132
133 Ok(Self {
134 backbone,
135 fm,
136 codec,
137 tokenizer,
138 voices_dir,
139 device,
140 max_frames: 2000,
141 })
142 }
143
144 pub fn synthesize(&mut self, text: &str, voice: Option<&str>) -> Result<audio::AudioBuffer> {
153 self.synthesize_with_options(text, voice, 1.0, 1.0, None)
154 }
155
156 pub fn synthesize_with_options(
189 &mut self,
190 text: &str,
191 voice: Option<&str>,
192 speed: f32,
193 gain: f32,
194 language: Option<&str>,
195 ) -> Result<audio::AudioBuffer> {
196 let synthesis_start = std::time::Instant::now();
197
198 if !(0.5..=3.0).contains(&speed) {
200 bail!("Speed must be between 0.5 and 3.0, got {}", speed);
201 }
202 if !(0.1..=2.0).contains(&gain) {
203 bail!("Gain must be between 0.1 and 2.0, got {}", gain);
204 }
205 let voice_name = voice.unwrap_or("casual_female");
206
207 let tokenize_start = std::time::Instant::now();
209 let token_ids = self.tokenizer.encode(text);
210 tracing::debug!("Tokenization: {:.3}s", tokenize_start.elapsed().as_secs_f32());
211 tracing::info!(
212 text_tokens = token_ids.len(),
213 voice = voice_name,
214 language = ?language,
215 "Synthesizing"
216 );
217
218 let voice_path = self
220 .voices_dir
221 .join(format!("{}.safetensors", voice_name));
222 if !voice_path.exists() {
223 bail!(
224 "Voice '{}' not found at {}\n\
225 \n\
226 Voice embeddings are separate files that must be downloaded.\n\
227 Download with:\n\
228 make download-models\n\
229 Or manually:\n\
230 uv run --with huggingface_hub hf download \\\n\
231 TrevorJS/voxtral-tts-q4-gguf \\\n\
232 --local-dir models\n\
233 \n\
234 Then voices will be available at: models/voxtral-tts/voice_embedding/*.safetensors",
235 voice_name,
236 voice_path.display()
237 );
238 }
239
240 let voice_bytes = std::fs::read(&voice_path)?;
241 let voice_embed: Tensor<Wgpu, 2> = tts::voice::load_voice_from_bytes(
242 &voice_bytes,
243 3072,
244 &self.device,
245 )
246 .context("Failed to load voice")?;
247
248 tracing::info!(
249 voice = voice_name,
250 frames = voice_embed.dims()[0],
251 "Voice loaded"
252 );
253
254 let special = tts::config::TtsSpecialTokens::default();
256 let bos = self
257 .backbone
258 .embed_tokens_from_ids(&[special.bos_token_id as i32], 1, 1);
259 let begin_audio = self.backbone.embed_tokens_from_ids(
260 &[special.begin_audio_token_id as i32],
261 1,
262 1,
263 );
264 let next_audio_text = self.backbone.embed_tokens_from_ids(
265 &[special.next_audio_text_token_id as i32],
266 1,
267 1,
268 );
269 let repeat_audio_text = self.backbone.embed_tokens_from_ids(
270 &[special.repeat_audio_text_token_id as i32],
271 1,
272 1,
273 );
274 let text_ids_i32: Vec<i32> = token_ids.iter().map(|&id| id as i32).collect();
275 let text_embeds = self
276 .backbone
277 .embed_tokens_from_ids(&text_ids_i32, 1, text_ids_i32.len());
278
279 let input_sequence = Tensor::cat(
280 vec![
281 bos,
282 begin_audio.clone(),
283 voice_embed.unsqueeze_dim::<3>(0),
284 next_audio_text,
285 text_embeds,
286 repeat_audio_text,
287 begin_audio,
288 ],
289 1,
290 );
291
292 let codebook = tts::embeddings::AudioCodebookEmbeddings::new(
293 self.backbone.audio_codebook_embeddings().clone(),
294 tts::config::AudioCodebookLayout::default(),
295 );
296
297 let gen_start = std::time::Instant::now();
299 let frames = pollster::block_on(self.backbone.generate_async(
300 input_sequence,
301 &self.fm,
302 &codebook,
303 self.max_frames,
304 ))
305 .map_err(|e| anyhow::anyhow!("Generation failed: {e}"))?;
306 tracing::info!("Frame generation: {:.2}s ({} frames)", gen_start.elapsed().as_secs_f32(), frames.len());
307
308 if frames.is_empty() {
309 bail!("No audio frames generated");
310 }
311
312 let decode_start = std::time::Instant::now();
314 let n_frames = frames.len();
315 let semantic_indices: Vec<usize> = frames.iter().map(|f| f.semantic_idx).collect();
316 let mut acoustic_data = Vec::with_capacity(n_frames * 36);
317 for frame in &frames {
318 for &level in &frame.acoustic_levels {
319 acoustic_data.push(level as f32);
320 }
321 }
322 let acoustic_tensor: Tensor<Wgpu, 2> = Tensor::from_data(
323 burn::tensor::TensorData::new(acoustic_data, [n_frames, 36]),
324 &self.device,
325 );
326 let waveform = self.codec.decode(&semantic_indices, acoustic_tensor);
327 let [_batch, total_samples] = waveform.dims();
328 tracing::info!("Codec decode: {:.2}s", decode_start.elapsed().as_secs_f32());
329
330 let postprocess_start = std::time::Instant::now();
331 let wav_data = waveform.to_data();
332 let mut samples: Vec<f32> = wav_data.as_slice::<f32>().unwrap()[..total_samples].to_vec();
333
334 let peak = samples.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
336 if peak > 1e-6 {
337 let gain = 0.95 / peak;
338 for s in &mut samples {
339 *s *= gain;
340 }
341 }
342
343 let mut audio = audio::AudioBuffer::new(samples, 24000);
344
345 if (speed - 1.0).abs() > 0.001 {
347 audio = audio.with_speed(speed);
348 tracing::debug!(speed = speed, "Speed adjusted");
349 }
350
351 if (gain - 1.0).abs() > 0.001 {
353 audio = audio.with_gain(gain);
354 }
355
356 let duration = audio.len() as f64 / audio.sample_rate as f64;
357 tracing::debug!("Post-processing: {:.3}s", postprocess_start.elapsed().as_secs_f32());
358
359 let total_synthesis = synthesis_start.elapsed().as_secs_f32();
360 tracing::info!(
361 frames = n_frames,
362 duration_sec = format!("{duration:.2}"),
363 speed = speed,
364 gain = gain,
365 total_time_sec = format!("{total_synthesis:.2}"),
366 "Audio generated"
367 );
368
369 Ok(audio)
370 }
371
372 pub fn list_voices(&self) -> Result<Vec<String>> {
377 if !self.voices_dir.exists() {
378 bail!(
379 "Voices directory not found at {}\n\
380 \n\
381 Voice embeddings must be downloaded.\n\
382 Download with:\n\
383 make download-models\n\
384 Or manually:\n\
385 uv run --with huggingface_hub hf download \\\n\
386 TrevorJS/voxtral-tts-q4-gguf \\\n\
387 --local-dir models",
388 self.voices_dir.display()
389 );
390 }
391
392 let mut voices: Vec<String> = std::fs::read_dir(&self.voices_dir)?
393 .filter_map(|e| e.ok())
394 .filter(|e| {
395 e.path()
396 .extension()
397 .is_some_and(|ext| ext == "safetensors")
398 })
399 .filter_map(|e| {
400 e.path()
401 .file_stem()
402 .map(|s| s.to_string_lossy().into_owned())
403 })
404 .collect();
405 voices.sort();
406 Ok(voices)
407 }
408
409 pub fn set_max_frames(&mut self, max_frames: usize) {
413 self.max_frames = max_frames;
414 }
415
416 pub fn set_euler_steps(&mut self, steps: usize) {
422 self.fm.set_euler_steps(steps);
423 }
424
425 pub fn save_wav<P: AsRef<Path>>(&self, path: P, audio: &audio::AudioBuffer) -> Result<()> {
431 audio.save(path)
432 }
433}
434
435pub use audio::AudioBuffer;