Skip to content

feat(qwen3-local): third offline engine — Qwen3-TTS via qwen3-tts.cpp, with zero-shot cloning - #49

Merged
willwade merged 4 commits into
mainfrom
feat/qwen3-local-offline
Oct 10, 2026
Merged

willwade merged 4 commits into
mainfrom
feat/qwen3-local-offline

Conversation

@willwade

Copy link
Copy Markdown
Contributor

Summary

Adds offline Qwen3-TTS as the third local engine (after sherpa-onnx and floravox) — and the only local engine with voice cloning. Backed by predict-woo/qwen3-tts.cpp (GGML; code MIT, models Apache-2.0), behind the qwen3-local feature.

Build story: the C++ library is NOT vendored — scripts/build-qwen3-local.sh clones/builds it, QWEN3_TTS_LIB points the build at it (opt-in precedent: speech-dispatcher/libclang). Models are user-converted GGUFs (modelsDir credential).

Feature parity (the bar this PR was held to)

Capability Status
Voices / locales default + 10 Qwen languages, proper bcp47/iso639-3/display
Voice strings "fr"-style language ids → codec language tokens; reference WAV path; emb: handles
Cloning ZeroShot: ECAPA embedding extracted once → emb: handle reused per utterance (cloner under --features qwen3-local,cloning); fully offline
SSML-in stripped (never spoken aloud)
Volume real PCM16 gain; rate/pitch have no upstream control (documented)
Word boundaries duration-scaled estimates (upstream exposes no timestamps — known gap, see notes)
check_credentials model load state
FFI/bindings works through the existing C ABI — Node/.NET get it with no binding changes

Safety findings baked in

  • Upstream is not concurrent-safe: parallel engine instances corrupt the heap (corrupted double-linked list, reproduced) → process-wide CPP_LOCK around every C++ call.
  • External-library FFI kept out of include/tts_wrapper.h via macro-wrapped declarations + a cbindgen whitelist of our real ABI (defends against future leaks too; C-harness CI validates).

Testing

  • 3 ignored live tests against a real build: synthesis+estimated boundaries; embedding clone roundtrip (identity → emb: → speech); language voice + SSML strip + volume — all passing (~67 s).
  • Live-verified end-to-end with a Personal Voice export and a 2-minute child-speech reference (timbre-grade caveat documented in-engine: x-vector-only cloning upstream; in-context mode is the follow-up).
  • CI feature set (310 tests), fmt, clippy -D warnings (both feature sets), doc — green.

Also in here

  • sherpa registry test skips floravox-* types (registry 1.1.0 drift — pre-existing failure this PR fixes).
  • cloud-core namespaced deps fix (dep:base64 collision).

Known gaps (deliberate, documented)

Accent comes from the model prior (x-vector-only upstream); no streaming; no timestamps; CPU ~0.1× realtime. The upstream-PR plan (in-context cloning + attention-tap timings) is recorded in the project's uncommitted plan notes.

…, with zero-shot cloning

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:<base64> 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: <speak> 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).
…cfg, pure helpers, docs

- 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.
- 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).
@willwade
willwade merged commit 030c87f into main Oct 10, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant