Skip to main content

rust_tts_wrapper/
sherpaonnx_engine.rs

1//! Sherpa-ONNX offline TTS engine with model registry.
2
3use 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
14/// Shared cancellation flag — set by `stop()`, read by the progress callback.
15static CANCEL_REQUESTED: AtomicBool = AtomicBool::new(false);
16
17// The sherpa-onnx generate callback must be 'static, but speak() holds the
18// caller's on_audio/on_boundary borrows only for the method body. The C++
19// runtime invokes the callback synchronously on the same thread inside the
20// generate call, so we stash the callback pointers here for the duration
21// (same technique as VISEME_CB in the cloud engine) and clear them after.
22// Synthesis per engine instance is serialised by the tts_instance mutex.
23type 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
33/// PCM delivery chunk size. Sentence batches arrive whole from the
34/// generate progress callback and larger ones are sliced into 8 KB chunks
35/// before pushing them through `on_audio` — matching the cloud engines'
36/// streamed-chunk shape so callers see the same multi-callback delivery
37/// instead of one giant buffer.
38const STREAMING_CHUNK_SIZE: usize = 8 * 1024;
39
40/// Maps a 2-letter ISO 639-1 code to its 3-letter ISO 639-3 equivalent for the
41/// languages covered by the Sherpa-ONNX model registry. Falls back to the
42/// input when no mapping is known.
43fn 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
93/// Offline TTS engine using [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx).
94pub struct SherpaOnnxEngine {
95    models: HashMap<String, SherpaModelInfo>,
96    model_dir: PathBuf,
97    loaded_model_id: String,
98    num_threads: i32,
99    /// Supertonic denoising step count (quality knob: 5 low → 12 high,
100    /// default 8). Ignored by other model types.
101    num_steps: i32,
102    /// Optional user-supplied reference clip (path to a 16-bit PCM wav) for
103    /// zero-shot voice-cloning models (zipvoice, pocket). When absent the
104    /// first bundled `test_wavs/*.wav` is used.
105    reference_audio: Option<PathBuf>,
106    /// Transcript matching [`Self::reference_audio`] — required by zipvoice
107    /// (its bundled test wavs have known transcripts). Ignored by pocket,
108    /// which clones from audio alone.
109    reference_text: Option<String>,
110    provider: Option<String>,
111    // Cached ONNX runtime instance. Recreating OfflineTts per speak() is
112    // expensive (model loading + ONNX init). Cache it so the first speak()
113    // pays the cost and subsequent calls reuse it.
114    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    /// Create a new Sherpa-ONNX engine.
134    ///
135    /// Credentials JSON keys:
136    /// - `modelPath`: directory containing downloaded models (defaults to
137    ///   `~/.rust-tts-wrapper/sherpaonnx`)
138    /// - `modelId`: id from the registry (e.g. `kokoro-en-v0_19`). Required —
139    ///   if absent, no model is loaded and `speak` will return an error rather
140    ///   than silently forcing a 305 MB download.
141    /// - `numThreads`: ONNX runtime intra-op thread count (default 2).
142    /// - `numSteps`: Supertonic denoising steps, 5–12 (default 8). Out-of-range
143    ///   values fall back to 8. Ignored by non-Supertonic models.
144    /// - `referenceAudio`: path to a 16-bit PCM wav used as the cloning
145    ///   reference for zero-shot models (zipvoice, pocket). Defaults to the
146    ///   model's bundled `test_wavs/` clip.
147    /// - `referenceText`: transcript of `referenceAudio` (zipvoice requires
148    ///   it to match the audio exactly; pocket ignores it).
149    /// - `provider`: `cpu` (default), `coreml`, `cuda`, `directml`, etc.
150    #[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            // Numeric values are coerced to strings so callers can write
162            // `{"numThreads": 2}` (JSON number) or `"2"` interchangeably —
163            // a strict HashMap<String, String> parse would silently drop
164            // the whole object on the first number and leave every option
165            // at its default with no error.
166            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                    // Clamp to Supertonic's supported quality range. Values
189                    // outside 5–12 degrade output or waste compute, so fall
190                    // back to the default rather than passing them through.
191                    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    /// Return the map of available models from the registry.
229    pub fn available_models(&self) -> &HashMap<String, SherpaModelInfo> {
230        &self.models
231    }
232
233    /// Resolve the reference clip for zero-shot cloning models: the
234    /// user-supplied `referenceAudio` credentials path wins, else the first
235    /// wav bundled under the model's `test_wavs/`.
236    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    /// Resolve the reference transcript (zipvoice only): the
251    /// user-supplied `referenceText` credential wins, else the known
252    /// transcript for a bundled sherpa-onnx test wav.
253    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
282/// Default directory for downloaded Sherpa-ONNX models.
283fn 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
293/// Parse the embedded `models.json` into a hashmap.
294fn load_models() -> HashMap<String, SherpaModelInfo> {
295    // Convert the typed registry crate into the wrapper's public
296    // SherpaModelInfo shape (API-compatible with the old embedded copy).
297    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        // Dispatch model config by model_type. The branches below mirror the
369        // file-layout conventions used by js-tts-wrapper and dotnet-tts-wrapper:
370        //
371        //   kokoro  → model.onnx + voices.bin + tokens.txt + espeak-ng-data/
372        //   matcha  → acoustic-model.onnx + vocoder.onnx + tokens.txt
373        //             (vocoder may be hifigan_v2.onnx, vocos-22khz-univ.onnx,
374        //              or live in a shared base dir)
375        //   vits    → model.onnx + tokens.txt + (lexicon.txt | espeak-ng-data/)
376        //             Piper / GitHub models prefer espeak-ng-data and ignore
377        //             dict_dir; Chinese models want a dict/ directory for
378        //             jieba segmentation.
379        //   zipvoice→ encoder/decoder .onnx (int8 variants preferred) +
380        //             tokens.txt + lexicon.txt + espeak-ng-data/ + a shared
381        //             vocos_24khz.onnx vocoder (base dir). Zero-shot cloning:
382        //             speak() needs a reference clip + its transcript.
383        //   pocket  → lm_flow/lm_main/encoder/decoder/text_conditioner .onnx
384        //             (int8 where shipped) + vocab.json + token_scores.json.
385        //             Zero-shot cloning from a reference clip alone.
386        //   mms /   → MMS models use the VITS config but typically have no
387        //   unknown   espeak-ng-data; they ship just model.onnx + tokens.txt
388        //             + lexicon.txt.
389        //
390        // The registry has ~1143 MMS entries that omit
391        // `model_type`, so empty/unknown falls through to VITS handling.
392        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        // Piper and GitHub archives often extract to a nested subdirectory
400        // (e.g. vits-piper-en_US-amy-low/en_US-amy-low.onnx). If the model
401        // dir has no top-level model files, descend into the single child
402        // directory (mirrors VoiceGarden's ResolveModelScanDir).
403        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 (Facebook Massively Multilingual Speech), and unknown
455            // model types all use the VITS config family.
456            "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            // Single-sentence mode matches the reference implementations and
474            // avoids extra allocations when the input is short.
475            max_num_sentences: 1,
476            ..Default::default()
477        };
478
479        // Use cached OfflineTts instance if available; create on first call.
480        // The Mutex guards the Option; we hold it for the entire synthesis
481        // since OfflineTts::generate_with_config needs &self.
482        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        // Supertonic needs both a speaker id and a language; the language is
492        // delivered through GenerationConfig.extra. We encode both in the
493        // voice id as "sid:lang" (produced by get_voices), and fall back to a
494        // bare integer + default "en" for backwards compatibility.
495        //
496        // Zipvoice / pocket are zero-shot cloning models: instead of a preset
497        // speaker they need a reference clip (and, for zipvoice, its exact
498        // transcript) delivered through GenerationConfig.
499        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            // Flow-matching step counts from the sherpa-onnx docs: zipvoice
518            // defaults to 4, pocket to 2 (higher = better quality, slower).
519            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        // Reset cancellation flag before synthesis.
545        CANCEL_REQUESTED.store(false, Ordering::SeqCst);
546
547        // Streaming delivery: sherpa-onnx's generate callback fires after
548        // each sentence batch (max_num_sentences=1 above) with the batch's
549        // NEWLY generated samples — not cumulative — so audio is delivered
550        // through `on_audio` while later sentences are still synthesising.
551        // Volume/pitch are applied per batch (batches are sentence-aligned,
552        // so per-batch resampling for pitch doesn't seam mid-speech).
553        //
554        // Word-boundary estimates (150-wpm baseline) fire progressively,
555        // anchored to delivered samples and scaled by 1/speed so they track
556        // the audio actually emitted; the reported times stay on the
557        // rate-1.0 baseline (existing callers, e.g. VoiceGarden-SPD and the
558        // SAPI adapter, compensate for rate themselves).
559        //
560        // The generate callback must be `'static`, so it cannot capture the
561        // method-lifetime `on_audio`/`on_boundary` borrows directly. The C++
562        // runtime invokes the callback synchronously on this same thread
563        // while the borrows are live, so the pointers are stashed in
564        // thread-locals for the duration of the call (the same technique as
565        // VISEME_CB above) and cleared afterwards.
566        #[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            // SAFETY (stash): the raw pointers are only dereferenced inside
574            // the generate callback, which sherpa-onnx calls synchronously
575            // on this thread between the stash and the clear below, while
576            // `on_audio`/`on_boundary` are still borrowed. Synthesis on a
577            // given engine instance is serialised by the `tts_instance`
578            // mutex held for the whole call.
579            // ptr→ptr transmute + explicit borrow-to-pointer are the
580            // standard lifetime-erasure idioms for synchronous callback
581            // stashing; the safety argument is documented above.
582            #[allow(clippy::transmute_ptr_to_ptr)]
583            let audio_ptr: Option<AudioCbPtr> = on_audio.as_mut().map(|cb| {
584                // SAFETY: the fat pointer's layout is identical; only the
585                // lifetime is erased. See the thread-local docs for why
586                // use is confined to this call.
587                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                // SAFETY: as above.
596                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            // The firer owns the plan and is shared with the 'static
607            // callback via Arc<Mutex<..>>, so the outer scope can flush
608            // the remainder after generation ends.
609            let firer = plan.map(|p| Arc::new(Mutex::new(EstimateFirer::new(p, time_scale))));
610            let firer_for_cb = firer.clone();
611
612            // A `None` result means the engine rejected the config outright;
613            // cancellation mid-stream still returns Some(audio-so-far),
614            // which we ignore — every batch was already streamed.
615            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                            // SAFETY (stash): see the thread-local docs.
627                            // Chunk to keep the documented 8 KB multi-
628                            // callback delivery shape.
629                            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                                            // SAFETY (stash): see above.
643                                            unsafe {
644                                                (*ptr)(
645                                                    &ev.word,
646                                                    ev.start_s,
647                                                    ev.end_s,
648                                                    ev.char_offset,
649                                                    ev.char_len,
650                                                    true, // wpm estimates
651                                                );
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            // Fire any estimates whose audio never arrived (short audio,
671            // cancellation): the boundary set is now closed.
672            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            // No streaming took place; estimates fire as before.
703            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        // The progress callback reads this flag on every chunk and aborts
747        // synthesis when set.
748        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        // Supertonic is addressed by (speaker, language) rather than a bare
756        // speaker id. Emit one voice per speaker × language pair, encoding
757        // both in the id as "sid:lang" so speak() can route the language
758        // through GenerationConfig.extra.
759        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
797/// Apply volume scaling and pitch shifting to a buffer of f32 samples.
798///
799/// Volume is a straightforward linear scale. Pitch shifting uses simple
800/// linear-interpolation resampling — it does change duration slightly, but it
801/// is the cheapest DSP approach that doesn't pull in an FFT dependency. The
802/// shift is a no-op when both factors are 1.0.
803fn apply_volume_and_pitch(samples: &[f32], volume: f32, pitch: f32) -> Vec<f32> {
804    if samples.is_empty() {
805        return Vec::new();
806    }
807    // First resample for pitch (changes length).
808    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    // Then scale amplitude for volume.
829    if (volume - 1.0).abs() > f32::EPSILON {
830        resampled.iter().map(|&s| s * volume).collect()
831    } else {
832        resampled
833    }
834}
835
836/// Convert f32 samples to little-endian PCM16 bytes (one allocation).
837#[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
847/// Write a 16-bit PCM mono WAV file. Returns `false` on I/O error.
848fn 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; // 16-bit mono
861                                     // WAV PCM header: block_align and bits_per_sample are u16, NOT u32 —
862                                     // emitting them as 4 bytes shifts every subsequent field by 2 bytes
863                                     // and produces a technically malformed header (most players tolerate
864                                     // it but it's wrong per the RIFF spec).
865    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(), // PCM chunk size
874        &1u16.to_le_bytes(),  // PCM format
875        &1u16.to_le_bytes(),  // mono
876        &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
890/// Play a WAV file using a platform-appropriate command.
891///
892/// - Linux: `aplay`
893/// - macOS: `afplay`
894/// - Windows: PowerShell `(New-Object Media.SoundPlayer).PlaySync()`
895///
896/// Failures are swallowed because playback is best-effort (audio has already
897/// been rendered to a file the caller can locate).
898fn 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
926// ===== Model-config builders =====
927//
928// These helpers encapsulate the file-layout differences between Kokoro,
929// Matcha, and the various VITS flavours (Piper, MMS, Coqui, Chinese, ...).
930// They mirror the per-model logic in js-tts-wrapper / dotnet-tts-wrapper.
931
932/// If `dir` has no top-level model files but has exactly one subdirectory,
933/// return that subdirectory. Mirrors VoiceGarden's `ResolveModelScanDir`.
934fn 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    // No top-level files — look for a single subdirectory.
948    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
960/// Find the primary model .onnx in a directory. Prefers `model.onnx`,
961/// then falls back to the first .onnx that isn't an acoustic model or
962/// vocoder. Mirrors VoiceGarden's `FindPrimaryModelOnnx`.
963fn 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                // Skip acoustic models and vocoders.
983                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
995/// Return `Some(path)` only when `dir/name` exists on disk; otherwise `None`.
996fn 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
1005/// Walk `dir` and return the path of the first child matching `name`, if any.
1006fn 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
1020/// Return the first existing file under `dir` matching one of `names`.
1021fn 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
1025/// Heuristic: is this a Piper voice or another "GitHub-style" archive model
1026/// (Coqui / icefall / mimic3 / melo / vctk / ljs / cantonese / zh / kokoro)?
1027/// These layouts ship `espeak-ng-data/` rather than a lexicon and shouldn't
1028/// be configured with `dict_dir` (jieba would otherwise warn on every call).
1029fn 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
1045/// Collect lexicon paths for a Kokoro model. Multilingual releases (e.g.
1046/// `kokoro-zh_en-int8`) ship several `lexicon-*.txt` files which
1047/// sherpa-onnx accepts as a comma-separated list; English-only Kokoro has
1048/// none and relies on `espeak-ng-data/` instead. Returns `None` when no
1049/// lexicon files are present so the field is left unset.
1050fn 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    // Sort for deterministic ordering across platforms / readdir order.
1068    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
1078/// Locate the dictionary directory for Chinese text normalisation. Kokoro zh
1079/// models keep `*.fst` files (date-zh.fst, number-zh.fst, phone-zh.fst) at the
1080/// model root; some VITS models nest a `dict/` folder. Returns `None` when no
1081/// FST/dict layout is present.
1082fn 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
1102/// Kokoro config: model.onnx + voices.bin + tokens.txt + (espeak-ng-data/ |
1103/// lexicon-*.txt + dict_dir). Quantised releases ship `model.int8.onnx`;
1104/// `find_primary_model_onnx` resolves both layouts.
1105fn build_kokoro_config(model_dir: &std::path::Path) -> sherpa_onnx::OfflineTtsKokoroModelConfig {
1106    // Prefer the canonical model.onnx; fall back to a directory scan that
1107    // skips vocoders and matcha acoustic models (handles model.int8.onnx).
1108    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        // Multilingual Kokoro (e.g. zh_en) ships lexicon-*.txt and Chinese FST
1115        // normalisation; English-only Kokoro uses espeak-ng-data instead, in
1116        // which case these resolve to None.
1117        lexicon: collect_lexicons(model_dir),
1118        dict_dir: find_dict_dir(model_dir),
1119        // length_scale left at default — rate is applied via GenerationConfig.speed.
1120        ..Default::default()
1121    }
1122}
1123
1124/// Supertonic config: four ONNX sessions (`duration_predictor`,
1125/// `text_encoder`, `vector_estimator`, `vocoder`) plus `tts.json`,
1126/// `unicode_indexer.bin`, and `voice.bin`. The sherpa-onnx release ships
1127/// int8-quantised onnx files (`<name>.int8.onnx`); fall back to the
1128/// unquantised `<name>.onnx` if the int8 variant is absent. Missing files
1129/// surface as `None` and sherpa-onnx will fail at `OfflineTts::create` with a
1130/// clear message — matching how the Kokoro builder handles an absent
1131/// `voices.bin`.
1132fn 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
1156/// Zipvoice config: encoder + decoder (int8 variants preferred) + tokens +
1157/// lexicon + espeak-ng-data, plus the shared `vocos_24khz.onnx` vocoder.
1158///
1159/// The vocoder is *not* bundled in the zipvoice archive — it lives in the
1160/// separate `vocoder-models` release, so it's resolved from the model dir
1161/// first (custom layouts) then the user's base model dir (the same
1162/// shared-vocoder convention as Matcha). A missing vocoder is a hard error
1163/// with the download URL rather than a sherpa-onnx create() panic.
1164fn 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        // feat_scale / t_shift / target_rms / guidance_scale: 0.0 makes the
1195        // C API fall back to the model's trained defaults (same behaviour as
1196        // the sherpa-onnx CLI, which leaves them unset).
1197        ..Default::default()
1198    })
1199}
1200
1201/// Pocket config: five ONNX components + two JSON sidecars. Only `decoder`,
1202/// `lm_flow`, and `lm_main` ship int8 variants in the quantised build —
1203/// `encoder` and `text_conditioner` are always plain `.onnx`.
1204fn 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
1223/// Exact transcripts for the reference wavs bundled in the sherpa-onnx
1224/// zero-shot archives (from the sherpa-onnx docs — zipvoice requires the
1225/// transcript to match the audio, a mismatch audibly degrades the clone).
1226const 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/// Minimal RIFF/PCM wav reader for reference clips: 16-bit PCM mono only
1252/// (the sherpa-onnx test wavs and typical cloning references are 16-bit
1253/// mono; anything else errors clearly rather than being silently mangled).
1254#[allow(clippy::cast_precision_loss)] // stereo downmix averages samples
1255fn 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    // Walk chunks for fmt (format + rate) and data (samples).
1270    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); // chunks are word-aligned
1296    }
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        // Downmix to mono by averaging channels (cloning references are
1306        // effectively mono anyway; sherpa-onnx expects a single channel).
1307        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
1323/// Parse a Supertonic voice id of the form `"sid:lang"` (e.g. `"6:ja"`)
1324/// into a `(speaker_id, language_code)` pair. A bare integer (`"6"`) is
1325/// accepted for backwards compatibility and defaults the language to `"en"`;
1326/// `None`/empty yields speaker 0 + `"en"`.
1327fn 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
1342/// Build one [`Voice`] per `(speaker, language)` pair for a Supertonic model.
1343/// Each voice id is `"sid:lang"` so [`SherpaOnnxEngine::speak`] can recover
1344/// both. With 10 preset speakers × 31 languages this yields 310 voices; the
1345/// list is deliberately exhaustive so callers can discover every combination.
1346fn 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
1368/// Matcha config: acoustic-model.onnx + vocoder.onnx + tokens.txt.
1369///
1370/// Matcha tarballs ship the acoustic model only — the vocoder is a separate
1371/// download (commonly `hifigan_v2.onnx`, or `vocos-22khz-univ.onnx` for newer
1372/// models). We look for the vocoder in the model directory first, then fall
1373/// back to the user's base model dir so a single shared vocoder can be reused
1374/// across Matcha models.
1375fn build_matcha_config(
1376    model_dir: &std::path::Path,
1377    base_dir: &std::path::Path,
1378) -> TtsResult<sherpa_onnx::OfflineTtsMatchaModelConfig> {
1379    // Acoustic model: try the canonical names in order of prevalence.
1380    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    // Vocoder: prefer co-located; fall back to shared in base_dir.
1392    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
1420/// VITS-family config. The right combination of lexicon / data_dir / dict_dir
1421/// depends on where the model came from:
1422///
1423/// - Piper / GitHub models → prefer `espeak-ng-data/`, never `dict_dir`.
1424/// - Chinese/Cantonese models → use `dict/` for jieba segmentation.
1425/// - MMS and other VITS → `lexicon.txt` if present, else nothing.
1426fn build_vits_config(
1427    model_dir: &std::path::Path,
1428    is_piper_or_github: bool,
1429    is_chinese: bool,
1430) -> sherpa_onnx::OfflineTtsVitsModelConfig {
1431    // Try the canonical name first, then scan for any non-acoustic .onnx
1432    // (handles Piper's en_US-amy-low.onnx naming convention).
1433    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    // Pick the right phonetic back-end.
1438    let (data_dir, dict_dir) = if is_piper_or_github {
1439        // Piper & friends ship espeak-ng-data; jieba would just complain.
1440        (existing_path(model_dir, "espeak-ng-data"), None)
1441    } else if is_chinese {
1442        // Chinese voices need jieba — point dict_dir at the bundled `dict/`.
1443        let dict = existing_path(model_dir, "dict").or_else(|| {
1444            // Some archives nest the dict directory under a child folder.
1445            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        // MMS / vanilla VITS: use espeak-ng-data if present, fall back to
1459        // a sibling dict/ directory only when lexicon.txt is absent.
1460        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        // Pitch > 1.0 shortens the buffer (fewer samples); pitch < 1.0 lengthens.
1513        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        // Unknown codes are returned lowercased (not "unknown") so callers
1559        // can still distinguish them in voice listings.
1560        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        // Some providers use BCP-47 with underscores; treat both.
1567        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        // Write a known buffer, read the header back, validate.
1574        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        // PCM format tag = 1, mono channels = 1.
1585        assert_eq!(u16::from_le_bytes([bytes[20], bytes[21]]), 1);
1586        assert_eq!(u16::from_le_bytes([bytes[22], bytes[23]]), 1);
1587        // Sample rate little-endian.
1588        assert_eq!(
1589            u32::from_le_bytes([bytes[24], bytes[25], bytes[26], bytes[27]]),
1590            22050
1591        );
1592        // 16-bit per sample.
1593        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        // Samples outside [-1.0, 1.0] must clamp rather than wrap. The
1601        // writer scales by 32767 (not 32768) so the clamped min is -32767.
1602        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]; // way out of range
1605        assert!(write_wav(&path, &samples, 16000));
1606
1607        let bytes = std::fs::read(&path).expect("wav written");
1608        // PCM data starts at byte 44.
1609        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        // Order matters — first one wins.
1629        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        // No matches.
1633        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    // ===== Model-config builders (one test per model family) =====
1653    //
1654    // These exercise the per-type dispatch in speak() without needing a real
1655    // sherpa-onnx runtime. Each test fakes the on-disk file layout for a
1656    // model family and verifies the resulting OfflineTts*ModelConfig points
1657    // at the expected paths.
1658
1659    use std::fs;
1660
1661    /// Build a temp directory resembling an extracted Kokoro archive.
1662    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        // Voices/data_dir are optional — their absence must surface as None
1696        // rather than a path to a nonexistent file.
1697        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        // Intentionally no voices.bin or espeak-ng-data/.
1701        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        // Matcha archives have shipped several acoustic-model names; the
1735        // builder must accept any of them in priority order.
1736        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        // Co-located vocoder is missing — fall back to a shared one in base.
1750        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        // Only vocoder + tokens, no acoustic.
1769        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    // ===== Supertonic builder / voice parsing tests =====
1776
1777    /// Build a temp directory resembling the extracted sherpa-onnx Supertonic
1778    /// int8 bundle: four `<name>.int8.onnx` files + tts.json +
1779    /// unicode_indexer.bin + voice.bin.
1780    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        // All seven fields must resolve — sherpa-onnx rejects the config if
1801        // any required path is None.
1802        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        // When the int8 variants are absent, the builder must pick the plain
1823        // `<name>.onnx` files (some custom Supertonic layouts ship unquantised).
1824        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        // An empty dir must produce None for every field rather than a path
1845        // to a nonexistent file (sherpa-onnx's create() handles the error).
1846        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    // ===== Zipvoice / pocket config builders =====
1858
1859    /// Temp dir resembling the extracted zipvoice distill-int8 archive,
1860    /// plus a base dir holding the shared vocos vocoder.
1861    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        // Unquantised archive (fp32 build): plain encoder/decoder must win.
1912        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        // The vocos vocoder is never bundled — a clear error beats a
1929        // sherpa-onnx create() failure deep in the C++ runtime.
1930        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    /// Temp dir resembling the pocket-tts int8 archive: only decoder,
1938    /// lm_flow, and lm_main are quantised; encoder + text_conditioner are
1939    /// plain .onnx even in the int8 build.
1940    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        // Never quantised upstream — the plain files must be picked.
1973        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    // ===== Reference-audio / wav reader =====
2015
2016    #[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        // Hand-roll a fmt chunk claiming 32-bit float (format 3).
2033        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()); // IEEE float
2040        bytes.extend_from_slice(&1u16.to_le_bytes()); // mono
2041        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()); // 32-bit
2045        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        // Sorted order: a.wav before b.wav, and test_wavs/ wins over loose.
2065        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        // Known bundled wav → known transcript, no credentials needed.
2075        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        // User transcript wins.
2080        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        // Unknown wav + no override → actionable error.
2087        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        // Backwards compatibility: a plain speaker id picks the default lang.
2104        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        // "6:" → sid 6, empty lang defaults to "en" rather than forwarding an
2117        // empty string that sherpa-onnx would reject.
2118        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        // A malformed sid must not panic — fall back to 0.
2124        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        // 2 speakers × 3 languages = 6 voices, ids encoded as "sid:lang".
2133        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        // Each combination is present, and ids round-trip back to (sid, lang).
2167        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        // Voice carries the matching language code.
2172        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        // Integration: the real registry entry must produce 10 speakers ×
2183        // 31 languages = 310 voices. This also verifies the JSON parses and
2184        // model_type "supertonic" routes through the expansion branch.
2185        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        // Spot-check a known pair and the id encoding.
2196        assert!(voices.iter().any(|v| v.id == "6:ja"));
2197        // Every voice carries the sherpaonnx provider tag.
2198        assert!(voices.iter().all(|v| v.provider == "sherpaonnx"));
2199    }
2200
2201    #[test]
2202    fn test_engine_num_steps_default_and_valid_override() {
2203        // No numSteps → default 8.
2204        assert_eq!(SherpaOnnxEngine::new("{}").num_steps, 8);
2205        // Valid in-range value passes through.
2206        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        // Below 5 and above 12 are outside Supertonic's supported range.
2214        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        // Piper archives use a domain-specific .onnx name rather than model.onnx.
2226        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        // The model is found by scanning for the first non-vocoder .onnx.
2237        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        // Chinese models ship a dict/ directory for jieba segmentation.
2255        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        // MMS-style: lexicon.txt present, no dict/, no espeak-ng-data.
2269        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        // MMS without lexicon.txt → fall back to dict/ if present.
2285        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        // Both model.onnx and a stray .onnx present — canonical wins.
2298        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        // Only vocoder/acoustic-steps files — none should match.
2308        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        // If the top dir has tokens.txt or any .onnx, return it as-is.
2326        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        // GitHub archives often extract to <name>/<name>/. If the outer dir
2335        // is empty except for a single subdir with the actual model, descend.
2336        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        // Ambiguous layout — don't guess, return the original.
2348        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    // ===== SherpaOnnxEngine public-API tests (no model download) =====
2365    //
2366    // Construct engines with various modelId values and verify registry
2367    // lookup, voice enumeration, and graceful failure paths. None of these
2368    // need an actual model on disk because they exit before generate().
2369
2370    #[test]
2371    fn test_engine_construction_with_model_id_does_not_load_yet() {
2372        // Setting a modelId is lazy — actual load happens on speak(). So
2373        // construction must succeed even when the model isn't downloaded.
2374        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        // JSON numbers must be coerced to strings, not silently dropped
2381        // (a strict HashMap<String, String> parse rejects the whole object).
2382        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        // The registry now ships as the sherpa-onnx-models crate; the old
2405        // parse_model JSON path is gone. What used to be per-field parse
2406        // tests is now: registry is large, typed, and defaults sane.
2407        let models = load_models();
2408        assert!(models.len() > 1700, "registry shrank: {}", models.len());
2409        // engines matches the family rule: upstream graphs are
2410        // sherpa-onnx-drivable; AACTools artifacts may list floravox /
2411        // rust-tts-wrapper (or a combination) instead
2412        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        // a known MMS entry (no explicit model_type in raw data) parses
2424        // with the vits-family default
2425        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        // Error message should hint at how many models ARE available so the
2439        // caller can pick a valid one.
2440        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        // Pick a known multi-speaker model from the registry and verify
2447        // get_voices() enumerates `num_speakers` voice ids without needing
2448        // the actual model files (it reads from the registry only).
2449        let engine = SherpaOnnxEngine::new(r#"{"modelId":"coqui-en-vctk"}"#);
2450        // If this particular id isn't in the registry, skip loudly so the
2451        // test output shows the skip rather than passing vacuously. A
2452        // silent `return` here previously masked the test becoming a no-op
2453        // when the model id was renamed.
2454        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        // All voices must carry the sherpaonnx provider tag.
2468        assert!(voices.iter().all(|v| v.provider == "sherpaonnx"));
2469    }
2470
2471    #[test]
2472    fn test_engine_get_voices_single_speaker_returns_one() {
2473        // kokoro-en-v0_19 is actually an 11-speaker model (the registry now
2474        // carries the real count), so use a genuinely single-speaker voice.
2475        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        // Unknown modelId: get_voices() still returns one voice (the
2485        // default speaker 0) rather than panicking.
2486        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); // default
2502    }
2503
2504    #[test]
2505    fn test_engine_num_threads_zero_falls_back_to_default() {
2506        // 0 would cause sherpa-onnx to use no threads — clamp to default.
2507        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}