7.6 KiB
Qwen3-TTS — Streaming-API (Token-Stream → Audio)
WebSocket-Endpoint, um Text inkrementell zu senden und Audio satzweise und chunked zurückzubekommen. LLM-Tokens sollen clientseitig zu vollstaendigen Woertern gepuffert werden; der Client splittet nicht selbst in Saetze. Der Server puffert den einströmenden Text, segmentiert ihn an Satz-/Teilsatzgrenzen und synthetisiert jeden Abschnitt, sobald er vollständig ist.
Endpunkte & Auth
| Lokal | Extern | |
|---|---|---|
| WebSocket | ws://localhost:8091/v1/audio/speech/stream |
wss://qwen3-tts.aquantico.de/v1/audio/speech/stream |
| REST (Vollausgabe) | http://localhost:8091/v1/audio/speech |
https://qwen3-tts.aquantico.de/v1/audio/speech |
| Health / Modelle | …/health, …/v1/models |
dito |
- Auth (nur extern): Header
Authorization: Bearer <TOKEN>. Lokal ist kein Token nötig. - TLS (extern): Das Zertifikat ist self-signed → Client-Zertifikatsprüfung ggf. deaktivieren
(
curl -k, bzw.ssl.CERT_NONEin Python). Intern kein TLS. - Audioformat: 24 000 Hz, mono, 16-bit PCM (little-endian). Bei
response_format: "wav"kommt pro Satz eine komplette WAV-Datei; bei"pcm"rohe PCM-Chunks (ohne Header).
Protokoll
Eine WebSocket-Verbindung = eine Session. Ablauf:
Client → Server (JSON-Textframes)
session.config— genau einmal, als allererste Nachricht (Timeout 10 s):{ "type": "session.config", "model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", "voice": "Ryan", "language": "German", "response_format": "pcm", "stream_audio": true, "split_granularity": "sentence" }input.text— beliebig viele; je Nachricht ein vollstaendiges Wort oder Wort mit Satzzeichen (max. 128 KB je Frame):Der Client soll rohe LLM-Tokens zu Woertern puffern und keine Saetze selbst splitten. Der Server haengt alles an einen Puffer an und loest Synthese aus, sobald eine Satz-/Teilsatzgrenze erkannt wird. Unvollstaendige Reste bleiben gepuffert. Max. 30 s Pause zwischen zwei Nachrichten (idle timeout).{"type": "input.text", "text": "Hallo "} {"type": "input.text", "text": "Aquantico, "} {"type": "input.text", "text": "willkommen!"}input.done— Eingabe beendet; verbleibender Pufferinhalt wird noch synthetisiert, dann folgtsession.done.
Server → Client
| Nachricht | Inhalt |
|---|---|
audio.start (JSON) |
{"type":"audio.start","sentence_index":0,"sentence_text":"…","format":"pcm","sample_rate":24000} — Beginn eines Satzes. sample_rate nur bei PCM-Streaming. |
| (Binärframe) | Audio-Bytes. Bei stream_audio:true + pcm mehrere Chunks, die schon während der Generierung kommen. Bei wav ein vollständiger WAV-Block. |
audio.done (JSON) |
{"type":"audio.done","sentence_index":0} — Satz fertig. |
session.done (JSON) |
{"type":"session.done","total_sentences":N} — Session fertig. |
error (JSON) |
{"type":"error","message":"…"} |
session.config — Felder
| Feld | Default | Beschreibung |
|---|---|---|
model |
— | Modell-ID (wird validiert). |
voice |
(modellabh.) | Eingebauter Sprecher, z. B. Ryan, Aiden, Vivian (CustomVoice). |
language |
Auto |
z. B. German, English. |
task_type |
CustomVoice |
CustomVoice | VoiceDesign | Base. |
instructions |
— | Natürlichsprachliche Stil-/Emotionssteuerung (z. B. „sachlich, ruhig"). |
response_format |
wav |
pcm (für echtes Low-Latency-Streaming empfohlen) oder wav. |
stream_audio |
false |
true = Audio-Chunks während der Generierung; false = ganzer Satz auf einmal. |
split_granularity |
sentence |
sentence oder clause (an Kommas etc. → noch kleinere Stücke, niedrigere Latenz). |
speed |
1.0 |
Sprechtempo. |
max_new_tokens |
(modellabh.) | Obergrenze Token. |
initial_codec_chunk_frames |
(deploy-cfg) | Größe des ersten Audio-Chunks (Latenz-Tuning). |
ref_audio / ref_text |
— | Referenz für Voice-Clone (Base-Task; nur mit klon-fähigem Modell). |
speaker_embedding |
— | Fester 2048-dim-Stimmvektor (1.7B) für konstantes Timbre. |
x_vector_only_mode |
— | Klon nur über x-Vector (ohne ICL-Transkript). |
Segmentierung & Stimm-Konsistenz
- Synthese erfolgt pro Segment (Satz bzw. Teilsatz).
split_granularity: "clause"senkt die Latenz, erzeugt aber mehr Segmentgrenzen. - ⚠️ Drift: Jedes Segment wird unabhängig generiert; Stimmlage/Stimmung können zwischen
Segmenten leicht schwanken (gemessen ~11 % F0-Streuung). Temperatur senken hilft nicht.
Gegenmittel: (a) mehr Text pro Segment bündeln, (b) Timbre über einen festen
speaker_embeddingverankern (Base-Modell nötig).
Gemessene Latenz
| Pfad | Time-to-first-audio | Hinweis |
|---|---|---|
| Lokal, 1 Stream | ~0,16 s | ab Satzende |
| Lokal, 8 parallele Streams | ~0,23 s | TTFT lastunabhängig stabil |
| Extern (WSS, Token-Stream à 0,12 s) | ~1,6 s gesamt | inkl. Token-Eintrudeln + Netz; ~0,3 s ab Satzende |
Python-Beispiel (Token-Stream → PCM)
import asyncio, json, ssl, inspect, re, websockets
WSS = "wss://qwen3-tts.aquantico.de/v1/audio/speech/stream"
TOKEN = "<BEARER_TOKEN>"
def connect():
ctx = ssl.create_default_context()
ctx.check_hostname = False # self-signed cert
ctx.verify_mode = ssl.CERT_NONE
hdr = {"Authorization": f"Bearer {TOKEN}"}
params = inspect.signature(websockets.connect).parameters
key = "additional_headers" if "additional_headers" in params else "extra_headers"
return websockets.connect(WSS, max_size=None, ssl=ctx, **{key: hdr})
async def speak(token_iter):
async with connect() as ws:
await ws.send(json.dumps({
"type": "session.config",
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"voice": "Ryan", "language": "German",
"response_format": "pcm", "stream_audio": True,
}))
async def reader():
async for m in ws:
if isinstance(m, (bytes, bytearray)):
handle_pcm(m) # an Player/Buffer geben (24kHz mono s16le)
else:
msg = json.loads(m)
if msg["type"] == "session.done": return
if msg["type"] == "error": raise RuntimeError(msg["message"])
rt = asyncio.create_task(reader())
async for word in words_from_tokens(token_iter):
await ws.send(json.dumps({"type": "input.text", "text": word}))
await ws.send(json.dumps({"type": "input.done"}))
await rt
async def words_from_tokens(token_iter):
buf = ""
for tok in token_iter:
buf += tok
while True:
m = re.match(r"(\S+\s+)(.*)", buf)
if not m:
break
yield m.group(1)
buf = m.group(2)
if buf:
yield buf
def handle_pcm(chunk: bytes):
... # z.B. sounddevice / Datei / Web-Client
Wichtige Limits
session.configmuss innerhalb 10 s kommen, sonst Abbruch.- Max. 30 s Pause zwischen Nachrichten (idle timeout).
session.config≤ 4 MB (für großeref_audio-Payloads),input.text≤ 128 KB pro Frame.
WebSocket-Logging
Fuer Diagnose kann der Logging-Proxy verwendet werden:
ws://localhost:8094/v1/audio/speech/stream
Er leitet an ws://localhost:8091/v1/audio/speech/stream weiter und schreibt
session.config, input.text, JSON-Events und Audio-Chunk-Groessen in die
Containerlogs (docker logs qwen3-tts). So ist sichtbar, ob seed, voice,
language und instructions tatsaechlich im WebSocket-Call gesendet werden.