by.waclaw.online / spark / audio / 02

Qwen3-TTS as a Speech API

The easy half of the stack: a containerised, OpenAI-compatible speech endpoint with voice cloning and streaming, built for GB10, covering English and Italian.

Series 2, article 2 of 4 · written 31 July 2026

This is the part of the series where things go well. Qwen3-TTS is Apache 2.0, small, fast, and — unusually for this hardware — has containers built by people who own the same box. You will have a working /v1/audio/speech endpoint on your LAN in under an hour, most of which is downloading weights.

What you are installing

Qwen3-TTS comes in 0.6B and 1.7B variants and offers three distinct ways to choose a voice, which the GB10 containers expose as separate services:

ModeHow you specify the voiceTypical port
VoiceCloneA reference recording plus its transcript. This is the one you want.8020
VoiceDesignA free-text description — "a warm middle-aged woman, slight Milanese accent, unhurried"8021
CustomVoiceBuilt-in named speakers shipped with the model8022
StreamingAs above, but emits WAV chunks as they are generated8023

Each 1.7B service occupies roughly 6 GB in bfloat16. Running all four simultaneously is about 24 GB — comfortably within budget on this machine, and the containers report low memory pressure doing exactly that. This is one of the few places where the one-model-at-a-time discipline can be relaxed without consequence.

Prerequisites

# DGX OS ships these; verify rather than assume
docker --version                       # 29.x
nvidia-smi | head -3                   # driver 580+
docker run --rm --gpus all nvidia/cuda:13.0.2-base-ubuntu24.04 nvidia-smi

You also need the model weights locally. Pull them into your model library rather than the default cache, so they live on the storage you chose:

export MODELS=/mnt/models
export HF_HOME=$MODELS/hf

pip install -U "huggingface_hub[cli]"
hf download Qwen/Qwen3-TTS-12Hz-1.7B --local-dir $MODELS/tts/qwen3-tts-1.7b
Which container. Two GB10-specific projects publish images: dgx-spark-faster-qwen3-tts, which uses CUDA graphs for a reported 6–10× speedup over naive inference and exposes the four services above, and qwen3-tts-server, which is a single service with both clone and design modes plus a pre-cloned voice library. They share an author lineage and an image family. Start with whichever has the more recent commits when you read this; the API surfaces are near-identical.

Preparing reference audio

This is where cloning quality is actually decided, and it gets far less attention than it deserves. The model can only reproduce what it hears — a reference with room echo produces a cloned voice with room echo, permanently.

# Normalise a reference into what the model expects
ffmpeg -i raw.m4a -ac 1 -ar 24000 -af \
  "highpass=f=70,afftdn=nf=-25,loudnorm=I=-19:TP=-3:LRA=7" \
  -t 12 EN_F_Anna.wav

# Save the transcript alongside, same stem
echo "The tram was late again, so I walked the last two stops." > EN_F_Anna.txt

The naming convention the containers expect is <LANG>_<GENDER>_<Name>.wav with a matching .txt. Drop them into the speakers directory and they are picked up without a restart — voices hot-reload, which makes iterating on a reference recording pleasant rather than tedious.

Running it

# /opt/spark/tts-qwen/compose.yml
services:
  qwen3-tts-clone:
    image: martinb78/faster-qwen3-tts-dgx-spark:latest-streaming
    container_name: qwen3-tts-clone
    restart: unless-stopped
    ports: ["8020:8020"]
    volumes:
      - /mnt/models/tts/qwen3-tts-1.7b:/models/qwen3-tts:ro
      - ./speakers:/config/speakers
      - ./out:/out
    environment:
      MODEL_PATH: /models/qwen3-tts
      HOST: 0.0.0.0
      PORT: "8020"
    deploy:
      resources:
        reservations:
          devices: [{driver: nvidia, count: all, capabilities: [gpu]}]
mkdir -p /opt/spark/tts-qwen/{speakers,out}
cp EN_F_Anna.{wav,txt} /opt/spark/tts-qwen/speakers/
cd /opt/spark/tts-qwen && docker compose up -d
curl -s localhost:8020/health

Environment variable names differ slightly between the two projects — check the compose file that ships with the image and adapt. The volume layout above is the part that matters: read-only weights, a writable speakers directory, and an output directory you can reach from the host.

Calling it

The endpoint is deliberately OpenAI-shaped, so anything that already speaks to OpenAI's TTS API works unchanged.

# What voices does it know about?
curl -s localhost:8020/v1/audio/voices | jq

# English
curl -s localhost:8020/v1/audio/speech \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "qwen3-tts",
    "voice": "EN_F_Anna",
    "input": "The build finished at four in the morning, which is not a time.",
    "response_format": "wav"
  }' --output out-en.wav

# Italian, same cloned identity
curl -s localhost:8020/v1/audio/speech \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "qwen3-tts",
    "voice": "EN_F_Anna",
    "input": "La build è finita alle quattro del mattino, che non è un orario.",
    "language": "it",
    "response_format": "wav"
  }' --output out-it.wav

Note what happened there: the same reference voice, recorded in English, speaking Italian. Cross-lingual cloning is the feature that makes this architecture work — you record a person once and they speak every language the engine supports. How well the accent transfers varies by voice and by language pair, and it is worth listening critically rather than assuming.

From Python, with the standard client:

from openai import OpenAI

tts = OpenAI(base_url="http://spark.local:8020/v1", api_key="not-needed")

with tts.audio.speech.with_streaming_response.create(
    model="qwen3-tts",
    voice="EN_F_Anna",
    input="Testo di prova per la clonazione vocale.",
    extra_body={"language": "it"},
) as r:
    r.stream_to_file("prova.wav")

Voice design, when you do not have a reference

The design mode takes a description instead of a recording. It is the fastest way to get a usable narrator voice that belongs to nobody, which is occasionally exactly what you want — and it sidesteps the consent question entirely.

curl -s localhost:8021/v1/audio/speech \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "qwen3-tts-design",
    "voice": "a calm male narrator in his fifties, measured pace, slight gravel, no accent",
    "input": "Chapter one. The instruments were installed in October.",
    "response_format": "wav"
  }' --output narrator.wav

Designed voices are not stable across restarts unless the implementation seeds them — if you find one you like, generate a sample and then clone that sample to pin it down. That converts a prompt into a reproducible speaker.

Streaming, and what it is for

Non-streaming synthesis of a 22-second response takes roughly 12–13 seconds on this hardware. Streaming delivers first audio in about 0.4 seconds and then sustains around 1.65–1.75× real time — audio arrives faster than it plays, so playback never starves.

That difference is the whole argument for streaming: for a file you are going to save, it is irrelevant; for anything conversational, it is the difference between a usable assistant and an awkward pause. If you are pairing this with the LLM endpoint from series 1, stream both and start speaking the first sentence while the model is still writing the third.

curl -N localhost:8023/v1/audio/speech \
  -H 'Content-Type: application/json' \
  -d '{"model":"qwen3-tts","voice":"EN_F_Anna","input":"...","stream":true}' \
  | ffplay -nodisp -autoexit -

Troubleshooting

SymptomCause and fix
Generation is ~30× slower than expectedThe classic ARM64 trap — Torch fell back to CPU. Inside the container: python -c "import torch; print(torch.__version__, torch.cuda.is_available())". A +cpu build means the image is wrong for this hardware.
Cloned voice sounds thin or roboticReference audio problem nine times out of ten. Too short, compressed, noisy, or the transcript does not match. Re-record before touching model settings.
Italian output has an English accentExpected to a degree with a cross-lingual clone. Try a reference recorded in Italian by the same speaker if you have one; otherwise accept it or use a designed Italian voice.
Numbers, dates and abbreviations read wronglyText normalisation is the caller's job. Expand "3.2 km", "1999", "e.g." into words before sending. Article 4 puts this in the gateway.
Long input truncated or driftingChunk on sentence boundaries and concatenate. Article 4 handles this too.
First request after startup is slowCUDA graph capture and warm-up. Send a throwaway request after the container starts.

Where this leaves you

You now have English and Italian at good quality behind an OpenAI-compatible endpoint, with one cloned identity usable in both. What you do not have is Polish, because this engine does not speak it. That is the next article, and it is a rougher ride.

Sources