Kleineres Modell

This commit is contained in:
2026-06-19 17:22:28 +02:00
parent a5230964cb
commit 404c632cfc
20 changed files with 66 additions and 60 deletions

71
docker/entrypoint.sh Executable file
View File

@@ -0,0 +1,71 @@
#!/bin/bash
# Container-Entrypoint: startet den vLLM-Omni TTS-Server und feuert nach
# Erreichen von /health einmalig einen Warmup-Request ab. Dieser absorbiert
# die einmalige torch.compile/CUDA-Graph-Kompilierung (~38 s), damit kein
# echter Nutzer-Request sie je trifft. Läuft bei JEDEM Containerstart
# (Reboot, Crash-Restart, manuell).
set -e
MODEL="${QWEN3_TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice}"
PORT=8091
UI_PORT=8092
WS_LOG_PORT=8094
# --- Runtime-Patches fuer Qwen3-TTS anwenden ---
if [ -f /patch_qwen3_tts_runtime.py ]; then
python3 /patch_qwen3_tts_runtime.py
fi
# --- Browser-UI im Container starten ---
if [ -f /voice_clone_ui.py ]; then
python3 /voice_clone_ui.py \
--host 0.0.0.0 \
--port "$UI_PORT" \
--api-base "http://localhost:${PORT}" \
--clone-api-base "http://qwen3-tts-clone:8093" \
--model "$MODEL" &
UI_PID=$!
echo "[ui] Voice-Cloning-UI laeuft auf Port ${UI_PORT}"
fi
# --- WebSocket-Logging-Proxy im Container starten ---
if [ -f /ws_log_proxy.py ]; then
python3 /ws_log_proxy.py \
--host 0.0.0.0 \
--port "$WS_LOG_PORT" \
--upstream "ws://localhost:${PORT}/v1/audio/speech/stream" &
WS_LOG_PID=$!
echo "[ws-log] Proxy laeuft auf Port ${WS_LOG_PORT}"
fi
# --- Server im Hintergrund starten ---
vllm-omni serve "$MODEL" \
--omni \
--host 0.0.0.0 \
--port "$PORT" \
--deploy-config /deploy/qwen3_tts.yaml \
--gpu-memory-utilization 0.10 \
--trust-remote-code &
SERVER_PID=$!
# --- Warmup im Hintergrund, sobald der Server gesund ist ---
(
for _ in $(seq 1 150); do
if curl -sf "http://localhost:${PORT}/health" >/dev/null 2>&1; then
echo "[warmup] Server gesund — sende Warmup-Request (kompiliert CUDA-Graphen)..."
t0=$SECONDS
curl -sf -X POST "http://localhost:${PORT}/v1/audio/speech" \
-H 'Content-Type: application/json' \
-d "{\"model\":\"${MODEL}\",\"input\":\"System wird aufgewärmt.\",\"voice\":\"Ryan\",\"language\":\"German\",\"response_format\":\"wav\"}" \
-o /dev/null \
&& echo "[warmup] fertig nach $(( SECONDS - t0 ))s — Server ist jetzt heiß." \
|| echo "[warmup] fehlgeschlagen (Server läuft trotzdem)."
break
fi
sleep 2
done
) &
# --- Auf den Server-Prozess warten (PID 1 Signalweiterleitung) ---
trap 'kill "$SERVER_PID" "${UI_PID:-}" "${WS_LOG_PID:-}" 2>/dev/null || true' TERM INT
wait "$SERVER_PID"

45
docker/entrypoint_clone.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/bash
set -e
MODEL="${QWEN3_TTS_CLONE_MODEL:-}"
PORT="${QWEN3_TTS_CLONE_PORT:-8091}"
if [ -z "$MODEL" ]; then
echo "[clone] QWEN3_TTS_CLONE_MODEL ist nicht gesetzt." >&2
exit 2
fi
echo "[clone] starte Clone-Modell: ${MODEL} auf Port ${PORT}"
if [ -f /patch_qwen3_tts_runtime.py ]; then
python3 /patch_qwen3_tts_runtime.py
fi
vllm-omni serve "$MODEL" \
--omni \
--host 0.0.0.0 \
--port "$PORT" \
--deploy-config /deploy/qwen3_tts_clone.yaml \
--trust-remote-code &
SERVER_PID=$!
(
for _ in $(seq 1 150); do
if curl -sf "http://localhost:${PORT}/health" >/dev/null 2>&1; then
echo "[warmup] Clone gesund — sende Warmup-Request (kompiliert CUDA-Graphen)..."
t0=$SECONDS
curl -sf -X POST "http://localhost:${PORT}/v1/audio/speech" \
-H 'Content-Type: application/json' \
--max-time 180 \
-d "{\"model\":\"${MODEL}\",\"input\":\"System wird aufgewärmt.\",\"language\":\"German\",\"response_format\":\"wav\",\"task_type\":\"VoiceDesign\",\"instructions\":\"Eine ruhige Stimme.\"}" \
-o /dev/null \
&& echo "[warmup] Clone fertig nach $(( SECONDS - t0 ))s — jetzt heiß." \
|| echo "[warmup] fehlgeschlagen (Clone läuft trotzdem)."
break
fi
sleep 2
done
) &
trap 'kill "$SERVER_PID" 2>/dev/null || true' TERM INT
wait "$SERVER_PID"

View File

@@ -0,0 +1,357 @@
#!/usr/bin/env python3
"""Runtime patches for the vllm-omni Qwen3-TTS container.
- Propagate WebSocket session.config.seed into every generated speech segment.
- Log WebSocket config and segment generation on the real 8091 endpoint.
"""
from pathlib import Path
STREAM = Path('/usr/local/lib/python3.12/dist-packages/vllm_omni/entrypoints/openai/serving_speech_stream.py')
AUDIO = Path('/usr/local/lib/python3.12/dist-packages/vllm_omni/entrypoints/openai/protocol/audio.py')
def replace_once(text: str, old: str, new: str, label: str) -> str:
if new in text:
return text
if old not in text:
raise RuntimeError(f'Patch anchor not found: {label}')
return text.replace(old, new, 1)
def patch_seed_field() -> None:
"""Add an optional `seed` field to StreamingSpeechSessionConfig.
The streaming WebSocket config model in protocol/audio.py ships without a
`seed` attribute, but the stream server reads `config.seed` (see seed
propagation patch below). Without this field every session.config fails with
AttributeError: 'StreamingSpeechSessionConfig' object has no attribute 'seed'.
"""
text = AUDIO.read_text()
# Anchor on this class's unique docstring + first field: the task_type line
# is not unique (it also appears in SpeechBatchItem / BatchSpeechRequest).
new = replace_once(
text,
' """Configuration sent as the first WebSocket message for streaming TTS."""\n\n'
' model: str | None = None\n',
' """Configuration sent as the first WebSocket message for streaming TTS."""\n\n'
' seed: int | None = None\n'
' model: str | None = None\n',
'StreamingSpeechSessionConfig.seed field',
)
if new != text:
AUDIO.write_text(new)
print('[runtime-patch] StreamingSpeechSessionConfig.seed field added', flush=True)
else:
print('[runtime-patch] StreamingSpeechSessionConfig.seed field already present', flush=True)
_PIPELINE_CODE = '''
async def _pipelined_handle_session(self, websocket):
"""Pipeline: N+1 synthesis overlaps with N audio transfer to client.
A synthesizer task submits each sentence to vllm as soon as the previous
one is submitted (not after it is fully sent), so vllm starts work on N+1
the moment GPU capacity is free from N. A separate sender task streams
audio chunks to the WebSocket in order. Lookahead depth is controlled by
QWEN3_TTS_PIPELINE_LOOKAHEAD (default 1).
"""
await websocket.accept()
try:
config = await self._receive_config(websocket)
if config is None:
return
logger.info(
"Streaming speech config: model=%s voice=%s task_type=%s language=%s"
" seed=%s split=%s stream_audio=%s instructions=%r speaker_embedding_dim=%s",
config.model, config.voice, config.task_type, config.language,
config.seed, config.split_granularity, config.stream_audio, config.instructions,
len(config.speaker_embedding) if config.speaker_embedding is not None else None,
)
if config.model and hasattr(self._speech_service, "_check_model"):
error = await self._speech_service._check_model(
OpenAICreateSpeechRequest(input="ping", model=config.model)
)
if error is not None:
await self._send_error(websocket, str(error))
return
boundary_re = SPLIT_CLAUSE if config.split_granularity == "clause" else SPLIT_SENTENCE
splitter = SentenceSplitter(boundary_re=boundary_re)
response_format = config.response_format or "wav"
sentence_q = asyncio.Queue()
lookahead = int(_os.environ.get("QWEN3_TTS_PIPELINE_LOOKAHEAD", "1"))
ready_q = asyncio.Queue(maxsize=max(1, lookahead))
pending_rids = set() # all request IDs submitted but not yet completed
def _build_req(text):
return OpenAICreateSpeechRequest(
input=text, model=config.model, voice=config.voice,
task_type=config.task_type, language=config.language,
instructions=config.instructions, response_format=response_format,
speed=config.speed, max_new_tokens=config.max_new_tokens,
initial_codec_chunk_frames=config.initial_codec_chunk_frames,
ref_audio=config.ref_audio, ref_text=config.ref_text,
x_vector_only_mode=config.x_vector_only_mode,
speaker_embedding=config.speaker_embedding,
stream=config.stream_audio, seed=config.seed,
)
async def _abort_all():
for rid in list(pending_rids):
try:
await self._speech_service.engine_client.abort(rid)
except Exception:
pass
pending_rids.clear()
async def _synthesizer():
while True:
item = await sentence_q.get()
if item is None:
await ready_q.put(None)
return
idx, text = item
logger.info(
"Streaming speech segment: index=%s chars=%s voice=%s"
" task_type=%s language=%s seed=%s speaker_embedding_dim=%s",
idx, len(text), config.voice, config.task_type, config.language,
config.seed,
len(config.speaker_embedding) if config.speaker_embedding is not None else None,
)
try:
req = _build_req(text)
if config.stream_audio:
rid, gen, _ = await self._speech_service._prepare_speech_generation(req)
pending_rids.add(rid)
await ready_q.put(("stream", idx, text, rid, gen))
else:
audio, _ = await self._speech_service._generate_audio_bytes(req)
await ready_q.put(("bytes", idx, text, None, audio))
except Exception as exc:
await ready_q.put(("error", idx, text, None, exc))
async def _sender():
total = 0
while True:
item = await ready_q.get()
if item is None:
await websocket.send_json({"type": "session.done", "total_sentences": total})
return
kind, idx, text, extra, data = item
if kind == "error":
logger.error("Generation failed for sentence %d: %s", idx, data)
await self._send_error(websocket, f"Generation failed for sentence {idx}: {data}")
total += 1
continue
hdr = {"type": "audio.start", "sentence_index": idx,
"sentence_text": text, "format": response_format}
if config.stream_audio and response_format == "pcm":
hdr["sample_rate"] = _PCM_SAMPLE_RATE
await websocket.send_json(hdr)
if config.stream_audio and response_format == "pcm" and idx == 0 and _LEADIN_SILENCE_BYTES:
await websocket.send_bytes(_LEADIN_SILENCE_BYTES)
sent_bytes = 0
failed = False
try:
if kind == "stream":
async with aclosing(
self._speech_service._generate_pcm_chunks(data, extra)
) as stream:
async for chunk in stream:
sent_bytes += len(chunk)
await websocket.send_bytes(chunk)
pending_rids.discard(extra)
else:
sent_bytes = len(data)
await websocket.send_bytes(data)
if response_format == "pcm" and _TRAILING_SILENCE_BYTES:
await websocket.send_bytes(_TRAILING_SILENCE_BYTES)
except WebSocketDisconnect:
raise
except Exception as exc:
failed = True
logger.error("Send failed for sentence %d: %s", idx, exc)
await self._send_error(websocket, f"Send failed for sentence {idx}: {exc}")
finally:
try:
await websocket.send_json({
"type": "audio.done", "sentence_index": idx,
"total_bytes": sent_bytes, "error": failed,
})
except Exception:
pass
total += 1
synth_task = asyncio.create_task(_synthesizer())
send_task = asyncio.create_task(_sender())
sentence_count = 0
try:
while True:
try:
raw = await asyncio.wait_for(
websocket.receive_text(), timeout=self._idle_timeout
)
except asyncio.TimeoutError:
await self._send_error(websocket, "Idle timeout: no message received")
synth_task.cancel()
send_task.cancel()
await asyncio.gather(synth_task, send_task, return_exceptions=True)
await _abort_all()
return
if len(raw) > _MAX_INPUT_TEXT_MESSAGE_SIZE:
await self._send_error(websocket, "input.text message too large")
continue
try:
msg = json.loads(raw)
except json.JSONDecodeError:
await self._send_error(websocket, "Invalid JSON message")
continue
if not isinstance(msg, dict):
await self._send_error(websocket, "WebSocket messages must be JSON objects")
continue
mtype = msg.get("type")
if mtype == "input.text":
t = msg.get("text", "")
if not isinstance(t, str):
await self._send_error(websocket, "input.text requires a string value")
continue
for s in splitter.add_text(t):
await sentence_q.put((sentence_count, s))
sentence_count += 1
elif mtype == "input.done":
rem = splitter.flush()
if rem:
await sentence_q.put((sentence_count, rem))
sentence_count += 1
await sentence_q.put(None)
break
else:
await self._send_error(websocket, f"Unknown message type: {mtype}")
await asyncio.gather(synth_task, send_task)
except BaseException:
synth_task.cancel()
send_task.cancel()
await asyncio.gather(synth_task, send_task, return_exceptions=True)
await _abort_all()
raise
except WebSocketDisconnect:
logger.info("Streaming speech: client disconnected")
except Exception as e:
logger.exception("Streaming speech session error: %s", e)
try:
await self._send_error(websocket, f"Internal error: {e}")
except Exception:
pass
OmniStreamingSpeechHandler.handle_session = _pipelined_handle_session
'''
def patch_pipeline_session() -> None:
"""Append pipelined handle_session to serving_speech_stream.py.
Synthesis of sentence N+1 is submitted to vllm as soon as N's request
is prepared, so vllm can start N+1 the moment N's GPU work finishes —
overlapping with the network transfer of N's audio to the client.
"""
text = STREAM.read_text()
sentinel = 'OmniStreamingSpeechHandler.handle_session = _pipelined_handle_session'
if sentinel in text:
print('[runtime-patch] pipeline session handler already present', flush=True)
return
STREAM.write_text(text + _PIPELINE_CODE)
print('[runtime-patch] pipeline session handler injected', flush=True)
def main() -> None:
patch_seed_field()
text = STREAM.read_text()
original = text
text = replace_once(
text,
' if config.model and hasattr(self._speech_service, "_check_model"):\n',
' logger.info(\n'
' "Streaming speech config: model=%s voice=%s task_type=%s language=%s seed=%s split=%s stream_audio=%s instructions=%r speaker_embedding_dim=%s",\n'
' config.model,\n'
' config.voice,\n'
' config.task_type,\n'
' config.language,\n'
' config.seed,\n'
' config.split_granularity,\n'
' config.stream_audio,\n'
' config.instructions,\n'
' len(config.speaker_embedding) if config.speaker_embedding is not None else None,\n'
' )\n\n'
' if config.model and hasattr(self._speech_service, "_check_model"):\n',
'config logging',
)
text = replace_once(
text,
' stream=config.stream_audio,\n )\n',
' stream=config.stream_audio,\n seed=config.seed,\n )\n'
' logger.info(\n'
' "Streaming speech segment: index=%s chars=%s voice=%s task_type=%s language=%s seed=%s speaker_embedding_dim=%s",\n'
' sentence_index,\n'
' len(sentence_text),\n'
' config.voice,\n'
' config.task_type,\n'
' config.language,\n'
' config.seed,\n'
' len(config.speaker_embedding) if config.speaker_embedding is not None else None,\n'
' )\n',
'seed propagation and segment logging',
)
# --- Silence padding (PCM streaming only) ---
# Lead-in silence at session start gives the client time to set up smooth
# playback before real audio (mitigates start-of-stream stutter). Trailing
# silence avoids the abrupt cut-off at the end. Both in ms via env vars.
text = replace_once(
text,
'_PCM_SAMPLE_RATE = 24000\n',
'_PCM_SAMPLE_RATE = 24000\n'
'import os as _os\n'
'_LEADIN_SILENCE_MS = int(_os.environ.get("QWEN3_TTS_LEADIN_SILENCE_MS", "250"))\n'
'_TRAILING_SILENCE_MS = int(_os.environ.get("QWEN3_TTS_TRAILING_SILENCE_MS", "300"))\n'
'_LEADIN_SILENCE_BYTES = b"\\x00" * (int(_PCM_SAMPLE_RATE * _LEADIN_SILENCE_MS / 1000) * 2)\n'
'_TRAILING_SILENCE_BYTES = b"\\x00" * (int(_PCM_SAMPLE_RATE * _TRAILING_SILENCE_MS / 1000) * 2)\n',
'silence padding constants',
)
text = replace_once(
text,
' await websocket.send_json(start_payload)\n',
' await websocket.send_json(start_payload)\n'
' if config.stream_audio and response_format == "pcm" and sentence_index == 0 and _LEADIN_SILENCE_BYTES:\n'
' await websocket.send_bytes(_LEADIN_SILENCE_BYTES)\n',
'lead-in silence',
)
text = replace_once(
text,
' # Send session.done\n'
' await websocket.send_json(\n',
' if config.stream_audio and config.response_format == "pcm" and _TRAILING_SILENCE_BYTES:\n'
' await websocket.send_bytes(_TRAILING_SILENCE_BYTES)\n\n'
' # Send session.done\n'
' await websocket.send_json(\n',
'trailing silence',
)
if text != original:
STREAM.write_text(text)
print('[runtime-patch] WebSocket seed propagation/logging + silence padding applied', flush=True)
else:
print('[runtime-patch] WebSocket seed propagation/logging already present', flush=True)
patch_pipeline_session()
if __name__ == '__main__':
main()

276
docker/voice_clone_ui.py Normal file
View File

@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""Serve the voice-cloning UI and proxy requests to the local Qwen3-TTS API."""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib import error, parse, request
import argparse
import json
import math
import os
import re
ROOT = Path(__file__).resolve().parent
UI_FILE = ROOT / "ui" / "voice-cloning.html"
VECTOR_DIR = Path("/root/.cache/qwen3-tts-ui/vectors")
NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,80}$")
class VoiceCloneHandler(BaseHTTPRequestHandler):
api_base = "http://localhost:8091"
clone_api_base = "http://localhost:8093"
model = os.environ.get("QWEN3_TTS_MODEL", "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice")
def do_GET(self):
if self.path in ("/", "/voice-cloning.html"):
self._send_file(UI_FILE, "text/html; charset=utf-8")
return
if self.path == "/health":
self._proxy_get("/health")
return
if self.path == "/api/config":
self._json_response(200, {"model": self.model})
return
if self.path == "/api/v1/audio/voices":
self._proxy_get("/v1/audio/voices")
return
if self.path == "/api/clone/health":
self._proxy_get_base(self.clone_api_base, "/health", timeout=3)
return
if self.path == "/api/voice-vectors":
self._list_vectors()
return
prefix = "/api/voice-vectors/"
if self.path.startswith(prefix):
self._get_vector(self.path[len(prefix):])
return
self.send_error(404)
def do_POST(self):
if self.path == "/api/v1/audio/speech":
self._proxy_post("/v1/audio/speech")
return
if self.path == "/api/v1/audio/voices":
self._proxy_post("/v1/audio/voices")
return
if self.path == "/api/clone/speech":
self._proxy_post_base(self.clone_api_base, "/v1/audio/speech", timeout=180)
return
if self.path == "/api/voice-vectors":
self._save_vector()
return
prefix = "/api/voice-vectors/"
if self.path.startswith(prefix) and self.path.endswith("/speech"):
self._speech_with_vector(self.path[len(prefix):-len("/speech")])
return
self.send_error(404)
def do_DELETE(self):
prefix = "/api/v1/audio/voices/"
if self.path.startswith(prefix):
name = parse.quote(parse.unquote(self.path[len(prefix):]), safe="")
self._proxy_delete(f"/v1/audio/voices/{name}")
return
vector_prefix = "/api/voice-vectors/"
if self.path.startswith(vector_prefix):
self._delete_vector(self.path[len(vector_prefix):])
return
self.send_error(404)
def log_message(self, fmt, *args):
print("%s - %s" % (self.address_string(), fmt % args))
def _json_response(self, status, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _read_json_body(self):
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length)
return json.loads(raw.decode("utf-8") or "{}")
def _clean_vector_name(self, raw_name):
name = parse.unquote(str(raw_name)).strip()
if not NAME_RE.match(name):
raise ValueError("Vector name must be 1-80 chars: letters, digits, dot, underscore or dash")
return name
def _vector_path(self, raw_name):
name = self._clean_vector_name(raw_name)
return VECTOR_DIR / f"{name}.json"
def _validate_embedding(self, value):
if isinstance(value, str):
value = json.loads(value)
if not isinstance(value, list) or not value:
raise ValueError("embedding must be a non-empty list")
if len(value) > 4096:
raise ValueError("embedding exceeds 4096 values")
emb = []
for item in value:
if not isinstance(item, (int, float)) or not math.isfinite(float(item)):
raise ValueError("embedding must contain only finite numbers")
emb.append(float(item))
return emb
def _list_vectors(self):
VECTOR_DIR.mkdir(parents=True, exist_ok=True)
vectors = []
for path in sorted(VECTOR_DIR.glob("*.json")):
try:
data = json.loads(path.read_text())
vectors.append({
"name": data.get("name", path.stem),
"dim": len(data.get("embedding", [])),
"description": data.get("description", ""),
"created_at": data.get("created_at", 0),
})
except Exception:
continue
self._json_response(200, {"vectors": vectors})
def _get_vector(self, raw_name):
try:
path = self._vector_path(raw_name)
if not path.exists():
self._json_response(404, {"error": "vector not found"})
return
self._json_response(200, json.loads(path.read_text()))
except Exception as exc:
self._json_response(400, {"error": str(exc)})
def _save_vector(self):
try:
data = self._read_json_body()
name = self._clean_vector_name(data.get("name", ""))
embedding = self._validate_embedding(data.get("embedding"))
VECTOR_DIR.mkdir(parents=True, exist_ok=True)
payload = {
"name": name,
"embedding": embedding,
"description": str(data.get("description", "")),
"created_at": data.get("created_at") or __import__("time").time(),
}
(VECTOR_DIR / f"{name}.json").write_text(json.dumps(payload))
self._json_response(200, {"success": True, "vector": {"name": name, "dim": len(embedding)}})
except Exception as exc:
self._json_response(400, {"error": str(exc)})
def _delete_vector(self, raw_name):
try:
path = self._vector_path(raw_name)
path.unlink(missing_ok=True)
self._json_response(200, {"success": True})
except Exception as exc:
self._json_response(400, {"error": str(exc)})
def _speech_with_vector(self, raw_name):
try:
path = self._vector_path(raw_name)
if not path.exists():
self._json_response(404, {"error": "vector not found"})
return
vector = json.loads(path.read_text()).get("embedding")
payload = self._read_json_body()
payload["speaker_embedding"] = self._validate_embedding(vector)
payload["task_type"] = "Base"
payload["x_vector_only_mode"] = True
body = json.dumps(payload).encode("utf-8")
upstream = request.Request(
f"{self.api_base}/v1/audio/speech",
data=body,
method="POST",
headers={"Content-Type": "application/json"},
)
self._send_upstream(upstream)
except Exception as exc:
self._json_response(400, {"error": str(exc)})
def _send_file(self, path, content_type):
if not path.exists():
self.send_error(404)
return
body = path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _proxy_get(self, target_path):
self._proxy_get_base(self.api_base, target_path)
def _proxy_get_base(self, base_url, target_path, timeout=180):
upstream = request.Request(f"{base_url}{target_path}", method="GET")
self._send_upstream(upstream, timeout=timeout)
def _proxy_post(self, target_path):
self._proxy_post_base(self.api_base, target_path)
def _proxy_post_base(self, base_url, target_path, timeout=180):
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length)
headers = {"Content-Type": self.headers.get("Content-Type", "application/json")}
upstream = request.Request(
f"{base_url}{target_path}",
data=body,
method="POST",
headers=headers,
)
self._send_upstream(upstream, timeout=timeout)
def _proxy_delete(self, target_path):
upstream = request.Request(f"{self.api_base}{target_path}", method="DELETE")
self._send_upstream(upstream)
def _send_upstream(self, upstream, timeout=180):
try:
with request.urlopen(upstream, timeout=timeout) as response:
body = response.read()
self.send_response(response.status)
self.send_header("Content-Type", response.headers.get("Content-Type", "application/octet-stream"))
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
except error.HTTPError as exc:
body = exc.read()
self.send_response(exc.code)
self.send_header("Content-Type", exc.headers.get("Content-Type", "text/plain; charset=utf-8"))
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
except Exception as exc:
body = json.dumps({"error": str(exc)}).encode("utf-8")
self.send_response(502)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8092)
parser.add_argument("--api-base", default="http://localhost:8091")
parser.add_argument("--clone-api-base", default="http://localhost:8093")
parser.add_argument("--model", default=os.environ.get("QWEN3_TTS_MODEL", "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"))
args = parser.parse_args()
VoiceCloneHandler.api_base = args.api_base.rstrip("/")
VoiceCloneHandler.clone_api_base = args.clone_api_base.rstrip("/")
VoiceCloneHandler.model = args.model
server = ThreadingHTTPServer((args.host, args.port), VoiceCloneHandler)
print(f"UI: http://{args.host}:{args.port}/")
print(f"Upstream: {VoiceCloneHandler.api_base}")
print(f"Clone upstream: {VoiceCloneHandler.clone_api_base}")
server.serve_forever()
if __name__ == "__main__":
main()

84
docker/ws_log_proxy.py Normal file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Logging WebSocket proxy for Qwen3-TTS streaming calls."""
import argparse
import asyncio
import datetime as dt
import json
import websockets
def now():
return dt.datetime.now().isoformat(timespec="seconds")
def shorten(value, limit=500):
text = value if isinstance(value, str) else repr(value)
return text if len(text) <= limit else text[:limit] + "..."
async def proxy(client_ws, upstream_url):
conn_id = f"ws-{id(client_ws):x}"
print(f"[{now()}] {conn_id} open -> {upstream_url}", flush=True)
async with websockets.connect(upstream_url, max_size=None) as upstream_ws:
async def client_to_upstream():
async for msg in client_ws:
if isinstance(msg, str):
try:
data = json.loads(msg)
typ = data.get("type")
if typ == "session.config":
log_data = dict(data)
if "speaker_embedding" in log_data:
emb = log_data["speaker_embedding"] or []
log_data["speaker_embedding"] = f"<{len(emb)} floats>"
print(f"[{now()}] {conn_id} C->S config {json.dumps(log_data, ensure_ascii=False, sort_keys=True)}", flush=True)
elif typ == "input.text":
print(f"[{now()}] {conn_id} C->S text {data.get('text')!r}", flush=True)
else:
print(f"[{now()}] {conn_id} C->S {shorten(msg)}", flush=True)
except Exception:
print(f"[{now()}] {conn_id} C->S {shorten(msg)}", flush=True)
else:
print(f"[{now()}] {conn_id} C->S binary {len(msg)} bytes", flush=True)
await upstream_ws.send(msg)
async def upstream_to_client():
async for msg in upstream_ws:
if isinstance(msg, str):
try:
data = json.loads(msg)
print(f"[{now()}] {conn_id} S->C {data}", flush=True)
except Exception:
print(f"[{now()}] {conn_id} S->C {shorten(msg)}", flush=True)
else:
print(f"[{now()}] {conn_id} S->C binary {len(msg)} bytes", flush=True)
await client_ws.send(msg)
await asyncio.gather(client_to_upstream(), upstream_to_client())
async def handler(client_ws):
try:
await proxy(client_ws, handler.upstream_url)
except Exception as exc:
print(f"[{now()}] proxy error: {exc}", flush=True)
try:
await client_ws.close()
except Exception:
pass
async def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8094)
parser.add_argument("--upstream", default="ws://localhost:8091/v1/audio/speech/stream")
args = parser.parse_args()
handler.upstream_url = args.upstream
print(f"WS log proxy: ws://{args.host}:{args.port}/v1/audio/speech/stream -> {args.upstream}", flush=True)
async with websockets.serve(handler, args.host, args.port, max_size=None):
await asyncio.Future()
if __name__ == "__main__":
asyncio.run(main())