Add native Qwen3 TTS streaming for Hermes

This commit is contained in:
Mikei386
2026-09-05 16:06:27 +02:00
parent 5f3d064bb4
commit 61aa20cb52
7 changed files with 382 additions and 4 deletions
+3 -1
View File
@@ -58,7 +58,9 @@ Qwen-Profil wird vom Profile Controller verwaltet.
- Uncensored: separates lokales Profil
- FLUX.2-klein-4B: Bildgenerierung und Editing; Qwen wird dafür kurz entladen und danach
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
`docs/STANDARD_PROFILE_MATRIX.md`.
+7 -1
View File
@@ -101,7 +101,9 @@ Standard, bis Hermes' Sitzungsfehler behoben ist.
- Athena-Dashboard: `http://192.168.1.212:8099`
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
werden lokal auf Athena verarbeitet. Für OpenClaw Talk liegt der lokale
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
bleiben. Die Installation landet in OpenClaws persistentem Datenverzeichnis
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`
- 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
+116 -2
View File
@@ -8,6 +8,7 @@ by the profile router. Request text is never logged or persisted.
from __future__ import annotations
import io
import http.client
import json
import os
import re
@@ -17,6 +18,7 @@ import time
import unicodedata
import urllib.error
import urllib.request
import urllib.parse
import wave
from array import array
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)
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]:
acquired = SYNTHESIS_LOCK.acquire(timeout=QUEUE_TIMEOUT)
if acquired:
@@ -797,7 +840,7 @@ class Handler(BaseHTTPRequestHandler):
)
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"})
return
try:
@@ -810,7 +853,7 @@ class Handler(BaseHTTPRequestHandler):
return
try:
request = json.loads(self.rfile.read(length))
text = request.get("text", "")
text = request.get("input", request.get("text", ""))
voice = request.get("voice", VOICE_ALIAS)
output_format = request.get("format", "mp3")
speed = float(request.get("speed", 1.0))
@@ -829,6 +872,9 @@ class Handler(BaseHTTPRequestHandler):
if not 0.5 <= speed <= 2.0:
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid speed"})
return
if self.path == "/tts/pcm-stream":
self._stream_qwen_pcm(text.strip(), request)
return
started = time.monotonic()
try:
audio, content_type = synthesize(text.strip(), output_format, speed)
@@ -842,6 +888,74 @@ class Handler(BaseHTTPRequestHandler):
f"{time.monotonic() - started:.2f}s")
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__":
print(f"TTS gateway ready on {HOST}:{PORT}; primary={QWEN_TTS_VOICE}; fallback=Piper")
+75
View File
@@ -1519,6 +1519,13 @@ class Handler(BaseHTTPRequestHandler):
else:
self._send_error(503, "Sprachausgabe ist nicht installiert",
"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":
if ENABLE_STT:
self._transcribe()
@@ -2062,6 +2069,74 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
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 ----------
def _audio_models_payload(self) -> dict: