Skip to content

feat(qwen3-tts-cpp): migrate to ServeurpersoCom/qwentts.cpp (streaming, speakers, voice design)#10316

Merged
mudler merged 14 commits into
masterfrom
feat/qwentts-cpp-migration
Jun 13, 2026
Merged

feat(qwen3-tts-cpp): migrate to ServeurpersoCom/qwentts.cpp (streaming, speakers, voice design)#10316
mudler merged 14 commits into
masterfrom
feat/qwentts-cpp-migration

Conversation

@localai-bot

Copy link
Copy Markdown
Collaborator

What

Migrates the qwen3-tts-cpp backend from predict-woo/qwen3-tts.cpp to ServeurpersoCom/qwentts.cpp (MIT), in place. Same backend name, meta-backend, and image variants - only the upstream library and backend internals change. The new upstream is an independent pure-C++/GGML port of the same Alibaba Qwen3-TTS 12Hz model with a clean C99 ABI (qt_*) purpose-built for language bindings, and it is structurally identical to our sibling omnivoice-cpp backend (same author).

Why

The previous upstream was file-output only, exposed a C++ class API (forcing a hand-written shim), shipped a single 0.6B Chinese-centric model, and had no license (all-rights-reserved by default). qwentts.cpp is better on every axis we care about.

New capabilities

  • Streaming TTS (stream: true on /v1/audio/speech) via the upstream chunk-callback API
  • Named speakers (CustomVoice): voice = serena, vivian, ryan, aiden, eric (sichuan), dylan (beijing), ...
  • Voice design: free-text instructions (e.g. "male, young adult, moderate pitch")
  • Voice cloning: voice = a 24kHz reference .wav path, optional ref_text (in-context)
  • 1.7B models and Q8_0 / Q4_K_M quantizations
  • MIT licensed; per-stage validation vs the PyTorch reference upstream

How

  • Shim (cpp/goqwen3ttscpp.{h,cpp}): flattens the struct-based qt_* ABI into flat qt3_* C functions (purego cannot marshal the structs). Synthesis mode (base/customvoice/voicedesign) is auto-detected from the talker GGUF; the shim passes speaker/instruct/ref through and surfaces qt_last_error().
  • Go backend: purego (no cgo), base.SingleThread; Load resolves talker + auto-discovers the *tokenizer*.gguf sibling; TTS (buffered) + TTSStream (one process-lifetime purego.NewCallback routed through a mutex-guarded channel). Request mapping (no proto change): instructions→instruct, voice→named-speaker-or-clone-path (by extension), ref_text + sampling via the params map.
  • Surfaces: MethodTTSStream added to the capability registry; backend/index.yaml metadata updated; gallery rewritten to 10 curated models on Serveurperso/Qwen3-TTS-GGUF (0.6B/1.7B x base/customvoice/voicedesign x Q8_0/Q4_K_M).

Testing

  • Unit specs (Ginkgo/Gomega): language normalization, voice→speaker/clone-path detection, option + sampling parsing, WAV header/PCM encoding.
  • e2e (real CPU inference, all green): all 4 CPU .so variants build; qt3_load + full talker→codec pipeline synthesize a WAV via TTS, and TTSStream emits chunked frames starting with a RIFF/WAVE header. golangci-lint: 0 issues.

Breaking change

The GGUF format differs - the old endo5501/qwen3-tts.cpp F16 weights are not compatible. Re-install a qwen3-tts-cpp* model from the gallery (now Serveurperso/Qwen3-TTS-GGUF) to upgrade. See docs/release-notes/qwen3-tts-cpp-qwentts-migration.md.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

🤖 Generated with Claude Code

mudler added 13 commits June 13, 2026 20:14
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
…esign

Map TTSRequest onto qwentts.cpp: instructions->instruct, voice->named
speaker or clone-reference path, params map->ref_text + sampling. Add
TTSStream over the qt chunk callback.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Register CppPCMFree before the n<=0 guard so a non-null buffer with zero
samples cannot leak (the C contract returns NULL on failure, so this is
defensive). Raised in code review.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
…0 & Q4_K_M

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Add resolveRequest unit specs (config audio_path used as the clone
reference when Voice is empty; per-request audio Voice overrides it; a
named-speaker Voice does not trigger cloning) plus a real-inference e2e
that clones from audio_path (confirmed ref_spk_emb=yes in the pipeline).

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

// writeWAV24k writes samples as a finalized 24 kHz mono 16-bit WAV at dst.
func writeWAV24k(dst string, samples []float32) error {
f, err := os.Create(dst)
// float32 slice in [-1,1] for use as cloning reference audio. qwentts expects
// 24 kHz; callers should supply 24 kHz reference clips.
func readWAVAsFloat(path string) ([]float32, error) {
f, err := os.Open(path)
}
var refPtr unsafe.Pointer
if len(ref) > 0 {
refPtr = unsafe.Pointer(&ref[0])
return fmt.Errorf("failed to load qwen3-tts model (error code: %d)", ret)
var n int32
ptr := CppTTS(req.Text, lang, instruct, speaker, refPtr, len(ref), refText,
s.seed, s.temperature, s.topK, s.topP, s.repPen, s.maxNew, unsafe.Pointer(&n))
if n <= 0 {
return fmt.Errorf("qwen3-tts: synthesis produced no samples")
}
src := unsafe.Slice((*float32)(unsafe.Pointer(ptr)), int(n)) //nolint:govet // C-allocated PCM, copied out before free
if n <= 0 {
return fmt.Errorf("qwen3-tts: synthesis produced no samples")
}
src := unsafe.Slice((*float32)(unsafe.Pointer(ptr)), int(n)) //nolint:govet // C-allocated PCM, copied out before free
if nSamples <= 0 || samples == nil || streamChan == nil {
return 1 // continue
}
src := unsafe.Slice(samples, int(nSamples))
}
var refPtr unsafe.Pointer
if len(ref) > 0 {
refPtr = unsafe.Pointer(&ref[0])
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
@mudler
mudler merged commit 4bb592c into master Jun 13, 2026
19 of 20 checks passed
@mudler
mudler deleted the feat/qwentts-cpp-migration branch June 13, 2026 21:10
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.

3 participants