Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 +
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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:<base64> 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`.
Expand Down Expand Up @@ -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.

Expand Down
28 changes: 28 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
39 changes: 39 additions & 0 deletions cbindgen.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
28 changes: 28 additions & 0 deletions scripts/build-qwen3-local.sh
Original file line number Diff line number Diff line change
@@ -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=<target-dir>/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"
14 changes: 14 additions & 0 deletions src/cloning/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ mod corpus;
mod elevenlabs;
mod google;
mod qwen;
#[cfg(feature = "qwen3-local")]
mod qwen3local;
mod registry;

pub use corpus::VoiceCorpus;
Expand Down Expand Up @@ -411,6 +413,8 @@ pub trait VoiceCloning: Send + Sync {
/// support.
#[must_use]
pub fn create_cloner(engine_id: &str, credentials_json: &str) -> Option<Arc<dyn VoiceCloning>> {
// (qwen3-local loads models and may fail — handled via the inner
// Result, mapped to None with the error printed.)
let creds: std::collections::HashMap<String, String> = if credentials_json.is_empty() {
std::collections::HashMap::new()
} else {
Expand All @@ -425,6 +429,14 @@ pub fn create_cloner(engine_id: &str, credentials_json: &str) -> Option<Arc<dyn
"google" => 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,
}
}
Expand All @@ -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
}
Expand Down
80 changes: 80 additions & 0 deletions src/cloning/qwen3local.rs
Original file line number Diff line number Diff line change
@@ -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<String, String>) -> TtsResult<Self> {
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<CloneOutcome> {
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<Vec<CloneHandle>> {
// 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(())
}
}
37 changes: 37 additions & 0 deletions src/factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -85,6 +87,32 @@ pub fn create_engine(engine_id: &str, credentials_json: &str) -> Option<Arc<dyn
return None;
}
}
"qwen3-local" => {
#[cfg(feature = "qwen3-local")]
{
let creds: std::collections::HashMap<String, String> =
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")]
{
Expand Down Expand Up @@ -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<EngineDescriptor> {
#[allow(unused_mut)]
let mut engines = Vec::new();
Expand Down Expand Up @@ -182,6 +211,14 @@ pub fn engine_list() -> Vec<EngineDescriptor> {
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(),
Expand Down
11 changes: 11 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading