* Add Dockerfile (cpu/cuda), entrypoint, docs, and a GHCR release workflow Multi-stage Dockerfile with cpu and cuda targets, built from the existing build scripts' cmake invocations. The CUDA target documents and applies the two docker-build-specific gotchas we hit running this in production: CMAKE_CUDA_ARCHITECTURES needs an explicit override for GPUs older than this project's own default arch list when building without GPU device access, and the CUDA driver stub library needs an explicit -L/-lcuda at link time since ggml's VMM pool allocator needs driver-API symbols that aren't present without a real driver. Also installs make/pkg-config/libopenblas-dev (CPU build) and libgomp1 (CUDA runtime), and copies the ggml shared libraries alongside the binaries with LD_LIBRARY_PATH set, since the binaries' baked-in RPATH points at the build-tree location that doesn't exist in the final stage. Adds a GitHub Actions workflow that builds both variants on every push to master and version tag, publishing to ghcr.io/serveurpersocom/qwentts.cpp, and validates the build (without pushing) on PRs that touch the Docker files. Verified end-to-end on a real GTX 1070 (Pascal): both cpu and cuda targets build clean, run, and produce valid synthesized WAV output through tts-server's HTTP API. * Dockerfile: add a vulkan build target (AMD/Intel GPUs) Uses the LunarG Vulkan SDK apt repo for glslc (ggml-vulkan's shader compiler), which Ubuntu 22.04's own repos don't package. Runtime image ships Mesa's Vulkan drivers; NVIDIA users should prefer :cuda instead, since NVIDIA's Vulkan ICD isn't bundled. * docker workflow: bump actions to latest majors (checkout v7, buildx v4, login v4, build-push v7) * Support Pascal (sm_61) in the default CUDA architecture list Adds 61-real to CMAKE_CUDA_ARCHITECTURES' default so Pascal cards (GTX 10-series etc.) work without an explicit override -- including in the Docker cuda target, where docker build has no GPU device to autodetect against in the first place. Real-only (no virtual/PTX): Pascal is now the oldest supported card, so it doesn't need to seed forward JIT compatibility for anything older the way the 75-virtual entry does for 7.5+. Verified end-to-end on a real GTX 1070 (sm_61): built the cuda Docker target with no --build-arg override, ran it with --gpus all, and confirmed via container logs that the GPU loaded the model and served a real synthesis request producing valid WAV output. Adjusts the Dockerfile comments and docs/DOCKER.md accordingly. * docker: pass through --max-prefill-tokens, add ref_text voice cloning - entrypoint.sh: forward MAX_PREFILL_TOKENS to --max-prefill-tokens, matching the existing optional-flag passthrough pattern - entrypoint.sh: a same-stem .txt next to a voice's .wav now supplies ref_text, enabling ICL clone mode instead of the x_vector_only fallback used when no transcript is given - switch voice registration's JSON construction from raw printf to jq for safe escaping of arbitrary transcript text; base64 payloads go through jq's --rawfile (not --arg) since large files blow past ARG_MAX as a command-line argument - add jq to all three runtime image stages (cpu/cuda/vulkan) for the above - docs/DOCKER.md: document both additions Verified end-to-end on a CPU build: both the ref_text and no-ref_text registration paths log correctly (ref_text=yes / ref_text=no) and /health responds after voice registration completes. * docker: opt-in fatal worst-case warmup synthesis ggml_backend_sched grows its compute buffer to fit the largest graph it has ever built and never shrinks it back, so VRAM use can ratchet up the first time a long reply arrives on live traffic. This adds an opt-in startup warmup: when WARMUP_VOICE is set, entrypoint.sh runs one real synthesis capped at WARMUP_MAX_NEW_TOKENS (default 750) frames after voice registration, forcing that worst-case decode/ codec-decode buffer growth to happen at startup instead of mid-request. Complements --max-prefill-tokens, which only covers the input side. Off by default (WARMUP_VOICE unset skips it entirely, matching every other optional flag in this entrypoint), and fatal on failure: if the warmup synthesis fails (most likely OOM), the container kills the server and exits non-zero rather than come up healthy and fail unpredictably later -- a deployment that can't afford its own configured worst case should know that at startup, not on a live request. Verified on a CPU build: warmup runs after voice registration, hits the configured frame cap exactly, and the container stays healthy. --------- Co-authored-by: Gary <gitea@gerasch.dev>
213 lines
7.8 KiB
Markdown
213 lines
7.8 KiB
Markdown
# qwentts.cpp
|
|
|
|
Local AI text-to-speech with named speakers, voice cloning and voice
|
|
design, powered by GGML. C++17 port of Qwen3-TTS 12 Hz (Qwen team,
|
|
Alibaba). 11 languages with Mandarin dialects, 24 kHz mono output,
|
|
runs on CPU, CUDA, Metal, Vulkan.
|
|
|
|
## Features
|
|
|
|
- Named speakers from the CustomVoice checkpoints, with per-speaker
|
|
Mandarin dialect overrides (eric -> sichuan, dylan -> beijing)
|
|
- Zero shot voice cloning from a reference clip, x-vector only or
|
|
in-context with a matching transcript
|
|
- Voice design from a free text attribute instruction (gender, age,
|
|
pitch, style)
|
|
- Streaming synthesis : stateful frame-by-frame codec decode, the first
|
|
audio callback fires one frame after the first Talker step and the
|
|
output matches the offline full decode exactly
|
|
- Two stage generation : the Talker LM emits the semantic codebook, a
|
|
code predictor MTP head emits the 15 acoustic codes per frame, both
|
|
KV cached
|
|
- Seedable Philox PRNG and an HF aligned sampling chain
|
|
(repetition penalty -> temperature -> top-k -> top-p -> multinomial)
|
|
- Q8_0 and Q4_K_M quantisation of the Qwen3 talker backbone (0.6B and
|
|
1.7B), the RVQ codec paths kept at F32
|
|
- Three tools : `qwen-tts` (text -> WAV), `qwen-codec`
|
|
(WAV <-> RVQ codes) and `tts-server` (OpenAI-compatible HTTP server
|
|
with a cloned voice registry)
|
|
|
|
## Build
|
|
|
|
```
|
|
git clone --recurse-submodules https://github.com/ServeurpersoCom/qwentts.cpp.git
|
|
cd qwentts.cpp
|
|
./buildcuda.sh # NVIDIA GPU
|
|
./buildvulkan.sh # AMD/Intel GPU (Vulkan)
|
|
./buildcpu.sh # CPU only
|
|
./buildall.sh # all backends, runtime DL loading
|
|
NVCC_CCBIN=g++-13 ./buildcuda.sh # rolling release distros (Arch w/ GCC 16, etc.)
|
|
```
|
|
|
|
Docker images (CPU and CUDA, built and published on every release) are
|
|
also available: see [docs/DOCKER.md](docs/DOCKER.md).
|
|
|
|
## Model conversion
|
|
|
|
Pre-converted GGUFs are available on Hugging Face :
|
|
|
|
https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF
|
|
|
|
Drop them in `models/` and skip to the quick start. To convert from
|
|
the original checkpoints :
|
|
|
|
```
|
|
./checkpoints.sh # hf download Qwen/Qwen3-TTS-12Hz-* -> checkpoints/
|
|
./convert.py # F32 GGUFs (one talker per mode/size + tokenizer) -> models/
|
|
./quantize.sh # BF16 / Q8_0 / Q4_K_M ; RVQ codebooks and projections stay F32
|
|
```
|
|
|
|
Two GGUFs load together : a talker
|
|
(`qwen-talker-{size}-{mode}-{variant}.gguf`, LM plus code predictor MTP
|
|
head plus optional speaker encoder) and a shared tokenizer
|
|
(`qwen-tokenizer-12hz-{variant}.gguf`, SEANet + ConvNeXt + DAC v2 +
|
|
RVQ). Modes are `base`, `customvoice` and `voicedesign` ; sizes are
|
|
0.6B and 1.7B (voicedesign is 1.7B only).
|
|
|
|
## Quick start
|
|
|
|
Each block is the command run by the matching script in `examples/`.
|
|
|
|
Default voice (`base.sh`) :
|
|
|
|
```
|
|
./build/qwen-tts \
|
|
--model models/qwen-talker-1.7b-base-Q8_0.gguf \
|
|
--codec models/qwen-tokenizer-12hz-Q8_0.gguf \
|
|
--lang English -o out.wav < prompt.txt
|
|
```
|
|
|
|
Voice cloning (`clone.sh`, Base, reference WAV plus its transcript) :
|
|
|
|
```
|
|
./build/qwen-tts \
|
|
--model models/qwen-talker-1.7b-base-Q8_0.gguf \
|
|
--codec models/qwen-tokenizer-12hz-Q8_0.gguf \
|
|
--ref-wav ref.wav --ref-text ref.txt \
|
|
--lang English -o out.wav < prompt.txt
|
|
```
|
|
|
|
Pre-encoded reference (`clone.sh`): `qwen-codec --talker` encodes a reference
|
|
WAV into two compact latents in one pass, the `.spk` speaker embedding and
|
|
the `.rvq` ICL codes, bit-identical to what the `--ref-wav` path computes
|
|
internally. Passing them via `--ref-spk` / `--ref-rvq` skips the speaker
|
|
encoder and the codec encode on every synthesis:
|
|
|
|
```
|
|
build/qwen-codec --model models/qwen-tokenizer-12hz-Q8_0.gguf \
|
|
--talker models/qwen-talker-1.7b-base-Q8_0.gguf -i ref.wav
|
|
build/qwen-tts \
|
|
--model models/qwen-talker-1.7b-base-Q8_0.gguf \
|
|
--codec models/qwen-tokenizer-12hz-Q8_0.gguf \
|
|
--ref-spk ref.spk --ref-rvq ref.rvq --ref-text ref.txt \
|
|
--lang English -o out.wav < prompt.txt
|
|
```
|
|
|
|
Named speaker (`customvoice.sh`, CustomVoice) :
|
|
|
|
```
|
|
./build/qwen-tts \
|
|
--model models/qwen-talker-1.7b-customvoice-Q8_0.gguf \
|
|
--codec models/qwen-tokenizer-12hz-Q8_0.gguf \
|
|
--speaker vivian \
|
|
--lang English -o out.wav < prompt.txt
|
|
```
|
|
|
|
Speakers : serena, vivian, uncle_fu, ryan, aiden, ono_anna, sohee,
|
|
eric (sichuan dialect), dylan (beijing dialect).
|
|
|
|
Voice design (`tts.sh`, VoiceDesign, attribute instruction) :
|
|
|
|
```
|
|
./build/qwen-tts \
|
|
--model models/qwen-talker-1.7b-voicedesign-Q8_0.gguf \
|
|
--codec models/qwen-tokenizer-12hz-Q8_0.gguf \
|
|
--instruct "male, young adult, moderate pitch" \
|
|
--lang English -o out.wav < prompt.txt
|
|
```
|
|
|
|
OpenAI-compatible server (`tts-server`) : `response_format` "pcm"
|
|
streams s16le as it is generated, "wav" returns a one-shot file. Cloned
|
|
voices register once over HTTP (a WAV extracted server side, or the
|
|
`.spk` / `.rvq` latents from `qwen-codec`), then any OAI client selects
|
|
them by name :
|
|
|
|
```
|
|
./build/tts-server \
|
|
--model models/qwen-talker-1.7b-base-Q8_0.gguf \
|
|
--codec models/qwen-tokenizer-12hz-Q8_0.gguf \
|
|
--alias qwen3-tts-base --port 8080
|
|
|
|
curl -X POST localhost:8080/v1/audio/voices -H "Content-Type: application/json" \
|
|
-d "{\"name\":\"freeman\",\"ref_text\":\"$(cat ref.txt)\",
|
|
\"spk_b64\":\"$(base64 -w0 ref.spk)\",\"rvq_b64\":\"$(base64 -w0 ref.rvq)\"}"
|
|
|
|
curl -X POST localhost:8080/v1/audio/speech -H "Content-Type: application/json" \
|
|
-d '{"input":"Hello world.","voice":"freeman","response_format":"wav",
|
|
"seed":42,"temperature":0.8}' -o out.wav
|
|
```
|
|
|
|
The speech body accepts optional sampling overrides (`seed`,
|
|
`max_new_tokens`, `temperature`, `top_k`, `top_p`,
|
|
`repetition_penalty`); unset fields keep the engine defaults and a
|
|
fixed seed makes the request reproducible.
|
|
|
|
## Embedding the library
|
|
|
|
The CLI tools are thin wrappers over a public ABI. Single-header,
|
|
single-name-prefix, plain C linkage so that C, C++, Python ctypes,
|
|
Rust bindgen and Go cgo all consume it the same way.
|
|
|
|
```c
|
|
#include "qwen.h"
|
|
|
|
struct qt_init_params iparams;
|
|
qt_init_default_params(&iparams);
|
|
iparams.talker_path = "models/qwen-talker-1.7b-base-Q8_0.gguf";
|
|
iparams.codec_path = "models/qwen-tokenizer-12hz-Q8_0.gguf";
|
|
|
|
struct qt_context * q = qt_init(&iparams);
|
|
|
|
struct qt_tts_params params;
|
|
qt_tts_default_params(¶ms);
|
|
params.text = "Hello world.";
|
|
params.lang = "English";
|
|
|
|
struct qt_audio audio = { 0 };
|
|
qt_synthesize(q, ¶ms, &audio);
|
|
/* audio.samples, audio.n_samples, audio.sample_rate, audio.channels */
|
|
qt_audio_free(&audio);
|
|
qt_free(q);
|
|
```
|
|
|
|
Base voice-clone latents can also be precomputed in-process, replacing
|
|
the `qwen-codec --talker ref.wav` shell-out: `qt_extract_voice_ref`
|
|
takes the decoded `.wav` contents as mono float32 PCM at 24 kHz and
|
|
fills a `struct qt_voice_ref` with the `.spk`-equivalent speaker
|
|
embedding plus the `.rvq`-equivalent `[num_codebooks, ref_T]` code
|
|
matrix. Pass those buffers back through `qt_tts_params.ref_spk_emb` /
|
|
`ref_codes`, and for reference-WAV-plus-transcription ICL mode keep
|
|
passing the transcript as `qt_tts_params.ref_text`. Release the buffers
|
|
with `qt_voice_ref_free`.
|
|
|
|
`tests/abi-c.c` is built with `-std=c99 -Wall -Werror -pedantic` on
|
|
every build (the `test-abi-c` target), so any regression that breaks
|
|
plain C consumability fails the build, not just an opt-in target.
|
|
|
|
For a binding-friendly shared library (libqwen.so / .dll / .dylib),
|
|
configure with `cmake -DQWEN_SHARED=ON ...`. The shared target exports
|
|
only the `qt_*` symbols ; every internal `pipeline_*` and `backend_*`
|
|
stays hidden inside the .so. The static `libqwen-core.a` is the default
|
|
build artefact and the one the bundled CLI tools link against.
|
|
|
|
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the model, the
|
|
GGUF layout, the inference pipeline, every CLI flag, the public API
|
|
reference and the validation results.
|
|
|
|
## License
|
|
|
|
MIT. See [LICENSE](LICENSE).
|
|
|
|
Upstream model : Qwen3-TTS by Alibaba / Qwen team, Apache 2.0.
|
|
Audio codec : Qwen3-TTS-Tokenizer-12Hz (Qwen team), Apache 2.0.
|