From 40e0a5644029103ddbf9816c57237804395ad90d Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 10 Oct 2026 22:18:31 +0100 Subject: [PATCH 1/4] =?UTF-8?q?feat(qwen3-local):=20third=20offline=20engi?= =?UTF-8?q?ne=20=E2=80=94=20Qwen3-TTS=20via=20qwen3-tts.cpp,=20with=20zero?= =?UTF-8?q?-shot=20cloning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local GGML inference (predict-woo/qwen3-tts.cpp, MIT; models Apache-2.0) behind the 'qwen3-local' feature. The C++ library is built by the user (scripts/build-qwen3-local.sh) and located via QWEN3_TTS_LIB — opt-in like speech-dispatcher, nothing vendored. Feature parity with the other engines: - Voices: default + all ten Qwen3 languages with proper bcp47/iso639-3/display codes; language voice strings map to the codec language token ids (pinned from upstream main.cpp). - Zero-shot cloning: reference WAV path as a voice string, or an emb: ECAPA speaker-embedding handle from the qwen3-local cloner (cloning feature) — embedding extracted once, reused per utterance without re-encoding. Fully offline: no upload, consent, or quota. CloningMode::ZeroShot. - SSML-in: input is stripped (tags never spoken); volume is real PCM gain; rate/pitch have no pipeline control (documented); word boundaries are duration-scaled estimates. - check_credentials reports model load state. Safety/infra: - Process-wide mutex around every C++ call — upstream corrupts the heap with concurrent engine instances (observed: 'corrupted double-linked list' under parallel test creation). - FFI declarations macro-wrapped + cbindgen whitelist: the external library's types stay out of include/tts_wrapper.h. - sherpa registry test skips floravox-* types (registry 1.1.0). - cloud-core feature uses dep: namespaced deps (required once qwen3-local also references base64). Tests: 3 ignored live tests (synthesis+estimates, embedding clone roundtrip, language/SSML/volume) — all passing against a real build; CI feature set + fmt + clippy + doc green. Live-verified end-to-end with a Personal Voice export and a 2-minute child-speech reference (cloud A/B recorded in the project notes). --- .env.example | 6 + Cargo.toml | 9 +- README.md | 1 + build.rs | 28 ++ cbindgen.toml | 39 +++ scripts/build-qwen3-local.sh | 28 ++ src/cloning/mod.rs | 14 + src/cloning/qwen3local.rs | 72 +++++ src/factory.rs | 37 +++ src/lib.rs | 7 + src/qwen3_local_engine.rs | 527 +++++++++++++++++++++++++++++++++++ tests/qwen3_local_live.rs | 152 ++++++++++ tests/sherpaonnx_models.rs | 6 + 13 files changed, 925 insertions(+), 1 deletion(-) create mode 100755 scripts/build-qwen3-local.sh create mode 100644 src/cloning/qwen3local.rs create mode 100644 src/qwen3_local_engine.rs create mode 100644 tests/qwen3_local_live.rs 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..8d78832 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", "display_names"] # 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..608ccb6 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,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..5492f2f --- /dev/null +++ b/src/cloning/qwen3local.rs @@ -0,0 +1,72 @@ +//! 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::{encode_embedding, Qwen3LocalEngine}; +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). + let wav = wav_bytes(&clip.pcm, clip.sample_rate); + let path = + std::env::temp_dir().join(format!("rust-tts-qwen3-emb-{}.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..bf5883c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,6 +62,13 @@ pub mod floravox_engine; mod sapi_engine; #[cfg(feature = "sherpaonnx")] pub mod sherpaonnx_engine; + +/// 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..eb0d0bc --- /dev/null +++ b/src/qwen3_local_engine.rs @@ -0,0 +1,527 @@ +//! 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::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). +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!(); + +/// 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), + ] +} + +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() +} + +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) +} + +/// Process-wide lock around every C++ entry point: the upstream +/// library is NOT safe with concurrent `Qwen3Tts` instances in one +/// process (GGML backend state is shared — concurrent engines corrupt +/// the heap; observed as `corrupted double-linked list` under parallel +/// test creation). Callers can create/destroy/speak from any threads; +/// this serializes the calls. Drop this if upstream ever isolates +/// backend state per instance. +static CPP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// 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", &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) }; + } + } +} + +/// Decode an `emb:` voice string into raw embedding floats. +pub(crate) 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()); + } + Ok(bytes + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect()) +} + +/// Encode raw embedding floats into an `emb:` voice string. +#[must_use] +pub(crate) 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 in the [-1,1]-normalised domain. +fn apply_gain(pcm: &[u8], gain: f32) -> Vec { + let mut out = Vec::with_capacity(pcm.len()); + for s in pcm.chunks_exact(2) { + let sample = i16::from_le_bytes([s[0], s[1]]); + #[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 +} + +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<()> { + let params = self.params.clone(); + let (pcm, rate) = self.synthesize_to_pcm16(text, 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, 2.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(text); + #[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; + cb(&b.text, start, end, 0, 0, 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 { + Ok(unsafe { qwen3_tts_is_loaded(self.tts) != 0 }) + } +} diff --git a/tests/qwen3_local_live.rs b/tests/qwen3_local_live.rs new file mode 100644 index 0000000..e5860f9 --- /dev/null +++ b/tests/qwen3_local_live.rs @@ -0,0 +1,152 @@ +#![cfg(feature = "qwen3-local")] + +//! 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 +//! ``` + +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] +#[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 '{}'. \ From 76e7554da92a84a7cd93b6dbff9ac6ebdbaa5f74 Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 10 Oct 2026 22:31:30 +0100 Subject: [PATCH 2/4] =?UTF-8?q?fix(qwen3-local):=20review=20pass=201=20?= =?UTF-8?q?=E2=80=94=20lock=20coverage,=20boundary=20text,=20test=20cfg,?= =?UTF-8?q?=20pure=20helpers,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CPP_LOCK now also held in check_credentials + Debug (was claimed 'every entry point' but two is_loaded calls ran unlocked). - Word boundaries computed from the SSML-stripped text (tag fragments were appearing as 'words'); unknown offsets now -1 per contract. - SSML strip moved to speak() — both the C++ input and the boundary estimates share one plain-text source. - tests/qwen3_local_live.rs compiles without the cloning feature (the clone test is per-fn gated); fixes the --all-targets build break. - Pure helpers (language table, embedding codec, PCM gain) split into always-compiled qwen3_local_support with 4 unit-test groups — CI coverage without the C++ library. - README: engines table row (was silently missing) + the dedicated feature section + engine count 28. - Volume clamp 0–4 (matches sherpa); display_names dropped from the feature (ICU not needed); temp file collision → pid+nanos. All three live tests green (68s); CI set + fmt + triple clippy clean. --- Cargo.toml | 2 +- README.md | 23 +++++ include/tts_wrapper.h | 5 ++ src/cloning/qwen3local.rs | 3 +- src/lib.rs | 4 + src/qwen3_local_engine.rs | 136 +++++++---------------------- src/qwen3_local_support.rs | 169 +++++++++++++++++++++++++++++++++++++ tests/qwen3_local_live.rs | 5 ++ 8 files changed, 241 insertions(+), 106 deletions(-) create mode 100644 src/qwen3_local_support.rs diff --git a/Cargo.toml b/Cargo.toml index 8d78832..123726e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,7 +89,7 @@ display_names = ["dep:icu_displaynames", "dep:icu_locid"] # 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", "display_names"] +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 608ccb6..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`. diff --git a/include/tts_wrapper.h b/include/tts_wrapper.h index 2330de8..233bfec 100644 --- a/include/tts_wrapper.h +++ b/include/tts_wrapper.h @@ -6,6 +6,11 @@ #include #include +/** + * Speaker-embedding size reported by the C++ pipeline (ECAPA x-vector). + */ +#define EMBEDDING_SIZE 1024 + typedef struct tts_ctx tts_ctx; /** diff --git a/src/cloning/qwen3local.rs b/src/cloning/qwen3local.rs index 5492f2f..991f266 100644 --- a/src/cloning/qwen3local.rs +++ b/src/cloning/qwen3local.rs @@ -10,7 +10,8 @@ use super::{ select_clips, wav_bytes, CloneHandle, CloneOutcome, CloningMode, VoiceCloning, VoiceIdentity, }; -use crate::qwen3_local_engine::{encode_embedding, Qwen3LocalEngine}; +use crate::qwen3_local_engine::Qwen3LocalEngine; +use crate::qwen3_local_support::encode_embedding; use crate::types::{TtsError, TtsResult}; use std::collections::HashMap; diff --git a/src/lib.rs b/src/lib.rs index bf5883c..6aed61c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,6 +63,10 @@ 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 diff --git a/src/qwen3_local_engine.rs b/src/qwen3_local_engine.rs index eb0d0bc..6899b26 100644 --- a/src/qwen3_local_engine.rs +++ b/src/qwen3_local_engine.rs @@ -22,6 +22,10 @@ //! 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}; @@ -32,6 +36,12 @@ use std::os::raw::{c_char, c_float, c_int}; // 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)] @@ -97,63 +107,6 @@ macro_rules! qwen3_ffi { qwen3_ffi!(); -/// 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), - ] -} - -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() -} - -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) -} - -/// Process-wide lock around every C++ entry point: the upstream -/// library is NOT safe with concurrent `Qwen3Tts` instances in one -/// process (GGML backend state is shared — concurrent engines corrupt -/// the heap; observed as `corrupted double-linked list` under parallel -/// test creation). Callers can create/destroy/speak from any threads; -/// this serializes the calls. Drop this if upstream ever isolates -/// backend state per instance. -static CPP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - /// 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 { @@ -164,7 +117,12 @@ pub struct Qwen3LocalEngine { impl std::fmt::Debug for Qwen3LocalEngine { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Qwen3LocalEngine") - .field("loaded", &unsafe { qwen3_tts_is_loaded(self.tts) != 0 }) + .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() } } @@ -368,48 +326,6 @@ impl Drop for Qwen3LocalEngine { } } -/// Decode an `emb:` voice string into raw embedding floats. -pub(crate) 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()); - } - Ok(bytes - .chunks_exact(4) - .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) - .collect()) -} - -/// Encode raw embedding floats into an `emb:` voice string. -#[must_use] -pub(crate) 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 in the [-1,1]-normalised domain. -fn apply_gain(pcm: &[u8], gain: f32) -> Vec { - let mut out = Vec::with_capacity(pcm.len()); - for s in pcm.chunks_exact(2) { - let sample = i16::from_le_bytes([s[0], s[1]]); - #[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 -} - impl TtsEngine for Qwen3LocalEngine { fn speak( &self, @@ -422,13 +338,20 @@ impl TtsEngine for Qwen3LocalEngine { 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(text, voice, params)?; + 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, 2.0)) + apply_gain(&pcm, volume.clamp(0.0, 4.0)) } else { pcm }; @@ -441,7 +364,7 @@ impl TtsEngine for Qwen3LocalEngine { 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(text); + 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)] @@ -451,7 +374,9 @@ impl TtsEngine for Qwen3LocalEngine { let start = total_secs * (i as f32) / words; #[allow(clippy::cast_precision_loss)] let end = total_secs * ((i + 1) as f32) / words; - cb(&b.text, start, end, 0, 0, true); + // No source-offset mapping exists (no timestamps): + // -1 per the callback contract for unknown offsets. + cb(&b.text, start, end, -1, -1, true); } } Ok(()) @@ -522,6 +447,9 @@ impl TtsEngine for Qwen3LocalEngine { /// 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..a7f5c60 --- /dev/null +++ b/src/qwen3_local_support.rs @@ -0,0 +1,169 @@ +//! 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 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) +} + +/// Process-wide lock around every C++ entry point: the upstream +/// library is NOT safe with concurrent `Qwen3Tts` instances in one +/// process (GGML backend state is shared — concurrent engines corrupt +/// the heap; observed as `corrupted double-linked list` under parallel +/// test creation). Callers can create/destroy/speak from any threads; +/// Decode an `emb:` voice string into raw embedding floats. +#[cfg(feature = "qwen3-local")] +/// # 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()); + } + Ok(bytes + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect()) +} + +/// Encode raw embedding floats into an `emb:` voice string. +#[cfg(feature = "qwen3-local")] +#[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.chunks_exact(2) { + let sample = i16::from_le_bytes([s[0], s[1]]); + #[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(feature = "qwen3-local")] + #[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 index e5860f9..7c6db3c 100644 --- a/tests/qwen3_local_live.rs +++ b/tests/qwen3_local_live.rs @@ -1,4 +1,7 @@ #![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 @@ -11,6 +14,7 @@ //! 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}; @@ -64,6 +68,7 @@ fn qwen3_local_synthesizes_and_estimates_boundaries() { } #[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 { From 8372ebe2de5f9caaead9164de8af6ea003aa789d Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 10 Oct 2026 22:38:06 +0100 Subject: [PATCH 3/4] =?UTF-8?q?fix(qwen3-local):=20review=20pass=202=20?= =?UTF-8?q?=E2=80=94=20the=20three=20things=20loop=202=20caught?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Temp file: pid + nanos (concurrent clone_voice was racing on one name — silent wrong-voice embedding or a deleted reference). - EMBEDDING_SIZE pub(crate) — it leaked into the public C header via cbindgen's include semantics (include is additive, not a whitelist; constants bypass it). Header verified clean after regen. - Embedding codec fns + round-trip test gated on any(qwen3-local, cloud) — they now run in CI without the library. - Mangled CPP_LOCK doc fragment removed from decode_embedding; dead double-strip in synthesize_to_pcm16 removed (speak strips once). --- include/tts_wrapper.h | 5 ----- src/cloning/qwen3local.rs | 13 ++++++++++--- src/qwen3_local_support.rs | 13 ++++--------- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/include/tts_wrapper.h b/include/tts_wrapper.h index 233bfec..2330de8 100644 --- a/include/tts_wrapper.h +++ b/include/tts_wrapper.h @@ -6,11 +6,6 @@ #include #include -/** - * Speaker-embedding size reported by the C++ pipeline (ECAPA x-vector). - */ -#define EMBEDDING_SIZE 1024 - typedef struct tts_ctx tts_ctx; /** diff --git a/src/cloning/qwen3local.rs b/src/cloning/qwen3local.rs index 991f266..26236c1 100644 --- a/src/cloning/qwen3local.rs +++ b/src/cloning/qwen3local.rs @@ -44,10 +44,17 @@ impl VoiceCloning for Qwen3LocalCloner { )); }; // The C API extracts embeddings from a WAV path: materialise the - // clip in the system temp dir (deleted after). + // 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 path = - std::env::temp_dir().join(format!("rust-tts-qwen3-emb-{}.wav", std::process::id())); + 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); diff --git a/src/qwen3_local_support.rs b/src/qwen3_local_support.rs index a7f5c60..69f86e9 100644 --- a/src/qwen3_local_support.rs +++ b/src/qwen3_local_support.rs @@ -3,7 +3,7 @@ //! the engine itself links. /// Speaker-embedding size reported by the C++ pipeline (ECAPA x-vector). -pub const EMBEDDING_SIZE: usize = 1024; +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). @@ -52,13 +52,8 @@ pub fn language_id_for(voice_or_lang: &str) -> Option { .map(|(_, _, _, lid)| *lid) } -/// Process-wide lock around every C++ entry point: the upstream -/// library is NOT safe with concurrent `Qwen3Tts` instances in one -/// process (GGML backend state is shared — concurrent engines corrupt -/// the heap; observed as `corrupted double-linked list` under parallel -/// test creation). Callers can create/destroy/speak from any threads; /// Decode an `emb:` voice string into raw embedding floats. -#[cfg(feature = "qwen3-local")] +#[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). @@ -78,7 +73,7 @@ pub fn decode_embedding(v: &str) -> Result, String> { } /// Encode raw embedding floats into an `emb:` voice string. -#[cfg(feature = "qwen3-local")] +#[cfg(any(feature = "qwen3-local", feature = "cloud"))] #[must_use] pub fn encode_embedding(embedding: &[f32]) -> String { use base64::Engine as _; @@ -134,7 +129,7 @@ mod tests { assert_eq!(id_to_iso639_3("en"), "eng"); } - #[cfg(feature = "qwen3-local")] + #[cfg(any(feature = "qwen3-local", feature = "cloud"))] #[test] fn embedding_codec_round_trips() { let emb: Vec = (0..EMBEDDING_SIZE) From 946dc1b859106f5b3898f22d634d8b6a232a2670 Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 10 Oct 2026 22:43:58 +0100 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20chunks=5Fexact(4)=20=E2=86=92=20as?= =?UTF-8?q?=5Fchunks::<4>()=20(clippy=20chunks=5Fexact=5Fto=5Fas=5Fchunks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/qwen3_local_support.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/qwen3_local_support.rs b/src/qwen3_local_support.rs index 69f86e9..82c7cd5 100644 --- a/src/qwen3_local_support.rs +++ b/src/qwen3_local_support.rs @@ -66,10 +66,8 @@ pub fn decode_embedding(v: &str) -> Result, String> { if bytes.len() % 4 != 0 { return Err("embedding byte length not a multiple of 4".into()); } - Ok(bytes - .chunks_exact(4) - .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) - .collect()) + 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. @@ -91,8 +89,8 @@ pub fn encode_embedding(embedding: &[f32]) -> String { #[must_use] pub fn apply_gain(pcm: &[u8], gain: f32) -> Vec { let mut out = Vec::with_capacity(pcm.len()); - for s in pcm.chunks_exact(2) { - let sample = i16::from_le_bytes([s[0], s[1]]); + 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());