2026-06-17 08:33:47 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""WebSocket streaming test for Qwen3-TTS /v1/audio/speech/stream endpoint."""
|
|
|
|
|
import sys
|
|
|
|
|
import json
|
|
|
|
|
import time
|
|
|
|
|
import wave
|
|
|
|
|
import struct
|
|
|
|
|
import asyncio
|
2026-06-18 17:27:42 +02:00
|
|
|
import re
|
2026-06-17 08:33:47 +02:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
import websockets
|
|
|
|
|
except ImportError:
|
|
|
|
|
print("Installing websockets...")
|
|
|
|
|
import subprocess
|
|
|
|
|
subprocess.check_call([sys.executable, "-m", "pip", "install", "websockets"])
|
|
|
|
|
import websockets
|
|
|
|
|
|
|
|
|
|
WS_URL = "ws://localhost:8091/v1/audio/speech/stream"
|
|
|
|
|
OUTPUT_FILE = "test_stream_output.wav"
|
|
|
|
|
SAMPLE_RATE = 24000
|
2026-06-18 17:27:42 +02:00
|
|
|
WORD_RE = re.compile(r"\S+\s*")
|
2026-06-17 08:33:47 +02:00
|
|
|
|
|
|
|
|
|
2026-06-18 17:27:42 +02:00
|
|
|
def iter_words(text: str):
|
|
|
|
|
"""Yield complete words, preserving trailing whitespace.
|
|
|
|
|
|
|
|
|
|
The WebSocket client must not forward raw LLM tokens such as partial
|
|
|
|
|
subwords. It buffers to words and lets the server detect sentence ends.
|
|
|
|
|
"""
|
|
|
|
|
for match in WORD_RE.finditer(text):
|
|
|
|
|
yield match.group(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def stream_tts(
|
|
|
|
|
text: str,
|
|
|
|
|
voice: str = "Ryan",
|
|
|
|
|
language: str = "German",
|
|
|
|
|
instructions: str = "Eine warme, ruhige deutsche Stimme, klar artikuliert, freundlich und natuerlich.",
|
|
|
|
|
seed: int = 42,
|
|
|
|
|
):
|
2026-06-17 08:33:47 +02:00
|
|
|
print(f"[→] WebSocket connect: {WS_URL}")
|
|
|
|
|
print(f" text: {text!r}")
|
2026-06-18 17:27:42 +02:00
|
|
|
print(f" voice: {voice}, language: {language}, seed: {seed}")
|
|
|
|
|
print(f" instruct: {instructions!r}")
|
2026-06-17 08:33:47 +02:00
|
|
|
|
|
|
|
|
pcm_chunks = []
|
|
|
|
|
total_bytes = 0
|
|
|
|
|
t0 = time.perf_counter()
|
|
|
|
|
first_chunk_time = None
|
|
|
|
|
|
|
|
|
|
async with websockets.connect(WS_URL) as ws:
|
|
|
|
|
# Send session config
|
2026-06-18 17:27:42 +02:00
|
|
|
config = {
|
2026-06-17 08:33:47 +02:00
|
|
|
"type": "session.config",
|
|
|
|
|
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
|
2026-06-18 17:27:42 +02:00
|
|
|
"task_type": "VoiceDesign",
|
2026-06-17 08:33:47 +02:00
|
|
|
"voice": voice,
|
|
|
|
|
"language": language,
|
2026-06-18 17:27:42 +02:00
|
|
|
"instructions": instructions,
|
|
|
|
|
"seed": seed,
|
2026-06-17 08:33:47 +02:00
|
|
|
"response_format": "pcm",
|
|
|
|
|
"stream_audio": True,
|
2026-06-18 17:27:42 +02:00
|
|
|
"split_granularity": "sentence",
|
|
|
|
|
}
|
|
|
|
|
print("[cfg]", json.dumps(config, ensure_ascii=False, sort_keys=True), flush=True)
|
|
|
|
|
await ws.send(json.dumps(config))
|
2026-06-17 08:33:47 +02:00
|
|
|
|
2026-06-18 17:27:42 +02:00
|
|
|
# Send word-by-word. Do not split into sentences client-side; the
|
|
|
|
|
# server still decides when a complete sentence is ready for audio.
|
|
|
|
|
for word in iter_words(text):
|
|
|
|
|
await ws.send(json.dumps({
|
|
|
|
|
"type": "input.text",
|
|
|
|
|
"text": word,
|
|
|
|
|
}))
|
2026-06-17 08:33:47 +02:00
|
|
|
await ws.send(json.dumps({"type": "input.done"}))
|
|
|
|
|
|
|
|
|
|
# Receive
|
|
|
|
|
async for message in ws:
|
|
|
|
|
if isinstance(message, bytes):
|
|
|
|
|
if first_chunk_time is None:
|
|
|
|
|
first_chunk_time = time.perf_counter() - t0
|
|
|
|
|
print(f"[OK] First audio chunk in {first_chunk_time:.3f}s")
|
|
|
|
|
pcm_chunks.append(message)
|
|
|
|
|
total_bytes += len(message)
|
|
|
|
|
elif isinstance(message, str):
|
|
|
|
|
msg = json.loads(message)
|
|
|
|
|
msg_type = msg.get("type", "")
|
|
|
|
|
if msg_type == "session.done":
|
|
|
|
|
break
|
|
|
|
|
elif msg_type == "error":
|
|
|
|
|
print(f"[FAIL] Server error: {msg.get('message')}")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
elif msg_type in ("audio.start", "audio.done"):
|
|
|
|
|
print(f" [{msg_type}] sentence {msg.get('sentence_index', '?')}")
|
|
|
|
|
|
|
|
|
|
elapsed = time.perf_counter() - t0
|
|
|
|
|
|
|
|
|
|
# Write PCM chunks as WAV
|
|
|
|
|
raw_pcm = b"".join(pcm_chunks)
|
|
|
|
|
with wave.open(OUTPUT_FILE, "wb") as wf:
|
|
|
|
|
wf.setnchannels(1)
|
|
|
|
|
wf.setsampwidth(2) # int16
|
|
|
|
|
wf.setframerate(SAMPLE_RATE)
|
|
|
|
|
wf.writeframes(raw_pcm)
|
|
|
|
|
|
|
|
|
|
audio_duration = (total_bytes / 2) / SAMPLE_RATE
|
|
|
|
|
print(f"[OK] Done: {elapsed:.2f}s total, {total_bytes/1024:.1f} KB PCM")
|
|
|
|
|
print(f" Audio duration: {audio_duration:.2f}s → saved to {OUTPUT_FILE}")
|
|
|
|
|
if first_chunk_time:
|
|
|
|
|
print(f" First-chunk latency: {first_chunk_time:.3f}s")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
asyncio.run(stream_tts(
|
|
|
|
|
text="Herzlich willkommen! Dieses System nutzt Qwen3-TTS für deutsche Sprachausgabe.",
|
|
|
|
|
voice="Ryan",
|
|
|
|
|
language="German",
|
|
|
|
|
))
|