- Python 74.7%
- JavaScript 18.6%
- HTML 4%
- Shell 2.4%
- Makefile 0.3%
Eigentliche UI-Dateien: neues Tailwind-Layout (index.html), Theme-Umschalter + Bubble-Stile + Mic-Status (app.js); README-Abschnitt aktualisiert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|---|---|---|
| app | ||
| config | ||
| deploy | ||
| Docs | ||
| scripts | ||
| tests | ||
| .env.example | ||
| .gitignore | ||
| BEDIENUNGSANLEITUNG.md | ||
| chat_client.py | ||
| docker-compose.yml | ||
| Dockerfile | ||
| Makefile | ||
| pyproject.toml | ||
| README.md | ||
Voice Assistant Gateway
Modulares FastAPI-Gateway für einen seniorengerechten Sprachassistenten — cloud-first, aber hybrid/lokal betreibbar, mit austauschbaren Audio-Endpunkten und STT-/LLM-/TTS-Providern.
Jede Achse — Hardware (Audio In/Out), Betrieb (lokal/cloud) und Software
(lokale/remote KI) — ist frei konfigurierbar, ohne Code zu ändern. Konzept und
Details: Docs/voice-assistant-architecture.md.
Praktische Bedienung: BEDIENUNGSANLEITUNG.md.
Features
- Pipeline mit getrennter Semantik/Sprache: STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS
- Provider austauschbar über Registry (OpenRouter remote; lokales STT via faster-whisper
.[local]; lokales TTS via piper (schnell) und chatterbox (hohe Qualität + Voice-Cloning, eigener Dienst)) - Aussprache-Normalisierung vor dem TTS (Ordinalia/Einheiten/Abkürzungen + YAML-Lexikon, provider-abhängig
TTS_NORMALIZE_LEVEL); Pflege perscripts/add_pronunciation.py - Geschichtete Konfiguration mit Profilen (
local-dev/hybrid/cloud) - Routing auf jeder Ebene: Default → Profil → Nutzer → Session → Request
- Authentifizierung (Bearer-Token) + persistente Nutzer/Sessions (SQLite)
- Resilienz: Fallback-Ketten je Modul (Provider fällt aus → nächster) + Metriken
- Betrieb: Tageskontingent pro Nutzer (
429) + zweistufige Notfall-Eskalation (Stichwörter + LLM) - Gesprächsgedächtnis pro Session: Verlauf wird gespeichert und fließt ins LLM
- Langzeit-Erinnerungen pro Nutzer: dauerhafte Fakten/Vorlieben als LLM-Kontext
- WebSocket-Streaming-Chat (
/ws/chat) als Echtzeit-Transport - REST-API für Chat, Transkription, Sprachausgabe, Geräte, Sessions, Config
- Ohne Secrets im Code — API-Keys nur über die Umgebung
Schnellstart
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install -e .[test]
cp config/voice-assistant.example.toml config/voice-assistant.toml
export OPENROUTER_API_KEY=sk-or-v1-... # nur für Cloud-/Hybrid-Profile nötig
make run
Fehlt .env, wird sie beim ersten make run aus .env.example erzeugt.
Die App läuft dann auf http://localhost:8080 (bzw. dem in .env gesetzten PORT).
Kurztest:
curl http://localhost:8080/health
curl http://localhost:8080/api/config
Sprechen → Antwort hören → erneut sprechen (Mikrofon-Loop):
python scripts/voice_loop.py --session mein-gespraech
Vollständige, copy-&-paste-fertige Schritt-für-Schritt-Anleitung (Bedienung, Einstellungen wechseln, Praxis-Tests & Reaktionszeiten): BEDIENUNGSANLEITUNG.md.
Konfiguration & Profile
Höhere Ebene gewinnt:
eingebaute Defaults < config/voice-assistant.toml (inkl. aktivem Profil)
< ENV / .env < Session-Route < Request
Profile umschalten per Umgebungsvariable (oder dauerhaft in .env):
VA_PROFILE=local-dev make run # alles lokal (faster-whisper / lokales LLM / piper)
VA_PROFILE=hybrid make run # STT/TTS remote, LLM lokal
VA_PROFILE=cloud make run # alles über OpenRouter
Secrets gehören nicht in
config/*.toml— nur in die Umgebung (export OPENROUTER_API_KEY=…). GesetzteDEFAULT_*_PROVIDER-Werte in.envüberschreiben einVA_PROFILE.
Pro Session: POST /api/sessions/{id}/route (input_endpoint, output_endpoint,
stt_provider, llm_provider, tts_provider, language), dann Aufrufe mit ?session_id=….
Pro Request: dieselben Felder im Body von /api/chat bzw. /api/speak.
Aktive Konfiguration prüfen: curl http://localhost:8080/api/config.
Lokales LLM (llama.cpp, unzensiert)
Die zentrale KI kann statt OpenRouter ein lokales, unzensiertes Modell über einen
llama.cpp-Server (OpenAI-kompatibel) sein. Der Provider local-openai-compatible
spricht direkt dagegen — kein Code, nur Server starten + Profil wählen.
make llm-up # startet den llama.cpp-Container (Default: Port 8001, GPU 1)
make llm-status # Container- + HTTP-Status
make llm-down # stoppt den Container
Das Modell wird über das --alias va_llm angesprochen; die Defaults zeigen bereits
auf http://127.0.0.1:8001/v1 mit Modell va_llm. Danach genügt ein lokales Profil:
VA_PROFILE=hybrid make run # STT/TTS remote, Haupt-LLM lokal (unzensiert)
VA_PROFILE=local-dev make run # komplett lokal (faster-whisper / llama.cpp / piper)
Alle Server-Parameter sind per ENV überschreibbar (Defaults in Klammern):
| Variable | Bedeutung | Default |
|---|---|---|
HOST_PORT |
Host-Port des Servers | 8001 |
GPU_DEVICE |
GPU-Index (von 3 GPUs) | 1 |
MODEL_REL_PATH |
Modellpfad relativ zu HF_HOME |
models/qwen3/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf |
HF_HOME |
Wurzel der Modell-Sammlung | ~/nvme2n1p7_home/huggingface |
MODEL_ALIAS |
API-Modellname (= LOCAL_LLM_MODEL) |
va_llm |
CONTAINER_NAME |
Docker-Containername | va_llm |
Beispiel (andere GPU/Port/Modell):
GPU_DEVICE=2 HOST_PORT=8101 MODEL_REL_PATH=models/qwen3/Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf \
bash scripts/llm-server/start-llm-server.sh
Wird
HOST_PORT/MODEL_ALIASgeändert, müssenLOCAL_LLM_BASE_URL/LOCAL_LLM_MODELim Gateway (.env) entsprechend angepasst werden.
Tempo im Sprach-Loop
Ein Reasoning-Modell (Qwen3) „denkt" per Default lang und antwortet ausführlich mit
Markdown/Emojis — schlecht zum Vorlesen und spürbar träge. Der Provider
local-openai-compatible stellt daher für gesprochene Antworten um:
| Setting | Default | Wirkung |
|---|---|---|
LOCAL_LLM_DISABLE_REASONING |
true |
schaltet die Qwen3-Denkphase ab (Time-to-first-word ~9× schneller) |
LOCAL_LLM_SYSTEM_PROMPT |
knapper Sprach-Prompt | kurze, vorlesbare Antworten in Fließtext (kein Markdown/Emoji) |
LOCAL_LLM_MAX_TOKENS |
0 (Server-Limit) |
optionaler harter Deckel, z. B. 256 |
LOCAL_LLM_TEMPERATURE |
0.3 |
Sampling-Temperatur |
Gemessen am Modell
va_llm: dieselbe Frage fällt von 5,5 s / 1433 Zeichen (Reasoning an, ausführlich) auf 0,7 s / ~190 Zeichen (Reasoning aus + Sprach-Prompt). Für unzensierte „freie" Gespräche bleibt der Prompt rein formal (nur Kürze/Format, keine inhaltlichen Einschränkungen); perLOCAL_LLM_SYSTEM_PROMPT=leerbar.
Zweiter Hebel — STT: faster-whisper läuft per Default auf auto (oft CPU) mit
Modell base. Auf einer RTX 3090 lohnt FASTER_WHISPER_DEVICE=cuda +
FASTER_WHISPER_COMPUTE_TYPE=float16; das verkürzt die Transkriptionszeit pro Turn.
Dritter Hebel — lokales TTS (piper): piper läuft in-process über die piper-Python-API
(im .[local]-Extra). Das Stimmmodell wird einmal geladen und prozessweit gecacht —
früher startete piper als Subprozess pro Satz und zahlte jedes Mal ~2 s Modell-Ladezeit.
Zusätzlich werden lokale Modelle beim Serverstart vorgeladen (Warm-up), sodass auch der
erste Nutzer keinen Kaltstart spürt. Messung (lokales Setup): erster Ton 5,8 s → ~1,6 s.
Höhere Sprachqualität — chatterbox (optional): Für deutlich natürlichere, klonbare
Stimmen gibt es den Provider chatterbox (Resemble AI, eigener HTTP-Dienst auf GPU, siehe
deploy/README.md). Wählbar pro Request/Session via tts_provider=chatterbox (piper bleibt
der schnelle Default). Chatterbox ist neural und ~echtzeit-langsam → besser für Qualität als
für minimale Latenz. Konfig: CHATTERBOX_BASE_URL, CHATTERBOX_VOICE (Referenz-WAV fürs
Cloning), CHATTERBOX_SPEED.
Komplett lokal: Profil local-dev
VA_PROFILE=local-dev betreibt alle KI-Module ohne Cloud. Die Route löst auf zu:
| Modul | Provider | Quelle |
|---|---|---|
| STT | faster-whisper |
lokales Whisper-Modell |
| LLM | local-openai-compatible |
llama.cpp-Server http://127.0.0.1:8001/v1, Modell va_llm |
| TTS | piper |
lokales Stimmmodell |
Voraussetzungen:
- LLM: llama.cpp-Container läuft (
make llm-up) - STT + TTS:
pip install -e .[local](installiert faster-whisper und piper-tts); ein piper-Stimmmodell (<name>.onnx+.onnx.json) imPIPER_VOICES_DIR(siehePIPER_*)
Start (Reihenfolge):
make llm-up # 35B-Modell laden; mit make llm-status auf "HTTP OK" warten
make run # Gateway nutzt jetzt das lokale, unzensierte Modell als zentrale KI
VA_PROFILEist in.envdauerhaft setzbar (aktuelllocal-dev) oder pro Lauf voranstellbar (VA_PROFILE=hybrid make run). Prüfen:curl http://localhost:8080/api/config.
API-Überblick
| Methode & Pfad | Zweck |
|---|---|
GET /health |
Liveness-Check |
POST /api/chat |
Text rein → Audio raus (?debug=true → JSON-Trace) |
POST /api/speak |
Text rein → TTS-Audio raus |
POST /api/transcribe |
Audio-Upload → Transkript |
GET /api/devices |
verfügbare Audio-Endpunkte + Capabilities |
POST /api/sessions/{id}/route |
Geräte/Provider/Sprache je Session setzen |
GET /api/config |
aktives Profil + aufgelöste Route (ohne Secrets) |
POST /api/admin/users |
Nutzer anlegen (Admin-Key) → Token einmalig |
GET /api/me |
aktueller Nutzer + Präferenzen |
PUT /api/me/prefs |
dauerhafte Routing-Präferenzen des Nutzers setzen |
GET/POST/DELETE /api/me/memories |
Langzeit-Erinnerungen des Nutzers verwalten |
WS /ws/chat |
Echtzeit-Chat über WebSocket (Text rein, Streaming-Events) |
WS /ws/voice |
Echtzeit-Sprache (Audio rein → Transkript → Antwort) |
GET /api/metrics |
Metriken (JSON, oder ?format=prometheus) |
Beispiel (Sprachausgabe an den Test-Loopback; piper = lokales TTS):
curl -X POST http://localhost:8080/api/speak \
-H 'Content-Type: application/json' \
-d '{"text":"Guten Morgen!","tts_provider":"piper","output_endpoint":"loopback"}'
Unbekannter Endpunkt/Provider → HTTP 422 mit Klartext-Hinweis.
Gesprächsgedächtnis
Wird bei /api/chat eine session_id mitgegeben, merkt sich der Assistent den
Verlauf: vergangene Turns werden gespeichert und beim nächsten Aufruf ans LLM
gegeben (begrenzt auf die letzten HISTORY_MAX_MESSAGES Nachrichten, Default 10).
Ohne session_id bleibt der Aufruf zustandslos.
curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \
-H 'Content-Type: application/json' -d '{"text":"Ich heiße Anna."}'
curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \
-H 'Content-Type: application/json' -d '{"text":"Wie war noch mein Name?"}'
Langzeit-Erinnerungen (über Sessions hinweg, pro Nutzer) werden über
/api/me/memories gepflegt und bei jedem Chat als Kontext ans LLM gegeben — auch
ohne session_id:
curl -X POST http://localhost:8080/api/me/memories \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"content":"Mag morgens Kamillentee."}'
Automatische Erinnerungen: Zusätzlich zur manuellen Pflege destilliert das LLM
nach je N Turns (Default 3) dauerhafte Fakten/Vorlieben aus dem Gespräch und legt sie
dedupliziert als Erinnerungen ab — best-effort und nicht-blockierend (Hintergrund-Task,
erhöht die Antwortlatenz nicht). Steuerung über MEMORY_EXTRACTION_* (siehe .env.example);
MEMORY_EXTRACTION_ENABLED=false schaltet es ab. Sinnvoll mit einem lokalen LLM, da pro
Turn ein zusätzlicher (kostenloser) Modellaufruf anfällt.
Echtzeit-Chat (WebSocket)
/ws/chat bietet einen dauerhaften, bidirektionalen Kanal. Der Client sendet pro
Turn eine JSON-Nachricht ({"text": "..."}, optional Provider/Endpunkt-Overrides),
der Server streamt strukturierte Events zurück: ack → semantic → Audio (binär)
→ done. Auth (Token-Query ?token=…), Session-Gedächtnis (?session_id=…) und
Erinnerungen gelten wie bei POST /api/chat.
Token-Streaming: Mit {"text": "...", "stream": true} schickt der Server die
LLM-Antwort schon während der Generierung als token-Events — spürbar geringere
wahrgenommene Latenz. OpenRouter und der lokale OpenAI-kompatible Provider streamen
via SSE; Provider ohne Streaming liefern die komplette Antwort als ein token-Event.
Audio-Streaming: Mit {"text": "...", "audio_stream": true} wird das Audio
satzweise erzeugt (chunked TTS) und pro fertigem Satz als audio-Event (JSON
mit seq + binärer Frame) gesendet — die Ausgabe beginnt, bevor die Antwort fertig
ist. stream und audio_stream lassen sich kombinieren.
Sprach-Eingang (/ws/voice): Der Client streamt Mikrofon-Audio als binäre
Frames; ein {"type":"end"}-Control-Frame schließt die Äußerung ab. Der Server
transkribiert (STT), sendet ein transcript-Event und durchläuft dann dieselbe
Antwort-Pipeline wie /ws/chat (inkl. stream/audio_stream). Damit ist
Sprach-zu-Sprach-Konversation über einen Kanal möglich.
VAD (automatische Äußerungserkennung): Mit {"type":"start","vad":true, "sample_rate":16000,"format":"pcm"} segmentiert der Server Äußerungen selbst anhand
von Stille (energie-basiert, reines Python) — ohne explizites end. Optional:
vad_silence_ms, vad_threshold.
Barge-in: Eine laufende Antwort lässt sich mit {"type":"interrupt"} (oder durch
eine neue Eingabe) abbrechen — der Server stoppt das Streaming und meldet
{"type":"interrupted"}. Wichtig für natürliche Gespräche.
Echte partielle Live-Transkripte (Streaming-STT-Dienst, wortweise während des Sprechens) und WebRTC sind als nächste Increments vorgesehen (siehe Architektur-Dokument). Heute läuft STT pro Äußerung.
Resilienz & Metriken
Fallback-Ketten: Pro Modul lässt sich eine Ersatz-Provider-Liste setzen. Fällt der primäre Provider aus (Timeout/Fehler), übernimmt transparent der nächste:
# z. B. Cloud-LLM mit lokalem Fallback
LLM_FALLBACK=local-openai-compatible
STT_FALLBACK=faster-whisper
TTS_FALLBACK=piper
Die Kette ist Route-Provider + *_FALLBACK (dedupliziert). Erfolgreiche Fallbacks
und Provider-Fehler werden gezählt.
Metriken (GET /api/metrics): Request-Counts/-Latenzen pro Pfad, Pipeline-Stufen
(stt/llm/tts), Fallback-/Fehlerzähler — als JSON oder Prometheus-Text
(?format=prometheus). In-Memory pro Prozess (keine externe Dependency).
curl http://localhost:8080/api/metrics
curl http://localhost:8080/api/metrics?format=prometheus
Kontingent & Notfall-Eskalation
Tageskontingent pro Nutzer begrenzt die Kosten (Cloud-LLM/TTS). Bei Überschreitung
HTTP 429 (bzw. error-Event über WebSocket):
DAILY_REQUEST_LIMIT=200 # 0 = unbegrenzt; pro Nutzer/Tag
Pro Nutzer übersteuerbar via prefs.daily_request_limit (siehe PUT /api/me/prefs).
Notfall-Eskalation (zweistufig): /api/chat und /ws/chat prüfen die Nutzereingabe
auf Notlagen-Signale (medizinisch, Selbstgefährdung, Hilferuf — de/en). Bei Treffer
wird der Vorfall protokolliert, optional ein Webhook ausgelöst und das Signal sichtbar
gemacht (X-Emergency-Header / emergency-Feld / WebSocket-emergency-Event). Eine
Notfall-Eingabe umgeht das Kontingent (wird nie geblockt).
- Stichwort-Heuristik im Hot-Path — sofort, ohne Latenz.
- LLM-Klassifikation als Hintergrund-Task, der nur läuft, wenn die Stichwörter
nichts fanden. Fängt verpasste Formulierungen (z. B. metaphorisch geäußerte
Suizidalität oder Schlaganfall-Symptome ohne Schlüsselwort) mit Konfidenz-Schwelle —
ohne die Antwortlatenz zu erhöhen. Eskaliert genauso (Log/Webhook), beim WebSocket
zusätzlich ein nachgelagertes
emergency-Event (source: "llm").
EMERGENCY_WEBHOOK_URL=https://example.org/alert # optional, Benachrichtigung
EMERGENCY_LLM_ENABLED=true # Stufe 2 (Default an); false = nur Stichwörter
EMERGENCY_LLM_MIN_CONFIDENCE=0.6 # Schwelle gegen Fehlalarme
⚠️ Die Erkennung (Heuristik und LLM) ist kein verlässlicher Lebensretter und kein Ersatz für einen echten Notruf. Sie kann Notlagen verpassen oder Fehlalarme auslösen. Erkannte Texte sind hochsensibel (DSGVO: Einwilligung, Aufbewahrung, Zugriff beachten).
Authentifizierung
Standardmäßig (AUTH_ENABLED=true) sind chat/speak/transcribe/sessions/me
durch ein Bearer-Token pro Nutzer geschützt. Nutzer/Sessions werden in SQLite
persistiert (DB_PATH, Default data/voice-assistant.db).
# 1) Nutzer anlegen (Admin-Key aus der Umgebung) — Token erscheint EINMALIG
export ADMIN_API_KEY=ein-langes-geheimnis
curl -X POST http://localhost:8080/api/admin/users \
-H "X-Admin-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"display_name":"Oma Anna"}'
# -> {"user_id":"…","display_name":"Oma Anna","token":"…"}
# 2) Mit dem Token aufrufen
curl http://localhost:8080/api/me -H "Authorization: Bearer <TOKEN>"
Dauerhafte Präferenzen pro Nutzer (PUT /api/me/prefs) fließen in die Route-Auflösung
ein (Ebene zwischen Profil und Session). Fremde Sessions → HTTP 403.
Lokale Entwicklung:
AUTH_ENABLED=falsesetzen — dann gilt ein anonymer Standardnutzer und es ist kein Token nötig.
Tests
make test # oder: pytest -q (offline, mit Stubs)
Abgedeckt: Config-Profile & Präzedenz, Route-Auflösung, Device Router, Auth/Mandanten, Gedächtnis, Streaming, Resilienz, Quota/Notfall.
Echter End-to-End-Test gegen OpenRouter (Netz-Aufrufe, geringe Kosten — prüft LLM, TTS und STT live, inkl. TTS→STT-Round-Trip):
make smoke # oder: python scripts/smoke_e2e.py
Port ändern
PORT=8003 make run # einmalig
sed -i 's/^PORT=.*/PORT=8003/' .env # dauerhaft
PORT=8003 docker compose up # mit Docker
Web-UI & Remote-Zugang
Das Gateway liefert unter / eine Web-Oberfläche aus (app/web/, Tailwind via CDN,
kein Build): responsives, modernes Layout mit Tag-/Nacht-Umschalter (folgt automatisch
dem System), Text-Eingabe + Mikrofon-Button (Aufnahme im Browser → /ws/voice → Antwort
wird vorgelesen), Stimmen-Auswahl (piper/chatterbox/cloud) sowie Identität/Logout und
(für Admins) eine Nutzerliste.
Zugriff aus dem lokalen Netz (LAN)
Der Server lauscht standardmäßig auf 0.0.0.0 (alle Interfaces). Für den Zugriff von
anderen Rechnern/Handys im LAN reichen zwei Dinge:
- Firewall öffnen für den Port (Beispiel ufw, auf die eigenen LAN-Subnetze beschränkt):
sudo ufw allow from 192.168.179.0/24 to any port 8003 proto tcp comment 'voice-assistant LAN' - Im Browser des anderen Geräts die LAN-IP des Servers aufrufen:
http://<server-lan-ip>:8003/.
⚠️ Mikrofon nur über HTTPS/localhost: Browser geben das Mikrofon nur in einem „secure context" frei. Über
http://<lan-ip>:8003funktioniert daher der Text-Chat, aber nicht der Mic-Button. Für Sprache von anderen Geräten den HTTPS-Weg nutzen (siehe unten) — am lokalen Rechner viahttp://localhost:8003geht das Mikrofon.
⚠️ Bei
AUTH_ENABLED=falsekann jeder im LAN den Dienst anonym nutzen. Für mehr als vertrautes Testen Auth aktivieren bzw. den SSO-Weg wählen.
Remote von unterwegs (HTTPS + SSO)
Für den Remote-Betrieb (Handy/Browser von unterwegs) hinter einem Reverse-Proxy mit
HTTPS + SSO (z. B. YunoHost): siehe deploy/README.md. Kernpunkte:
- HTTPS ist Pflicht — Browser geben das Mikrofon nur im „secure context" frei.
- Forward-/Trusted-Header-Auth: der Proxy/SSO authentifiziert, reicht die Identität
per Header durch (
TRUSTED_AUTH_HEADER); das Gateway legt Nutzer automatisch an. Akzeptiert wird der Header nur von der Proxy-Quell-IP (TRUSTED_PROXY_IPS). - WebSocket-Upgrade im nginx nicht vergessen (sonst kein Mikrofon).
Deployment
- Docker:
docker compose up --build(reichtOPENROUTER_API_KEYaus der Shell durch) - systemd: Vorlagen unter
deploy/(voice-assistant.service,voice-assistant.env.example) - Remote über YunoHost/Reverse-Proxy:
deploy/README.md(HTTPS, SSO, nginx, Firewall)
Projektstruktur (Kurzform)
app/ Gateway: config, dependencies, api/, core/, audio/, pipeline/, providers/
config/ voice-assistant.example.toml (lokale .toml ist gitignored)
deploy/ systemd-Unit + env-Beispiel
tests/ Pytest-Suite
Docs/ Architektur-Dokument
Lizenz / Status
Frühes, aktiv entwickeltes Projektgerüst. Audio-Hardware-/Streaming-Anbindung, Authentifizierung, Persistenz und Gedächtnis sind als nächste Schritte vorgesehen (siehe Roadmap im Architektur-Dokument).