1use crate::boundaries::{EstimateFirer, EstimatePlan};
4use crate::engine::TtsEngine;
5use crate::types::{
6 Gender, LanguageCode, SherpaLanguage, SherpaModelInfo, TtsError, TtsResult, Voice,
7};
8use std::collections::HashMap;
9use std::fmt;
10use std::path::PathBuf;
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::{Arc, Mutex};
13
14static CANCEL_REQUESTED: AtomicBool = AtomicBool::new(false);
16
17type AudioCbPtr = *mut dyn FnMut(&[u8]);
24type BoundaryCbPtr = *mut dyn FnMut(&str, f32, f32, i32, i32, bool);
25
26thread_local! {
27 static STREAM_AUDIO_CB: std::cell::RefCell<Option<AudioCbPtr>> =
28 const { std::cell::RefCell::new(None) };
29 static STREAM_BOUNDARY_CB: std::cell::RefCell<Option<BoundaryCbPtr>> =
30 const { std::cell::RefCell::new(None) };
31}
32
33const STREAMING_CHUNK_SIZE: usize = 8 * 1024;
39
40fn iso639_3(lang_code: &str) -> String {
44 let lower = lang_code.to_ascii_lowercase();
45 let two = lower.split(['-', '_']).next().unwrap_or(&lower);
46 let three = match two {
47 "en" => "eng",
48 "zh" => "zho",
49 "de" => "deu",
50 "fr" => "fra",
51 "es" => "spa",
52 "ru" => "rus",
53 "ar" => "ara",
54 "ko" => "kor",
55 "ja" => "jpn",
56 "it" => "ita",
57 "pt" => "por",
58 "pl" => "pol",
59 "nl" => "nld",
60 "tr" => "tur",
61 "cs" => "ces",
62 "uk" => "ukr",
63 "vi" => "vie",
64 "th" => "tha",
65 "hi" => "hin",
66 "bn" => "ben",
67 "fa" => "fas",
68 "hu" => "hun",
69 "el" => "ell",
70 "fi" => "fin",
71 "sv" => "swe",
72 "da" => "dan",
73 "no" => "nor",
74 "he" => "heb",
75 "ms" => "msa",
76 "id" => "ind",
77 "ro" => "ron",
78 "sk" => "slk",
79 "bg" => "bul",
80 "ca" => "cat",
81 "hr" => "hrv",
82 "lt" => "lit",
83 "lv" => "lav",
84 "sr" => "srp",
85 "sl" => "slv",
86 "et" => "est",
87 "tl" => "tgl",
88 _ => return lower,
89 };
90 three.to_string()
91}
92
93pub struct SherpaOnnxEngine {
95 models: HashMap<String, SherpaModelInfo>,
96 model_dir: PathBuf,
97 loaded_model_id: String,
98 num_threads: i32,
99 num_steps: i32,
102 reference_audio: Option<PathBuf>,
106 reference_text: Option<String>,
110 provider: Option<String>,
111 tts_instance: Mutex<Option<sherpa_onnx::OfflineTts>>,
115}
116
117impl fmt::Debug for SherpaOnnxEngine {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 f.debug_struct("SherpaOnnxEngine")
120 .field("loaded_model_id", &self.loaded_model_id)
121 .field("num_threads", &self.num_threads)
122 .field("num_steps", &self.num_steps)
123 .field("provider", &self.provider)
124 .field(
125 "tts_cached",
126 &self.tts_instance.lock().is_ok_and(|g| g.is_some()),
127 )
128 .finish_non_exhaustive()
129 }
130}
131
132impl SherpaOnnxEngine {
133 #[must_use]
151 pub fn new(credentials_json: &str) -> Self {
152 let mut model_dir = default_model_dir();
153 let mut model_id = String::new();
154 let mut num_threads = 2;
155 let mut num_steps = 8;
156 let mut provider: Option<String> = None;
157 let mut reference_audio: Option<PathBuf> = None;
158 let mut reference_text: Option<String> = None;
159
160 if !credentials_json.is_empty() {
161 if let Ok(creds) =
167 serde_json::from_str::<HashMap<String, serde_json::Value>>(credentials_json)
168 {
169 let creds: HashMap<String, String> = creds
170 .into_iter()
171 .map(|(k, v)| match v {
172 serde_json::Value::String(s) => (k, s),
173 other => (k, other.to_string()),
174 })
175 .collect();
176 if let Some(dir) = creds.get("modelPath") {
177 model_dir = PathBuf::from(dir);
178 }
179 if let Some(id) = creds.get("modelId") {
180 model_id.clone_from(id);
181 }
182 if let Some(t) = creds.get("numThreads").and_then(|s| s.parse::<i32>().ok()) {
183 if t > 0 {
184 num_threads = t;
185 }
186 }
187 if let Some(s) = creds.get("numSteps").and_then(|s| s.parse::<i32>().ok()) {
188 if (5..=12).contains(&s) {
192 num_steps = s;
193 }
194 }
195 if let Some(p) = creds.get("provider") {
196 if !p.is_empty() {
197 provider = Some(p.clone());
198 }
199 }
200 if let Some(r) = creds.get("referenceAudio") {
201 if !r.is_empty() {
202 reference_audio = Some(PathBuf::from(r));
203 }
204 }
205 if let Some(t) = creds.get("referenceText") {
206 if !t.is_empty() {
207 reference_text = Some(t.clone());
208 }
209 }
210 }
211 }
212
213 let models = load_models();
214
215 SherpaOnnxEngine {
216 models,
217 model_dir,
218 loaded_model_id: model_id,
219 num_threads,
220 num_steps,
221 provider,
222 reference_audio,
223 reference_text,
224 tts_instance: Mutex::new(None),
225 }
226 }
227
228 pub fn available_models(&self) -> &HashMap<String, SherpaModelInfo> {
230 &self.models
231 }
232
233 fn resolve_reference_audio(&self, model_dir: &std::path::Path) -> TtsResult<(Vec<f32>, i32)> {
237 let wav = match &self.reference_audio {
238 Some(p) => p.clone(),
239 None => bundled_reference_wav(model_dir).ok_or_else(|| {
240 TtsError(format!(
241 "no reference audio for zero-shot cloning: pass a 'referenceAudio' \
242 path in credentials, or bundle a wav under {}/test_wavs/",
243 model_dir.display()
244 ))
245 })?,
246 };
247 read_wav_mono_16bit(&wav)
248 }
249
250 fn resolve_reference_text(&self, model_dir: &std::path::Path) -> TtsResult<String> {
254 if let Some(t) = &self.reference_text {
255 return Ok(t.clone());
256 }
257 let wav = self
258 .reference_audio
259 .clone()
260 .or_else(|| bundled_reference_wav(model_dir));
261 if let Some(name) = wav
262 .as_ref()
263 .and_then(|p| p.file_name())
264 .and_then(|n| n.to_str())
265 {
266 if let Some((_, text)) = ZIPVOICE_TEST_WAV_TRANSCRIPTS
267 .iter()
268 .find(|(known, _)| *known == name)
269 {
270 return Ok(text.to_string());
271 }
272 }
273 Err(TtsError(
274 "zipvoice requires the reference clip's exact transcript: pass \
275 'referenceText' in credentials (it must match 'referenceAudio' \
276 verbatim — a mismatch audibly degrades the clone)"
277 .into(),
278 ))
279 }
280}
281
282fn default_model_dir() -> PathBuf {
284 let home = std::env::var("HOME")
285 .or_else(|_| std::env::var("USERPROFILE"))
286 .unwrap_or_else(|_| ".".into());
287 let mut dir = PathBuf::from(home);
288 dir.push(".rust-tts-wrapper");
289 dir.push("sherpaonnx");
290 dir
291}
292
293fn load_models() -> HashMap<String, SherpaModelInfo> {
295 sherpa_onnx_models::models()
298 .iter()
299 .map(|(id, m)| {
300 (
301 id.clone(),
302 SherpaModelInfo {
303 id: m.id.clone(),
304 model_type: m.model_type.clone(),
305 engines: m.engines.clone(),
306 name: m.name.clone(),
307 language: m
308 .language
309 .iter()
310 .map(|l| SherpaLanguage {
311 lang_code: l.lang_code.clone(),
312 language_name: l.language_name.clone(),
313 country: l.country.clone(),
314 })
315 .collect(),
316 sample_rate: m.sample_rate,
317 num_speakers: m.num_speakers,
318 quality: m.quality.clone(),
319 url: m.url.clone(),
320 compression: m.compression,
321 filesize_mb: m.filesize_mb,
322 license: m.license.clone(),
323 license_url: m.license_url.clone(),
324 },
325 )
326 })
327 .collect()
328}
329
330impl TtsEngine for SherpaOnnxEngine {
331 #[allow(clippy::too_many_lines)]
332 fn speak(
333 &self,
334 text: &str,
335 voice: Option<&str>,
336 rate: f32,
337 pitch: f32,
338 volume: f32,
339 mut on_audio: Option<crate::engine::OnAudioCallback>,
340 mut on_boundary: Option<crate::engine::OnBoundaryCallback>,
341 _on_mark: Option<crate::engine::OnMarkCallback>,
342 ) -> TtsResult<()> {
343 if self.loaded_model_id.is_empty() {
344 return Err(TtsError(
345 "No SherpaOnnx modelId configured. Pass modelId in credentials JSON. \
346 See available_models() for the registry."
347 .into(),
348 ));
349 }
350
351 let model_info = self.models.get(&self.loaded_model_id).ok_or_else(|| {
352 TtsError(format!(
353 "Model '{}' not found in registry ({} models available)",
354 self.loaded_model_id,
355 self.models.len()
356 ))
357 })?;
358
359 let model_dir = self.model_dir.join(&self.loaded_model_id);
360 if !model_dir.exists() {
361 return Err(TtsError(format!(
362 "Model directory not found: {}. Download from: {}",
363 model_dir.display(),
364 model_info.url
365 )));
366 }
367
368 let id_lower = self.loaded_model_id.to_ascii_lowercase();
393 let is_piper_or_github = is_piper_or_github_model(&id_lower);
394 let is_chinese = id_lower.starts_with("vits-icefall-zh")
395 || id_lower.contains("cantonese")
396 || id_lower.starts_with("mms_zho")
397 || id_lower.starts_with("mms_cmn");
398
399 let model_dir = resolve_model_scan_dir(&model_dir);
404
405 let model_config = match model_info.model_type.as_str() {
406 "kokoro" => sherpa_onnx::OfflineTtsModelConfig {
407 kokoro: build_kokoro_config(&model_dir),
408 num_threads: self.num_threads,
409 debug: false,
410 provider: self.provider.clone(),
411 ..Default::default()
412 },
413 "matcha" => sherpa_onnx::OfflineTtsModelConfig {
414 matcha: build_matcha_config(&model_dir, &self.model_dir)?,
415 num_threads: self.num_threads,
416 debug: false,
417 provider: self.provider.clone(),
418 ..Default::default()
419 },
420 "kitten" => sherpa_onnx::OfflineTtsModelConfig {
421 kitten: sherpa_onnx::OfflineTtsKittenModelConfig {
422 model: Some(model_dir.join("model.onnx").to_string_lossy().to_string()),
423 voices: Some(model_dir.join("voices.bin").to_string_lossy().to_string()),
424 tokens: Some(model_dir.join("tokens.txt").to_string_lossy().to_string()),
425 data_dir: existing_path(&model_dir, "espeak-ng-data"),
426 ..Default::default()
427 },
428 num_threads: self.num_threads,
429 debug: false,
430 provider: self.provider.clone(),
431 ..Default::default()
432 },
433 "supertonic" => sherpa_onnx::OfflineTtsModelConfig {
434 supertonic: build_supertonic_config(&model_dir),
435 num_threads: self.num_threads,
436 debug: false,
437 provider: self.provider.clone(),
438 ..Default::default()
439 },
440 "zipvoice" => sherpa_onnx::OfflineTtsModelConfig {
441 zipvoice: build_zipvoice_config(&model_dir, &self.model_dir)?,
442 num_threads: self.num_threads,
443 debug: false,
444 provider: self.provider.clone(),
445 ..Default::default()
446 },
447 "pocket" => sherpa_onnx::OfflineTtsModelConfig {
448 pocket: build_pocket_config(&model_dir),
449 num_threads: self.num_threads,
450 debug: false,
451 provider: self.provider.clone(),
452 ..Default::default()
453 },
454 "vits" | "mms" | "unknown" | "" => sherpa_onnx::OfflineTtsModelConfig {
457 vits: build_vits_config(&model_dir, is_piper_or_github, is_chinese),
458 num_threads: self.num_threads,
459 debug: false,
460 provider: self.provider.clone(),
461 ..Default::default()
462 },
463 other => {
464 return Err(TtsError(format!(
465 "Unsupported SherpaOnnx model_type '{other}' for model '{}'",
466 self.loaded_model_id
467 )));
468 }
469 };
470
471 let config = sherpa_onnx::OfflineTtsConfig {
472 model: model_config,
473 max_num_sentences: 1,
476 ..Default::default()
477 };
478
479 let mut tts_guard = self.tts_instance.lock().unwrap();
483 if tts_guard.is_none() {
484 let tts = sherpa_onnx::OfflineTts::create(&config)
485 .ok_or_else(|| TtsError("Failed to create SherpaOnnx TTS engine".into()))?;
486 *tts_guard = Some(tts);
487 }
488 let tts = tts_guard.as_ref().expect("tts was just initialised");
489
490 let sid = voice.and_then(|v| v.parse::<i32>().ok()).unwrap_or(0);
491 let gen_config = if model_info.model_type == "supertonic" {
500 let (sid, lang) = parse_supertonic_voice(voice);
501 let mut extra = HashMap::new();
502 extra.insert("lang".to_string(), serde_json::Value::String(lang));
503 sherpa_onnx::GenerationConfig {
504 sid,
505 speed: rate.max(0.1),
506 num_steps: self.num_steps,
507 extra: Some(extra),
508 ..Default::default()
509 }
510 } else if model_info.model_type == "zipvoice" || model_info.model_type == "pocket" {
511 let (ref_samples, ref_rate) = self.resolve_reference_audio(&model_dir)?;
512 let reference_text = if model_info.model_type == "zipvoice" {
513 Some(self.resolve_reference_text(&model_dir)?)
514 } else {
515 None
516 };
517 let num_steps = if model_info.model_type == "zipvoice" {
520 4
521 } else {
522 2
523 };
524 sherpa_onnx::GenerationConfig {
525 speed: rate.max(0.1),
526 num_steps,
527 reference_audio: Some(ref_samples),
528 reference_sample_rate: ref_rate,
529 reference_text,
530 ..Default::default()
531 }
532 } else {
533 sherpa_onnx::GenerationConfig {
534 sid,
535 speed: rate.max(0.1),
536 ..Default::default()
537 }
538 };
539
540 let volume_factor = volume.clamp(0.0, 4.0);
541 let pitch_factor = pitch.clamp(0.25, 4.0);
542 let wants_callback = on_audio.is_some();
543
544 CANCEL_REQUESTED.store(false, Ordering::SeqCst);
546
547 #[allow(clippy::cast_precision_loss)]
567 let time_scale = 1.0 / rate.max(0.1);
568 let sample_rate_out = model_info.sample_rate.max(1);
569
570 if wants_callback {
571 let plan = on_boundary.is_some().then(|| EstimatePlan::build(text));
572
573 #[allow(clippy::transmute_ptr_to_ptr)]
583 let audio_ptr: Option<AudioCbPtr> = on_audio.as_mut().map(|cb| {
584 unsafe {
588 std::mem::transmute::<*mut (dyn FnMut(&[u8]) + '_), AudioCbPtr>(
589 std::ptr::from_mut(&mut **cb),
590 )
591 }
592 });
593 #[allow(clippy::transmute_ptr_to_ptr)]
594 let boundary_ptr: Option<BoundaryCbPtr> = on_boundary.as_mut().map(|cb| {
595 unsafe {
597 std::mem::transmute::<
598 *mut (dyn FnMut(&str, f32, f32, i32, i32, bool) + '_),
599 BoundaryCbPtr,
600 >(std::ptr::from_mut(&mut **cb))
601 }
602 });
603 STREAM_AUDIO_CB.with(|c| *c.borrow_mut() = audio_ptr);
604 STREAM_BOUNDARY_CB.with(|c| *c.borrow_mut() = boundary_ptr);
605
606 let firer = plan.map(|p| Arc::new(Mutex::new(EstimateFirer::new(p, time_scale))));
610 let firer_for_cb = firer.clone();
611
612 let result = tts.generate_with_config(
616 text,
617 &gen_config,
618 Some(move |batch: &[f32], _progress: f32| -> bool {
619 if CANCEL_REQUESTED.load(Ordering::SeqCst) {
620 return false;
621 }
622 let processed = apply_volume_and_pitch(batch, volume_factor, pitch_factor);
623 let pcm = samples_to_le_bytes(&processed);
624 STREAM_AUDIO_CB.with(|c| {
625 if let Some(ptr) = *c.borrow() {
626 for chunk in pcm.chunks(STREAMING_CHUNK_SIZE) {
630 unsafe { (*ptr)(chunk) };
631 }
632 }
633 });
634 if let Some(f) = firer_for_cb.as_ref() {
635 if let Ok(mut guard) = f.lock() {
636 guard.on_samples(
637 batch.len() as u64,
638 Some(sample_rate_out),
639 &mut |ev| {
640 STREAM_BOUNDARY_CB.with(|c| {
641 if let Some(ptr) = *c.borrow() {
642 unsafe {
644 (*ptr)(
645 &ev.word,
646 ev.start_s,
647 ev.end_s,
648 ev.char_offset,
649 ev.char_len,
650 true, );
652 }
653 }
654 });
655 },
656 );
657 }
658 }
659 !CANCEL_REQUESTED.load(Ordering::SeqCst)
660 }),
661 );
662
663 STREAM_AUDIO_CB.with(|c| *c.borrow_mut() = None);
664 STREAM_BOUNDARY_CB.with(|c| *c.borrow_mut() = None);
665
666 if result.is_none() {
667 return Err(TtsError("SherpaOnnx synthesis returned no audio".into()));
668 }
669
670 if let (Some(f), Some(cb)) = (firer.as_ref(), on_boundary.as_mut()) {
673 if let Ok(mut f) = f.lock() {
674 f.flush(&mut |ev| {
675 cb(
676 &ev.word,
677 ev.start_s,
678 ev.end_s,
679 ev.char_offset,
680 ev.char_len,
681 true,
682 );
683 });
684 }
685 }
686 } else {
687 let audio = tts
688 .generate_with_config(
689 text,
690 &gen_config,
691 Some(|_s: &[f32], _p: f32| -> bool {
692 !CANCEL_REQUESTED.load(Ordering::SeqCst)
693 }),
694 )
695 .ok_or_else(|| TtsError("SherpaOnnx synthesis returned no audio".into()))?;
696 let processed = apply_volume_and_pitch(audio.samples(), volume_factor, pitch_factor);
697 let filename = std::env::temp_dir().join("rust-tts-wrapper-sherpa.wav");
698 if write_wav(&filename, &processed, audio.sample_rate()) {
699 play_wav_file(&filename);
700 }
701
702 if let Some(cb) = on_boundary.as_mut() {
704 let plan = EstimatePlan::build(text);
705 for i in 0..plan.len() {
706 let ev = plan.event(i).expect("in range");
707 cb(
708 &ev.word,
709 ev.start_s,
710 ev.end_s,
711 ev.char_offset,
712 ev.char_len,
713 true,
714 );
715 }
716 }
717 }
718
719 Ok(())
720 }
721
722 fn speak_sync(
723 &self,
724 text: &str,
725 voice: Option<&str>,
726 rate: f32,
727 pitch: f32,
728 volume: f32,
729 on_audio: Option<crate::engine::OnAudioCallback>,
730 on_boundary: Option<crate::engine::OnBoundaryCallback>,
731 on_mark: Option<crate::engine::OnMarkCallback>,
732 ) -> TtsResult<()> {
733 self.speak(
734 text,
735 voice,
736 rate,
737 pitch,
738 volume,
739 on_audio,
740 on_boundary,
741 on_mark,
742 )
743 }
744
745 fn stop(&self) -> TtsResult<()> {
746 CANCEL_REQUESTED.store(true, Ordering::SeqCst);
749 Ok(())
750 }
751
752 fn get_voices(&self) -> TtsResult<Vec<Voice>> {
753 let model_info = self.models.get(&self.loaded_model_id);
754
755 if let Some(info) = model_info {
760 if info.model_type == "supertonic" {
761 return Ok(supertonic_voices(info));
762 }
763 }
764
765 let num_speakers = model_info.map_or(1, |m| m.num_speakers);
766 let lang = model_info
767 .and_then(|m| m.language.first())
768 .map(|l| l.language_name.clone())
769 .unwrap_or_default();
770 let lang_code = model_info
771 .and_then(|m| m.language.first())
772 .map(|l| l.lang_code.clone())
773 .unwrap_or_default();
774 let iso639 = iso639_3(&lang_code);
775 let mut voices = Vec::new();
776 for i in 0..num_speakers {
777 voices.push(Voice {
778 id: format!("{i}"),
779 name: format!("Speaker {i}"),
780 gender: Gender::Unknown,
781 provider: "sherpaonnx".to_string(),
782 language_codes: vec![LanguageCode {
783 bcp47: lang.clone(),
784 iso639_3: iso639.clone(),
785 display: crate::types::locale_display_name(&lang),
786 }],
787 });
788 }
789 Ok(voices)
790 }
791
792 fn engine_id(&self) -> &'static str {
793 "sherpaonnx"
794 }
795}
796
797fn apply_volume_and_pitch(samples: &[f32], volume: f32, pitch: f32) -> Vec<f32> {
804 if samples.is_empty() {
805 return Vec::new();
806 }
807 let resampled: Vec<f32> = if (pitch - 1.0).abs() > f32::EPSILON {
809 #[allow(clippy::cast_precision_loss)]
810 let out_len = ((samples.len() as f32) / pitch).round().max(1.0) as usize;
811 let mut out = Vec::with_capacity(out_len);
812 #[allow(clippy::cast_precision_loss)]
813 let step = (samples.len() as f32) / out_len as f32;
814 let mut idx = 0.0f32;
815 while (idx as usize) < samples.len() {
816 let i = idx as usize;
817 #[allow(clippy::cast_precision_loss)]
818 let frac = idx - i as f32;
819 let next = samples.get(i + 1).copied().unwrap_or(samples[i]);
820 let v = samples[i] * (1.0 - frac) + next * frac;
821 out.push(v);
822 idx += step;
823 }
824 out
825 } else {
826 samples.to_vec()
827 };
828 if (volume - 1.0).abs() > f32::EPSILON {
830 resampled.iter().map(|&s| s * volume).collect()
831 } else {
832 resampled
833 }
834}
835
836#[allow(clippy::cast_possible_truncation)]
838fn samples_to_le_bytes(samples: &[f32]) -> Vec<u8> {
839 let mut pcm = Vec::with_capacity(samples.len() * 2);
840 for &s in samples {
841 let s16 = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
842 pcm.extend_from_slice(&s16.to_le_bytes());
843 }
844 pcm
845}
846
847fn write_wav(path: &std::path::Path, samples: &[f32], sample_rate: i32) -> bool {
849 use std::io::Write;
850 let Ok(mut f) = std::fs::File::create(path) else {
851 return false;
852 };
853 let mut pcm = Vec::with_capacity(samples.len() * 2);
854 for &s in samples {
855 let s16 = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
856 pcm.extend_from_slice(&s16.to_ne_bytes());
857 }
858 let data_len = pcm.len() as u32;
859 let sample_rate = sample_rate as u32;
860 let byte_rate = sample_rate * 2; let block_align: u16 = 2;
866 let bits_per_sample: u16 = 16;
867 let riff_len = 36 + data_len;
868 let header = [
869 b"RIFF".as_slice(),
870 &riff_len.to_le_bytes(),
871 b"WAVE",
872 b"fmt ",
873 &16u32.to_le_bytes(), &1u16.to_le_bytes(), &1u16.to_le_bytes(), &sample_rate.to_le_bytes(),
877 &byte_rate.to_le_bytes(),
878 &block_align.to_le_bytes(),
879 &bits_per_sample.to_le_bytes(),
880 b"data",
881 &data_len.to_le_bytes(),
882 ]
883 .concat();
884 if f.write_all(&header).is_err() || f.write_all(&pcm).is_err() {
885 return false;
886 }
887 true
888}
889
890fn play_wav_file(path: &std::path::Path) {
899 let result = if cfg!(target_os = "linux") {
900 std::process::Command::new("aplay")
901 .arg("-q")
902 .arg(path)
903 .spawn()
904 .map(|mut c| c.wait())
905 } else if cfg!(target_os = "macos") {
906 std::process::Command::new("afplay")
907 .arg(path)
908 .spawn()
909 .map(|mut c| c.wait())
910 } else if cfg!(target_os = "windows") {
911 let p = path.to_string_lossy().replace('\'', "''");
912 std::process::Command::new("powershell")
913 .args([
914 "-NoProfile",
915 "-Command",
916 &format!("(New-Object Media.SoundPlayer '{p}').PlaySync()"),
917 ])
918 .spawn()
919 .map(|mut c| c.wait())
920 } else {
921 return;
922 };
923 let _ = result;
924}
925
926fn resolve_model_scan_dir(dir: &std::path::Path) -> std::path::PathBuf {
935 let has_top = dir.join("tokens.txt").exists()
936 || dir.join("model.onnx").exists()
937 || dir.join("voices.bin").exists()
938 || dir.join("espeak-ng-data").exists()
939 || std::fs::read_dir(dir).is_ok_and(|entries| {
940 entries
941 .filter_map(Result::ok)
942 .any(|e| e.path().extension().is_some_and(|ext| ext == "onnx"))
943 });
944 if has_top {
945 return dir.to_path_buf();
946 }
947 if let Ok(entries) = std::fs::read_dir(dir) {
949 let subdirs: Vec<_> = entries
950 .filter_map(Result::ok)
951 .filter(|e| e.path().is_dir())
952 .collect();
953 if subdirs.len() == 1 {
954 return subdirs[0].path();
955 }
956 }
957 dir.to_path_buf()
958}
959
960fn find_primary_model_onnx(dir: &std::path::Path) -> Option<std::path::PathBuf> {
964 let model_onnx = dir.join("model.onnx");
965 if model_onnx.exists() {
966 return Some(model_onnx);
967 }
968 std::fs::read_dir(dir)
969 .ok()?
970 .filter_map(Result::ok)
971 .find_map(|entry| {
972 let path = entry.path();
973 if path
974 .extension()
975 .is_some_and(|ext| ext.eq_ignore_ascii_case("onnx"))
976 {
977 let name = path
978 .file_name()
979 .and_then(|n| n.to_str())
980 .map(str::to_ascii_lowercase)
981 .unwrap_or_default();
982 if !name.starts_with("model-steps")
984 && !name.starts_with("vocos")
985 && !name.starts_with("vocoder")
986 && !name.starts_with("hifigan")
987 {
988 return Some(path);
989 }
990 }
991 None
992 })
993}
994
995fn existing_path(dir: &std::path::Path, name: &str) -> Option<String> {
997 let p = dir.join(name);
998 if p.exists() {
999 Some(p.to_string_lossy().to_string())
1000 } else {
1001 None
1002 }
1003}
1004
1005fn find_file(dir: &std::path::Path, name: &str) -> Option<std::path::PathBuf> {
1007 std::fs::read_dir(dir)
1008 .ok()?
1009 .filter_map(Result::ok)
1010 .find_map(|entry| {
1011 let path = entry.path();
1012 if path.file_name().is_some_and(|n| n == name) {
1013 Some(path)
1014 } else {
1015 None
1016 }
1017 })
1018}
1019
1020fn first_existing(dir: &std::path::Path, names: &[&str]) -> Option<std::path::PathBuf> {
1022 names.iter().map(|n| dir.join(n)).find(|p| p.exists())
1023}
1024
1025fn is_piper_or_github_model(model_id: &str) -> bool {
1030 const PREFIXES: &[&str] = &[
1031 "piper-",
1032 "coqui-",
1033 "icefall-",
1034 "mimic3-",
1035 "melo-",
1036 "vctk-",
1037 "zh-",
1038 "ljs-",
1039 "cantonese-",
1040 "kokoro-",
1041 ];
1042 PREFIXES.iter().any(|p| model_id.starts_with(p))
1043}
1044
1045fn collect_lexicons(dir: &std::path::Path) -> Option<String> {
1051 let mut paths: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
1052 .ok()?
1053 .filter_map(Result::ok)
1054 .map(|e| e.path())
1055 .filter(|p| {
1056 p.is_file()
1057 && p.extension()
1058 .is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
1059 && p.file_name()
1060 .and_then(|n| n.to_str())
1061 .is_some_and(|n| n == "lexicon.txt" || n.starts_with("lexicon-"))
1062 })
1063 .collect();
1064 if paths.is_empty() {
1065 return None;
1066 }
1067 paths.sort();
1069 Some(
1070 paths
1071 .iter()
1072 .map(|p| p.to_string_lossy().to_string())
1073 .collect::<Vec<_>>()
1074 .join(","),
1075 )
1076}
1077
1078fn find_dict_dir(dir: &std::path::Path) -> Option<String> {
1083 let dict_sub = dir.join("dict");
1084 if dict_sub.is_dir() {
1085 return Some(dict_sub.to_string_lossy().to_string());
1086 }
1087 let has_fst = std::fs::read_dir(dir)
1088 .ok()?
1089 .filter_map(Result::ok)
1090 .any(|e| {
1091 e.path()
1092 .extension()
1093 .is_some_and(|ext| ext.eq_ignore_ascii_case("fst"))
1094 });
1095 if has_fst {
1096 Some(dir.to_string_lossy().to_string())
1097 } else {
1098 None
1099 }
1100}
1101
1102fn build_kokoro_config(model_dir: &std::path::Path) -> sherpa_onnx::OfflineTtsKokoroModelConfig {
1106 let model = find_primary_model_onnx(model_dir).unwrap_or_else(|| model_dir.join("model.onnx"));
1109 sherpa_onnx::OfflineTtsKokoroModelConfig {
1110 model: Some(model.to_string_lossy().to_string()),
1111 voices: existing_path(model_dir, "voices.bin"),
1112 tokens: Some(model_dir.join("tokens.txt").to_string_lossy().to_string()),
1113 data_dir: existing_path(model_dir, "espeak-ng-data"),
1114 lexicon: collect_lexicons(model_dir),
1118 dict_dir: find_dict_dir(model_dir),
1119 ..Default::default()
1121 }
1122}
1123
1124fn build_supertonic_config(
1133 model_dir: &std::path::Path,
1134) -> sherpa_onnx::OfflineTtsSupertonicModelConfig {
1135 sherpa_onnx::OfflineTtsSupertonicModelConfig {
1136 duration_predictor: first_existing(
1137 model_dir,
1138 &["duration_predictor.int8.onnx", "duration_predictor.onnx"],
1139 )
1140 .map(|p| p.to_string_lossy().to_string()),
1141 text_encoder: first_existing(model_dir, &["text_encoder.int8.onnx", "text_encoder.onnx"])
1142 .map(|p| p.to_string_lossy().to_string()),
1143 vector_estimator: first_existing(
1144 model_dir,
1145 &["vector_estimator.int8.onnx", "vector_estimator.onnx"],
1146 )
1147 .map(|p| p.to_string_lossy().to_string()),
1148 vocoder: first_existing(model_dir, &["vocoder.int8.onnx", "vocoder.onnx"])
1149 .map(|p| p.to_string_lossy().to_string()),
1150 tts_json: existing_path(model_dir, "tts.json"),
1151 unicode_indexer: existing_path(model_dir, "unicode_indexer.bin"),
1152 voice_style: existing_path(model_dir, "voice.bin"),
1153 }
1154}
1155
1156fn build_zipvoice_config(
1165 model_dir: &std::path::Path,
1166 base_dir: &std::path::Path,
1167) -> TtsResult<sherpa_onnx::OfflineTtsZipvoiceModelConfig> {
1168 let quant_onnx = |stem: &str| {
1169 first_existing(
1170 model_dir,
1171 &[&format!("{stem}.int8.onnx"), &format!("{stem}.onnx")],
1172 )
1173 .map(|p| p.to_string_lossy().to_string())
1174 };
1175
1176 let vocoder = first_existing(model_dir, &["vocos_24khz.onnx", "vocoder.onnx"])
1177 .or_else(|| first_existing(base_dir, &["vocos_24khz.onnx", "vocoder.onnx"]));
1178 let vocoder = vocoder.ok_or_else(|| {
1179 TtsError(
1180 "Zipvoice requires the vocos_24khz.onnx vocoder, which is not bundled \
1181 with the model. Download it into {} from:\n \
1182 https://github.com/k2-fsa/sherpa-onnx/releases/download/vocoder-models/vocos_24khz.onnx"
1183 .replace("{}", &base_dir.display().to_string()),
1184 )
1185 })?;
1186
1187 Ok(sherpa_onnx::OfflineTtsZipvoiceModelConfig {
1188 tokens: existing_path(model_dir, "tokens.txt"),
1189 encoder: quant_onnx("encoder"),
1190 decoder: quant_onnx("decoder"),
1191 vocoder: Some(vocoder.to_string_lossy().to_string()),
1192 data_dir: existing_path(model_dir, "espeak-ng-data"),
1193 lexicon: existing_path(model_dir, "lexicon.txt"),
1194 ..Default::default()
1198 })
1199}
1200
1201fn build_pocket_config(model_dir: &std::path::Path) -> sherpa_onnx::OfflineTtsPocketModelConfig {
1205 let component = |stems: &[&str]| {
1206 stems
1207 .iter()
1208 .find_map(|s| first_existing(model_dir, &[s]))
1209 .map(|p| p.to_string_lossy().to_string())
1210 };
1211 sherpa_onnx::OfflineTtsPocketModelConfig {
1212 lm_flow: component(&["lm_flow.int8.onnx", "lm_flow.onnx"]),
1213 lm_main: component(&["lm_main.int8.onnx", "lm_main.onnx"]),
1214 encoder: component(&["encoder.int8.onnx", "encoder.onnx"]),
1215 decoder: component(&["decoder.int8.onnx", "decoder.onnx"]),
1216 text_conditioner: component(&["text_conditioner.int8.onnx", "text_conditioner.onnx"]),
1217 vocab_json: existing_path(model_dir, "vocab.json"),
1218 token_scores_json: existing_path(model_dir, "token_scores.json"),
1219 ..Default::default()
1220 }
1221}
1222
1223const ZIPVOICE_TEST_WAV_TRANSCRIPTS: &[(&str, &str)] = &[(
1227 "leijun-1.wav",
1228 "那还是三十六年前, 一九八七年. 我呢考上了武汉大学的计算机系.",
1229)];
1230
1231fn bundled_reference_wav(model_dir: &std::path::Path) -> Option<std::path::PathBuf> {
1232 for sub in ["test_wavs", "."] {
1233 let dir = model_dir.join(sub);
1234 if dir.is_dir() {
1235 let mut wavs: Vec<_> = std::fs::read_dir(&dir)
1236 .into_iter()
1237 .flatten()
1238 .filter_map(Result::ok)
1239 .map(|e| e.path())
1240 .filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("wav")))
1241 .collect();
1242 wavs.sort();
1243 if let Some(first) = wavs.first() {
1244 return Some(first.clone());
1245 }
1246 }
1247 }
1248 None
1249}
1250
1251#[allow(clippy::cast_precision_loss)] fn read_wav_mono_16bit(path: &std::path::Path) -> TtsResult<(Vec<f32>, i32)> {
1256 let bytes = std::fs::read(path)
1257 .map_err(|e| TtsError(format!("cannot read reference wav {}: {e}", path.display())))?;
1258 let rd = |off: usize| -> Option<u32> {
1259 bytes
1260 .get(off..off + 4)
1261 .map(|b| u32::from_le_bytes(b.try_into().unwrap()))
1262 };
1263 if bytes.len() < 44 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
1264 return Err(TtsError(format!(
1265 "{} is not a RIFF/WAVE file",
1266 path.display()
1267 )));
1268 }
1269 let (mut format, mut channels, mut rate, mut bits) = (0u16, 0u16, 0i32, 0u16);
1271 let mut samples: Vec<f32> = Vec::new();
1272 let mut off = 12;
1273 while off + 8 <= bytes.len() {
1274 let id = &bytes[off..off + 4];
1275 let len = rd(off + 4).unwrap_or(0) as usize;
1276 let body = off + 8;
1277 match id {
1278 b"fmt " if len >= 16 => {
1279 format = u16::from_le_bytes(bytes[body..body + 2].try_into().unwrap());
1280 channels = u16::from_le_bytes(bytes[body + 2..body + 4].try_into().unwrap());
1281 rate = rd(body + 4).unwrap_or(0) as i32;
1282 bits = u16::from_le_bytes(bytes[body + 14..body + 16].try_into().unwrap());
1283 }
1284 b"data" => {
1285 let end = (body + len).min(bytes.len());
1286 samples = bytes[body..end]
1287 .as_chunks::<2>()
1288 .0
1289 .iter()
1290 .map(|c| f32::from(i16::from_le_bytes(*c)) / 32768.0)
1291 .collect();
1292 }
1293 _ => {}
1294 }
1295 off = body + len + (len & 1); }
1297 if format != 1 || bits != 16 {
1298 return Err(TtsError(format!(
1299 "{}: only 16-bit PCM reference wavs are supported (got format {format}, {bits}-bit). \
1300 Convert with e.g. `ffmpeg -i in.mp3 -ac 1 -ar 24000 -c:a pcm_s16le out.wav`.",
1301 path.display()
1302 )));
1303 }
1304 if channels > 1 {
1305 let frame_len = channels as usize;
1308 samples = samples
1309 .chunks_exact(frame_len)
1310 .map(|fr| fr.iter().sum::<f32>() / frame_len as f32)
1311 .collect();
1312 }
1313 if samples.is_empty() || rate == 0 {
1314 return Err(TtsError(format!(
1315 "{}: wav has no usable samples (rate {rate}, {} samples)",
1316 path.display(),
1317 samples.len()
1318 )));
1319 }
1320 Ok((samples, rate))
1321}
1322
1323fn parse_supertonic_voice(voice: Option<&str>) -> (i32, String) {
1328 const DEFAULT_LANG: &str = "en";
1329 match voice {
1330 None | Some("") => (0, DEFAULT_LANG.to_string()),
1331 Some(v) => match v.split_once(':') {
1332 Some((sid_str, lang)) => {
1333 let sid = sid_str.parse::<i32>().unwrap_or(0);
1334 let lang = if lang.is_empty() { DEFAULT_LANG } else { lang };
1335 (sid, lang.to_string())
1336 }
1337 None => (v.parse::<i32>().unwrap_or(0), DEFAULT_LANG.to_string()),
1338 },
1339 }
1340}
1341
1342fn supertonic_voices(info: &SherpaModelInfo) -> Vec<Voice> {
1347 let mut voices = Vec::new();
1348 for lang in &info.language {
1349 let iso639 = iso639_3(&lang.lang_code);
1350 let display = crate::types::locale_display_name(&lang.lang_code);
1351 for sid in 0..info.num_speakers {
1352 voices.push(Voice {
1353 id: format!("{sid}:{}", lang.lang_code),
1354 name: format!("Speaker {sid} ({})", lang.language_name),
1355 gender: Gender::Unknown,
1356 provider: "sherpaonnx".to_string(),
1357 language_codes: vec![LanguageCode {
1358 bcp47: lang.lang_code.clone(),
1359 iso639_3: iso639.clone(),
1360 display: display.clone(),
1361 }],
1362 });
1363 }
1364 }
1365 voices
1366}
1367
1368fn build_matcha_config(
1376 model_dir: &std::path::Path,
1377 base_dir: &std::path::Path,
1378) -> TtsResult<sherpa_onnx::OfflineTtsMatchaModelConfig> {
1379 let acoustic = first_existing(
1381 model_dir,
1382 &[
1383 "acoustic-model.onnx",
1384 "model-steps-3.onnx",
1385 "model-steps-1000.onnx",
1386 "model.onnx",
1387 ],
1388 )
1389 .ok_or_else(|| TtsError("Matcha acoustic model not found".into()))?;
1390
1391 let vocoder = first_existing(
1393 model_dir,
1394 &[
1395 "hifigan_v2.onnx",
1396 "hifigan_v2_en_zh.onnx",
1397 "hifigan_vitimator_v2.onnx",
1398 "vocos-22khz-univ.onnx",
1399 "vocoder.onnx",
1400 ],
1401 )
1402 .or_else(|| {
1403 first_existing(
1404 base_dir,
1405 &["vocos-22khz-univ.onnx", "hifigan_v2.onnx", "vocoder.onnx"],
1406 )
1407 });
1408
1409 Ok(sherpa_onnx::OfflineTtsMatchaModelConfig {
1410 acoustic_model: Some(acoustic.to_string_lossy().to_string()),
1411 vocoder: vocoder.as_ref().map(|p| p.to_string_lossy().to_string()),
1412 lexicon: existing_path(model_dir, "lexicon.txt"),
1413 tokens: Some(model_dir.join("tokens.txt").to_string_lossy().to_string()),
1414 data_dir: existing_path(model_dir, "espeak-ng-data"),
1415 dict_dir: existing_path(model_dir, "dict"),
1416 ..Default::default()
1417 })
1418}
1419
1420fn build_vits_config(
1427 model_dir: &std::path::Path,
1428 is_piper_or_github: bool,
1429 is_chinese: bool,
1430) -> sherpa_onnx::OfflineTtsVitsModelConfig {
1431 let model = find_primary_model_onnx(model_dir)
1434 .or_else(|| first_existing(model_dir, &["vits-model.onnx", "generator.onnx"]))
1435 .unwrap_or_else(|| model_dir.join("model.onnx"));
1436
1437 let (data_dir, dict_dir) = if is_piper_or_github {
1439 (existing_path(model_dir, "espeak-ng-data"), None)
1441 } else if is_chinese {
1442 let dict = existing_path(model_dir, "dict").or_else(|| {
1444 std::fs::read_dir(model_dir).ok().and_then(|entries| {
1446 entries.filter_map(Result::ok).find_map(|e| {
1447 let p = e.path();
1448 if p.is_dir() && p.join("dict.txt").exists() {
1449 Some(p.to_string_lossy().to_string())
1450 } else {
1451 None
1452 }
1453 })
1454 })
1455 });
1456 (existing_path(model_dir, "espeak-ng-data"), dict)
1457 } else {
1458 let has_lexicon = model_dir.join("lexicon.txt").exists();
1461 let dict = if has_lexicon {
1462 None
1463 } else {
1464 existing_path(model_dir, "dict")
1465 };
1466 (existing_path(model_dir, "espeak-ng-data"), dict)
1467 };
1468
1469 sherpa_onnx::OfflineTtsVitsModelConfig {
1470 model: Some(model.to_string_lossy().to_string()),
1471 tokens: Some(model_dir.join("tokens.txt").to_string_lossy().to_string()),
1472 lexicon: existing_path(model_dir, "lexicon.txt"),
1473 data_dir,
1474 dict_dir,
1475 ..Default::default()
1476 }
1477}
1478
1479#[cfg(test)]
1480mod tests {
1481 use super::*;
1482
1483 #[test]
1484 fn test_apply_volume_and_pitch_identity() {
1485 let samples = [0.0_f32, 0.5, -0.5, 1.0, -1.0];
1486 let out = apply_volume_and_pitch(&samples, 1.0, 1.0);
1487 assert_eq!(out.len(), samples.len());
1488 for (a, b) in samples.iter().zip(out.iter()) {
1489 assert!((a - b).abs() < f32::EPSILON);
1490 }
1491 }
1492
1493 #[test]
1494 fn test_apply_volume_scales_amplitude() {
1495 let samples = [0.5_f32, -0.5];
1496 let out = apply_volume_and_pitch(&samples, 2.0, 1.0);
1497 assert!((out[0] - 1.0).abs() < f32::EPSILON);
1498 assert!((out[1] - (-1.0)).abs() < f32::EPSILON);
1499 }
1500
1501 #[test]
1502 fn test_apply_volume_zero_silences() {
1503 let samples = [0.5_f32, -0.25, 0.8];
1504 let out = apply_volume_and_pitch(&samples, 0.0, 1.0);
1505 for s in &out {
1506 assert!(s.abs() < f32::EPSILON);
1507 }
1508 }
1509
1510 #[test]
1511 fn test_apply_pitch_changes_length() {
1512 let samples = vec![0.5_f32; 100];
1514 let shorter = apply_volume_and_pitch(&samples, 1.0, 2.0);
1515 let longer = apply_volume_and_pitch(&samples, 1.0, 0.5);
1516 assert!(shorter.len() < samples.len());
1517 assert!(longer.len() > samples.len());
1518 }
1519
1520 #[test]
1521 fn test_apply_volume_and_pitch_empty_input() {
1522 assert_eq!(apply_volume_and_pitch(&[], 1.0, 1.0).first(), None);
1523 assert_eq!(apply_volume_and_pitch(&[], 2.0, 0.5).first(), None);
1524 }
1525
1526 #[test]
1527 fn test_is_piper_or_github_model_known_piper_prefix() {
1528 assert!(is_piper_or_github_model("piper-en-amy-medium"));
1529 assert!(is_piper_or_github_model("coqui-en-ljspeech"));
1530 assert!(is_piper_or_github_model("icefall-tts"));
1531 assert!(is_piper_or_github_model("mimic3-en"));
1532 assert!(is_piper_or_github_model("melo-en"));
1533 assert!(is_piper_or_github_model("vctk-en"));
1534 assert!(is_piper_or_github_model("zh-cantonese"));
1535 assert!(is_piper_or_github_model("ljs-en"));
1536 assert!(is_piper_or_github_model("cantonese-yue-xiaomaiiwn"));
1537 assert!(is_piper_or_github_model("kokoro-en-v0_19"));
1538 }
1539
1540 #[test]
1541 fn test_is_piper_or_github_model_other_returns_false() {
1542 assert!(!is_piper_or_github_model("mms-en"));
1543 assert!(!is_piper_or_github_model("vits-en"));
1544 assert!(!is_piper_or_github_model("matcha-en"));
1545 }
1546
1547 #[test]
1548 fn test_iso639_3_known_codes() {
1549 assert_eq!(iso639_3("en-US"), "eng");
1550 assert_eq!(iso639_3("es-ES"), "spa");
1551 assert_eq!(iso639_3("fr"), "fra");
1552 assert_eq!(iso639_3("de-DE"), "deu");
1553 assert_eq!(iso639_3("zh-CN"), "zho");
1554 }
1555
1556 #[test]
1557 fn test_iso639_3_unknown_returns_input_lowercased() {
1558 assert_eq!(iso639_3("xx-XX"), "xx-xx");
1561 assert_eq!(iso639_3("Unknown-Lang"), "unknown-lang");
1562 }
1563
1564 #[test]
1565 fn test_iso639_3_handles_underscore_separator() {
1566 assert_eq!(iso639_3("en_US"), "eng");
1568 assert_eq!(iso639_3("pt_BR"), "por");
1569 }
1570
1571 #[test]
1572 fn test_write_wav_round_trip_header() {
1573 let dir = std::env::temp_dir();
1575 let path = dir.join("rtw_test_write_wav.wav");
1576 let samples = vec![0.0_f32, 0.5, -0.5, 1.0, -1.0];
1577 assert!(write_wav(&path, &samples, 22050));
1578
1579 let bytes = std::fs::read(&path).expect("wav written");
1580 assert!(bytes.len() > 44, "WAV must include 44-byte header");
1581 assert_eq!(&bytes[0..4], b"RIFF");
1582 assert_eq!(&bytes[8..12], b"WAVE");
1583 assert_eq!(&bytes[12..16], b"fmt ");
1584 assert_eq!(u16::from_le_bytes([bytes[20], bytes[21]]), 1);
1586 assert_eq!(u16::from_le_bytes([bytes[22], bytes[23]]), 1);
1587 assert_eq!(
1589 u32::from_le_bytes([bytes[24], bytes[25], bytes[26], bytes[27]]),
1590 22050
1591 );
1592 assert_eq!(u16::from_le_bytes([bytes[34], bytes[35]]), 16);
1594
1595 let _ = std::fs::remove_file(&path);
1596 }
1597
1598 #[test]
1599 fn test_write_wav_clamps_samples() {
1600 let dir = std::env::temp_dir();
1603 let path = dir.join("rtw_test_write_wav_clamp.wav");
1604 let samples = vec![5.0_f32, -5.0]; assert!(write_wav(&path, &samples, 16000));
1606
1607 let bytes = std::fs::read(&path).expect("wav written");
1608 let first_sample = i16::from_le_bytes([bytes[44], bytes[45]]);
1610 let second_sample = i16::from_le_bytes([bytes[46], bytes[47]]);
1611 assert_eq!(first_sample, 32767);
1612 assert_eq!(second_sample, -32767);
1613
1614 let _ = std::fs::remove_file(&path);
1615 }
1616
1617 #[test]
1618 fn test_first_existing_returns_first_match() {
1619 let dir = std::env::temp_dir();
1620 let a = dir.join("rtw_first_a.txt");
1621 let b = dir.join("rtw_first_b.txt");
1622 std::fs::write(&a, b"x").unwrap();
1623 std::fs::write(&b, b"y").unwrap();
1624
1625 let r = first_existing(&dir, &["rtw_first_a.txt", "rtw_first_b.txt"]).unwrap();
1626 assert_eq!(r.file_name().unwrap().to_str().unwrap(), "rtw_first_a.txt");
1627
1628 let r = first_existing(&dir, &["missing.txt", "rtw_first_b.txt"]).unwrap();
1630 assert_eq!(r.file_name().unwrap().to_str().unwrap(), "rtw_first_b.txt");
1631
1632 assert!(first_existing(&dir, &["nope.txt", "alsonope.txt"]).is_none());
1634
1635 let _ = std::fs::remove_file(&a);
1636 let _ = std::fs::remove_file(&b);
1637 }
1638
1639 #[test]
1640 fn test_existing_path_only_when_present() {
1641 let dir = std::env::temp_dir();
1642 let p = dir.join("rtw_existing.txt");
1643 std::fs::write(&p, b"x").unwrap();
1644 assert_eq!(
1645 existing_path(&dir, "rtw_existing.txt").as_deref(),
1646 Some(p.to_str().unwrap())
1647 );
1648 assert!(existing_path(&dir, "rtw_missing.txt").is_none());
1649 let _ = std::fs::remove_file(&p);
1650 }
1651
1652 use std::fs;
1660
1661 fn fake_kokoro_dir() -> tempfile::TempDir {
1663 let d = tempfile::tempdir().expect("tmp");
1664 fs::write(d.path().join("model.onnx"), b"x").unwrap();
1665 fs::write(d.path().join("voices.bin"), b"x").unwrap();
1666 fs::write(d.path().join("tokens.txt"), b"x").unwrap();
1667 fs::create_dir(d.path().join("espeak-ng-data")).unwrap();
1668 d
1669 }
1670
1671 #[test]
1672 fn test_build_kokoro_config_points_at_canonical_files() {
1673 let d = fake_kokoro_dir();
1674 let cfg = build_kokoro_config(d.path());
1675 assert_eq!(
1676 cfg.model.as_deref(),
1677 Some(d.path().join("model.onnx").to_str().unwrap())
1678 );
1679 assert_eq!(
1680 cfg.voices.as_deref(),
1681 Some(d.path().join("voices.bin").to_str().unwrap())
1682 );
1683 assert_eq!(
1684 cfg.tokens.as_deref(),
1685 Some(d.path().join("tokens.txt").to_str().unwrap())
1686 );
1687 assert_eq!(
1688 cfg.data_dir.as_deref(),
1689 Some(d.path().join("espeak-ng-data").to_str().unwrap())
1690 );
1691 }
1692
1693 #[test]
1694 fn test_build_kokoro_config_missing_files_are_none() {
1695 let d = tempfile::tempdir().unwrap();
1698 fs::write(d.path().join("model.onnx"), b"x").unwrap();
1699 fs::write(d.path().join("tokens.txt"), b"x").unwrap();
1700 let cfg = build_kokoro_config(d.path());
1702 assert!(cfg.model.is_some());
1703 assert!(cfg.tokens.is_some());
1704 assert!(cfg.voices.is_none());
1705 assert!(cfg.data_dir.is_none());
1706 }
1707
1708 fn fake_matcha_dir() -> tempfile::TempDir {
1709 let d = tempfile::tempdir().unwrap();
1710 fs::write(d.path().join("acoustic-model.onnx"), b"x").unwrap();
1711 fs::write(d.path().join("hifigan_v2.onnx"), b"x").unwrap();
1712 fs::write(d.path().join("tokens.txt"), b"x").unwrap();
1713 fs::write(d.path().join("lexicon.txt"), b"x").unwrap();
1714 d
1715 }
1716
1717 #[test]
1718 fn test_build_matcha_config_finds_acoustic_and_vocoder() {
1719 let d = fake_matcha_dir();
1720 let base = tempfile::tempdir().unwrap();
1721 let cfg = build_matcha_config(d.path(), base.path()).expect("matcha config");
1722 assert!(cfg
1723 .acoustic_model
1724 .as_deref()
1725 .unwrap()
1726 .ends_with("acoustic-model.onnx"));
1727 assert!(cfg.vocoder.as_deref().unwrap().ends_with("hifigan_v2.onnx"));
1728 assert!(cfg.lexicon.is_some());
1729 assert!(cfg.tokens.is_some());
1730 }
1731
1732 #[test]
1733 fn test_build_matcha_config_accepts_legacy_acoustic_names() {
1734 for name in ["model-steps-3.onnx", "model-steps-1000.onnx", "model.onnx"] {
1737 let d = tempfile::tempdir().unwrap();
1738 fs::write(d.path().join(name), b"x").unwrap();
1739 fs::write(d.path().join("hifigan_v2.onnx"), b"x").unwrap();
1740 fs::write(d.path().join("tokens.txt"), b"x").unwrap();
1741 let base = tempfile::tempdir().unwrap();
1742 let cfg = build_matcha_config(d.path(), base.path()).expect("matcha config");
1743 assert!(cfg.acoustic_model.is_some(), "failed for acoustic {name}");
1744 }
1745 }
1746
1747 #[test]
1748 fn test_build_matcha_config_vocoder_fallback_to_base_dir() {
1749 let d = tempfile::tempdir().unwrap();
1751 fs::write(d.path().join("acoustic-model.onnx"), b"x").unwrap();
1752 fs::write(d.path().join("tokens.txt"), b"x").unwrap();
1753
1754 let base = tempfile::tempdir().unwrap();
1755 fs::write(base.path().join("vocos-22khz-univ.onnx"), b"x").unwrap();
1756
1757 let cfg = build_matcha_config(d.path(), base.path()).expect("matcha config");
1758 assert!(cfg
1759 .vocoder
1760 .as_deref()
1761 .unwrap()
1762 .ends_with("vocos-22khz-univ.onnx"));
1763 }
1764
1765 #[test]
1766 fn test_build_matcha_config_errors_without_acoustic() {
1767 let d = tempfile::tempdir().unwrap();
1768 fs::write(d.path().join("hifigan_v2.onnx"), b"x").unwrap();
1770 fs::write(d.path().join("tokens.txt"), b"x").unwrap();
1771 let base = tempfile::tempdir().unwrap();
1772 assert!(build_matcha_config(d.path(), base.path()).is_err());
1773 }
1774
1775 fn fake_supertonic_int8_dir() -> tempfile::TempDir {
1781 let d = tempfile::tempdir().expect("tmp");
1782 for name in [
1783 "duration_predictor.int8.onnx",
1784 "text_encoder.int8.onnx",
1785 "vector_estimator.int8.onnx",
1786 "vocoder.int8.onnx",
1787 ] {
1788 fs::write(d.path().join(name), b"x").unwrap();
1789 }
1790 fs::write(d.path().join("tts.json"), b"{}").unwrap();
1791 fs::write(d.path().join("unicode_indexer.bin"), b"x").unwrap();
1792 fs::write(d.path().join("voice.bin"), b"x").unwrap();
1793 d
1794 }
1795
1796 #[test]
1797 fn test_build_supertonic_config_resolves_all_int8_files() {
1798 let d = fake_supertonic_int8_dir();
1799 let cfg = build_supertonic_config(d.path());
1800 for (field, val) in [
1803 ("duration_predictor", cfg.duration_predictor.as_deref()),
1804 ("text_encoder", cfg.text_encoder.as_deref()),
1805 ("vector_estimator", cfg.vector_estimator.as_deref()),
1806 ("vocoder", cfg.vocoder.as_deref()),
1807 ] {
1808 assert!(
1809 val.is_some_and(|p| p.ends_with(&format!("{field}.int8.onnx"))),
1810 "{field} should resolve to its int8 onnx, got {val:?}"
1811 );
1812 }
1813 assert!(cfg.tts_json.is_some_and(|p| p.ends_with("tts.json")));
1814 assert!(cfg
1815 .unicode_indexer
1816 .is_some_and(|p| p.ends_with("unicode_indexer.bin")));
1817 assert!(cfg.voice_style.is_some_and(|p| p.ends_with("voice.bin")));
1818 }
1819
1820 #[test]
1821 fn test_build_supertonic_config_falls_back_to_plain_onnx() {
1822 let d = tempfile::tempdir().unwrap();
1825 for name in [
1826 "duration_predictor.onnx",
1827 "text_encoder.onnx",
1828 "vector_estimator.onnx",
1829 "vocoder.onnx",
1830 ] {
1831 fs::write(d.path().join(name), b"x").unwrap();
1832 }
1833 let cfg = build_supertonic_config(d.path());
1834 assert!(cfg
1835 .duration_predictor
1836 .as_deref()
1837 .unwrap()
1838 .ends_with("duration_predictor.onnx"));
1839 assert!(cfg.vocoder.as_deref().unwrap().ends_with("vocoder.onnx"));
1840 }
1841
1842 #[test]
1843 fn test_build_supertonic_config_missing_files_are_none() {
1844 let d = tempfile::tempdir().unwrap();
1847 let cfg = build_supertonic_config(d.path());
1848 assert!(cfg.duration_predictor.is_none());
1849 assert!(cfg.text_encoder.is_none());
1850 assert!(cfg.vector_estimator.is_none());
1851 assert!(cfg.vocoder.is_none());
1852 assert!(cfg.tts_json.is_none());
1853 assert!(cfg.unicode_indexer.is_none());
1854 assert!(cfg.voice_style.is_none());
1855 }
1856
1857 fn fake_zipvoice_dirs() -> (tempfile::TempDir, tempfile::TempDir) {
1862 let model = tempfile::tempdir().unwrap();
1863 let base = tempfile::tempdir().unwrap();
1864 for name in [
1865 "encoder.int8.onnx",
1866 "decoder.int8.onnx",
1867 "encoder.onnx",
1868 "decoder.onnx",
1869 "tokens.txt",
1870 "lexicon.txt",
1871 ] {
1872 std::fs::write(model.path().join(name), b"x").unwrap();
1873 }
1874 std::fs::create_dir(model.path().join("espeak-ng-data")).unwrap();
1875 std::fs::write(base.path().join("vocos_24khz.onnx"), b"x").unwrap();
1876 (model, base)
1877 }
1878
1879 #[test]
1880 fn test_build_zipvoice_config_prefers_int8_and_shared_vocoder() {
1881 let (model, base) = fake_zipvoice_dirs();
1882 let cfg = build_zipvoice_config(model.path(), base.path()).expect("config");
1883 assert!(cfg
1884 .encoder
1885 .as_deref()
1886 .is_some_and(|p| p.ends_with("encoder.int8.onnx")));
1887 assert!(cfg
1888 .decoder
1889 .as_deref()
1890 .is_some_and(|p| p.ends_with("decoder.int8.onnx")));
1891 assert!(cfg
1892 .vocoder
1893 .as_deref()
1894 .is_some_and(|p| p.starts_with(base.path().to_str().unwrap())));
1895 assert!(cfg
1896 .tokens
1897 .as_deref()
1898 .is_some_and(|p| p.ends_with("tokens.txt")));
1899 assert!(cfg
1900 .lexicon
1901 .as_deref()
1902 .is_some_and(|p| p.ends_with("lexicon.txt")));
1903 assert!(cfg
1904 .data_dir
1905 .as_deref()
1906 .is_some_and(|p| p.ends_with("espeak-ng-data")));
1907 }
1908
1909 #[test]
1910 fn test_build_zipvoice_config_falls_back_to_plain_onnx() {
1911 let (model, base) = fake_zipvoice_dirs();
1913 std::fs::remove_file(model.path().join("encoder.int8.onnx")).unwrap();
1914 std::fs::remove_file(model.path().join("decoder.int8.onnx")).unwrap();
1915 let cfg = build_zipvoice_config(model.path(), base.path()).expect("config");
1916 assert!(cfg
1917 .encoder
1918 .as_deref()
1919 .is_some_and(|p| p.ends_with("encoder.onnx")));
1920 assert!(cfg
1921 .decoder
1922 .as_deref()
1923 .is_some_and(|p| p.ends_with("decoder.onnx")));
1924 }
1925
1926 #[test]
1927 fn test_build_zipvoice_config_missing_vocoder_errors_with_url() {
1928 let (model, base) = fake_zipvoice_dirs();
1931 std::fs::remove_file(base.path().join("vocos_24khz.onnx")).unwrap();
1932 let err = build_zipvoice_config(model.path(), base.path()).unwrap_err();
1933 assert!(err.0.contains("vocos_24khz.onnx"));
1934 assert!(err.0.contains("vocoder-models"));
1935 }
1936
1937 fn fake_pocket_int8_dir() -> tempfile::TempDir {
1941 let d = tempfile::tempdir().unwrap();
1942 for name in [
1943 "lm_flow.int8.onnx",
1944 "lm_main.int8.onnx",
1945 "encoder.onnx",
1946 "decoder.int8.onnx",
1947 "text_conditioner.onnx",
1948 "vocab.json",
1949 "token_scores.json",
1950 ] {
1951 std::fs::write(d.path().join(name), b"x").unwrap();
1952 }
1953 d
1954 }
1955
1956 #[test]
1957 fn test_build_pocket_config_int8_layout() {
1958 let d = fake_pocket_int8_dir();
1959 let cfg = build_pocket_config(d.path());
1960 assert!(cfg
1961 .lm_flow
1962 .as_deref()
1963 .is_some_and(|p| p.ends_with("lm_flow.int8.onnx")));
1964 assert!(cfg
1965 .lm_main
1966 .as_deref()
1967 .is_some_and(|p| p.ends_with("lm_main.int8.onnx")));
1968 assert!(cfg
1969 .decoder
1970 .as_deref()
1971 .is_some_and(|p| p.ends_with("decoder.int8.onnx")));
1972 assert!(cfg
1974 .encoder
1975 .as_deref()
1976 .is_some_and(|p| p.ends_with("encoder.onnx") && !p.contains("int8")));
1977 assert!(cfg
1978 .text_conditioner
1979 .as_deref()
1980 .is_some_and(|p| p.ends_with("text_conditioner.onnx")));
1981 assert!(cfg
1982 .vocab_json
1983 .as_deref()
1984 .is_some_and(|p| p.ends_with("vocab.json")));
1985 assert!(cfg
1986 .token_scores_json
1987 .as_deref()
1988 .is_some_and(|p| p.ends_with("token_scores.json")));
1989 }
1990
1991 #[test]
1992 fn test_build_pocket_config_fp32_layout() {
1993 let d = tempfile::tempdir().unwrap();
1994 for name in [
1995 "lm_flow.onnx",
1996 "lm_main.onnx",
1997 "encoder.onnx",
1998 "decoder.onnx",
1999 "text_conditioner.onnx",
2000 ] {
2001 std::fs::write(d.path().join(name), b"x").unwrap();
2002 }
2003 let cfg = build_pocket_config(d.path());
2004 assert!(cfg
2005 .lm_flow
2006 .as_deref()
2007 .is_some_and(|p| p.ends_with("lm_flow.onnx")));
2008 assert!(cfg
2009 .decoder
2010 .as_deref()
2011 .is_some_and(|p| p.ends_with("decoder.onnx")));
2012 }
2013
2014 #[test]
2017 fn test_read_wav_mono_16bit_round_trip() {
2018 let d = tempfile::tempdir().unwrap();
2019 let p = d.path().join("ref.wav");
2020 let samples = vec![0.0_f32, 0.25, -0.25, 0.5, -0.5];
2021 write_wav(&p, &samples, 24000);
2022 let (read, rate) = read_wav_mono_16bit(&p).expect("reads back");
2023 assert_eq!(rate, 24000);
2024 assert_eq!(read.len(), samples.len());
2025 for (a, b) in read.iter().zip(samples.iter()) {
2026 assert!((a - b).abs() < 0.001, "sample drifted: {a} vs {b}");
2027 }
2028 }
2029
2030 #[test]
2031 fn test_read_wav_rejects_non_pcm16() {
2032 let mut bytes = Vec::new();
2034 bytes.extend_from_slice(b"RIFF");
2035 bytes.extend_from_slice(&36u32.to_le_bytes());
2036 bytes.extend_from_slice(b"WAVE");
2037 bytes.extend_from_slice(b"fmt ");
2038 bytes.extend_from_slice(&16u32.to_le_bytes());
2039 bytes.extend_from_slice(&3u16.to_le_bytes()); bytes.extend_from_slice(&1u16.to_le_bytes()); bytes.extend_from_slice(&24000u32.to_le_bytes());
2042 bytes.extend_from_slice(&48000u32.to_le_bytes());
2043 bytes.extend_from_slice(&2u16.to_le_bytes());
2044 bytes.extend_from_slice(&32u16.to_le_bytes()); bytes.extend_from_slice(b"data");
2046 bytes.extend_from_slice(&4u32.to_le_bytes());
2047 bytes.extend_from_slice(&[0, 0, 0, 0]);
2048 let d = tempfile::tempdir().unwrap();
2049 let p = d.path().join("f32.wav");
2050 std::fs::write(&p, bytes).unwrap();
2051 let err = read_wav_mono_16bit(&p).unwrap_err();
2052 assert!(err.0.contains("16-bit PCM"));
2053 }
2054
2055 #[test]
2056 fn test_bundled_reference_wav_prefers_test_wavs_sorted() {
2057 let d = tempfile::tempdir().unwrap();
2058 assert!(bundled_reference_wav(d.path()).is_none());
2059 std::fs::create_dir(d.path().join("test_wavs")).unwrap();
2060 std::fs::write(d.path().join("test_wavs/b.wav"), b"x").unwrap();
2061 std::fs::write(d.path().join("test_wavs/a.wav"), b"x").unwrap();
2062 std::fs::write(d.path().join("loose.wav"), b"x").unwrap();
2063 let wav = bundled_reference_wav(d.path()).expect("found");
2064 assert!(wav.ends_with("test_wavs/a.wav"));
2066 }
2067
2068 #[test]
2069 fn test_resolve_reference_text_bundled_transcript_and_overrides() {
2070 let d = tempfile::tempdir().unwrap();
2071 std::fs::create_dir(d.path().join("test_wavs")).unwrap();
2072 std::fs::write(d.path().join("test_wavs/leijun-1.wav"), b"x").unwrap();
2073
2074 let engine = SherpaOnnxEngine::new(r#"{"modelId":"zipvoice-zh_en-emilia-distill-int8"}"#);
2076 let text = engine.resolve_reference_text(d.path()).expect("transcript");
2077 assert!(text.contains("武汉大学"));
2078
2079 let engine = SherpaOnnxEngine::new(r#"{"modelId":"x","referenceText":"custom words"}"#);
2081 assert_eq!(
2082 engine.resolve_reference_text(d.path()).expect("override"),
2083 "custom words"
2084 );
2085
2086 std::fs::write(d.path().join("test_wavs/mystery.wav"), b"x").unwrap();
2088 std::fs::remove_file(d.path().join("test_wavs/leijun-1.wav")).unwrap();
2089 let engine = SherpaOnnxEngine::new(r#"{"modelId":"x"}"#);
2090 let err = engine.resolve_reference_text(d.path()).unwrap_err();
2091 assert!(err.0.contains("referenceText"));
2092 }
2093
2094 #[test]
2095 fn test_parse_supertonic_voice_sid_lang_form() {
2096 assert_eq!(parse_supertonic_voice(Some("6:ja")), (6, "ja".to_string()));
2097 assert_eq!(parse_supertonic_voice(Some("0:en")), (0, "en".to_string()));
2098 assert_eq!(parse_supertonic_voice(Some("9:ko")), (9, "ko".to_string()));
2099 }
2100
2101 #[test]
2102 fn test_parse_supertonic_voice_bare_integer_defaults_en() {
2103 assert_eq!(parse_supertonic_voice(Some("6")), (6, "en".to_string()));
2105 assert_eq!(parse_supertonic_voice(Some("0")), (0, "en".to_string()));
2106 }
2107
2108 #[test]
2109 fn test_parse_supertonic_voice_none_and_empty() {
2110 assert_eq!(parse_supertonic_voice(None), (0, "en".to_string()));
2111 assert_eq!(parse_supertonic_voice(Some("")), (0, "en".to_string()));
2112 }
2113
2114 #[test]
2115 fn test_parse_supertonic_voice_empty_lang_after_colon() {
2116 assert_eq!(parse_supertonic_voice(Some("6:")), (6, "en".to_string()));
2119 }
2120
2121 #[test]
2122 fn test_parse_supertonic_voice_non_numeric_sid_falls_back_to_zero() {
2123 assert_eq!(
2125 parse_supertonic_voice(Some("abc:ja")),
2126 (0, "ja".to_string())
2127 );
2128 }
2129
2130 #[test]
2131 fn test_supertonic_voices_one_per_speaker_language_pair() {
2132 let info = SherpaModelInfo {
2134 id: "test".into(),
2135 model_type: "supertonic".into(),
2136 engines: "sherpa-onnx".into(),
2137 name: "test".into(),
2138 language: vec![
2139 SherpaLanguage {
2140 lang_code: "en".into(),
2141 language_name: "English".into(),
2142 country: String::new(),
2143 },
2144 SherpaLanguage {
2145 lang_code: "ja".into(),
2146 language_name: "Japanese".into(),
2147 country: String::new(),
2148 },
2149 SherpaLanguage {
2150 lang_code: "ko".into(),
2151 language_name: "Korean".into(),
2152 country: String::new(),
2153 },
2154 ],
2155 sample_rate: 24000,
2156 num_speakers: 2,
2157 quality: String::new(),
2158 url: String::new(),
2159 compression: false,
2160 filesize_mb: 0.0,
2161 license: String::new(),
2162 license_url: String::new(),
2163 };
2164 let voices = supertonic_voices(&info);
2165 assert_eq!(voices.len(), 6);
2166 let ids: Vec<&str> = voices.iter().map(|v| v.id.as_str()).collect();
2168 assert!(ids.contains(&"0:en"));
2169 assert!(ids.contains(&"1:ko"));
2170 assert!(ids.contains(&"0:ja"));
2171 let ja_voice = voices
2173 .iter()
2174 .find(|v| v.id == "1:ja")
2175 .expect("1:ja voice exists");
2176 assert_eq!(ja_voice.language_codes[0].bcp47, "ja");
2177 assert_eq!(ja_voice.provider, "sherpaonnx");
2178 }
2179
2180 #[test]
2181 fn test_engine_get_voices_supertonic_enumerates_speakers_times_languages() {
2182 let engine = SherpaOnnxEngine::new(r#"{"modelId":"supertonic-3-multilingual"}"#);
2186 if !engine.models.contains_key("supertonic-3-multilingual") {
2187 eprintln!(
2188 "skipping: 'supertonic-3-multilingual' missing from registry; \
2189 check src/models.json"
2190 );
2191 return;
2192 }
2193 let voices = engine.get_voices().expect("voices");
2194 assert_eq!(voices.len(), 10 * 31);
2195 assert!(voices.iter().any(|v| v.id == "6:ja"));
2197 assert!(voices.iter().all(|v| v.provider == "sherpaonnx"));
2199 }
2200
2201 #[test]
2202 fn test_engine_num_steps_default_and_valid_override() {
2203 assert_eq!(SherpaOnnxEngine::new("{}").num_steps, 8);
2205 assert_eq!(SherpaOnnxEngine::new(r#"{"numSteps":"10"}"#).num_steps, 10);
2207 assert_eq!(SherpaOnnxEngine::new(r#"{"numSteps":"5"}"#).num_steps, 5);
2208 assert_eq!(SherpaOnnxEngine::new(r#"{"numSteps":"12"}"#).num_steps, 12);
2209 }
2210
2211 #[test]
2212 fn test_engine_num_steps_out_of_range_clamps_to_default() {
2213 assert_eq!(SherpaOnnxEngine::new(r#"{"numSteps":"3"}"#).num_steps, 8);
2215 assert_eq!(SherpaOnnxEngine::new(r#"{"numSteps":"20"}"#).num_steps, 8);
2216 }
2217
2218 #[test]
2219 fn test_engine_num_steps_non_numeric_keeps_default() {
2220 assert_eq!(SherpaOnnxEngine::new(r#"{"numSteps":"fast"}"#).num_steps, 8);
2221 }
2222
2223 fn fake_piper_dir() -> tempfile::TempDir {
2224 let d = tempfile::tempdir().unwrap();
2225 fs::write(d.path().join("en_US-amy-low.onnx"), b"x").unwrap();
2227 fs::write(d.path().join("tokens.txt"), b"x").unwrap();
2228 fs::create_dir(d.path().join("espeak-ng-data")).unwrap();
2229 d
2230 }
2231
2232 #[test]
2233 fn test_build_vits_config_piper_uses_espeak_data_no_dict() {
2234 let d = fake_piper_dir();
2235 let cfg = build_vits_config(d.path(), true, false);
2236 assert!(cfg
2238 .model
2239 .as_deref()
2240 .unwrap()
2241 .ends_with("en_US-amy-low.onnx"));
2242 assert!(cfg.data_dir.is_some(), "Piper needs espeak-ng-data");
2243 assert!(
2244 cfg.dict_dir.is_none(),
2245 "Piper must NOT set dict_dir (jieba would warn)"
2246 );
2247 }
2248
2249 #[test]
2250 fn test_build_vits_config_chinese_uses_dict_dir() {
2251 let d = tempfile::tempdir().unwrap();
2252 fs::write(d.path().join("model.onnx"), b"x").unwrap();
2253 fs::write(d.path().join("tokens.txt"), b"x").unwrap();
2254 let dict_dir = d.path().join("dict");
2256 fs::create_dir(&dict_dir).unwrap();
2257
2258 let cfg = build_vits_config(d.path(), false, true);
2259 assert_eq!(
2260 cfg.dict_dir.as_deref(),
2261 Some(dict_dir.to_str().unwrap()),
2262 "Chinese models must point dict_dir at bundled dict/"
2263 );
2264 }
2265
2266 #[test]
2267 fn test_build_vits_config_mms_with_lexicon_no_dict() {
2268 let d = tempfile::tempdir().unwrap();
2270 fs::write(d.path().join("model.onnx"), b"x").unwrap();
2271 fs::write(d.path().join("tokens.txt"), b"x").unwrap();
2272 fs::write(d.path().join("lexicon.txt"), b"x").unwrap();
2273
2274 let cfg = build_vits_config(d.path(), false, false);
2275 assert!(cfg.lexicon.is_some());
2276 assert!(
2277 cfg.dict_dir.is_none(),
2278 "dict_dir must not be set when lexicon.txt is present"
2279 );
2280 }
2281
2282 #[test]
2283 fn test_build_vits_config_mms_without_lexicon_uses_dict_fallback() {
2284 let d = tempfile::tempdir().unwrap();
2286 fs::write(d.path().join("model.onnx"), b"x").unwrap();
2287 fs::write(d.path().join("tokens.txt"), b"x").unwrap();
2288 fs::create_dir(d.path().join("dict")).unwrap();
2289
2290 let cfg = build_vits_config(d.path(), false, false);
2291 assert!(cfg.dict_dir.is_some());
2292 }
2293
2294 #[test]
2295 fn test_find_primary_model_onnx_prefers_canonical_name() {
2296 let d = tempfile::tempdir().unwrap();
2297 fs::write(d.path().join("model.onnx"), b"x").unwrap();
2299 fs::write(d.path().join("vits-en-foo.onnx"), b"x").unwrap();
2300 let r = find_primary_model_onnx(d.path()).expect("found");
2301 assert!(r.to_str().unwrap().ends_with("model.onnx"));
2302 }
2303
2304 #[test]
2305 fn test_find_primary_model_onnx_skips_vocoders_and_acoustic_steps() {
2306 let d = tempfile::tempdir().unwrap();
2307 fs::write(d.path().join("model-steps-3.onnx"), b"x").unwrap();
2309 fs::write(d.path().join("vocos-22khz-univ.onnx"), b"x").unwrap();
2310 fs::write(d.path().join("hifigan_v2.onnx"), b"x").unwrap();
2311 fs::write(d.path().join("vocoder.onnx"), b"x").unwrap();
2312 assert!(find_primary_model_onnx(d.path()).is_none());
2313 }
2314
2315 #[test]
2316 fn test_find_primary_model_onnx_picks_first_unmatched() {
2317 let d = tempfile::tempdir().unwrap();
2318 fs::write(d.path().join("en_US-amy-low.onnx"), b"x").unwrap();
2319 let r = find_primary_model_onnx(d.path()).expect("found");
2320 assert!(r.to_str().unwrap().ends_with("en_US-amy-low.onnx"));
2321 }
2322
2323 #[test]
2324 fn test_resolve_model_scan_dir_uses_top_when_files_present() {
2325 let d = tempfile::tempdir().unwrap();
2327 fs::write(d.path().join("tokens.txt"), b"x").unwrap();
2328 let r = resolve_model_scan_dir(d.path());
2329 assert_eq!(r, d.path());
2330 }
2331
2332 #[test]
2333 fn test_resolve_model_scan_dir_descends_into_single_subdir() {
2334 let d = tempfile::tempdir().unwrap();
2337 let inner = d.path().join("vits-piper-en_US-amy-low");
2338 fs::create_dir(&inner).unwrap();
2339 fs::write(inner.join("model.onnx"), b"x").unwrap();
2340
2341 let r = resolve_model_scan_dir(d.path());
2342 assert!(r.ends_with("vits-piper-en_US-amy-low"));
2343 }
2344
2345 #[test]
2346 fn test_resolve_model_scan_dir_no_descent_when_multiple_subdirs() {
2347 let d = tempfile::tempdir().unwrap();
2349 fs::create_dir(d.path().join("a")).unwrap();
2350 fs::create_dir(d.path().join("b")).unwrap();
2351 let r = resolve_model_scan_dir(d.path());
2352 assert_eq!(r, d.path());
2353 }
2354
2355 #[test]
2356 fn test_find_file_locates_named_child() {
2357 let d = tempfile::tempdir().unwrap();
2358 fs::write(d.path().join("foo.txt"), b"x").unwrap();
2359 let r = find_file(d.path(), "foo.txt").expect("found");
2360 assert!(r.to_str().unwrap().ends_with("foo.txt"));
2361 assert!(find_file(d.path(), "missing.txt").is_none());
2362 }
2363
2364 #[test]
2371 fn test_engine_construction_with_model_id_does_not_load_yet() {
2372 let engine = SherpaOnnxEngine::new(r#"{"modelId":"piper-en_US-amy-low"}"#);
2375 assert_eq!(engine.loaded_model_id, "piper-en_US-amy-low");
2376 }
2377
2378 #[test]
2379 fn test_engine_construction_accepts_numeric_credential_values() {
2380 let engine = SherpaOnnxEngine::new(
2383 r#"{"modelId":"piper-en_US-amy-low","numThreads":4,"numSteps":10}"#,
2384 );
2385 assert_eq!(engine.loaded_model_id, "piper-en_US-amy-low");
2386 assert_eq!(engine.num_threads, 4);
2387 assert_eq!(engine.num_steps, 10);
2388 }
2389
2390 #[test]
2391 fn test_engine_speak_without_model_id_errors_clearly() {
2392 let engine = SherpaOnnxEngine::new("");
2393 let err = engine
2394 .speak("hi", None, 1.0, 1.0, 1.0, None, None, None)
2395 .unwrap_err();
2396 assert!(
2397 err.to_string().contains("modelId"),
2398 "missing-model error should mention modelId: {err}"
2399 );
2400 }
2401
2402 #[test]
2403 fn test_registry_crate_supplies_the_models() {
2404 let models = load_models();
2408 assert!(models.len() > 1700, "registry shrank: {}", models.len());
2409 for m in models.values() {
2413 assert!(!m.engines.is_empty(), "{} has no engines value", m.id);
2414 assert!(
2415 m.engines.split(',').any(|e| e.trim() == "sherpa-onnx")
2416 || m.engines.contains("floravox")
2417 || m.engines.contains("rust-tts-wrapper"),
2418 "{} has an unknown engines value: {}",
2419 m.id,
2420 m.engines
2421 );
2422 }
2423 let mms = models
2426 .values()
2427 .find(|m| m.id.starts_with("mms_"))
2428 .expect("MMS entries present");
2429 assert_eq!(mms.engines, "sherpa-onnx");
2430 }
2431
2432 #[test]
2433 fn test_engine_speak_with_unknown_model_id_errors_with_count() {
2434 let engine = SherpaOnnxEngine::new(r#"{"modelId":"not-a-real-model"}"#);
2435 let err = engine
2436 .speak("hi", None, 1.0, 1.0, 1.0, None, None, None)
2437 .unwrap_err();
2438 assert!(err.to_string().contains("not found in registry"));
2441 assert!(err.to_string().contains("models available"));
2442 }
2443
2444 #[test]
2445 fn test_engine_get_voices_multi_speaker_enumeration() {
2446 let engine = SherpaOnnxEngine::new(r#"{"modelId":"coqui-en-vctk"}"#);
2450 let known = engine.models.contains_key("coqui-en-vctk");
2455 if !known {
2456 eprintln!(
2457 "skipping: 'coqui-en-vctk' is no longer in the registry; \
2458 update the model id in this test"
2459 );
2460 return;
2461 }
2462 let voices = engine.get_voices().expect("voices");
2463 assert!(
2464 !voices.is_empty(),
2465 "expected at least 1 voice for multi-speaker model"
2466 );
2467 assert!(voices.iter().all(|v| v.provider == "sherpaonnx"));
2469 }
2470
2471 #[test]
2472 fn test_engine_get_voices_single_speaker_returns_one() {
2473 let engine = SherpaOnnxEngine::new(r#"{"modelId":"piper-en_US-amy-low"}"#);
2476 let voices = engine.get_voices().expect("voices");
2477 assert_eq!(voices.len(), 1);
2478 assert_eq!(voices[0].id, "0");
2479 assert_eq!(voices[0].name, "Speaker 0");
2480 }
2481
2482 #[test]
2483 fn test_engine_get_voices_for_unloaded_model_id_returns_one_default() {
2484 let engine = SherpaOnnxEngine::new(r#"{"modelId":"doesnt-matter"}"#);
2487 let voices = engine.get_voices().expect("voices");
2488 assert_eq!(voices.len(), 1);
2489 }
2490
2491 #[test]
2492 fn test_engine_num_threads_parsed_from_credentials() {
2493 let engine = SherpaOnnxEngine::new(r#"{"numThreads":"4","provider":"cpu"}"#);
2494 assert_eq!(engine.num_threads, 4);
2495 assert_eq!(engine.provider.as_deref(), Some("cpu"));
2496 }
2497
2498 #[test]
2499 fn test_engine_num_threads_invalid_falls_back_to_default() {
2500 let engine = SherpaOnnxEngine::new(r#"{"numThreads":"not-a-number"}"#);
2501 assert_eq!(engine.num_threads, 2); }
2503
2504 #[test]
2505 fn test_engine_num_threads_zero_falls_back_to_default() {
2506 let engine = SherpaOnnxEngine::new(r#"{"numThreads":"0"}"#);
2508 assert_eq!(engine.num_threads, 2);
2509 }
2510
2511 #[test]
2512 fn test_engine_model_path_override() {
2513 let engine =
2514 SherpaOnnxEngine::new(r#"{"modelPath":"/tmp/custom-model-dir","modelId":"foo"}"#);
2515 assert_eq!(
2516 engine.model_dir,
2517 std::path::PathBuf::from("/tmp/custom-model-dir")
2518 );
2519 assert_eq!(engine.loaded_model_id, "foo");
2520 }
2521}