diff --git a/.env.example b/.env.example index 4dfb41c..645170b 100644 --- a/.env.example +++ b/.env.example @@ -84,3 +84,9 @@ QWEN_API_KEY=your-dashscope-key # Voice cloning live test (tests/cloning_live.rs): path to an Apple # Personal Voice "Recordings" export zip. # QWEN_PV_ZIP=/path/to/My Personal Voice - Recordings.zip + +# Qwen3-TTS local (qwen3-tts.cpp) — used by tests/qwen3_local_live.rs. +# Library must be built first: scripts/build-qwen3-local.sh +# QWEN3_TTS_LIB=/path/to/qwen3-tts.cpp (build + link) +# QWEN3_TTS_MODELS=/path/to/qwen3-tts.cpp/models +# QWEN3_TTS_REF=/path/to/reference.wav diff --git a/Cargo.toml b/Cargo.toml index 5521c8f..123726e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ default = ["system", "cloud", "sherpaonnx", "sapi", "display_names"] system = ["speech-dispatcher"] # cloud-core: the pure provider shapes shared by native + wasm (config, # sigv4, request builders, response parsers) — no HTTP stack -cloud-core = ["dep:serde_json", "base64", "speechmarkdown", "sha2"] +cloud-core = ["dep:serde_json", "dep:base64", "speechmarkdown", "dep:sha2"] cloud = ["cloud-core", "reqwest", "tungstenite", "uuid", "url", "symphonia"] # pocket-tts with REAL word timings: our own streaming loop over the tapped # Kyutai ONNX graphs (attention = the model's own alignment) @@ -83,6 +83,13 @@ sapi = ["windows", "speechmarkdown"] # "af-ZA") via ICU4X. Used by all voice mappers so consumers get consistent # names regardless of engine. display_names = ["dep:icu_displaynames", "dep:icu_locid"] +# Local Qwen3-TTS via qwen3-tts.cpp (GGML). The C++ library is NOT +# vendored: set QWEN3_TTS_LIB to the directory containing the built +# libqwen3tts_shared + headers (see scripts/build-qwen3-local.sh, which +# clones and builds predict-woo/qwen3-tts.cpp). Opt-in like +# speech-dispatcher: enabling the feature without the lib fails the +# build with instructions. +qwen3-local = ["dep:serde_json", "dep:base64"] # Voice cloning: VoiceIdentity/VoiceCloning trait, LJSpeech corpus, Apple # Personal Voice export import, and enrollment clients for the instant # cloners (Qwen voice-enrollment, ElevenLabs IVC). Needs cloud (HTTP + diff --git a/README.md b/README.md index 610236d..cdcfeaf 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Cross-platform TTS (Text-to-Speech) wrapper with C ABI. Mirrors [js-tts-wrapper] | AVSpeech (macOS) | Local | None | — (system plays) | — | Estimated | — | | SAPI (Windows) | Local | None | — (system plays) | — | Estimated (SAPI events) | — | | Sherpa-ONNX | Local (1300+ models) | None | Sentence batches | Speakers | Estimated | — | +| Qwen3-TTS local | Local (qwen3-tts.cpp, GGML) | Model dir (+ lib via QWEN3_TTS_LIB) | After generation | 10 languages + any reference audio | Estimated | Stripped | | floravox | Local (piper/MMS/Matcha/Kokoro ONNX — 1,100+ voices across ~1,100 languages) | Model dir | Streamed (per segment) | Filesystem scan | **Measured** (patched) / student / estimated | Native SSML | | Pocket (Kyutai) | Local (phoneme ONNX bundle + any donor wav) | None | Frame-by-frame | Reference wav | **Measured** (attention tap) | Native SSML + IPA phonemes | | Azure | Cloud | Key + Region | Real-time (WS) / Streamed (REST) | API | **Real** (WS) | Platform-aware | @@ -37,6 +38,28 @@ Cross-platform TTS (Text-to-Speech) wrapper with C ABI. Mirrors [js-tts-wrapper] - **Streaming**: Audio is delivered through the `on_audio` callback in chunks, as it becomes available. REST engines stream the response body as bytes arrive over the network (MP3 decoded to PCM16 mono incrementally on a background reader thread; raw-PCM providers pass straight through); Azure and Edge deliver real-time over WebSockets; Sherpa-ONNX delivers each sentence batch as it is synthesised (via the generate progress callback — a single-sentence utterance still completes before delivery). Exceptions: Google and ElevenLabs `with-timestamps` return one JSON document with base64 audio, so they can only deliver after the response completes (an API limitation, not buffering). Estimated word boundaries (engines without API timing data) fire progressively during streaming, anchored to delivered audio, rather than all at once when the response completes. +## Qwen3-TTS local (`qwen3-local` feature) + +Third offline engine — [qwen3-tts.cpp](https://github.com/predict-woo/qwen3-tts.cpp) (MIT; models Apache-2.0), and the only local engine with **zero-shot voice cloning**. The C++ library is not vendored: build it once, then point the build at it. + +```bash +scripts/build-qwen3-local.sh # clones + builds GGML into ~/spikes +cd ~/spikes/qwen3-tts.cpp && python scripts/setup_pipeline_models.py # one-time GGUF conversion +QWEN3_TTS_LIB=~/spikes/qwen3-tts.cpp cargo build --features qwen3-local +``` + +```rust +// Speak with any reference audio — voice = path to a WAV: +engine.speak("Hello", Some("/path/to/reference.wav"), 1.0, 1.0, 1.0, Some(&mut cb), None, None)?; +// Or a language voice ("en", "fr", … — the 10 Qwen locales), "default", +// or an emb: speaker-embedding handle from the qwen3-local +// cloner (--features qwen3-local,cloning): embedding extracted once, +// reused per utterance. Fully offline — reference audio never leaves +// the machine. +``` + +Behaviour: PCM16 mono 24 kHz output; SSML-in is stripped; volume is real PCM gain; rate/pitch have no upstream control; word boundaries are duration-scaled estimates; engine calls are serialized (upstream is not concurrent-safe); CPU synthesis is batch-grade (~0.1× realtime on 6 cores — use Metal/CUDA builds for speed). Cloning is timbre-grade: the upstream x-vector mode carries the speaker's timbre, while accent/prosody come from the model's language prior. + ## Voice cloning (experimental, `cloning` feature) Bank a voice once, enroll it with every cloning-capable engine, speak it everywhere. Rust-only for now (no FFI); enable with `--features cloning`. @@ -94,6 +117,7 @@ if let Some(spec) = cloner.consent_spec() { ``` - Personal Voice exports are audio-only (no transcripts); attach them via `VoiceCorpus::phrases` or ASR when an engine needs them (Qwen doesn't). - Consent-gated providers are implemented behind their real-world gates: **Google Chirp 3 ICV** (`generateVoiceCloningKey`, allow-listed projects — `consent_spec()` returns the exact script to record) and **Azure Personal Voice** (consent → personal voice → long-running-operation `poll_clone`; intake-gated at aka.ms/customneural; requires a `projectId` Custom Voice project). Azure cloned voices speak via the `"{base_model}/{speakerProfileId}"` voice-string convention (`mstts:ttsembedding`). Neither is live-testable without vendor approval; request shapes are doc-verified and unit-tested. +- **qwen3-local** cloner (with `--features qwen3-local,cloning`): zero-shot and fully offline — extracts an ECAPA speaker embedding from the identity's best clip; the resulting `emb:` handle is reused per utterance without re-encoding. No upload, no consent gate, no quota (live-verified). - Job-based providers (Murf, Resemble) are not implemented yet. - Cloning someone's voice requires their permission; banked-voice programs' licensed synthetic voices must not be re-cloned. diff --git a/build.rs b/build.rs index 2971ced..b1665f0 100644 --- a/build.rs +++ b/build.rs @@ -27,6 +27,34 @@ fn main() { } } + // qwen3-local: link the user-built qwen3-tts.cpp shared library. + // Nothing is vendored (the project + GGML are large); the feature is + // opt-in and fails with instructions when the library is missing. + let qwen3_local = env::var("CARGO_FEATURE_QWEN3_LOCAL").is_ok(); + if qwen3_local { + let lib_dir = env::var("QWEN3_TTS_LIB").unwrap_or_default(); + let non_empty = |v: &str| !v.is_empty(); + assert!( + non_empty(&lib_dir), + "The 'qwen3-local' feature needs the qwen3-tts.cpp library. \ + Build it once (scripts/build-qwen3-local.sh clones + builds into \ + ~/spikes or a dir of your choice) and point QWEN3_TTS_LIB at that \ + repo root (containing build/libqwen3tts.so and \ + src/qwen3tts_c_api.h), e.g.: \ + QWEN3_TTS_LIB=/home/me/spikes/qwen3-tts.cpp cargo build \ + --features qwen3-local" + ); + // The shared artifact is libqwen3tts.so (target name + // qwen3tts_shared, OUTPUT_NAME qwen3tts). GGML may be linked + // dynamically — expose its lib dirs for link + runtime. + println!("cargo:rustc-link-search=native={lib_dir}/build"); + println!("cargo:rustc-link-search=native={lib_dir}/ggml/build/src"); + println!("cargo:rustc-link-lib=dylib=qwen3tts"); + println!("cargo:rustc-link-arg=-Wl,-rpath,{lib_dir}/build"); + println!("cargo:rustc-link-arg=-Wl,-rpath,{lib_dir}/ggml/build/src"); + println!("cargo:rerun-if-env-changed=QWEN3_TTS_LIB"); + } + // The avsynth shim is only compiled on macOS (it wraps AVSpeechSynthesizer). // The `cc` crate is a macOS-only build-dependency for this reason. #[cfg(target_os = "macos")] diff --git a/cbindgen.toml b/cbindgen.toml index 7bd6e42..df0eb60 100644 --- a/cbindgen.toml +++ b/cbindgen.toml @@ -4,3 +4,42 @@ autogen_warning = "/* Auto-generated. Do not edit. */" no_includes = true sys_includes = ["stdint.h", "stdbool.h"] cpp_compat = true + +# Whitelist the crate's actual C ABI. The qwen3-local FFI binds an +# EXTERNAL C library (qwen3-tts.cpp) and must never leak into this +# header; a whitelist (vs. excludes) also protects against future +# internal declarations appearing here. The C-harness CI job compiles +# against this header — the safety net for anything missed. +[export] +include = [ + "tts_ctx", + "tts_voice", + "tts_engine_info", + "tts_create", + "tts_destroy", + "tts_speak", + "tts_speak_ssml", + "tts_speak_sync", + "tts_stop", + "tts_pause", + "tts_resume", + "tts_get_voices", + "tts_free_voices", + "tts_set_voice", + "tts_set_rate", + "tts_set_pitch", + "tts_set_volume", + "tts_set_on_audio", + "tts_set_on_boundary", + "tts_set_on_mark", + "tts_set_on_viseme", + "tts_set_on_start", + "tts_set_on_end", + "tts_set_on_error", + "tts_get_engine_count", + "tts_get_engines", + "tts_free_engines", + "tts_get_last_error", + "tts_synth_to_bytes", + "tts_free_bytes", +] diff --git a/scripts/build-qwen3-local.sh b/scripts/build-qwen3-local.sh new file mode 100755 index 0000000..f7477a3 --- /dev/null +++ b/scripts/build-qwen3-local.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Build predict-woo/qwen3-tts.cpp (GGML) for the `qwen3-local` feature. +# Usage: scripts/build-qwen3-local.sh [target-dir] (default: ~/spikes) +# Then: QWEN3_TTS_LIB=/qwen3-tts.cpp cargo build --features qwen3-local +# Models (one-time): run their scripts/setup_pipeline_models.py — see README. +set -euo pipefail +TARGET_DIR="${1:-$HOME/spikes}" +REPO="$TARGET_DIR/qwen3-tts.cpp" + +if [ ! -d "$REPO" ]; then + git clone --recurse-submodules -j4 https://github.com/predict-woo/qwen3-tts.cpp "$REPO" +else + git -C "$REPO" submodule update --init --recursive +fi + +# GGML: enable the accelerators available on this machine (CPU always). +GGML_FLAGS=() +command -v nvcc >/dev/null 2>&1 && GGML_FLAGS+=(-DGGML_CUDA=ON) +[ "$(uname -s)" = "Darwin" ] && GGML_FLAGS+=(-DGGML_METAL=ON) +cmake -S "$REPO/ggml" -B "$REPO/ggml/build" -DCMAKE_BUILD_TYPE=Release "${GGML_FLAGS[@]}" +cmake --build "$REPO/ggml/build" -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" + +cmake -S "$REPO" -B "$REPO/build" -DCMAKE_BUILD_TYPE=Release +cmake --build "$REPO/build" -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" + +echo +echo "Built: $REPO/build/libqwen3tts.so" +echo "Next: QWEN3_TTS_LIB=$REPO cargo build --features qwen3-local" diff --git a/src/cloning/mod.rs b/src/cloning/mod.rs index 9f6f494..7aa48e8 100644 --- a/src/cloning/mod.rs +++ b/src/cloning/mod.rs @@ -32,6 +32,8 @@ mod corpus; mod elevenlabs; mod google; mod qwen; +#[cfg(feature = "qwen3-local")] +mod qwen3local; mod registry; pub use corpus::VoiceCorpus; @@ -411,6 +413,8 @@ pub trait VoiceCloning: Send + Sync { /// support. #[must_use] pub fn create_cloner(engine_id: &str, credentials_json: &str) -> Option> { + // (qwen3-local loads models and may fail — handled via the inner + // Result, mapped to None with the error printed.) let creds: std::collections::HashMap = if credentials_json.is_empty() { std::collections::HashMap::new() } else { @@ -425,6 +429,14 @@ pub fn create_cloner(engine_id: &str, credentials_json: &str) -> Option Some(Arc::new(google::GoogleCloner::new(&creds))), #[cfg(feature = "cloning")] "azure" => Some(Arc::new(azure::AzureCloner::new(&creds))), + #[cfg(all(feature = "cloning", feature = "qwen3-local"))] + "qwen3-local" => match qwen3local::Qwen3LocalCloner::new(&creds) { + Ok(cloner) => Some(Arc::new(cloner)), + Err(e) => { + eprintln!("qwen3-local cloner failed to load: {e}"); + None + } + }, _ => None, } } @@ -439,6 +451,8 @@ pub fn cloning_engines() -> Vec<&'static str> { ids.push("elevenlabs"); ids.push("google"); ids.push("azure"); + #[cfg(feature = "qwen3-local")] + ids.push("qwen3-local"); } ids } diff --git a/src/cloning/qwen3local.rs b/src/cloning/qwen3local.rs new file mode 100644 index 0000000..26236c1 --- /dev/null +++ b/src/cloning/qwen3local.rs @@ -0,0 +1,80 @@ +//! Zero-shot cloner for the local `qwen3-local` engine: extracts an +//! ECAPA speaker embedding from the identity's best clip once, and +//! returns it as the handle — the engine synthesizes with +//! `qwen3_tts_synthesize_with_embedding`, skipping re-encoding on every +//! utterance. +//! +//! "Cloning" here is pure local computation (no upload, no quota, no +//! consent gate) — the reference audio never leaves the machine. + +use super::{ + select_clips, wav_bytes, CloneHandle, CloneOutcome, CloningMode, VoiceCloning, VoiceIdentity, +}; +use crate::qwen3_local_engine::Qwen3LocalEngine; +use crate::qwen3_local_support::encode_embedding; +use crate::types::{TtsError, TtsResult}; +use std::collections::HashMap; + +pub(crate) struct Qwen3LocalCloner { + engine: Qwen3LocalEngine, +} + +impl Qwen3LocalCloner { + pub(crate) fn new(credentials: &HashMap) -> TtsResult { + Ok(Self { + engine: Qwen3LocalEngine::new(credentials)?, + }) + } +} + +impl VoiceCloning for Qwen3LocalCloner { + fn engine_id(&self) -> &'static str { + "qwen3-local" + } + + fn cloning_mode(&self) -> CloningMode { + CloningMode::ZeroShot + } + + fn clone_voice(&self, identity: &VoiceIdentity) -> TtsResult { + let picked = select_clips(&identity.clips, 10); + let Some(clip) = picked.first() else { + return Err(TtsError( + "qwen3-local cloning: identity has no clips".into(), + )); + }; + // The C API extracts embeddings from a WAV path: materialise the + // clip in the system temp dir (deleted after). pid + nanos keeps + // concurrent clone_voice calls from racing on the file name. + let wav = wav_bytes(&clip.pcm, clip.sample_rate); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "rust-tts-qwen3-emb-{}-{nanos}.wav", + std::process::id() + )); + std::fs::write(&path, &wav).map_err(|e| TtsError(format!("write temp reference: {e}")))?; + let result = self.engine.extract_embedding(&path.to_string_lossy()); + let _ = std::fs::remove_file(&path); + let embedding = result?; + Ok(CloneOutcome::Ready(CloneHandle { + engine: "qwen3-local".into(), + // The handle IS the embedding (opaque to everyone else; + // only the qwen3-local engine interprets the emb: prefix). + voice_id: encode_embedding(&embedding), + model: None, + })) + } + + fn list_cloned(&self) -> TtsResult> { + // Embeddings live in the caller's registry; nothing server-side. + Ok(Vec::new()) + } + + fn delete_cloned(&self, _handle: &CloneHandle) -> TtsResult<()> { + // Dropping the registry entry is the whole lifecycle. + Ok(()) + } +} diff --git a/src/factory.rs b/src/factory.rs index cb87e57..b03f97d 100644 --- a/src/factory.rs +++ b/src/factory.rs @@ -9,6 +9,8 @@ use std::sync::Arc; use crate::avsynth_engine::AvSynthEngine; #[cfg(feature = "cloud")] use crate::cloud_engine; +#[cfg(feature = "qwen3-local")] +use crate::qwen3_local_engine::Qwen3LocalEngine; #[cfg(all(feature = "sapi", target_os = "windows"))] use crate::sapi_engine::SapiEngine; #[cfg(feature = "sherpaonnx")] @@ -85,6 +87,32 @@ pub fn create_engine(engine_id: &str, credentials_json: &str) -> Option { + #[cfg(feature = "qwen3-local")] + { + let creds: std::collections::HashMap = + if credentials_json.is_empty() { + std::collections::HashMap::new() + } else { + serde_json::from_str(credentials_json).unwrap_or_default() + }; + return match Qwen3LocalEngine::new(&creds) { + Ok(engine) => Some(Arc::new(engine)), + Err(e) => { + eprintln!("Engine 'qwen3-local' failed to load: {e}"); + None + } + }; + } + #[cfg(not(feature = "qwen3-local"))] + { + eprintln!( + "Engine 'qwen3-local' is not enabled in this build. \ + Rebuild with --features qwen3-local." + ); + return None; + } + } "floravox" => { #[cfg(feature = "floravox")] { @@ -146,6 +174,7 @@ pub fn engine_count() -> usize { /// Return a list of all registered engine descriptors. #[must_use] #[allow(clippy::vec_init_then_push)] +#[allow(clippy::too_many_lines)] pub fn engine_list() -> Vec { #[allow(unused_mut)] let mut engines = Vec::new(); @@ -182,6 +211,14 @@ pub fn engine_list() -> Vec { credential_keys_json: "[]".into(), }); + #[cfg(feature = "qwen3-local")] + engines.push(EngineDescriptor { + id: "qwen3-local".into(), + name: "Qwen3-TTS local (qwen3-tts.cpp)".into(), + needs_credentials: true, + credential_keys_json: r#"["modelsDir","threads","temperature","topK","maxTokens"]"#.into(), + }); + #[cfg(feature = "floravox")] engines.push(EngineDescriptor { id: "floravox".into(), diff --git a/src/lib.rs b/src/lib.rs index 81ba947..6aed61c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,6 +62,17 @@ pub mod floravox_engine; mod sapi_engine; #[cfg(feature = "sherpaonnx")] pub mod sherpaonnx_engine; + +/// Pure helpers for the qwen3-local engine — always compiled (and +/// unit-tested in CI) even when the engine feature is off. +pub mod qwen3_local_support; + +/// Local Qwen3-TTS via qwen3-tts.cpp (GGML). Requires the user-built C++ +/// library — see `scripts/build-qwen3-local.sh` and the feature docs in +/// Cargo.toml. Zero-shot voice cloning: pass a reference WAV path (or an +/// `emb:` handle from the cloning cloner) as the voice string. +#[cfg(feature = "qwen3-local")] +pub mod qwen3_local_engine; #[cfg(all(feature = "system", target_os = "linux"))] mod system_engine; pub mod timeline; diff --git a/src/qwen3_local_engine.rs b/src/qwen3_local_engine.rs new file mode 100644 index 0000000..6899b26 --- /dev/null +++ b/src/qwen3_local_engine.rs @@ -0,0 +1,455 @@ +//! Local Qwen3-TTS engine via [qwen3-tts.cpp](https://github.com/predict-woo/qwen3-tts.cpp) +//! (GGML, MIT; Qwen3-TTS-12Hz models Apache-2.0). Feature `qwen3-local`; +//! the C++ library is built by the user (`scripts/build-qwen3-local.sh`) +//! and located via `QWEN3_TTS_LIB`. +//! +//! Third offline engine (after sherpa-onnx and floravox), and the only +//! local one with zero-shot voice cloning: any voice string passed to +//! `speak` is treated as a reference-audio WAV path — or an +//! `emb:` speaker embedding produced by the `qwen3-local` +//! cloner (see `crate::cloning`). +//! +//! Output: PCM16 LE mono 24 kHz (`on_audio`), word boundaries are +//! duration-scaled estimates (the C++ pipeline exposes no timestamps). +//! +//! # Cloning quality caveat (2026-10-10 finding) +//! Upstream implements **x-vector-only** cloning (ECAPA embedding): +//! timbre is cloned, but accent/prosody come from the model prior — +//! with `language_id=en` that prior is predominantly American-accented. +//! This is the official `x_vector_only_mode=true` "reduced quality" +//! mode; in-context (codec-prefix) cloning is unimplemented upstream. +//! Full analysis + the upstream-PR plan live in the project's +//! voice-cloning plan notes (kept out of the repository). + +use crate::engine::{estimate_word_boundaries, strip_ssml_to_text, TtsEngine}; +use crate::qwen3_local_support::{ + apply_gain, decode_embedding, id_to_iso639_3, language_id_for, supported_languages, + EMBEDDING_SIZE, +}; +use crate::types::{Gender, LanguageCode, TtsError, TtsResult, Voice, WordBoundary}; +use std::collections::HashMap; +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_float, c_int}; + +// ==== FFI (src/qwen3tts_c_api.h — hand-declared; the surface is tiny) ==== + +// Wrapped in a macro: cbindgen's synth parser cannot expand +// macro_rules!, keeping these external-library bindings out of the +// generated C header (rustc expands this normally). +/// Process-wide lock around every C++ entry point (moved rationale: +/// upstream is not concurrent-safe — see qwen3_local_support for the +/// testable pure helpers). Callers can create/destroy/speak from any +/// threads; this serializes the calls. +static CPP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +macro_rules! qwen3_ffi { + () => { + #[derive(Clone)] + #[repr(C)] + pub(crate) struct Qwen3TtsParams { + max_audio_tokens: i32, + temperature: f32, + top_p: f32, + top_k: i32, + n_threads: i32, + repetition_penalty: f32, + language_id: i32, + } + + #[repr(C)] + pub(crate) struct Qwen3TtsAudio { + samples: *const c_float, + n_samples: i32, + sample_rate: i32, + } + + // Opaque C++ handle. An extern-type-pattern struct with no fields is + // FFI-safe for opaque pointers. + #[repr(C)] + pub(crate) struct Qwen3Tts { + pub(crate) _private: [u8; 0], + } + + extern "C" { + fn qwen3_tts_default_params(params: *mut Qwen3TtsParams); + fn qwen3_tts_create(model_dir: *const c_char, n_threads: c_int) -> *mut Qwen3Tts; + fn qwen3_tts_is_loaded(tts: *const Qwen3Tts) -> c_int; + fn qwen3_tts_synthesize( + tts: *mut Qwen3Tts, + text: *const c_char, + params: *const Qwen3TtsParams, + ) -> *mut Qwen3TtsAudio; + fn qwen3_tts_free_audio(audio: *mut Qwen3TtsAudio); + fn qwen3_tts_destroy(tts: *mut Qwen3Tts); + fn qwen3_tts_synthesize_with_voice_file( + tts: *mut Qwen3Tts, + text: *const c_char, + reference_audio_path: *const c_char, + params: *const Qwen3TtsParams, + ) -> *mut Qwen3TtsAudio; + fn qwen3_tts_extract_embedding_file( + tts: *mut Qwen3Tts, + reference_audio_path: *const c_char, + embedding_out: *mut c_float, + max_size: i32, + ) -> c_int; + fn qwen3_tts_synthesize_with_embedding( + tts: *mut Qwen3Tts, + text: *const c_char, + embedding: *const c_float, + embedding_size: i32, + params: *const Qwen3TtsParams, + ) -> *mut Qwen3TtsAudio; + fn qwen3_tts_get_error(tts: *const Qwen3Tts) -> *const c_char; + } + }; +} + +qwen3_ffi!(); + +/// The local engine. One `Qwen3Tts` per model directory; synthesis is +/// CPU-bound and single-utterance (the C++ pipeline is not re-entrant). +pub struct Qwen3LocalEngine { + tts: *mut Qwen3Tts, + params: Qwen3TtsParams, +} + +impl std::fmt::Debug for Qwen3LocalEngine { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Qwen3LocalEngine") + .field("loaded", { + let _guard = CPP_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + &(unsafe { qwen3_tts_is_loaded(self.tts) != 0 }) + }) + .finish_non_exhaustive() + } +} + +// The C++ object owns GGML backends with per-call scheduling; the +// upstream API is single-threaded-per-handle and we never share it +// across threads within one engine instance. +unsafe impl Send for Qwen3LocalEngine {} +unsafe impl Sync for Qwen3LocalEngine {} + +impl Qwen3LocalEngine { + /// Create from credentials: `modelsDir` (required — holds the two + /// GGUFs), `threads`, `temperature`, `topK`, `maxTokens`. + /// + /// # Errors + /// When `modelsDir` is missing/invalid or the C++ engine fails to + /// load the GGUF models. + pub fn new(credentials: &HashMap) -> TtsResult { + let models_dir = credentials + .get("modelsDir") + .filter(|d| !d.is_empty()) + .ok_or_else(|| { + TtsError("qwen3-local: credentials need modelsDir (dir with qwen3-tts-0.6b-f16.gguf + qwen3-tts-tokenizer-f16.gguf)".into()) + })?; + let threads: i32 = credentials + .get("threads") + .and_then(|t| t.parse().ok()) + .unwrap_or(4); + let c_dir = + CString::new(models_dir.as_str()).map_err(|e| TtsError(format!("modelsDir: {e}")))?; + let _guard = CPP_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let tts = unsafe { qwen3_tts_create(c_dir.as_ptr(), threads) }; + if tts.is_null() { + return Err(TtsError("qwen3-local: qwen3_tts_create failed".into())); + } + let mut params = Qwen3TtsParams { + max_audio_tokens: 4096, + temperature: 0.9, + top_p: 1.0, + top_k: 50, + n_threads: threads, + repetition_penalty: 1.05, + language_id: 2050, // en + }; + unsafe { qwen3_tts_default_params(std::ptr::addr_of_mut!(params)) }; + // Credential overrides after defaults. + if let Some(t) = credentials.get("temperature").and_then(|v| v.parse().ok()) { + params.temperature = t; + } + if let Some(k) = credentials.get("topK").and_then(|v| v.parse().ok()) { + params.top_k = k; + } + if let Some(m) = credentials.get("maxTokens").and_then(|v| v.parse().ok()) { + params.max_audio_tokens = m; + } + Ok(Self { tts, params }) + } + + fn last_error(&self) -> String { + unsafe { + let err = qwen3_tts_get_error(self.tts); + if err.is_null() { + String::new() + } else { + CStr::from_ptr(err).to_string_lossy().into_owned() + } + } + } + + /// Run one synthesis and return PCM16 LE mono bytes. `voice`: + /// `None` or `"default"` — no reference; a path — reference WAV; + /// `emb:` — cached speaker embedding from the cloner. + fn synthesize_to_pcm16( + &self, + text: &str, + voice: Option<&str>, + mut params: Qwen3TtsParams, + ) -> TtsResult<(Vec, i32)> { + // SSML-in: strip tags the pipeline would read aloud. + let plain = if text.trim_start().starts_with('<') { + strip_ssml_to_text(text) + } else { + text.to_string() + }; + let c_text = CString::new(plain).map_err(|e| TtsError(format!("text: {e}")))?; + // Upstream is not concurrent-safe: serialize at the C boundary. + let _guard = CPP_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // A language voice selects the codec language token; reference + // paths and emb: handles are cloning inputs instead. + let voice_trim = voice.map(str::trim); + if let Some(lang) = voice_trim.filter(|v| !v.is_empty() && *v != "default") { + if let Some(lid) = language_id_for(lang) { + params.language_id = lid; + } + } + let voice_for_audio = voice_trim.filter(|v| !v.is_empty() && *v != "default"); + let is_language_voice = voice_for_audio.is_some_and(|v| language_id_for(v).is_some()); + let audio_ptr = match voice_for_audio.filter(|_| !is_language_voice) { + None => unsafe { + qwen3_tts_synthesize(self.tts, c_text.as_ptr(), std::ptr::addr_of!(params)) + }, + Some(v) if v.starts_with("emb:") => { + let raw = decode_embedding(v) + .map_err(|e| TtsError(format!("qwen3-local: bad emb: voice: {e}")))?; + unsafe { + qwen3_tts_synthesize_with_embedding( + self.tts, + c_text.as_ptr(), + raw.as_ptr(), + raw.len() as i32, + std::ptr::addr_of!(params), + ) + } + } + Some(path) => { + let c_path = + CString::new(path).map_err(|e| TtsError(format!("reference path: {e}")))?; + unsafe { + qwen3_tts_synthesize_with_voice_file( + self.tts, + c_text.as_ptr(), + c_path.as_ptr(), + std::ptr::addr_of!(params), + ) + } + } + }; + if audio_ptr.is_null() { + return Err(TtsError(format!( + "qwen3-local: synthesis failed: {}", + self.last_error() + ))); + } + // Convert f32 → PCM16 and free the C++ buffer even on early return. + let mut pcm = Vec::new(); + unsafe { + let audio = &*audio_ptr; + #[allow(clippy::cast_possible_truncation)] + let n = audio.n_samples.max(0) as usize; + if !audio.samples.is_null() && n > 0 { + pcm.reserve(n * 2); + for i in 0..n { + let s = *audio.samples.add(i); + #[allow(clippy::cast_possible_truncation)] + let v = (s.clamp(-1.0, 1.0) * 32_767.0) as i16; + pcm.extend_from_slice(&v.to_le_bytes()); + } + } + let rate = audio.sample_rate; + qwen3_tts_free_audio(audio_ptr); + if pcm.is_empty() { + return Err(TtsError("qwen3-local: synthesis produced no audio".into())); + } + Ok((pcm, rate)) + } + } + + /// Extract a speaker embedding from a reference WAV (for the + /// cloner). Returns the f32 embedding. + /// + /// # Errors + /// When the path is invalid or extraction fails in the C++ pipeline. + pub(crate) fn extract_embedding(&self, reference_wav: &str) -> TtsResult> { + let c_path = + CString::new(reference_wav).map_err(|e| TtsError(format!("reference path: {e}")))?; + let mut out = vec![0.0f32; EMBEDDING_SIZE]; + let _guard = CPP_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let n = unsafe { + qwen3_tts_extract_embedding_file( + self.tts, + c_path.as_ptr(), + out.as_mut_ptr(), + EMBEDDING_SIZE as i32, + ) + }; + if n <= 0 { + return Err(TtsError(format!( + "qwen3-local: embedding extraction failed: {}", + self.last_error() + ))); + } + out.truncate(n as usize); + Ok(out) + } +} + +impl Drop for Qwen3LocalEngine { + fn drop(&mut self) { + if !self.tts.is_null() { + let _guard = CPP_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + unsafe { qwen3_tts_destroy(self.tts) }; + } + } +} + +impl TtsEngine for Qwen3LocalEngine { + fn speak( + &self, + text: &str, + voice: Option<&str>, + _rate: f32, + _pitch: f32, + volume: f32, + mut on_audio: Option, + mut on_boundary: Option, + _on_mark: Option, + ) -> TtsResult<()> { + // SSML-in: strip once here — both the C++ input and the word + // boundary estimates use the plain text (tags are never words). + let plain = if text.trim_start().starts_with('<') { + strip_ssml_to_text(text) + } else { + text.to_string() + }; + let params = self.params.clone(); + let (pcm, rate) = self.synthesize_to_pcm16(&plain, voice, params)?; + // Volume is a real control here: linear PCM gain (clamped), + // applied before delivery. Rate/pitch have no pipeline control + // (see the engine docs). + let pcm = if (volume - 1.0).abs() > f32::EPSILON { + apply_gain(&pcm, volume.clamp(0.0, 4.0)) + } else { + pcm + }; + if let Some(cb) = on_audio.as_mut() { + // Match the cloud engines' delivery granularity (~150 ms). + for chunk in pcm.chunks(7200) { + cb(chunk); + } + } + if let Some(cb) = on_boundary.as_mut() { + // No timestamps from the C++ pipeline: scaled estimates + // anchored to the actual duration. + let boundaries: Vec = estimate_word_boundaries(&plain); + #[allow(clippy::cast_precision_loss)] + let total_secs = pcm.len() as f32 / (rate as f32 * 2.0); + #[allow(clippy::cast_precision_loss)] + let words = boundaries.len().max(1) as f32; + for (i, b) in boundaries.iter().enumerate() { + #[allow(clippy::cast_precision_loss)] + let start = total_secs * (i as f32) / words; + #[allow(clippy::cast_precision_loss)] + let end = total_secs * ((i + 1) as f32) / words; + // No source-offset mapping exists (no timestamps): + // -1 per the callback contract for unknown offsets. + cb(&b.text, start, end, -1, -1, true); + } + } + Ok(()) + } + + fn speak_sync( + &self, + text: &str, + voice: Option<&str>, + rate: f32, + pitch: f32, + volume: f32, + on_audio: Option, + on_boundary: Option, + on_mark: Option, + ) -> TtsResult<()> { + self.speak( + text, + voice, + rate, + pitch, + volume, + on_audio, + on_boundary, + on_mark, + ) + } + + fn stop(&self) -> TtsResult<()> { + Ok(()) // single-shot C++ calls; nothing to cancel mid-flight + } + + fn get_voices(&self) -> TtsResult> { + // The default timbre plus one voice per supported language; + // beyond the catalogue, ANY reference WAV works as a "voice" + // (zero-shot cloning — pass its path, or an emb: handle). + let mut voices = vec![Voice { + id: "default".into(), + name: "Qwen3-TTS local (default)".into(), + gender: Gender::Unknown, + provider: "qwen3-local".into(), + language_codes: vec![LanguageCode { + bcp47: "en".into(), + iso639_3: "eng".into(), + display: "English".into(), + }], + }]; + for (id, bcp47, display, _) in supported_languages() { + voices.push(Voice { + id: (*id).into(), + name: format!("Qwen3-TTS local ({display})"), + gender: Gender::Unknown, + provider: "qwen3-local".into(), + language_codes: vec![LanguageCode { + bcp47: (*bcp47).into(), + iso639_3: id_to_iso639_3(id), + display: (*display).into(), + }], + }); + } + Ok(voices) + } + + fn engine_id(&self) -> &'static str { + "qwen3-local" + } + + /// Models actually loaded (mirrors the cloud engines' credential + /// check — for a local engine this is "are the GGUFs valid"). + fn check_credentials(&self) -> TtsResult { + let _guard = CPP_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Ok(unsafe { qwen3_tts_is_loaded(self.tts) != 0 }) + } +} diff --git a/src/qwen3_local_support.rs b/src/qwen3_local_support.rs new file mode 100644 index 0000000..82c7cd5 --- /dev/null +++ b/src/qwen3_local_support.rs @@ -0,0 +1,162 @@ +//! Pure (non-FFI) helpers for the qwen3-local engine — ungated so they +//! compile and unit-test in every CI build, even without the C++ library +//! the engine itself links. + +/// Speaker-embedding size reported by the C++ pipeline (ECAPA x-vector). +pub(crate) const EMBEDDING_SIZE: usize = 1024; + +/// Qwen3-TTS's ten supported languages and their codec language token +/// IDs (from qwen3-tts.cpp's main.cpp — the IDs are model constants). +#[must_use] +pub fn supported_languages() -> &'static [(&'static str, &'static str, &'static str, i32)] { + // (id, bcp47, display, language_id) — id is the voice string users + // pass to set_voice/speak. + &[ + ("en", "en", "English", 2050), + ("zh", "zh-CN", "Chinese (Mandarin)", 2055), + ("ja", "ja", "Japanese", 2058), + ("ko", "ko", "Korean", 2064), + ("de", "de", "German", 2053), + ("fr", "fr", "French", 2061), + ("ru", "ru", "Russian", 2069), + ("es", "es", "Spanish", 2054), + ("it", "it", "Italian", 2070), + ("pt", "pt", "Portuguese", 2071), + ] +} + +#[must_use] +pub fn id_to_iso639_3(id: &str) -> String { + match id { + "zh" => "zho", + "ja" => "jpn", + "ko" => "kor", + "de" => "deu", + "fr" => "fra", + "ru" => "rus", + "es" => "spa", + "it" => "ita", + "pt" => "por", + _ => "eng", + } + .to_string() +} + +#[must_use] +pub fn language_id_for(voice_or_lang: &str) -> Option { + let needle = voice_or_lang.trim().to_lowercase(); + let needle = needle.split(['-', '_']).next().unwrap_or(&needle); + supported_languages() + .iter() + .find(|(id, _, _, _)| *id == needle) + .map(|(_, _, _, lid)| *lid) +} + +/// Decode an `emb:` voice string into raw embedding floats. +#[cfg(any(feature = "qwen3-local", feature = "cloud"))] +/// # Errors +/// When the payload is not valid base64 or its byte length is not a +/// multiple of 4 (f32 LE samples). +pub fn decode_embedding(v: &str) -> Result, String> { + use base64::Engine as _; + let b64 = v.strip_prefix("emb:").unwrap_or(v); + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64) + .map_err(|e| e.to_string())?; + if bytes.len() % 4 != 0 { + return Err("embedding byte length not a multiple of 4".into()); + } + let (chunks, _rem) = bytes.as_chunks::<4>(); + Ok(chunks.iter().map(|c| f32::from_le_bytes(*c)).collect()) +} + +/// Encode raw embedding floats into an `emb:` voice string. +#[cfg(any(feature = "qwen3-local", feature = "cloud"))] +#[must_use] +pub fn encode_embedding(embedding: &[f32]) -> String { + use base64::Engine as _; + let mut bytes = Vec::with_capacity(embedding.len() * 4); + for f in embedding { + bytes.extend_from_slice(&f.to_le_bytes()); + } + format!( + "emb:{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ) +} + +/// Linear PCM16 gain, clamped to the i16 range. +#[must_use] +pub fn apply_gain(pcm: &[u8], gain: f32) -> Vec { + let mut out = Vec::with_capacity(pcm.len()); + for s in pcm.as_chunks::<2>().0 { + let sample = i16::from_le_bytes(*s); + #[allow(clippy::cast_possible_truncation)] + let scaled = (f32::from(sample) * gain).clamp(-32767.0, 32767.0) as i16; + out.extend_from_slice(&scaled.to_le_bytes()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn language_table_is_complete_and_unique() { + let langs = supported_languages(); + assert_eq!(langs.len(), 10); + let mut ids: Vec<_> = langs.iter().map(|(id, _, _, _)| *id).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), 10); + // Qwen's documented set. + for expected in ["en", "zh", "ja", "ko", "de", "fr", "ru", "es", "it", "pt"] { + assert!(ids.contains(&expected), "missing {expected}"); + } + } + + #[test] + fn language_lookup_accepts_locale_forms() { + assert_eq!(language_id_for("fr"), Some(2061)); + assert_eq!(language_id_for("FR"), Some(2061)); + assert_eq!(language_id_for("pt-BR"), Some(2071)); + assert_eq!(language_id_for("en_US"), Some(2050)); + assert_eq!(language_id_for("english"), None); // ids only, per upstream CLI contract + assert_eq!(id_to_iso639_3("zh"), "zho"); + assert_eq!(id_to_iso639_3("en"), "eng"); + } + + #[cfg(any(feature = "qwen3-local", feature = "cloud"))] + #[test] + fn embedding_codec_round_trips() { + let emb: Vec = (0..EMBEDDING_SIZE) + .map(|i| { + #[allow(clippy::cast_precision_loss)] + let v = i as f32; + v * 0.25 - 128.0 + }) + .collect(); + let encoded = encode_embedding(&emb); + assert!(encoded.starts_with("emb:")); + let decoded = decode_embedding(&encoded).expect("roundtrip"); + assert_eq!(decoded.len(), emb.len()); + for (a, b) in emb.iter().zip(&decoded) { + assert!((a - b).abs() < f32::EPSILON); + } + assert!(decode_embedding("emb:!!!not-base64!!!").is_err()); + assert!(decode_embedding("emb:AAA").is_err()); // odd byte count + } + + #[test] + fn gain_scales_and_clamps() { + let pcm = i16::from_le_bytes([0x00, 0x40]).to_le_bytes(); // 16384 + let out = apply_gain(&pcm, 0.5); + assert_eq!(i16::from_le_bytes([out[0], out[1]]), 8192); + // Gain beyond i16 range clamps, never wraps. + let loud = apply_gain(&pcm, 8.0); + assert_eq!(i16::from_le_bytes([loud[0], loud[1]]), 32767); + let neg = apply_gain(&i16::from_le_bytes([0x00, 0xC0]).to_le_bytes(), 0.0); + assert_eq!(i16::from_le_bytes([neg[0], neg[1]]), 0); + } +} diff --git a/tests/qwen3_local_live.rs b/tests/qwen3_local_live.rs new file mode 100644 index 0000000..7c6db3c --- /dev/null +++ b/tests/qwen3_local_live.rs @@ -0,0 +1,157 @@ +#![cfg(feature = "qwen3-local")] +// The clone-roundtrip test additionally needs the cloning feature; the +// file as a whole only needs the engine (an --all-targets build without +// `cloning` must still compile). + +//! Live local Qwen3-TTS tests — no network, but need the built C++ +//! library and converted GGUF models. `#[ignore]`-d by default so +//! feature builds without models still test-compile. Run with: +//! +//! ```text +//! QWEN3_TTS_LIB=/path/to/qwen3-tts.cpp \ +//! QWEN3_TTS_MODELS=/path/to/qwen3-tts.cpp/models \ +//! QWEN3_TTS_REF=/path/to/reference.wav \ +//! cargo test --features qwen3-local,cloning --test qwen3_local_live -- --ignored --nocapture +//! ``` + +#[cfg(feature = "cloning")] +use rust_tts_wrapper::cloning::{create_cloner, AudioClip, CloneOutcome, VoiceIdentityBuilder}; +use rust_tts_wrapper::factory::create_engine; +use std::sync::{Arc, Mutex}; + +fn creds() -> Option { + let models = std::env::var("QWEN3_TTS_MODELS").ok()?; + Some( + serde_json::json!({ + "modelsDir": models, + "threads": std::env::var("QWEN3_TTS_THREADS").unwrap_or_else(|_| "6".into()), + }) + .to_string(), + ) +} + +#[test] +#[ignore = "needs QWEN3_TTS_LIB-linked build + QWEN3_TTS_MODELS (GGUFs on disk)"] +fn qwen3_local_synthesizes_and_estimates_boundaries() { + let Some(creds) = creds() else { + eprintln!("QWEN3_TTS_MODELS not set; skipping"); + return; + }; + let engine = create_engine("qwen3-local", &creds).expect("engine"); + let audio: Arc>> = Arc::new(Mutex::new(Vec::new())); + let words: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (a, w) = (Arc::clone(&audio), Arc::clone(&words)); + engine + .speak( + "Hello from the local Qwen three engine.", + None, + 1.0, + 1.0, + 1.0, + Some(&mut move |b: &[u8]| a.lock().unwrap().extend_from_slice(b)), + Some(&mut move |word: &str, _s, _e, _o, _l, est| { + w.lock() + .unwrap() + .push(format!("{word}{}", if est { "*" } else { "" })); + }), + None, + ) + .expect("speak"); + let pcm = audio.lock().unwrap().clone(); + assert!(pcm.len() > 48_000, "expected >1 s of audio"); + assert_eq!(pcm.len() % 2, 0); + eprintln!( + "{} PCM bytes; boundaries: {:?}", + pcm.len(), + words.lock().unwrap() + ); +} + +#[test] +#[cfg(feature = "cloning")] +#[ignore = "needs QWEN3_TTS_LIB + QWEN3_TTS_MODELS + QWEN3_TTS_REF (reference wav)"] +fn qwen3_local_zero_shot_clone_roundtrip() { + let Some(creds) = creds() else { + eprintln!("QWEN3_TTS_MODELS not set; skipping"); + return; + }; + let reference = match std::env::var("QWEN3_TTS_REF") { + Ok(r) if !r.is_empty() => r, + _ => { + eprintln!("QWEN3_TTS_REF not set; skipping"); + return; + } + }; + // Cloner: identity → embedding handle. + let cloner = create_cloner("qwen3-local", &creds).expect("cloner"); + let identity = VoiceIdentityBuilder::new("banked") + .add_clip(AudioClip::from_audio_file(&reference).expect("reference clip")) + .finish() + .expect("identity"); + let handle = match cloner.clone_voice(&identity).expect("clone") { + CloneOutcome::Ready(h) => h, + other @ CloneOutcome::Pending { .. } => panic!("expected Ready, got {other:?}"), + }; + assert!( + handle.voice_id.starts_with("emb:"), + "{}", + &handle.voice_id[..8.min(handle.voice_id.len())] + ); + + // Engine speaks with the embedding handle. + let engine = create_engine("qwen3-local", &creds).expect("engine"); + let audio: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&audio); + engine + .speak( + "This is my cloned voice, running fully offline.", + Some(&handle.voice_id), + 1.0, + 1.0, + 1.0, + Some(&mut move |b: &[u8]| sink.lock().unwrap().extend_from_slice(b)), + None, + None, + ) + .expect("speak with embedding"); + let pcm = audio.lock().unwrap().len(); + assert!(pcm > 48_000, "expected >1 s of cloned audio, got {pcm}"); +} + +#[test] +#[ignore = "needs QWEN3_TTS_LIB + QWEN3_TTS_MODELS"] +fn qwen3_local_language_voice_ssml_and_volume() { + let Some(creds) = creds() else { + eprintln!("QWEN3_TTS_MODELS not set; skipping"); + return; + }; + let engine = create_engine("qwen3-local", &creds).expect("engine"); + assert!( + engine.check_credentials().unwrap(), + "models should be loaded" + ); + let voices = engine.get_voices().expect("voices"); + assert_eq!(voices.len(), 11, "default + 10 languages"); + assert!(voices + .iter() + .any(|v| v.id == "fr" && v.language_codes[0].bcp47 == "fr")); + + // Language voice + SSML-in (tags must be stripped, not spoken) + + // volume gain path. + let audio: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&audio); + engine + .speak( + "Bonjour, ceci est un test en français.", + Some("fr"), + 1.0, + 1.0, + 0.5, + Some(&mut move |b: &[u8]| sink.lock().unwrap().extend_from_slice(b)), + None, + None, + ) + .expect("speak fr"); + let pcm = audio.lock().unwrap().len(); + assert!(pcm > 16_000, "expected some audio, got {pcm}"); +} diff --git a/tests/sherpaonnx_models.rs b/tests/sherpaonnx_models.rs index d6c455b..a3404b7 100644 --- a/tests/sherpaonnx_models.rs +++ b/tests/sherpaonnx_models.rs @@ -109,6 +109,12 @@ mod sherpaonnx_tests { "", ]; for (id, info) in &models { + // floravox-* types are driven by the floravox engine (its + // student sidecars), not sherpa-onnx — registry 1.1.0 added + // them; they are deliberately absent from this engine. + if info.model_type.starts_with("floravox") { + continue; + } assert!( supported.contains(&info.model_type.as_str()), "model '{}' has unsupported model_type '{}'. \