first commit
This commit is contained in:
147
STREAMING_API.md
Normal file
147
STREAMING_API.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Qwen3-TTS — Streaming-API (Token-Stream → Audio)
|
||||
|
||||
WebSocket-Endpoint, um Text **inkrementell** (z. B. Token für Token aus einem LLM)
|
||||
zu senden und Audio **satzweise und chunked** zurückzubekommen. 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):
|
||||
```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 Token/Fragment/Satz (max. 128 KB je Frame):
|
||||
```json
|
||||
{"type": "input.text", "text": "Hallo"}
|
||||
{"type": "input.text", "text": ", wie geht es "}
|
||||
{"type": "input.text", "text": "Ihnen?"}
|
||||
```
|
||||
Der Server hängt alles an einen Puffer an und löst Synthese aus, **sobald eine
|
||||
Satz-/Teilsatzgrenze erkannt wird**. Unvollständige 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, 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())
|
||||
for tok in token_iter: # Tokens direkt aus dem LLM
|
||||
await ws.send(json.dumps({"type": "input.text", "text": tok}))
|
||||
await ws.send(json.dumps({"type": "input.done"}))
|
||||
await rt
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user