OpenAI-compatible HTTP server for Qwen3-TTS-12Hz-1.7B-Base with 9 preset voices, user-registrable voice clones, token-level PCM streaming, sentence chunking, emotion control, and multilingual generation.
Built for low-latency conversational voice agents — the first audio frame arrives in ~130 ms on an RTX 4080 SUPER and the model generates speech at ~3–3.4× real-time.
Companion project: qwen3-asr-server — the matching speech-to-text server.
- 🎙️
/v1/audio/speech— OpenAI-compatible single-shot synthesis (WAV/MP3/Opus)- Accepts JSON body (
inputfield) or query parameters — drop-in for OpenAI SDK - OpenAI voice aliases (
alloy,echo,fable,onyx,nova,shimmer) mapped automatically
- Accepts JSON body (
- 📦
/v1/audio/speech/stream— sentence-chunked streaming (length-prefixed framing) - ⚡
/v1/audio/speech/pcm-stream— token-level PCM streaming (~130 ms first chunk) - 🎭
instruct=parameter for emotion/style control ("Excited and speak quickly.","Whisper softly.") - 🗣️ 9 preset voices (
ryan,aiden,dylan,eric,serena,vivian,sohee,ono_anna,uncle_fu) lifted from the CustomVoice checkpoint and shipped as a ~37 KB sidecar bundle - 🧬 Voice cloning via
POST /v1/voices— upload a reference clip, get back a persistentvc_<id>voice usable everywhere the preset names are - 🌍 Multilingual — English, French, German, Spanish, Italian, Portuguese, Japanese, Korean, Chinese
- 🚦 Barge-in friendly — streams abort cleanly on client disconnect
- 🐳 Docker/Podman — single-container deploy, Ubuntu 24.04, CUDA 12.8
- 🐌 CPU fallback for smoke tests (
--cpu)
| Tier | Backend | Requirement | ~Real-time factor | First PCM chunk |
|---|---|---|---|---|
| 1 (default) | faster-qwen3-tts + CUDA graphs |
pip install faster-qwen3-tts (in container by default) |
~3–3.4× | ~130 ms |
| 2 | qwen_tts + flash_attention_2 |
pip install flash-attn |
~2× | ~400 ms |
| 3 | qwen_tts + SDPA |
PyTorch 2.x built-in, no extra install | ~1.5× | ~500 ms |
| CPU | transformers | no GPU required | ~0.01–0.05× | 30+ s |
The server selects the best available tier at startup and logs which one it's using.
The container image always installs faster-qwen3-tts (tier 1).
Before running the container, install the NVIDIA driver and container toolkit on the host.
# --- NVIDIA driver (Ubuntu 24.04, from the official CUDA repo) ---
# Open kernel module — recommended for Turing, Ampere, Ada Lovelace, Blackwell
curl -fsSL https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2404/x86_64/3bf863cc.pub \
| sudo gpg --dearmor -o /etc/apt/keyrings/nvidia-cuda.gpg
printf 'Types: deb\nURIs: https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2404/x86_64/\nSuites: /\nSigned-By: /etc/apt/keyrings/nvidia-cuda.gpg\n' \
| sudo tee /etc/apt/sources.list.d/nvidia-cuda.sources
sudo apt-get update
sudo apt-get install -y nvidia-driver-open nvidia-container-toolkit
# For Docker — configure runtime and restart
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
# For Podman — generate CDI specs (enables --device nvidia.com/gpu=all)
sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
# Verify
nvidia-smiDriver note:
nvidia-driver-openuses the open kernel module and works on Turing+. For Pascal and older GPUs use the proprietary variant (nvidia-driver-XXX).
# 1. Run the container — mount a volume for the HF model cache (~3 GB on first run)
docker run -d --name qwen3-tts \
--gpus all \
-p 8001:8001 \
-v qwen3-hf-cache:/root/.cache/huggingface \
ghcr.io/malaiwah/qwen3-tts-server:latest
# Podman equivalent (requires CDI — see Host prerequisites above):
# podman run -d --name qwen3-tts \
# --device nvidia.com/gpu=all \
# -p 8001:8001 \
# -v qwen3-hf-cache:/root/.cache/huggingface \
# ghcr.io/malaiwah/qwen3-tts-server:latest
# (Optional) pass a HuggingFace token if needed:
# -e HF_TOKEN=hf_xxx
# 2. Wait for the model to load (first run downloads ~3 GB; subsequent runs reuse volume)
docker logs -f qwen3-tts # look for "✓ Server ready — backend: faster-qwen3-tts"
# 3. Try it — using OpenAI-compatible JSON body
curl -s -X POST http://localhost:8001/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"model":"tts-1","input":"Hello from Qwen3","voice":"alloy"}' \
-o hello.mp3
mpv hello.mp3
# Or query-parameter style (also supported):
curl -s -X POST "http://localhost:8001/v1/audio/speech?text=Hello+from+Qwen3&voice=ryan" \
-o hello.mp3git clone https://github.com/malaiwah/qwen3-tts-server.git
cd qwen3-tts-server
uv venv && source .venv/bin/activate
# GPU (tier 1 — fastest, ~3.4× real-time):
uv pip install torch --index-url https://download.pytorch.org/whl/cu128 # match your CUDA
uv pip install -e ".[gpu]" # installs faster-qwen3-tts
python server.py # starts on :8001
# CPU fallback (very slow, smoke tests only):
uv pip install torch --index-url https://download.pytorch.org/whl/cpu
uv pip install -e "."
python server.py --cpu
# In another shell:
./test-tts.py "Hello from Qwen3" # writes out.mp3
./test-tts.py "Bonjour le monde" --language French --voice aiden
./test-tts.py "Speak slowly" --instruct "Calm and slow." -o slow.wav --format wavThe server accepts the exact JSON body format the OpenAI Python SDK sends:
from openai import OpenAI
client = OpenAI(
api_key="not-needed",
base_url="http://localhost:8001/v1",
)
# OpenAI voice aliases are remapped automatically:
# alloy→ryan, echo→aiden, fable→dylan, onyx→eric, nova→serena, shimmer→vivian
response = client.audio.speech.create(
model="tts-1",
voice="alloy",
input="The quick brown fox jumps over the lazy dog.",
)
response.stream_to_file("output.mp3")flowchart LR
A[Text prompt] -->|POST /v1/audio/speech| B[qwen3-tts-server<br/>:8001]
B -->|MP3/WAV/PCM| C[Audio file]
C -->|POST /v1/audio/transcriptions| D[qwen3-asr-server<br/>:8002]
D -->|JSON transcript| E[Recovered text]
E -.compare.-> A
# 1. Synthesise
./test-tts.py "The quick brown fox jumps over the lazy dog." -o sample.wav --format wav
# 2. Transcribe — run test-asr.py from the qwen3-asr-server repo:
# https://github.com/malaiwah/qwen3-asr-server
# Or hit the ASR endpoint directly:
curl -s -X POST http://localhost:8002/v1/audio/transcriptions \
-F model=Qwen/Qwen3-ASR-1.7B \
-F [email protected]
# → {"text": "The quick brown fox jumps over the lazy dog."}To run both services together with automatic GPU sharing and startup ordering:
# Clone either repo or save the docker-compose.yml, then:
HF_TOKEN=hf_xxx docker compose up -dSee docker-compose.yml for the full configuration including VRAM budget notes.
| Component | Spec |
|---|---|
| GPU | NVIDIA GeForce RTX 4080 SUPER (16 GB VRAM, Ada Lovelace) |
| CPU | Intel Core i7-14700 KF (20 cores / 28 threads) |
| RAM | 32 GB DDR5 |
| OS | Ubuntu 24.04.4 LTS |
| Driver | NVIDIA 595.58.03 (CUDA 13.x) |
| GPU | VRAM | Driver | CUDA compat | Notes |
|---|---|---|---|---|
| NVIDIA GeForce RTX 5090 (Vast.ai VM) | 32 GB | 580.95.05 / 595.58.03 | 13.0 / 13.2 | Tier-1; ~19 ms/step, ~4.5× RTF — see two-pass results below |
| NVIDIA GeForce RTX 4090 (Vast.ai VM) | 24 GB | 580.126.09 | 13.0 | Tier-1; ~19 ms/step, ~4.2× RTF |
| NVIDIA GRID A100D-20C (Vultr vGPU) | 20 GB | 550.90.07 | ≤ 12.4 | cu128 image works fine; driver locks at 550 |
| CPU-only (no GPU) | — | — | — | --cpu flag; very slow |
| Workload | Length | Wall time | Real-time factor |
|---|---|---|---|
| First PCM chunk (320 ms audio) | 7 680 samples | 131 ms | ~2.4× |
| Short reply (~9 s audio) | 27 chunks | 2.66 s | ~3.4× |
| Medium reply (~40 s audio) | 126 chunks | 12.58 s | ~3.2× |
| Long reply (~80 s audio) | 255 chunks | 25.47 s | ~3.2× |
VRAM footprint: ~4.4 GB (bfloat16 + CUDA-graph warmup).
Measured on Vultr GRID A100D-20C (20 GB vGPU), driver 550.90.07, CUDA 12.4, cu128 container, faster-qwen3-tts (CUDA graphs, tier 1).
The A100D-20C is a virtualised 20 GB slice of an A100 80 GB. The vGPU hypervisor allocates ≈25% of the compute budget, so single-stream inference is slower than a consumer card despite the HBM2e memory:
| Workload | Audio | Wall time | Real-time factor | Step time |
|---|---|---|---|---|
| Short sentence (9 words) | ~3 s | ~2.3 s | ~1.4× | ~57 ms/step |
| First PCM chunk | 320 ms | ~350 ms | — | — |
VRAM footprint: ~14.4 GB when co-located with the ASR vLLM server (18.8 GB / 20 GB total).
Measured on Vast.ai NVIDIA GeForce RTX 5090 (32 GB VRAM, Blackwell sm_12.0), Ubuntu 22.04 VM, faster-qwen3-tts (CUDA graphs, tier 1) — steady-state (4 runs averaged per pass).
Two passes were run on the same machine to isolate the effect of a driver upgrade:
| Pass | Driver | CUDA compat | Short (~3s) wall | Short RTF | Medium (~12s) wall | Medium RTF | Long (~30s) wall | Long RTF | PCM first chunk |
|---|---|---|---|---|---|---|---|---|---|
| 1 (stock) | 580.95.05 | 13.0 | 0.78 s | 4.34× | 2.80 s | 4.53× | 6.70 s | 4.57× | 178 ms |
| 2 (upgraded) | 595.58.03 | 13.2 | 0.81 s | 4.39× | 2.73 s | 4.56× | 6.73 s | 4.59× | 177 ms |
TTS performance is identical across both driver versions — within measurement noise. The faster-qwen3-tts tier-1 backend is insensitive to the driver bump on this GPU.
VRAM footprint: ~4.4 GB TTS alone; ~28 GB when co-located with ASR (both models on one RTX 5090).
Measured on Vast.ai NVIDIA GeForce RTX 4090 (24 GB VRAM), driver 580.126.09, CUDA 13.0, Ubuntu 22.04 VM, faster-qwen3-tts (CUDA graphs, tier 1) — steady-state (warm model, 4 runs averaged).
| Workload | Audio | Wall time | Real-time factor | Step time |
|---|---|---|---|---|
| Short sentence (~3.1 s) | ~3.1 s | 0.74 s | ~4.2× | ~19 ms/step |
| Longer sentence (~8.8 s) | ~8.8 s | 2.04 s | ~4.3× | ~19 ms/step |
VRAM footprint: ~4.4 GB (same as RTX 4080 SUPER).
The RTX 4090's larger SM count and higher memory bandwidth deliver ~0.5–1× faster RTF and ~10 ms/step lower latency compared with the RTX 4080 SUPER, while the VRAM footprint is identical — leaving ~19 GB free for co-located workloads.
Accepts JSON body or query parameters.
| Param | Default | Notes |
|---|---|---|
input / text |
(required) | Text to synthesise. input preferred (OpenAI SDK compat). |
model |
tts-1 |
Accepted for OpenAI compat; always uses the configured Qwen3-TTS checkpoint. |
voice |
ryan |
Preset name (ryan, vivian, …), OpenAI alias (alloy, nova, …), or a registered clone ID (vc_ab12cd34). See GET /v1/voices. |
language |
English |
English, French, Chinese, German, Spanish, … |
instruct |
(none) | Style hint, e.g. "Excited and speak quickly." |
response_format |
mp3 |
mp3, wav, or opus |
speed |
1.0 |
Accepted for OpenAI compat; Qwen3-TTS does not support speed control. |
Same parameters as above (JSON body or query params). Streams one self-contained audio file per sentence with length-prefixed framing:
[4 bytes BE uint32: chunk_length][chunk_length bytes: audio]
[4 bytes BE uint32: chunk_length][chunk_length bytes: audio]
... (stream ends on socket close)
Yields raw 24 kHz mono int16 PCM frames as the model generates them.
First chunk latency ≈ 130 ms on RTX 4080 SUPER.
A zero-length frame marks end of stream.
Requires faster-qwen3-tts (tier-1 backend — installed by default in the container).
| Param | Default | Notes |
|---|---|---|
chunk_size |
4 |
Codec frames per yield (lower = lower latency, more overhead) |
Returns preset speakers and user-registered clones, plus the OpenAI alias mapping:
{
"data": [
{"id": "aiden", "kind": "preset", "name": "aiden", "gender": "m", "lang": "en,zh"},
{"id": "vivian", "kind": "preset", "name": "vivian", "gender": "f", "lang": "en,zh"},
{"id": "vc_ab12cd34", "kind": "custom", "name": "Alice", "created_at": 1734567890, "ref_text": "...", "x_vector_only_mode": false, "icl_mode": true}
],
"openai_voice_aliases": {"alloy": "ryan", ...}
}GET /voices is kept as a deprecated alias that returns the same shape.
Multipart form with an audio reference clip, returns the new voice record (201 Created).
| Form field | Required | Notes |
|---|---|---|
name |
yes | Human-friendly label (any string). |
audio |
yes | Reference clip. 3–30 s recommended. WAV/MP3/FLAC/OGG. |
ref_text |
no | Transcript of the clip. When provided (or auto-transcribed — see below), enables full in-context learning mode (closer clone, prosody-aware). Without it, falls back to x-vector-only mode. |
language |
no | Language hint (e.g. "English", "French"). Forwarded to the ASR service when auto-transcribing. |
auto_transcribe |
no | Per-request override of the default (on when QWEN3_TTS_ASR_URL is set). Send false to force x-vector-only mode. |
# x-vector-only clone (no transcript needed)
curl -X POST http://localhost:8001/v1/voices \
-F "name=Alice" \
-F "audio=@alice_sample.wav"
# → {"id":"vc_ab12cd34","kind":"custom","name":"Alice",...}
# Full ICL clone (prosody-aware)
curl -X POST http://localhost:8001/v1/voices \
-F "name=Bob" \
-F "audio=@bob_sample.wav" \
-F "ref_text=The quick brown fox jumps over the lazy dog."
# Use the clone like any other voice
curl -X POST http://localhost:8001/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{"input":"Hello from my clone","voice":"vc_ab12cd34"}' \
--output clone_out.mp3Clones persist to $QWEN3_TTS_VOICES_DIR/custom/ (default /data/voices/custom/)
as <id>.safetensors + <id>.json sidecars, ~13 KB total per clone.
Mount that path as a volume so clones survive container restarts.
Auto-transcription is wired in via QWEN3_TTS_ASR_URL. When set, any
POST /v1/voices without ref_text forwards the audio clip to that
endpoint (OpenAI-compatible POST /v1/audio/transcriptions, matching
qwen3-asr-server) and uses the returned text to enable full ICL mode. The
response includes "auto_transcribed": true when this path was taken.
The ASR call is best-effort: any failure (network, auth, empty result)
falls back silently to x-vector-only mode. Typical compose wiring:
# docker-compose.yml — tts service environment
environment:
- QWEN3_TTS_ASR_URL=http://asr:8000
- QWEN3_TTS_ASR_API_KEY=${QWEN_API_KEY:-}Returns 200 {"id":"vc_...","deleted":true} on success, 404 if unknown, 403 if the target is a preset (presets are immutable).
Standard introspection endpoints. /health includes the active backend tier.
Plain-text Prometheus exposition for scraping by Prometheus/VictoriaMetrics/OTEL collectors. Always unauthenticated (standard ops convention). Exposed metrics:
| Metric | Type | Labels | Meaning |
|---|---|---|---|
qwen3_tts_requests_total |
counter | method, path, status |
HTTP requests handled |
qwen3_tts_request_duration_seconds |
histogram | method, path |
Request latency |
qwen3_tts_requests_in_flight |
gauge | — | Requests currently in progress |
qwen3_tts_model_ready |
gauge | — | 1 when the model is loaded, 0 otherwise |
qwen3_tts_backend_info |
gauge | device, backend, model_id |
Always 1; labels describe the backend |
Standard process_* and python_gc_* metrics are included automatically by
prometheus_client.
By default the server is unauthenticated (matching typical self-hosted usage).
To require a bearer token on /v1/* endpoints set either an env var or a CLI flag:
# Env var (recommended for containers)
docker run -e QWEN_API_KEY=sk-mysecret ghcr.io/malaiwah/qwen3-tts-server:latest …
# Or CLI flag
python server.py --api-key sk-mysecretCallers must then send Authorization: Bearer sk-mysecret.
/health and /metrics stay unauthenticated regardless, so ops scrapers and
container health checks keep working.
from openai import OpenAI
client = OpenAI(api_key="sk-mysecret", base_url="http://localhost:8001/v1")| Env var | Default | Purpose |
|---|---|---|
QWEN3_TTS_MODEL_ID |
Qwen/Qwen3-TTS-12Hz-1.7B-Base |
Override the HF model. Only -Base supports the clone path. |
QWEN3_TTS_MAX_TEXT_LENGTH |
10000 |
Maximum input characters (returns HTTP 413 if exceeded). |
QWEN3_TTS_MAX_REF_AUDIO_BYTES |
10485760 |
Maximum reference-audio upload size for POST /v1/voices. |
QWEN3_TTS_VOICES_DIR |
/data/voices |
Where user-registered clones persist. Mount a volume here. |
QWEN3_TTS_PRESET_BUNDLE |
assets/preset/bundle.safetensors |
Override preset bundle location. |
QWEN3_TTS_DEFAULT_VOICE |
ryan |
Default voice when the client omits voice. |
QWEN3_TTS_ASR_URL |
(unset) | Enable auto-transcription for POST /v1/voices by forwarding audio to an OpenAI-compatible /v1/audio/transcriptions endpoint (e.g. http://qwen3-asr:8000). Unset → silent x-vector-only fallback. |
QWEN3_TTS_ASR_API_KEY |
(unset) | Bearer token for the ASR endpoint above. |
QWEN3_TTS_ASR_MODEL |
Qwen/Qwen3-ASR-1.7B |
model field sent with each transcription request. |
QWEN3_TTS_ASR_TIMEOUT |
30 |
Seconds before the ASR call gives up and we fall back to x-vector-only. |
HF_HOME |
/root/.cache/huggingface |
Where weights are cached. Mount a volume here. |
HF_TOKEN |
(unset) | HuggingFace token for gated downloads. |
QWEN_API_KEY |
(unset) | Require Authorization: Bearer <key> on /v1/*. |
CLI flags:
| Flag | Default | Purpose |
|---|---|---|
--host |
0.0.0.0 |
Bind address |
--port |
8001 |
Listen port |
--api-key |
(env / off) | Overrides QWEN_API_KEY for bearer-token auth |
--cpu |
(off) | Force CPU mode. Very slow. |
--log-level |
info |
Uvicorn log level |
A standalone benchmark.py measures steady-state wall-clock latency + RTF
across short/medium/long workloads:
./benchmark.py # built-in workloads, 5 runs
./benchmark.py --url http://my-gpu-host:8001 --voice aiden --language French
./benchmark.py --workloads short,long --runs 10
./benchmark.py --text "Custom sentence to time."First run per workload is warmup (excluded from the average); the rest feed a simple mean. The RTX 4080 SUPER / 5090 numbers in the tables above were produced with this script.
docker build -t qwen3-tts-server:latest -f Containerfile .
# or
podman build -t qwen3-tts-server:latest -f Containerfile .The CI workflow builds and pushes to ghcr.io/malaiwah/qwen3-tts-server:latest
on every push to main and version tags (v0.1.0, etc.).
Supply-chain metadata. Each pushed image carries:
- an SPDX SBOM attestation (generated by buildx)
- a SLSA build provenance attestation (mode=max)
Inspect either with:
docker buildx imagetools inspect \
--format '{{ json .SBOM }}' \
ghcr.io/malaiwah/qwen3-tts-server:latestVulnerability scanning. Every push to main / tags runs Trivy against the
freshly-built image and uploads SARIF findings to this repo's
Security tab.
The scan is non-blocking — upstream CUDA base images routinely carry
known CVEs outside our control.
Smoke tests run without a GPU or model load and are CI-safe:
uv pip install -e ".[test]"
pytest -qCPU inference is orders of magnitude slower — 30 s+ per short sentence. Use it only for smoke tests, API surface exploration, or environments without a GPU.
For conversational use, a CUDA GPU with ≥ 6 GB VRAM is required.
- Qwen team @ Alibaba for the Qwen3-TTS-Base and Qwen3-TTS-CustomVoice models. The preset-voice bundle in
assets/preset/is derived from CustomVoice; provenance is documented in discussion #45 and mirrored atmalaiwah/qwen3-tts-preset-voices. faster-qwen3-ttsfor CUDA-graph-accelerated inferenceqwen-ttsfor the reference inference path
MIT — free for any use; please credit the upstream Qwen model card and follow its license terms separately.