Files
Qwen3-tts/STREAMING_API.md
2026-06-18 17:27:42 +02:00

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_NONE in 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)

  1. 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"
    }
    
  2. input.text — beliebig viele; je Nachricht ein vollstaendiges Wort oder Wort mit Satzzeichen (max. 128 KB je Frame):
    {"type": "input.text", "text": "Hallo "}
    {"type": "input.text", "text": "Aquantico, "}
    {"type": "input.text", "text": "willkommen!"}
    
    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).
  3. input.done — Eingabe beendet; verbleibender Pufferinhalt wird noch synthetisiert, dann folgt session.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_embedding verankern (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.config muss innerhalb 10 s kommen, sonst Abbruch.
  • Max. 30 s Pause zwischen Nachrichten (idle timeout).
  • session.config ≤ 4 MB (für große ref_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.