Add native Qwen3 TTS streaming for Hermes
This commit is contained in:
1 parent
5f3d064bb4
commit
61aa20cb52
7 files changed
+382
-4
No files matched your search
@@ -58,7 +58,9 @@ Qwen-Profil wird vom Profile Controller verwaltet.
|
|||||||
- Uncensored: separates lokales Profil
|
- Uncensored: separates lokales Profil
|
||||||
- FLUX.2-klein-4B: Bildgenerierung und Editing; Qwen wird dafür kurz entladen und danach
|
- FLUX.2-klein-4B: Bildgenerierung und Editing; Qwen wird dafür kurz entladen und danach
|
||||||
automatisch wiederhergestellt
|
automatisch wiederhergestellt
|
||||||
- Qwen3-TTS 1.7B: RTX 3060; Piper bleibt CPU-Fallback
|
- Qwen3-TTS 1.7B: RTX 3060; Piper bleibt CPU-Fallback. Der Router reicht
|
||||||
|
zusätzlich natives 24-kHz-PCM für den optionalen Hermes-Streaming-Adapter
|
||||||
|
unter `integrations/hermes-qwen3-stream` durch.
|
||||||
|
|
||||||
Die verbindlichen Werte stehen in `config/profile-matrix.json` und
|
Die verbindlichen Werte stehen in `config/profile-matrix.json` und
|
||||||
`docs/STANDARD_PROFILE_MATRIX.md`.
|
`docs/STANDARD_PROFILE_MATRIX.md`.
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ Standard, bis Hermes' Sitzungsfehler behoben ist.
|
|||||||
- Athena-Dashboard: `http://192.168.1.212:8099`
|
- Athena-Dashboard: `http://192.168.1.212:8099`
|
||||||
|
|
||||||
Der Router stellt Sprache OpenAI-kompatibel bereit: Sprachausgabe über
|
Der Router stellt Sprache OpenAI-kompatibel bereit: Sprachausgabe über
|
||||||
`/v1/audio/speech` und Spracherkennung über `/v1/audio/transcriptions`. Das
|
`/v1/audio/speech`, natives Qwen-PCM-Streaming über
|
||||||
|
`/v1/audio/speech/pcm-stream` und Spracherkennung über
|
||||||
|
`/v1/audio/transcriptions`. Das
|
||||||
Whisper-Modell liegt persistent im Docker-Volume `whisper-data`; Audiodaten
|
Whisper-Modell liegt persistent im Docker-Volume `whisper-data`; Audiodaten
|
||||||
werden lokal auf Athena verarbeitet. Für OpenClaw Talk liegt der lokale
|
werden lokal auf Athena verarbeitet. Für OpenClaw Talk liegt der lokale
|
||||||
Realtime-Provider unter
|
Realtime-Provider unter
|
||||||
@@ -110,6 +112,10 @@ verbindet Mikrofon → Athena Whisper → normalen OpenClaw-Agenten → aktives
|
|||||||
Athena-TTS, sodass Modell, Werkzeuge und Memory auch im Sprachmodus erhalten
|
Athena-TTS, sodass Modell, Werkzeuge und Memory auch im Sprachmodus erhalten
|
||||||
bleiben. Die Installation landet in OpenClaws persistentem Datenverzeichnis
|
bleiben. Die Installation landet in OpenClaws persistentem Datenverzeichnis
|
||||||
und bleibt deshalb bei normalen Container-Updates bestehen.
|
und bleibt deshalb bei normalen Container-Updates bestehen.
|
||||||
|
|
||||||
|
Für Hermes liegt unter `integrations/hermes-qwen3-stream` ein optionales,
|
||||||
|
persistentes Backend-Plugin. Es nutzt den nativen PCM-Strom und verkürzt den
|
||||||
|
Beginn der Sprachausgabe, ohne den Modellrouter oder die Textprofile zu ändern.
|
||||||
- Portainer: `https://192.168.1.212:9443`
|
- Portainer: `https://192.168.1.212:9443`
|
||||||
- Hermes-Dashboard auf Unraid: `http://192.168.1.2:9119`
|
- Hermes-Dashboard auf Unraid: `http://192.168.1.2:9119`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Hermes Qwen3-TTS PCM streaming adapter
|
||||||
|
|
||||||
|
This optional Hermes backend plugin uses Athena's native
|
||||||
|
`/v1/audio/speech/pcm-stream` route. It starts playback while Qwen3-TTS is
|
||||||
|
still synthesizing the current sentence instead of waiting for a complete
|
||||||
|
audio file.
|
||||||
|
|
||||||
|
Install this directory as `${HERMES_HOME}/plugins/qwen3-stream`, enable the
|
||||||
|
plugin and set:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tts:
|
||||||
|
provider: qwen3-stream
|
||||||
|
streaming:
|
||||||
|
provider: qwen3-stream
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter reuses `tts.openai.base_url`, `tts.openai.api_key`, model, voice
|
||||||
|
and language unless an explicit `tts.qwen3-stream` section overrides them.
|
||||||
|
This avoids copying the Athena credential into another file.
|
||||||
|
|
||||||
|
Rollback is immediate: restore `tts.provider` and `tts.streaming.provider` to
|
||||||
|
`openai`, disable the plugin and restart the Hermes gateway.
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterator, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from agent.tts_provider import TTSProvider
|
||||||
|
from tools.tool_backend_helpers import resolve_openai_audio_api_key
|
||||||
|
from tools.tts_streaming import StreamingTTSProvider, register as register_streamer
|
||||||
|
from tools.tts_tool import _load_tts_config
|
||||||
|
|
||||||
|
|
||||||
|
NAME = "qwen3-stream"
|
||||||
|
SAMPLE_RATE = 24000
|
||||||
|
|
||||||
|
|
||||||
|
def _settings() -> Dict[str, Any]:
|
||||||
|
config = _load_tts_config()
|
||||||
|
own = dict(config.get(NAME) or {})
|
||||||
|
fallback = dict(config.get("openai") or {})
|
||||||
|
own.setdefault("base_url", fallback.get("base_url", ""))
|
||||||
|
own.setdefault("api_key", fallback.get("api_key", ""))
|
||||||
|
own.setdefault("model", fallback.get("model", "tts-1"))
|
||||||
|
own.setdefault("voice", fallback.get("voice", "alloy"))
|
||||||
|
own.setdefault("language", fallback.get("language", "German"))
|
||||||
|
own.setdefault("chunk_size", 4)
|
||||||
|
return own
|
||||||
|
|
||||||
|
|
||||||
|
def _url(path: str, section: Optional[Dict[str, Any]] = None) -> str:
|
||||||
|
cfg = section or _settings()
|
||||||
|
base = str(cfg.get("base_url") or "").rstrip("/")
|
||||||
|
if not base:
|
||||||
|
raise RuntimeError("tts.qwen3-stream.base_url is not configured")
|
||||||
|
if not base.endswith("/v1"):
|
||||||
|
base += "/v1"
|
||||||
|
return base + path
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(section: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
|
||||||
|
cfg = section or _settings()
|
||||||
|
key = str(cfg.get("api_key") or resolve_openai_audio_api_key() or "").strip()
|
||||||
|
headers = {"Accept": "application/octet-stream"}
|
||||||
|
if key:
|
||||||
|
headers["Authorization"] = f"Bearer {key}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(text: str, section: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
|
cfg = section or _settings()
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"input": text,
|
||||||
|
"model": cfg.get("model") or "tts-1",
|
||||||
|
"voice": cfg.get("voice") or "alloy",
|
||||||
|
"language": cfg.get("language") or "German",
|
||||||
|
}
|
||||||
|
instruct = str(cfg.get("instruct") or "").strip()
|
||||||
|
if instruct:
|
||||||
|
payload["instruct"] = instruct
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class Qwen3PCMStreamer(StreamingTTSProvider):
|
||||||
|
sample_rate = SAMPLE_RATE
|
||||||
|
channels = 1
|
||||||
|
sample_width = 2
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def available() -> bool:
|
||||||
|
try:
|
||||||
|
return bool(_settings().get("base_url"))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stream(self, text: str) -> Iterator[bytes]:
|
||||||
|
cfg = dict(_settings())
|
||||||
|
cfg.update(self.section or {})
|
||||||
|
payload = _payload(text, cfg)
|
||||||
|
payload["chunk_size"] = max(1, int(cfg.get("chunk_size", 4)))
|
||||||
|
with requests.post(
|
||||||
|
_url("/audio/speech/pcm-stream", cfg),
|
||||||
|
json=payload,
|
||||||
|
headers=_headers(cfg),
|
||||||
|
stream=True,
|
||||||
|
timeout=(5, 120),
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
pending = b""
|
||||||
|
# An explicit read size prevents urllib3 from buffering the
|
||||||
|
# unknown-length response until connection close.
|
||||||
|
for chunk in response.iter_content(chunk_size=4096):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
data = pending + chunk
|
||||||
|
even = len(data) & ~1
|
||||||
|
if even:
|
||||||
|
yield data[:even]
|
||||||
|
pending = data[even:]
|
||||||
|
|
||||||
|
|
||||||
|
class Qwen3TTSProvider(TTSProvider):
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return NAME
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
return "Athena Qwen3-TTS Streaming"
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return Qwen3PCMStreamer.available()
|
||||||
|
|
||||||
|
def list_voices(self) -> List[Dict[str, Any]]:
|
||||||
|
voice = str(_settings().get("voice") or "alloy")
|
||||||
|
return [{"id": voice, "display": voice, "language": "de"}]
|
||||||
|
|
||||||
|
def synthesize(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
output_path: str,
|
||||||
|
*,
|
||||||
|
voice: Optional[str] = None,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
speed: Optional[float] = None,
|
||||||
|
format: str = "mp3",
|
||||||
|
**extra: Any,
|
||||||
|
) -> str:
|
||||||
|
cfg = _settings()
|
||||||
|
payload = _payload(text, cfg)
|
||||||
|
payload["response_format"] = format
|
||||||
|
if voice:
|
||||||
|
payload["voice"] = voice
|
||||||
|
if model:
|
||||||
|
payload["model"] = model
|
||||||
|
if speed is not None:
|
||||||
|
payload["speed"] = speed
|
||||||
|
response = requests.post(
|
||||||
|
_url("/audio/speech", cfg),
|
||||||
|
json=payload,
|
||||||
|
headers=_headers(cfg),
|
||||||
|
timeout=(5, 120),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
Path(output_path).write_bytes(response.content)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
register_streamer(NAME)(Qwen3PCMStreamer)
|
||||||
|
|
||||||
|
|
||||||
|
def register(ctx) -> None:
|
||||||
|
ctx.register_tts_provider(Qwen3TTSProvider())
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
name: qwen3-stream
|
||||||
|
version: 0.1.1
|
||||||
|
description: Native PCM streaming adapter for the local Athena Qwen3-TTS service
|
||||||
|
author: Mike AI local stack
|
||||||
|
kind: backend
|
||||||
@@ -8,6 +8,7 @@ by the profile router. Request text is never logged or persisted.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -17,6 +18,7 @@ import time
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
import wave
|
import wave
|
||||||
from array import array
|
from array import array
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
@@ -728,6 +730,47 @@ def synthesize_qwen(text: str, output_format: str,
|
|||||||
return _convert(audio, "pcm", 1.0) if output_format == "pcm" else (audio, content_type)
|
return _convert(audio, "pcm", 1.0) if output_format == "pcm" else (audio, content_type)
|
||||||
|
|
||||||
|
|
||||||
|
def open_qwen_pcm_stream(text: str, chunk_size: int = 4) \
|
||||||
|
-> tuple[http.client.HTTPConnection, http.client.HTTPResponse]:
|
||||||
|
"""Open Qwen's native token-level PCM stream without buffering it.
|
||||||
|
|
||||||
|
The upstream emits headerless 24 kHz mono signed 16-bit little-endian
|
||||||
|
PCM. Keeping this response streaming is what lets playback begin while
|
||||||
|
the remainder of the sentence is still being synthesized.
|
||||||
|
"""
|
||||||
|
parsed = urllib.parse.urlparse(QWEN_TTS_URL)
|
||||||
|
if parsed.scheme != "http" or not parsed.hostname:
|
||||||
|
raise RuntimeError("QWEN_TTS_URL must be an http URL")
|
||||||
|
port = parsed.port or 80
|
||||||
|
prefix = parsed.path.rstrip("/")
|
||||||
|
payload = json.dumps({
|
||||||
|
"model": QWEN_TTS_MODEL,
|
||||||
|
"input": prepare_for_qwen_speech(text),
|
||||||
|
"voice": QWEN_TTS_VOICE,
|
||||||
|
"language": QWEN_TTS_LANGUAGE,
|
||||||
|
"chunk_size": chunk_size,
|
||||||
|
}, separators=(",", ":")).encode()
|
||||||
|
connection = http.client.HTTPConnection(
|
||||||
|
parsed.hostname, port, timeout=QWEN_TTS_TIMEOUT)
|
||||||
|
try:
|
||||||
|
connection.request(
|
||||||
|
"POST",
|
||||||
|
f"{prefix}/v1/audio/speech/pcm-stream",
|
||||||
|
body=payload,
|
||||||
|
headers={"Content-Type": "application/json",
|
||||||
|
"Accept": "application/octet-stream"},
|
||||||
|
)
|
||||||
|
response = connection.getresponse()
|
||||||
|
if response.status != HTTPStatus.OK:
|
||||||
|
message = response.read(512).decode(errors="replace")
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Qwen PCM stream failed ({response.status}): {message}")
|
||||||
|
return connection, response
|
||||||
|
except Exception:
|
||||||
|
connection.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def synthesize(text: str, output_format: str, speed: float) -> tuple[bytes, str]:
|
def synthesize(text: str, output_format: str, speed: float) -> tuple[bytes, str]:
|
||||||
acquired = SYNTHESIS_LOCK.acquire(timeout=QUEUE_TIMEOUT)
|
acquired = SYNTHESIS_LOCK.acquire(timeout=QUEUE_TIMEOUT)
|
||||||
if acquired:
|
if acquired:
|
||||||
@@ -797,7 +840,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802
|
def do_POST(self) -> None: # noqa: N802
|
||||||
if self.path != "/tts":
|
if self.path not in {"/tts", "/tts/pcm-stream"}:
|
||||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -810,7 +853,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
request = json.loads(self.rfile.read(length))
|
request = json.loads(self.rfile.read(length))
|
||||||
text = request.get("text", "")
|
text = request.get("input", request.get("text", ""))
|
||||||
voice = request.get("voice", VOICE_ALIAS)
|
voice = request.get("voice", VOICE_ALIAS)
|
||||||
output_format = request.get("format", "mp3")
|
output_format = request.get("format", "mp3")
|
||||||
speed = float(request.get("speed", 1.0))
|
speed = float(request.get("speed", 1.0))
|
||||||
@@ -829,6 +872,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if not 0.5 <= speed <= 2.0:
|
if not 0.5 <= speed <= 2.0:
|
||||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid speed"})
|
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid speed"})
|
||||||
return
|
return
|
||||||
|
if self.path == "/tts/pcm-stream":
|
||||||
|
self._stream_qwen_pcm(text.strip(), request)
|
||||||
|
return
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
try:
|
try:
|
||||||
audio, content_type = synthesize(text.strip(), output_format, speed)
|
audio, content_type = synthesize(text.strip(), output_format, speed)
|
||||||
@@ -842,6 +888,74 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
f"{time.monotonic() - started:.2f}s")
|
f"{time.monotonic() - started:.2f}s")
|
||||||
self.send_bytes(HTTPStatus.OK, audio, content_type)
|
self.send_bytes(HTTPStatus.OK, audio, content_type)
|
||||||
|
|
||||||
|
def _stream_qwen_pcm(self, text: str, request: dict) -> None:
|
||||||
|
"""Unframe Qwen's PCM frames and relay their audio immediately."""
|
||||||
|
try:
|
||||||
|
chunk_size = max(1, min(32, int(request.get("chunk_size", 4))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.send_json(HTTPStatus.BAD_REQUEST,
|
||||||
|
{"error": "invalid chunk_size"})
|
||||||
|
return
|
||||||
|
acquired = SYNTHESIS_LOCK.acquire(timeout=QUEUE_TIMEOUT)
|
||||||
|
if not acquired:
|
||||||
|
self.send_json(HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
{"error": "speech queue timeout"})
|
||||||
|
return
|
||||||
|
connection = None
|
||||||
|
started = time.monotonic()
|
||||||
|
headers_sent = False
|
||||||
|
try:
|
||||||
|
connection, response = open_qwen_pcm_stream(text, chunk_size)
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", "application/octet-stream")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
headers_sent = True
|
||||||
|
first = True
|
||||||
|
while True:
|
||||||
|
frame_header = response.read(4)
|
||||||
|
if not frame_header:
|
||||||
|
break
|
||||||
|
if len(frame_header) != 4:
|
||||||
|
raise RuntimeError("truncated Qwen PCM frame header")
|
||||||
|
frame_length = int.from_bytes(frame_header, "big")
|
||||||
|
if frame_length == 0:
|
||||||
|
break
|
||||||
|
if frame_length > MAX_AUDIO_BYTES:
|
||||||
|
raise RuntimeError("Qwen PCM frame is too large")
|
||||||
|
remaining = frame_length
|
||||||
|
while remaining:
|
||||||
|
chunk = response.read(min(16384, remaining))
|
||||||
|
if not chunk:
|
||||||
|
raise RuntimeError("truncated Qwen PCM frame")
|
||||||
|
if first:
|
||||||
|
print("tts-gateway: first Qwen PCM chunk in "
|
||||||
|
f"{time.monotonic() - started:.2f}s")
|
||||||
|
first = False
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
self.wfile.flush()
|
||||||
|
remaining -= len(chunk)
|
||||||
|
with STATE_LOCK:
|
||||||
|
STATE["last_backend"] = "qwen3-tts-1.7b-stream"
|
||||||
|
STATE["last_error"] = None
|
||||||
|
except Exception as exc:
|
||||||
|
with STATE_LOCK:
|
||||||
|
STATE["last_error"] = type(exc).__name__
|
||||||
|
# Once PCM started, simply close the truncated response. Sending
|
||||||
|
# JSON into the audio stream would produce loud corrupt samples.
|
||||||
|
if not headers_sent and not self.wfile.closed:
|
||||||
|
try:
|
||||||
|
self.send_json(HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
{"error": "local PCM stream failed"})
|
||||||
|
except (OSError, BrokenPipeError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if connection is not None:
|
||||||
|
connection.close()
|
||||||
|
SYNTHESIS_LOCK.release()
|
||||||
|
self.close_connection = True
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print(f"TTS gateway ready on {HOST}:{PORT}; primary={QWEN_TTS_VOICE}; fallback=Piper")
|
print(f"TTS gateway ready on {HOST}:{PORT}; primary={QWEN_TTS_VOICE}; fallback=Piper")
|
||||||
|
|||||||
@@ -1519,6 +1519,13 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
self._send_error(503, "Sprachausgabe ist nicht installiert",
|
self._send_error(503, "Sprachausgabe ist nicht installiert",
|
||||||
"server_error", "feature_disabled")
|
"server_error", "feature_disabled")
|
||||||
|
elif (path == "/v1/audio/speech/pcm-stream"
|
||||||
|
and self.command == "POST"):
|
||||||
|
if ENABLE_TTS:
|
||||||
|
self._speech_pcm_stream()
|
||||||
|
else:
|
||||||
|
self._send_error(503, "Sprachausgabe ist nicht installiert",
|
||||||
|
"server_error", "feature_disabled")
|
||||||
elif path == "/v1/audio/transcriptions" and self.command == "POST":
|
elif path == "/v1/audio/transcriptions" and self.command == "POST":
|
||||||
if ENABLE_STT:
|
if ENABLE_STT:
|
||||||
self._transcribe()
|
self._transcribe()
|
||||||
@@ -2062,6 +2069,74 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(audio)
|
self.wfile.write(audio)
|
||||||
|
|
||||||
|
def _speech_pcm_stream(self) -> None:
|
||||||
|
"""Pass through Qwen's native 24 kHz PCM stream without buffering."""
|
||||||
|
try:
|
||||||
|
body = self._read_body()
|
||||||
|
data = json.loads(body)
|
||||||
|
except ValueError as exc:
|
||||||
|
self._send_error(400, str(exc) or "ungültiges JSON",
|
||||||
|
"invalid_request_error", "invalid_body")
|
||||||
|
return
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
self._send_error(400, "Request muss ein JSON-Objekt sein",
|
||||||
|
"invalid_request_error", "invalid_request")
|
||||||
|
return
|
||||||
|
text = data.get("input", data.get("text"))
|
||||||
|
if not isinstance(text, str) or not text.strip() or len(text) > 8000:
|
||||||
|
self._send_error(400, "'input' fehlt, ist leer oder zu lang",
|
||||||
|
"invalid_request_error", "invalid_input")
|
||||||
|
return
|
||||||
|
voice = data.get("voice", TTS_DEFAULT_VOICE)
|
||||||
|
if voice not in TTS_VOICES:
|
||||||
|
self._send_error(400, f"ungültige Stimme: {voice!r}",
|
||||||
|
"invalid_request_error", "invalid_voice")
|
||||||
|
return
|
||||||
|
|
||||||
|
hostport = TTS_WORKER_URL.split("://", 1)[-1]
|
||||||
|
host, _, port = hostport.partition(":")
|
||||||
|
connection = None
|
||||||
|
headers_sent = False
|
||||||
|
try:
|
||||||
|
connection = http.client.HTTPConnection(
|
||||||
|
host, int(port) if port else 80, timeout=TTS_CONNECT_TIMEOUT)
|
||||||
|
connection.request(
|
||||||
|
"POST", "/tts/pcm-stream", body=body,
|
||||||
|
headers={"Content-Type": "application/json",
|
||||||
|
"Accept": "application/octet-stream"})
|
||||||
|
connection.sock.settimeout(TTS_TIMEOUT)
|
||||||
|
response = connection.getresponse()
|
||||||
|
if response.status != 200:
|
||||||
|
message = response.read(512).decode(errors="replace")
|
||||||
|
self._send_error(503,
|
||||||
|
f"TTS-Stream fehlgeschlagen: {message}",
|
||||||
|
"server_error", "tts_failed")
|
||||||
|
return
|
||||||
|
self._last_code = 200
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/octet-stream")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
headers_sent = True
|
||||||
|
while True:
|
||||||
|
chunk = response.read1(16384)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
self.wfile.flush()
|
||||||
|
except (OSError, http.client.HTTPException) as exc:
|
||||||
|
if not headers_sent:
|
||||||
|
try:
|
||||||
|
self._send_error(502, f"TTS-Worker nicht erreichbar: {exc}",
|
||||||
|
"server_error", "tts_unavailable")
|
||||||
|
except (OSError, BrokenPipeError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if connection is not None:
|
||||||
|
connection.close()
|
||||||
|
self.close_connection = True
|
||||||
|
|
||||||
# ---------- Audio-Discovery ----------
|
# ---------- Audio-Discovery ----------
|
||||||
|
|
||||||
def _audio_models_payload(self) -> dict:
|
def _audio_models_payload(self) -> dict:
|
||||||
|
|||||||
Reference in new issue
Block a user