Add reproducible Piper TTS service

This commit is contained in:
Mikei386 committed 2026-08-21 17:02:32 +02:00
1 parent 53dfffc289
commit a5cf11582a
19 files changed
+316 -34

No files matched your search

+2
View File
@@ -5,6 +5,8 @@ ROUTER_API_KEY=GENERATED_BY_INSTALLER
CONTROLLER_TOKEN=GENERATED_BY_INSTALLER CONTROLLER_TOKEN=GENERATED_BY_INSTALLER
WEBUI_SECRET_KEY=GENERATED_BY_INSTALLER WEBUI_SECRET_KEY=GENERATED_BY_INSTALLER
OPENWEBUI_IMAGE=ghcr.io/open-webui/open-webui:v0.9.5 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 AI_DNS=192.168.1.1
FAST_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf FAST_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf
+3 -1
View File
@@ -42,9 +42,11 @@ Neustart an; danach wird derselbe Befehl erneut ausgeführt.
| Profile Router | `<WG-IP>:8081` | OpenAI-kompatible API, Profilwahl | | Profile Router | `<WG-IP>:8081` | OpenAI-kompatible API, Profilwahl |
| llama.cpp | nur Docker-intern | Inferenz und integrierte Vision | | llama.cpp | nur Docker-intern | Inferenz und integrierte Vision |
| Profile Controller | nur Docker-intern | eng begrenzter Containerwechsel | | 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 | | 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 ARR- und Unraid-Werkzeuge besitzen dagegen bereits getrennte Container unter
`platform/mcp/`. Open WebUI erreicht sie ausschließlich über das interne `platform/mcp/`. Open WebUI erreicht sie ausschließlich über das interne
`mike-ai-tools`-Netz; llama.cpp erhält keine MCP-Konfiguration und keine `mike-ai-tools`-Netz; llama.cpp erhält keine MCP-Konfiguration und keine
+44 -1
View File
@@ -314,7 +314,11 @@ services:
IMAGE_DIR: /data/images IMAGE_DIR: /data/images
CHAT_IMAGE_ALLOW_REMOTE_URLS: "false" CHAT_IMAGE_ALLOW_REMOTE_URLS: "false"
ENABLE_IMAGE_GENERATION: "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" ENABLE_STT: "false"
ports: ports:
- "${AI_BIND_ADDRESS:-127.0.0.1}:8081:8081" - "${AI_BIND_ADDRESS:-127.0.0.1}:8081:8081"
@@ -335,6 +339,39 @@ services:
depends_on: depends_on:
profile-controller: profile-controller:
condition: service_healthy 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: open-webui:
image: ${OPENWEBUI_IMAGE:-ghcr.io/open-webui/open-webui:v0.9.5} image: ${OPENWEBUI_IMAGE:-ghcr.io/open-webui/open-webui:v0.9.5}
@@ -351,6 +388,11 @@ services:
OLLAMA_BASE_URL: "" OLLAMA_BASE_URL: ""
OPENAI_API_BASE_URLS: http://router:8081/v1 OPENAI_API_BASE_URLS: http://router:8081/v1
OPENAI_API_KEYS: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}" 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_SIGNUP: ${OPENWEBUI_ENABLE_SIGNUP:-false}
ENABLE_FOLLOW_UP_GENERATION: ${OPENWEBUI_ENABLE_FOLLOW_UP_GENERATION:-false} ENABLE_FOLLOW_UP_GENERATION: ${OPENWEBUI_ENABLE_FOLLOW_UP_GENERATION:-false}
# Seed native MCP connections on a fresh Open WebUI database. Secrets # Seed native MCP connections on a fresh Open WebUI database. Secrets
@@ -387,5 +429,6 @@ networks:
volumes: volumes:
open-webui-data: open-webui-data:
piper-data:
router-state: router-state:
router-images: router-images:
+2
View File
@@ -55,3 +55,5 @@ LLAMA_THREADS_BATCH=6
OPENWEBUI_IMAGE=ghcr.io/open-webui/open-webui:v0.9.5 OPENWEBUI_IMAGE=ghcr.io/open-webui/open-webui:v0.9.5
OPENWEBUI_ENABLE_SIGNUP=false OPENWEBUI_ENABLE_SIGNUP=false
OPENWEBUI_ENABLE_FOLLOW_UP_GENERATION=false OPENWEBUI_ENABLE_FOLLOW_UP_GENERATION=false
PIPER_TTS_VERSION=1.6.0
PIPER_VOICE=de_DE-thorsten-high
+7 -2
View File
@@ -22,6 +22,7 @@ Heimnetz / VPN-Clients
+-- llama-medium > exakt einer aktiv +-- llama-medium > exakt einer aktiv
+-- llama-long --/ +-- llama-long --/
+-- llama-experimental +-- llama-experimental
+-- Piper-TTS (CPU, nur intern)
+-- internes MCP-Netz +-- internes MCP-Netz
+-- Web-MCP + TinySearch + SearXNG +-- Web-MCP + TinySearch + SearXNG
+-- Home-Assistant-MCP-Relay +-- Home-Assistant-MCP-Relay
@@ -37,6 +38,7 @@ Heimnetz / VPN-Clients
| Profile Router | nur WireGuard, Port 8081 | OpenAI-API und Profilwahl | | Profile Router | nur WireGuard, Port 8081 | OpenAI-API und Profilwahl |
| Profile Controller | nein | startet ausschließlich vier bekannte Profile | | Profile Controller | nein | startet ausschließlich vier bekannte Profile |
| llama.cpp Profile | nein | Inferenz, Tool Calling, integrierte Vision | | llama.cpp Profile | nein | Inferenz, Tool Calling, integrierte Vision |
| Piper | nein | lokale deutsche Text-to-Speech-Ausgabe |
| MCP-Tool-Stack | nein | voneinander getrennte Werkzeugbereiche | | MCP-Tool-Stack | nein | voneinander getrennte Werkzeugbereiche |
| SearXNG/TinySearch | nein | Suchbackend des Web-MCP | | SearXNG/TinySearch | nein | Suchbackend des Web-MCP |
@@ -80,8 +82,11 @@ nicht automatisch in die Produktionsprofile aufgenommen.
## Optionale Erweiterungen ## Optionale Erweiterungen
Bildgenerierung, Whisper und TTS benötigen eigene Modelle und bleiben im Piper läuft als eigener CPU-Container und wird von Open WebUI über den Router
Basissystem deaktiviert. Home Assistant, ARR und Unraid sind vorbereitete 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 MCP-Profile: Sie werden erst gestartet, wenn die jeweilige root-only
Secret-Datei vorhanden ist. Multimodale Bildanalyse erfolgt direkt über Qwen Secret-Datei vorhanden ist. Multimodale Bildanalyse erfolgt direkt über Qwen
plus Projektor. Nicht installierte Worker-Endpunkte antworten klar mit plus Projektor. Nicht installierte Worker-Endpunkte antworten klar mit
+1
View File
@@ -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. | | 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. | | 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. | | 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 ## Abnahmezustand am 21. August 2026
+1 -1
View File
@@ -12,7 +12,7 @@
| ARR-MCP | `arr-mcp` 1.0.1 plus dokumentierter Sonarr-Patch | eigener optionaler Container | optional | | ARR-MCP | `arr-mcp` 1.0.1 plus dokumentierter Sonarr-Patch | eigener optionaler Container | optional |
| Unraid-MCP | lokales `runraid`-Binary | eigener optionaler Container | optional | | Unraid-MCP | lokales `runraid`-Binary | eigener optionaler Container | optional |
| Whisper | ggml-org/whisper.cpp | Service im Router-Deploy | 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 | | FLUX.2 klein | Black Forest Labs | Worker und Modellmanifest | optional |
| LLama-GUI | separates Upstream-Projekt | nur Betriebsrolle dokumentiert | optional | | LLama-GUI | separates Upstream-Projekt | nur Betriebsrolle dokumentiert | optional |
| Glances | Distribution | nur Betriebsrolle dokumentiert | optional | | Glances | Distribution | nur Betriebsrolle dokumentiert | optional |
+4 -1
View File
@@ -76,7 +76,7 @@ Der Router übernimmt:
- direkte integrierte Vision in allen Qwen-Profilen - direkte integrierte Vision in allen Qwen-Profilen
- FLUX-Hotswap zur Bildgenerierung - FLUX-Hotswap zur Bildgenerierung
- Whisper Speech-to-Text - Whisper Speech-to-Text
- XTTS Text-to-Speech - XTTS Text-to-Speech (historische Referenz; Zielsystem verwendet Piper)
- Zustands- und Modellendpunkte - Zustands- und Modellendpunkte
## Vision ## Vision
@@ -114,6 +114,9 @@ leitet das Bild dann direkt weiter und führt keinen Modellwechsel mehr aus.
### XTTS ### 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 - Modell: Coqui XTTS-v2
- CPU-only - CPU-only
- Stimme: `claribel` - Stimme: `claribel`
+2 -1
View File
@@ -69,7 +69,8 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
- [ ] FLUX erzeugt Standard- und High-Bild - [ ] FLUX erzeugt Standard- und High-Bild
- [ ] Qwen-Profil wird nach FLUX wiederhergestellt - [ ] Qwen-Profil wird nach FLUX wiederhergestellt
- [ ] Whisper transkribiert deutsche und englische Testdatei - [ ] 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 - [ ] STT/TTS blockieren das Textmodell nicht unzulässig
## Phase F – Sicherheitsprüfung ## Phase F – Sicherheitsprüfung
+18 -1
View File
@@ -46,6 +46,9 @@ sudo ./install.sh --config config/install.env
Wenn erstmals ein NVIDIA-Treiber installiert wurde, endet das Skript bewusst 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 mit Code 20. Dann neu starten und denselben Befehl erneut ausführen. Das Skript
ist auf Wiederholung ausgelegt und löscht keine vorhandenen Modelldateien. 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 Der Installer zeigt nur den öffentlichen WireGuard-Schlüssel. Diesen am
Heim-Peer eintragen: Heim-Peer eintragen:
@@ -72,9 +75,23 @@ sudo docker compose --env-file /etc/mike-ai/stack.env \
curl http://<WIREGUARD-IP>:8081/health curl http://<WIREGUARD-IP>: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; Zusätzlich prüfen: Uni-LAN sieht keine KI-Ports; Heimnetz erreicht beide;
gestopptes WireGuard lässt KI-Container nicht ins Internet; jeder Profilwechsel 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 ## Werkzeug-Container
+2 -2
View File
@@ -6,7 +6,7 @@
- IQ4_XS Pure für Medium - IQ4_XS Pure für Medium
- BF16-`mmproj` für die integrierte Vision aller Qwen-Profile - BF16-`mmproj` für die integrierte Vision aller Qwen-Profile
- festgeschriebener llama.cpp-Commit - festgeschriebener llama.cpp-Commit
- Router, Whisper, XTTS und Websuche - Router, Piper-TTS, Whisper und Websuche
- spezialisierte MCPs nach Sicherheitsprofil - spezialisierte MCPs nach Sicherheitsprofil
- relevante Benchmarkresultate - relevante Benchmarkresultate
@@ -33,7 +33,7 @@
8. Medium und Long einzeln testen. 8. Medium und Long einzeln testen.
9. Router installieren und Profilwechsel testen. 9. Router installieren und Profilwechsel testen.
10. Web, HA, ARR und Unraid nacheinander hinzufügen. 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. 12. Standardbenchmark und Sicherheitsprüfung ausführen.
13. Erst danach Clients umstellen. 13. Erst danach Clients umstellen.
+2 -1
View File
@@ -38,7 +38,8 @@
- Terminal ausschließlich als isolierten `sandbox-mcp`, nie als Host-Shell. - Terminal ausschließlich als isolierten `sandbox-mcp`, nie als Host-Shell.
- Open WebUI, Hermes und weitere Clients mit denselben zentralen Endpunkten - Open WebUI, Hermes und weitere Clients mit denselben zentralen Endpunkten
verbinden und pro Chat nur benötigte Werkzeuggruppen aktivieren. 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 ## Phase 5 – Betrieb
+3 -3
View File
@@ -34,7 +34,7 @@ Pflichtrollen:
- BF16 Vision-Projektor - BF16 Vision-Projektor
- Whisper large-v3-turbo - Whisper large-v3-turbo
- FLUX.2 klein - 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 ## 2. Externe Komponenten und Commits – teilweise gesichert
@@ -140,7 +140,7 @@ Empfehlung: System unter 85 Prozent, Modelllaufwerk unter 90 Prozent halten.
Bereits gesichert: Bereits gesichert:
- llama.cpp-Commit - llama.cpp-Commit
- zentrale Python-Versionen für FLUX und XTTS - zentrale Python-Versionen für FLUX und Piper
- TinySearch-/SearXNG-Image-Digests - TinySearch-/SearXNG-Image-Digests
Noch offen: Noch offen:
@@ -148,7 +148,7 @@ Noch offen:
- vollständiges `pip freeze` je produktivem Venv - vollständiges `pip freeze` je produktivem Venv
- CUDA-kompatible Wheel-Quelle - CUDA-kompatible Wheel-Quelle
- FLUX-Revision - FLUX-Revision
- XTTS-Modellrevision - Piper-Paketversion und exakter Stimmenname
- Whisper-Commit und Buildoptionen - Whisper-Commit und Buildoptionen
- Docker-Engine-/Compose-Version - Docker-Engine-/Compose-Version
+2
View File
@@ -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_IMAGE=${OPENWEBUI_IMAGE:-ghcr.io/open-webui/open-webui:v0.9.5}
OPENWEBUI_ENABLE_SIGNUP=${OPENWEBUI_ENABLE_SIGNUP:-false} OPENWEBUI_ENABLE_SIGNUP=${OPENWEBUI_ENABLE_SIGNUP:-false}
OPENWEBUI_ENABLE_FOLLOW_UP_GENERATION=${OPENWEBUI_ENABLE_FOLLOW_UP_GENERATION:-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} AI_DNS=${WG_DNS:-1.1.1.1}
FAST_MODEL_FILE=$FAST_MODEL_FILE FAST_MODEL_FILE=$FAST_MODEL_FILE
MEDIUM_MODEL_FILE=$MEDIUM_MODEL_FILE MEDIUM_MODEL_FILE=$MEDIUM_MODEL_FILE
+25 -16
View File
@@ -25,33 +25,42 @@ else
fail "nvidia-smi fehlt" fail "nvidia-smi fehlt"
fi fi
for service in mike-ai-llama-ui mike-ai-profile-router; do container_healthy() {
if systemctl is-active --quiet "$service"; then local name=$1 state health
pass "$service aktiv" 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 else
fail "$service nicht aktiv" fail "$container fehlt oder ist nicht gesund"
fi fi
done done
for optional in mike-ai-whisper mike-ai-xtts mike-ai-web-search; do ACTIVE_LLAMA="$(docker ps --format '{{.Names}}' | grep -Ec '^mike-ai-llama-(fast|medium|long|experimental)$' || true)"
if systemctl is-active --quiet "$optional"; then 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" pass "$optional aktiv"
else else
warn "$optional nicht aktiv oder nicht installiert" warn "$optional nicht aktiv oder nicht installiert"
fi fi
done done
if curl -fsS --max-time 3 http://127.0.0.1:8080/health >/dev/null; then if docker exec mike-ai-router python -c \
pass "llama.cpp Health-Check" "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 else
fail "llama.cpp auf Port 8080 nicht gesund" fail "Router-Liveness intern nicht erreichbar"
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"
fi fi
if systemctl list-unit-files --no-legend 2>/dev/null | grep -Eq '(vision-rx|whisper-rx|granite-rx).*enabled'; then if systemctl list-unit-files --no-legend 2>/dev/null | grep -Eq '(vision-rx|whisper-rx|granite-rx).*enabled'; then
+24
View File
@@ -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"]
+15
View File
@@ -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
+153
View File
@@ -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()
+6 -4
View File
@@ -153,13 +153,15 @@ IMAGE_QUALITY = {"standard": 30, "high": 50}
IMAGE_DEFAULT_QUALITY = "standard" IMAGE_DEFAULT_QUALITY = "standard"
IMAGE_MAX_N = 4 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_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_TIMEOUT = float(os.environ.get("TTS_TIMEOUT", "300")) # s, pro Synthese
TTS_CONNECT_TIMEOUT = float(os.environ.get("TTS_CONNECT_TIMEOUT", "5")) TTS_CONNECT_TIMEOUT = float(os.environ.get("TTS_CONNECT_TIMEOUT", "5"))
TTS_MODEL = "xtts-v2" # virtuelles Modell für /v1/audio/speech TTS_MODEL = os.environ.get("TTS_MODEL", "xtts-v2")
TTS_VOICES = ("claribel",) TTS_VOICES = tuple(v.strip() for v in os.environ.get(
TTS_DEFAULT_VOICE = "claribel" "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_FORMATS = ("mp3", "wav")
TTS_DEFAULT_FORMAT = "mp3" TTS_DEFAULT_FORMAT = "mp3"