Add reproducible Piper TTS service
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 |
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user