# 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 `. 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): ```json { "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): ```json {"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) ```python import asyncio, json, ssl, inspect, re, websockets WSS = "wss://qwen3-tts.aquantico.de/v1/audio/speech/stream" 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: ```text 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.