diff --git a/.env.example b/.env.example index 8212eed..fb90a26 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,8 @@ ROUTER_API_KEY=GENERATED_BY_INSTALLER CONTROLLER_TOKEN=GENERATED_BY_INSTALLER WEBUI_SECRET_KEY=GENERATED_BY_INSTALLER OPENWEBUI_IMAGE=ghcr.io/open-webui/open-webui:v0.9.5 +PIPER_TTS_VERSION=1.6.0 +PIPER_VOICE=de_DE-thorsten-high AI_DNS=192.168.1.1 FAST_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf diff --git a/README.md b/README.md index 74d47f9..bf27476 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,11 @@ Neustart an; danach wird derselbe Befehl erneut ausgeführt. | Profile Router | `:8081` | OpenAI-kompatible API, Profilwahl | | llama.cpp | nur Docker-intern | Inferenz und integrierte Vision | | Profile Controller | nur Docker-intern | eng begrenzter Containerwechsel | +| Piper | nur Docker-intern | lokale deutsche Sprachausgabe | | MCP-Tool-Stack | nur Docker-intern | Web, Home Assistant, ARR und Unraid | -Bildgenerierung und TTS/STT bleiben optionale Dienste. Web-, Home-Assistant-, +Piper-TTS ist ein reproduzierbarer Kerndienst; STT und Bildgenerierung bleiben +optionale Dienste. Web-, Home-Assistant-, ARR- und Unraid-Werkzeuge besitzen dagegen bereits getrennte Container unter `platform/mcp/`. Open WebUI erreicht sie ausschließlich über das interne `mike-ai-tools`-Netz; llama.cpp erhält keine MCP-Konfiguration und keine diff --git a/compose.yaml b/compose.yaml index c1b29e6..4627f49 100644 --- a/compose.yaml +++ b/compose.yaml @@ -314,7 +314,11 @@ services: IMAGE_DIR: /data/images CHAT_IMAGE_ALLOW_REMOTE_URLS: "false" ENABLE_IMAGE_GENERATION: "false" - ENABLE_TTS: "false" + ENABLE_TTS: "true" + TTS_WORKER_URL: http://piper:8085 + TTS_MODEL: piper + TTS_VOICES: alloy + TTS_DEFAULT_VOICE: alloy ENABLE_STT: "false" ports: - "${AI_BIND_ADDRESS:-127.0.0.1}:8081:8081" @@ -335,6 +339,39 @@ services: depends_on: profile-controller: condition: service_healthy + piper: + condition: service_healthy + + piper: + build: + context: platform/docker/piper + args: + PIPER_TTS_VERSION: ${PIPER_TTS_VERSION:-1.6.0} + image: mike-ai/piper:local + container_name: mike-ai-piper + restart: unless-stopped + read_only: true + tmpfs: + - /tmp:size=256m,mode=1777 + volumes: + - piper-data:/data + environment: + PIPER_DATA_DIR: /data + PIPER_VOICE: ${PIPER_VOICE:-de_DE-thorsten-high} + PIPER_VOICE_ALIAS: alloy + PIPER_HOST: 0.0.0.0 + PIPER_PORT: "8085" + PIPER_MAX_TEXT_CHARS: "8000" + networks: [frontend] + security_opt: ["no-new-privileges:true"] + cap_drop: [ALL] + cap_add: [CHOWN, SETUID, SETGID] + healthcheck: + test: [CMD, curl, -fsS, "http://127.0.0.1:8085/status"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 120s open-webui: image: ${OPENWEBUI_IMAGE:-ghcr.io/open-webui/open-webui:v0.9.5} @@ -351,6 +388,11 @@ services: OLLAMA_BASE_URL: "" OPENAI_API_BASE_URLS: http://router:8081/v1 OPENAI_API_KEYS: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}" + AUDIO_TTS_ENGINE: openai + AUDIO_TTS_OPENAI_API_BASE_URL: http://router:8081/v1 + AUDIO_TTS_OPENAI_API_KEY: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}" + AUDIO_TTS_MODEL: piper + AUDIO_TTS_VOICE: alloy ENABLE_SIGNUP: ${OPENWEBUI_ENABLE_SIGNUP:-false} ENABLE_FOLLOW_UP_GENERATION: ${OPENWEBUI_ENABLE_FOLLOW_UP_GENERATION:-false} # Seed native MCP connections on a fresh Open WebUI database. Secrets @@ -387,5 +429,6 @@ networks: volumes: open-webui-data: + piper-data: router-state: router-images: diff --git a/config/install.env.example b/config/install.env.example index 0d4b424..36048fd 100644 --- a/config/install.env.example +++ b/config/install.env.example @@ -55,3 +55,5 @@ LLAMA_THREADS_BATCH=6 OPENWEBUI_IMAGE=ghcr.io/open-webui/open-webui:v0.9.5 OPENWEBUI_ENABLE_SIGNUP=false OPENWEBUI_ENABLE_FOLLOW_UP_GENERATION=false +PIPER_TTS_VERSION=1.6.0 +PIPER_VOICE=de_DE-thorsten-high diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e148dcd..5bd9e9c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -22,6 +22,7 @@ Heimnetz / VPN-Clients +-- llama-medium > exakt einer aktiv +-- llama-long --/ +-- llama-experimental + +-- Piper-TTS (CPU, nur intern) +-- internes MCP-Netz +-- Web-MCP + TinySearch + SearXNG +-- Home-Assistant-MCP-Relay @@ -37,6 +38,7 @@ Heimnetz / VPN-Clients | Profile Router | nur WireGuard, Port 8081 | OpenAI-API und Profilwahl | | Profile Controller | nein | startet ausschließlich vier bekannte Profile | | llama.cpp Profile | nein | Inferenz, Tool Calling, integrierte Vision | +| Piper | nein | lokale deutsche Text-to-Speech-Ausgabe | | MCP-Tool-Stack | nein | voneinander getrennte Werkzeugbereiche | | SearXNG/TinySearch | nein | Suchbackend des Web-MCP | @@ -80,8 +82,11 @@ nicht automatisch in die Produktionsprofile aufgenommen. ## Optionale Erweiterungen -Bildgenerierung, Whisper und TTS benötigen eigene Modelle und bleiben im -Basissystem deaktiviert. Home Assistant, ARR und Unraid sind vorbereitete +Piper läuft als eigener CPU-Container und wird von Open WebUI über den Router +angesprochen. Sein Port wird nicht veröffentlicht. Das Stimmenmodell liegt im +persistenten Volume `piper-data` und wird beim ersten Start reproduzierbar +nachgeladen. Bildgenerierung und Whisper bleiben im Basissystem deaktiviert. +Home Assistant, ARR und Unraid sind vorbereitete MCP-Profile: Sie werden erst gestartet, wenn die jeweilige root-only Secret-Datei vorhanden ist. Multimodale Bildanalyse erfolgt direkt über Qwen plus Projektor. Nicht installierte Worker-Endpunkte antworten klar mit diff --git a/docs/ATHENA_REBUILD_LOG.md b/docs/ATHENA_REBUILD_LOG.md index e7f166c..f177e05 100644 --- a/docs/ATHENA_REBUILD_LOG.md +++ b/docs/ATHENA_REBUILD_LOG.md @@ -38,6 +38,7 @@ Chats oder privaten Nutzdaten. | Werkzeug-/Kontextschutz | Große MCP-Antworten und wiederholte identische Aufrufe konnten Kontextfenster sprengen beziehungsweise Tool-Schleifen erzeugen. | Globaler Stability Guard begrenzt Resultate, verdichtet alte Inhalte profilabhängig und stoppt Wiederholungen; JSON-Schemas und Bilder werden nicht beschädigt. | | Leistungsdaten | Benchmarkdaten sollten sichtbar sein, ohne private Chat-Inhalte zu protokollieren. | Ein globaler Abschlussfilter schreibt ausschließlich technische Zahlen in eine lokal rotierende JSONL-Datei und zeigt eine knappe Statuszeile. | | Antwortaktionen | Wiederkehrende Nachbearbeitungen sollten bewusst per Klick statt als permanenter Zusatzprompt laufen. | Eine versionierte globale Action bietet lokale Kurzfassung, Checkliste, Diagnose, Unsicherheits-/Quellenprüfung, Thinking-Verbesserung und Markdown-Kopie; keine Schreib- oder Profilwechselaktion. | +| Sprachausgabe | Beim ersten Leerhostaufbau war kein TTS-Dienst Bestandteil des Compose-Stacks. | Piper `piper-tts` 1.6.0 läuft als eigener interner CPU-Container mit persistenter deutscher Stimme; Open WebUI nutzt ihn ausschließlich über den authentifizierten Router. | ## Abnahmezustand am 21. August 2026 diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index b3e5ee5..3f7906e 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -12,7 +12,7 @@ | ARR-MCP | `arr-mcp` 1.0.1 plus dokumentierter Sonarr-Patch | eigener optionaler Container | optional | | Unraid-MCP | lokales `runraid`-Binary | eigener optionaler Container | optional | | Whisper | ggml-org/whisper.cpp | Service im Router-Deploy | optional | -| XTTS-v2 | Coqui | Worker, Service und Lockdatei | optional | +| Piper TTS | Open Home Foundation, `piper-tts` 1.6.0 | eigener interner CPU-Container, Stimme `de_DE-thorsten-high` | Kern | | FLUX.2 klein | Black Forest Labs | Worker und Modellmanifest | optional | | LLama-GUI | separates Upstream-Projekt | nur Betriebsrolle dokumentiert | optional | | Glances | Distribution | nur Betriebsrolle dokumentiert | optional | diff --git a/docs/CURRENT_REFERENCE.md b/docs/CURRENT_REFERENCE.md index 7569c06..b69d513 100644 --- a/docs/CURRENT_REFERENCE.md +++ b/docs/CURRENT_REFERENCE.md @@ -76,7 +76,7 @@ Der Router übernimmt: - direkte integrierte Vision in allen Qwen-Profilen - FLUX-Hotswap zur Bildgenerierung - Whisper Speech-to-Text -- XTTS Text-to-Speech +- XTTS Text-to-Speech (historische Referenz; Zielsystem verwendet Piper) - Zustands- und Modellendpunkte ## Vision @@ -114,6 +114,9 @@ leitet das Bild dann direkt weiter und führt keinen Modellwechsel mehr aus. ### XTTS +Dieser Abschnitt beschreibt ausschließlich den alten Referenzhost. Im neuen +Docker-Zielsystem ersetzt Piper (`de_DE-thorsten-high`) diesen Dienst. + - Modell: Coqui XTTS-v2 - CPU-only - Stimme: `claribel` diff --git a/docs/DISASTER_RECOVERY.md b/docs/DISASTER_RECOVERY.md index c1b2961..0be55f9 100644 --- a/docs/DISASTER_RECOVERY.md +++ b/docs/DISASTER_RECOVERY.md @@ -69,7 +69,8 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden. - [ ] FLUX erzeugt Standard- und High-Bild - [ ] Qwen-Profil wird nach FLUX wiederhergestellt - [ ] Whisper transkribiert deutsche und englische Testdatei -- [ ] XTTS erzeugt deutsche WAV- und MP3-Ausgabe +- [ ] Piper ist gesund und erzeugt über den Router deutsche WAV- und MP3-Ausgabe +- [ ] Stimme und `piper-tts`-Version entsprechen der Installationskonfiguration - [ ] STT/TTS blockieren das Textmodell nicht unzulässig ## Phase F – Sicherheitsprüfung diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 70e6c43..f647b8c 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -46,6 +46,9 @@ sudo ./install.sh --config config/install.env Wenn erstmals ein NVIDIA-Treiber installiert wurde, endet das Skript bewusst mit Code 20. Dann neu starten und denselben Befehl erneut ausführen. Das Skript ist auf Wiederholung ausgelegt und löscht keine vorhandenen Modelldateien. +Beim ersten Stackstart lädt der interne Piper-Container die konfigurierte +deutsche Stimme in sein persistentes Volume. Dadurch kann seine erste +Bereitschaft je nach Internetverbindung etwas länger dauern. Der Installer zeigt nur den öffentlichen WireGuard-Schlüssel. Diesen am Heim-Peer eintragen: @@ -72,9 +75,23 @@ sudo docker compose --env-file /etc/mike-ai/stack.env \ curl http://:8081/health ``` +Die TTS-Verbindung wird für eine frische Open-WebUI-Datenbank automatisch als +OpenAI-kompatibler Audio-Endpunkt des Routers vorbelegt. Der Router reicht sie +intern an Piper weiter; Port 8085 wird nicht am Host veröffentlicht. Ein +Ende-zu-Ende-Test ohne Ausgabe des API-Schlüssels: + +```bash +set -a; source /etc/mike-ai/stack.env; set +a +curl -fsS http://127.0.0.1:8081/v1/audio/speech \ + -H "Authorization: Bearer $ROUTER_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"model":"piper","voice":"alloy","input":"Hallo von Athena.","response_format":"mp3"}' \ + -o /tmp/athena-piper-test.mp3 +``` + Zusätzlich prüfen: Uni-LAN sieht keine KI-Ports; Heimnetz erreicht beide; gestopptes WireGuard lässt KI-Container nicht ins Internet; jeder Profilwechsel -startet exakt einen llama-Container; Text, Tool Call und Bild funktionieren. +startet exakt einen llama-Container; Text, Tool Call, Bild und Sprachausgabe funktionieren. ## Werkzeug-Container diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index dc2d5a7..0903c37 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -6,7 +6,7 @@ - IQ4_XS Pure für Medium - BF16-`mmproj` für die integrierte Vision aller Qwen-Profile - festgeschriebener llama.cpp-Commit -- Router, Whisper, XTTS und Websuche +- Router, Piper-TTS, Whisper und Websuche - spezialisierte MCPs nach Sicherheitsprofil - relevante Benchmarkresultate @@ -33,7 +33,7 @@ 8. Medium und Long einzeln testen. 9. Router installieren und Profilwechsel testen. 10. Web, HA, ARR und Unraid nacheinander hinzufügen. -11. STT, TTS und den Projektor für integrierte Vision ergänzen. +11. Piper-TTS prüfen; optional STT ergänzen und den Projektor für integrierte Vision prüfen. 12. Standardbenchmark und Sicherheitsprüfung ausführen. 13. Erst danach Clients umstellen. diff --git a/docs/NEW_HOST_ROADMAP.md b/docs/NEW_HOST_ROADMAP.md index 9dc5237..b140b6d 100644 --- a/docs/NEW_HOST_ROADMAP.md +++ b/docs/NEW_HOST_ROADMAP.md @@ -38,7 +38,8 @@ - Terminal ausschließlich als isolierten `sandbox-mcp`, nie als Host-Shell. - Open WebUI, Hermes und weitere Clients mit denselben zentralen Endpunkten verbinden und pro Chat nur benötigte Werkzeuggruppen aktivieren. -- Whisper, TTS oder Bildgenerierung jeweils als eigener Container. +- Piper-TTS als eigener interner CPU-Container; Whisper oder Bildgenerierung + bei Bedarf ebenfalls jeweils als eigener Container. ## Phase 5 – Betrieb diff --git a/docs/RECOVERY_REQUIREMENTS.md b/docs/RECOVERY_REQUIREMENTS.md index 9a2864b..d02c2e6 100644 --- a/docs/RECOVERY_REQUIREMENTS.md +++ b/docs/RECOVERY_REQUIREMENTS.md @@ -34,7 +34,7 @@ Pflichtrollen: - BF16 Vision-Projektor - Whisper large-v3-turbo - FLUX.2 klein -- XTTS-v2 und verwendete Stimme +- Piper `piper-tts` 1.6.0 und Stimme `de_DE-thorsten-high` ## 2. Externe Komponenten und Commits – teilweise gesichert @@ -140,7 +140,7 @@ Empfehlung: System unter 85 Prozent, Modelllaufwerk unter 90 Prozent halten. Bereits gesichert: - llama.cpp-Commit -- zentrale Python-Versionen für FLUX und XTTS +- zentrale Python-Versionen für FLUX und Piper - TinySearch-/SearXNG-Image-Digests Noch offen: @@ -148,7 +148,7 @@ Noch offen: - vollständiges `pip freeze` je produktivem Venv - CUDA-kompatible Wheel-Quelle - FLUX-Revision -- XTTS-Modellrevision +- Piper-Paketversion und exakter Stimmenname - Whisper-Commit und Buildoptionen - Docker-Engine-/Compose-Version diff --git a/install.sh b/install.sh index 12d49ea..8a6ed90 100755 --- a/install.sh +++ b/install.sh @@ -203,6 +203,8 @@ WEBUI_SECRET_KEY=$(<$SECRETS_DIR/webui-secret) OPENWEBUI_IMAGE=${OPENWEBUI_IMAGE:-ghcr.io/open-webui/open-webui:v0.9.5} OPENWEBUI_ENABLE_SIGNUP=${OPENWEBUI_ENABLE_SIGNUP:-false} OPENWEBUI_ENABLE_FOLLOW_UP_GENERATION=${OPENWEBUI_ENABLE_FOLLOW_UP_GENERATION:-false} +PIPER_TTS_VERSION=${PIPER_TTS_VERSION:-1.6.0} +PIPER_VOICE=${PIPER_VOICE:-de_DE-thorsten-high} AI_DNS=${WG_DNS:-1.1.1.1} FAST_MODEL_FILE=$FAST_MODEL_FILE MEDIUM_MODEL_FILE=$MEDIUM_MODEL_FILE diff --git a/platform/checks/verify-platform.sh b/platform/checks/verify-platform.sh index d770a1b..5493574 100755 --- a/platform/checks/verify-platform.sh +++ b/platform/checks/verify-platform.sh @@ -25,33 +25,42 @@ else fail "nvidia-smi fehlt" fi -for service in mike-ai-llama-ui mike-ai-profile-router; do - if systemctl is-active --quiet "$service"; then - pass "$service aktiv" +container_healthy() { + local name=$1 state health + state="$(docker inspect --format '{{.State.Status}}' "$name" 2>/dev/null || true)" + health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$name" 2>/dev/null || true)" + [[ $state == running && ( -z $health || $health == healthy ) ]] +} + +for container in mike-ai-profile-controller mike-ai-router mike-ai-piper mike-ai-open-webui; do + if container_healthy "$container"; then + pass "$container gesund" else - fail "$service nicht aktiv" + fail "$container fehlt oder ist nicht gesund" fi done -for optional in mike-ai-whisper mike-ai-xtts mike-ai-web-search; do - if systemctl is-active --quiet "$optional"; then +ACTIVE_LLAMA="$(docker ps --format '{{.Names}}' | grep -Ec '^mike-ai-llama-(fast|medium|long|experimental)$' || true)" +if [[ $ACTIVE_LLAMA -eq 1 ]]; then + pass "exakt ein llama.cpp-Profil aktiv" +else + fail "$ACTIVE_LLAMA llama.cpp-Profile aktiv (erwartet: 1)" +fi + +for optional in mike-ai-mcp-web mike-ai-mcp-homeassistant mike-ai-mcp-arr mike-ai-mcp-unraid-official; do + if container_healthy "$optional"; then pass "$optional aktiv" else warn "$optional nicht aktiv oder nicht installiert" fi done -if curl -fsS --max-time 3 http://127.0.0.1:8080/health >/dev/null; then - pass "llama.cpp Health-Check" +if docker exec mike-ai-router python -c \ + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8081/health', timeout=3)" \ + >/dev/null 2>&1; then + pass "Router-Liveness intern erreichbar" else - fail "llama.cpp auf Port 8080 nicht gesund" -fi - -if STATUS="$(curl -fsS --max-time 3 http://127.0.0.1:8081/status 2>/dev/null)"; then - PROFILE="$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("current_profile"))' <<<"$STATUS" 2>/dev/null)" - pass "Router erreichbar, Profil ${PROFILE:-unbekannt}" -else - fail "Router auf Port 8081 nicht erreichbar" + fail "Router-Liveness intern nicht erreichbar" fi if systemctl list-unit-files --no-legend 2>/dev/null | grep -Eq '(vision-rx|whisper-rx|granite-rx).*enabled'; then diff --git a/platform/docker/piper/Dockerfile b/platform/docker/piper/Dockerfile new file mode 100644 index 0000000..66940fd --- /dev/null +++ b/platform/docker/piper/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-slim-bookworm + +ARG PIPER_TTS_VERSION=1.6.0 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl ffmpeg gosu \ + && python -m pip install --no-cache-dir "piper-tts==${PIPER_TTS_VERSION}" \ + && useradd --system --uid 10003 --home-dir /nonexistent --shell /usr/sbin/nologin piper \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY piper_worker.py /app/piper_worker.py +COPY entrypoint.sh /usr/local/bin/mike-ai-piper-entrypoint +RUN chmod 0755 /usr/local/bin/mike-ai-piper-entrypoint + +ENV PIPER_DATA_DIR=/data \ + PIPER_VOICE=de_DE-thorsten-high \ + PIPER_VOICE_ALIAS=alloy \ + PIPER_HOST=0.0.0.0 \ + PIPER_PORT=8085 + +VOLUME ["/data"] +EXPOSE 8085 +ENTRYPOINT ["/usr/local/bin/mike-ai-piper-entrypoint"] diff --git a/platform/docker/piper/entrypoint.sh b/platform/docker/piper/entrypoint.sh new file mode 100644 index 0000000..2d7de88 --- /dev/null +++ b/platform/docker/piper/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu + +data_dir=${PIPER_DATA_DIR:-/data} +voice=${PIPER_VOICE:-de_DE-thorsten-high} + +mkdir -p "$data_dir" +chown 10003:10003 "$data_dir" + +if [ ! -s "$data_dir/$voice.onnx" ] || [ ! -s "$data_dir/$voice.onnx.json" ]; then + echo "Downloading Piper voice: $voice" + gosu piper python -m piper.download_voices --data-dir "$data_dir" "$voice" +fi + +exec gosu piper python /app/piper_worker.py diff --git a/platform/docker/piper/piper_worker.py b/platform/docker/piper/piper_worker.py new file mode 100644 index 0000000..1324a41 --- /dev/null +++ b/platform/docker/piper/piper_worker.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Small, private Piper worker for the Mike AI profile router. + +The public OpenAI-compatible endpoint remains in the router. This worker only +accepts the narrow internal /status and /tts protocol and never logs input text. +""" + +from __future__ import annotations + +import io +import json +import os +import subprocess +import threading +import wave +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from piper import PiperVoice, SynthesisConfig + + +DATA_DIR = Path(os.getenv("PIPER_DATA_DIR", "/data")) +VOICE_NAME = os.getenv("PIPER_VOICE", "de_DE-thorsten-high") +VOICE_ALIAS = os.getenv("PIPER_VOICE_ALIAS", "alloy") +HOST = os.getenv("PIPER_HOST", "0.0.0.0") +PORT = int(os.getenv("PIPER_PORT", "8085")) +MAX_TEXT_CHARS = int(os.getenv("PIPER_MAX_TEXT_CHARS", "8000")) +MAX_REQUEST_BYTES = int(os.getenv("PIPER_MAX_REQUEST_BYTES", "65536")) + +VOICE_PATH = DATA_DIR / f"{VOICE_NAME}.onnx" +VOICE = PiperVoice.load(str(VOICE_PATH)) +SYNTHESIS_LOCK = threading.Lock() + + +def synthesize_wav(text: str, speed: float) -> bytes: + """Synthesize a complete WAV in memory without retaining the text.""" + output = io.BytesIO() + config = SynthesisConfig(length_scale=1.0 / speed) + with SYNTHESIS_LOCK, wave.open(output, "wb") as wav_file: + VOICE.synthesize_wav(text, wav_file, syn_config=config) + return output.getvalue() + + +def wav_to_mp3(wav_bytes: bytes) -> bytes: + """Convert Piper's WAV to the MP3 format Open WebUI requests by default.""" + result = subprocess.run( + [ + "ffmpeg", "-hide_banner", "-loglevel", "error", + "-f", "wav", "-i", "pipe:0", + "-codec:a", "libmp3lame", "-b:a", "96k", + "-f", "mp3", "pipe:1", + ], + input=wav_bytes, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=120, + ) + if result.returncode != 0: + raise RuntimeError("ffmpeg conversion failed") + return result.stdout + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt: str, *args: object) -> None: + # Deliberately omit URLs and request bodies from the log. + print(f"piper-worker: {self.command} -> {args[1] if len(args) > 1 else '-'}") + + def send_bytes(self, status: int, body: bytes, content_type: str) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def send_json(self, status: int, payload: dict) -> None: + self.send_bytes( + status, + json.dumps(payload, separators=(",", ":")).encode(), + "application/json", + ) + + def do_GET(self) -> None: # noqa: N802 + if self.path != "/status": + self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + return + self.send_json( + HTTPStatus.OK, + { + "ready": True, + "engine": "piper", + "model": VOICE_NAME, + "voices": [VOICE_ALIAS], + }, + ) + + def do_POST(self) -> None: # noqa: N802 + if self.path != "/tts": + self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + return + + try: + content_length = int(self.headers.get("Content-Length", "0")) + except ValueError: + content_length = 0 + if content_length <= 0 or content_length > MAX_REQUEST_BYTES: + self.send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "invalid request size"}) + return + + try: + request = json.loads(self.rfile.read(content_length)) + text = request.get("text", "") + voice = request.get("voice", VOICE_ALIAS) + output_format = request.get("format", "mp3") + speed = float(request.get("speed", 1.0)) + except (json.JSONDecodeError, TypeError, ValueError): + self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid JSON request"}) + return + + if not isinstance(text, str) or not text.strip() or len(text) > MAX_TEXT_CHARS: + self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid text"}) + return + if voice != VOICE_ALIAS: + self.send_json(HTTPStatus.BAD_REQUEST, {"error": "unknown voice"}) + return + if output_format not in {"wav", "mp3"}: + self.send_json(HTTPStatus.BAD_REQUEST, {"error": "unsupported format"}) + return + if not 0.5 <= speed <= 2.0: + self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid speed"}) + return + + try: + audio = synthesize_wav(text.strip(), speed) + if output_format == "mp3": + audio = wav_to_mp3(audio) + content_type = "audio/mpeg" + else: + content_type = "audio/wav" + except (OSError, RuntimeError, subprocess.SubprocessError): + self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "synthesis failed"}) + return + + self.send_bytes(HTTPStatus.OK, audio, content_type) + + +if __name__ == "__main__": + print(f"Piper worker ready: {VOICE_NAME} as {VOICE_ALIAS} on {HOST}:{PORT}") + ThreadingHTTPServer((HOST, PORT), Handler).serve_forever() diff --git a/router/ai_profile_router.py b/router/ai_profile_router.py index a9b58b2..cd1d51b 100755 --- a/router/ai_profile_router.py +++ b/router/ai_profile_router.py @@ -153,13 +153,15 @@ IMAGE_QUALITY = {"standard": 30, "high": 50} IMAGE_DEFAULT_QUALITY = "standard" IMAGE_MAX_N = 4 -# --- Sprachausgabe (XTTS-v2, multilingual, CPU-only) --- +# --- Sprachausgabe (austauschbarer interner TTS-Worker, CPU-only) --- TTS_WORKER_URL = os.environ.get("TTS_WORKER_URL", "http://127.0.0.1:8085") TTS_TIMEOUT = float(os.environ.get("TTS_TIMEOUT", "300")) # s, pro Synthese TTS_CONNECT_TIMEOUT = float(os.environ.get("TTS_CONNECT_TIMEOUT", "5")) -TTS_MODEL = "xtts-v2" # virtuelles Modell für /v1/audio/speech -TTS_VOICES = ("claribel",) -TTS_DEFAULT_VOICE = "claribel" +TTS_MODEL = os.environ.get("TTS_MODEL", "xtts-v2") +TTS_VOICES = tuple(v.strip() for v in os.environ.get( + "TTS_VOICES", "claribel").split(",") if v.strip()) +TTS_DEFAULT_VOICE = os.environ.get( + "TTS_DEFAULT_VOICE", TTS_VOICES[0] if TTS_VOICES else "claribel") TTS_FORMATS = ("mp3", "wav") TTS_DEFAULT_FORMAT = "mp3"