- neuer Flag --stream-audio setzt audio_stream:true im /ws/voice-Start-Frame - durchgehender Roh-PCM-Player (ffplay/aplay/paplay), Haeppchen werden sofort eingespeist -> Antwort beginnt nach dem ersten Satz, nicht erst nach der ganzen - Schreiben via asyncio.to_thread (Event-Loop bleibt frei -> kein Keepalive-Timeout); Player wird nach Verbindungsschluss geleert - ohne Flag unveraendert (komplettes Audio nach Verbindungsschluss) - Doku-Hinweis ergaenzt; live verifiziert (STT lokal + LLM lokal + TTS remote, satzweise) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
19 KiB
Bedienungsanleitung — Voice Assistant Gateway
Schritt-für-Schritt-Anleitung zum Ausprobieren: mit dem Assistenten sprechen, Einstellungen ändern und Praxis-Tests mit Reaktionszeiten. Technische Hintergründe: Architektur-Dokument, Kurzüberblick: README.
Tipp: Alle Befehle, die JSON liefern, enden hier auf
| jq(hübsche, lesbare Ausgabe). Dafürjqinstallieren:sudo apt install jq. Befehle, die Audio liefern, schreiben in eine Datei und spielen sie ab (keinjq).
In den Beispielen wird die Adresse als Variable genutzt — einmal setzen, dann überall einsetzbar (Port aus deiner
.env, hier8003):export URL=http://localhost:8003
Einrichtung
1. Voraussetzungen
- Python 3.11+ (
python3 --version) - jq für lesbare JSON-Ausgabe (
sudo apt install jq) - Für den Sprech-Loop:
arecord(Paketalsa-utils) und ein Player (ffplay/aplay/paplay) — auf den meisten Linux-Desktops vorhanden - OpenRouter-API-Key — nötig für Profile mit Cloud-KI (
hybrid,cloud); für rein lokalen Betrieb (local-dev) nicht - Für lokales STT (Provider
faster-whisper): einmaligpip install -e .[local](lädt beim ersten Lauf ein Whisper-Modell). Für lokales LLM: ein laufender Ollama-Server (http://127.0.0.1:11434) mit einem Modell (ollama pull llama3.2) - Optional: Docker
2. Installation
cd voice-assistant-scaffold
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
3. API-Key hinterlegen (für Cloud/Hybrid)
Der Schlüssel wird aus der Umgebung gelesen, nie aus einer Datei:
echo 'export OPENROUTER_API_KEY=sk-or-v1-DEIN_KEY' >> ~/.bashrc
chmod 600 ~/.bashrc
source ~/.bashrc
echo ${OPENROUTER_API_KEY:0:8} # zeigt nur den Anfang zur Kontrolle
Sicherheit: Key nie in
.env/config/*.toml. Bei Leak im OpenRouter-Dashboard löschen (= widerrufen) und neu erzeugen.
4. Profil (Betriebsart) wählen
| Profil | Bedeutung | Key nötig? |
|---|---|---|
local-dev |
alles lokal (eigene KI) | nein |
hybrid |
STT/TTS Cloud, Haupt-LLM lokal | ja |
cloud |
alles über OpenRouter (Standard) | ja |
Dauerhaft in .env: VA_PROFILE=cloud — oder einmalig: VA_PROFILE=cloud make run.
5. Starten und Stoppen
make run # startet im Vordergrund (Port aus .env, hier 8003)
Beenden mit Strg + C. Schnelltest in einem zweiten Terminal:
curl -s $URL/health | jq
curl -s $URL/api/config | jq
Im Hintergrund (Logs in Datei):
nohup make run > server.log 2>&1 & # starten
pkill -f "uvicorn app.main:app" # stoppen
Docker: export OPENROUTER_API_KEY=…; docker compose up --build.
Port ändern: PORT=8005 make run (einmalig) bzw. PORT= in .env (dauerhaft).
Teil A — Mit dem Assistenten sprechen
A1. Sprech-Loop: sprechen → hören → erneut sprechen (empfohlen)
Der mitgelieferte Helfer nimmt vom Mikrofon auf, schickt die Aufnahme an das Gateway und spielt die Antwort ab — fortlaufend, mit Gedächtnis:
source .venv/bin/activate
python scripts/voice_loop.py --session mein-gespraech
Ablauf je Runde:
- [Enter] drücken → sprechen (z. B. „Guten Tag, wie heißt du?")
- [Enter] drücken → Aufnahme stoppt; der Assistent antwortet hörbar
- wieder [Enter] → erneut sprechen; der Verlauf bleibt erhalten
- Strg + C → Loop beenden
Nützliche Optionen:
python scripts/voice_loop.py --stream-audio # satzweises Vorlesen: Antwort beginnt frueher
python scripts/voice_loop.py --recorder pw-record # PipeWire-Aufnahme (Standard bei 'auto')
python scripts/voice_loop.py --recorder arecord --device hw:1,0 # ALSA, bestimmtes Mikrofon
python scripts/voice_loop.py --llm-provider openrouter --tts-provider openrouter
python scripts/voice_loop.py --token "$TOKEN" # falls AUTH_ENABLED=true
python scripts/voice_loop.py --file frage.wav # ohne Mikrofon: WAV senden (Test)
Aufnahmewerkzeug:
--recorder auto(Standard) bevorzugt PipeWire (pw-record), sonstparecord/arecord. Falls die Aufnahme scheitert (z. B. „pw_context_connect() failed" oder „Fehler beim Öffnen des Gerätes"), ist der verlässlichste Weg ein direktes ALSA-Hardware-Gerät:arecord -l # Kartennummern der Mikrofone python scripts/voice_loop.py --recorder arecord --device plughw:2,0(
plughw:KARTE,GERÄTausarecord -l; z. B. onboard oft Karte 2, USB-Webcam Karte 4.)
A2. Nur tippen → Antwort hören
python chat_client.py "Erzähl mir bitte einen guten Morgen-Spruch"
Spielt die gesprochene Antwort ab (erwartet Port 8003).
A3. Einzelschritte verstehen (manueller Loop)
Pro Gesprächsrunde drei Schritte — gut, um die Pipeline zu verstehen:
# 1) Aufnehmen (Strg+C zum Stoppen)
arecord -f S16_LE -r 16000 -c 1 frage.wav
# 2) Transkribieren (Audio rein -> Text raus)
curl -s -X POST $URL/api/transcribe \
-F "file=@frage.wav" -F "language=de" -F "stt_provider=openrouter" | jq
# 3) Antwort erzeugen (Text rein -> Audio raus) und abspielen
curl -s -X POST "$URL/api/chat?session_id=loop" \
-H 'Content-Type: application/json' \
-d '{"text":"Guten Tag, wie heißt du?"}' --output antwort.pcm
ffplay -loglevel quiet -nodisp -autoexit -f s16le -ar 24000 -ac 1 antwort.pcm
# alternativ: aplay -f S16_LE -r 24000 -c 1 antwort.pcm
A4. Einzelne Bausteine direkt aufrufen
# Nur Sprachausgabe (Text -> Audio):
curl -s -X POST $URL/api/speak \
-H 'Content-Type: application/json' \
-d '{"text":"Guten Morgen, wie geht es Ihnen?"}' --output gruss.pcm
ffplay -loglevel quiet -nodisp -autoexit -f s16le -ar 24000 -ac 1 gruss.pcm
# Chat als Text-Trace (ohne Audio), schön lesbar:
curl -s -X POST "$URL/api/chat?debug=true" \
-H 'Content-Type: application/json' \
-d '{"text":"Wie wird das Wetter morgen?"}' | jq
A5. Weitere Features
- Fortlaufendes Gespräch (Gedächtnis):
?session_id=nameanhängen — der Verlauf fließt in die nächste Antwort. Ohnesession_idist jeder Aufruf eigenständig. Wie viele Nachrichten einfließen, steuertHISTORY_MAX_MESSAGES(Standard 10). - Echtzeit-Streaming: WebSocket
/ws/chatmit{"text":"…","stream":true}liefert die Antwort wortweise;"audio_stream":truezusätzlich das Audio satzweise. - Unterbrechen (Barge-in): während der Assistent spricht
{"type":"interrupt"}senden → laufende Antwort wird abgebrochen. - Automatische Sprechpausen-Erkennung (VAD): im Start-Frame von
/ws/voice{"type":"start","vad":true,"format":"pcm","sample_rate":16000}→ kein manuelles Ende nötig. - Notfall-Erkennung: Bei Notlagen-Signalen („Schmerzen in der Brust", „gestürzt"…) wird eskaliert (Details siehe Teil 9). ⚠️ Nur Heuristik, kein Notruf-Ersatz.
Teil B — Einstellungen ändern (User / Entwickler / Admin)
B1. Software / KI wechseln (lokal ↔ remote) — wirkt sofort
Welche KI (STT/LLM/TTS, lokal oder über die Cloud) genutzt wird, lässt sich auf mehreren Ebenen festlegen. Höhere Ebene gewinnt:
| Ebene | Wer | Wie | Beispiel |
|---|---|---|---|
| Profil/Global | Admin/Entwickler | VA_PROFILE bzw. .env |
VA_PROFILE=hybrid |
| Fallback | Admin | *_FALLBACK in .env |
LLM_FALLBACK=local-openai-compatible |
| Pro Nutzer | User/Admin | PUT /api/me/prefs |
{"llm_provider":"openrouter"} |
| Pro Session | User | POST /api/sessions/{id}/route |
{"tts_provider":"piper"} |
| Pro Aufruf | User | Felder im Request-Body | {"text":"…","llm_provider":"openrouter"} |
# Verfügbare Provider + aktuell aufgelöste Auswahl ansehen:
curl -s $URL/api/config | jq '{profile, default_route, available}'
# Pro Aufruf umschalten (hier: lokales TTS statt Cloud):
curl -s -X POST "$URL/api/chat?debug=true" \
-H 'Content-Type: application/json' \
-d '{"text":"Test","llm_provider":"openrouter","tts_provider":"piper"}' | jq '.route'
# Pro Session dauerhaft (gilt für alle Aufrufe mit dieser session_id):
curl -s -X POST $URL/api/sessions/oma-anna/route \
-H 'Content-Type: application/json' \
-d '{"llm_provider":"openrouter","language":"de"}' | jq
Profil global umschalten (Entwickler/Admin): VA_PROFILE=local-dev make run.
Hybrid-Beispiel (Aufnahme + STT + LLM lokal, nur TTS remote):
# einmalig: lokales STT installieren
pip install -e .[local]
# Server mit kleinem lokalem Ollama-Modell (muss in 'ollama list' stehen):
echo 'LOCAL_LLM_MODEL=llama3.2:latest' >> .env
make run
# in Terminal 2 — Sprech-Loop mit der Hybrid-Kombi (MOTU = plughw:5,0):
python scripts/voice_loop.py --recorder arecord --device plughw:5,0 --session hybrid \
--stt-provider faster-whisper \
--llm-provider local-openai-compatible \
--tts-provider openrouter
Erster Turn ist langsamer (Whisper- und Ollama-Modell laden), danach zügig. STT-Modell
und Gerät steuern FASTER_WHISPER_MODEL/FASTER_WHISPER_DEVICE in .env.
B2. Soundquelle & Ausgabe-Gerät wechseln (Mikrofon, Lautsprecher, Bluetooth, Handy)
Wichtig — aktueller Stand: Die Geräte-Endpunkte im Gateway (
input_endpoint/output_endpoint) sind die Auswahl-/Routing-Ebene (sie werden validiert und in/api/devicesaufgelistet), aber die eigentlichen Gerätetreiber sind noch Platzhalter — es fließt also noch kein echtes Geräte-Audio durch das Gateway. Welches Mikrofon/welcher Lautsprecher/welches Bluetooth-Gerät tatsächlich genutzt wird, steuerst du heute auf Betriebssystem-Ebene (bei Aufnahme/Wiedergabe).
Welche Geräte gibt es?
arecord -L # Eingabegeräte (Mikrofone)
aplay -L # Ausgabegeräte (Lautsprecher/Kopfhörer)
Mikrofon (Quelle) wählen:
python scripts/voice_loop.py --device hw:1,0 # Helfer mit bestimmtem Mikrofon
arecord -D hw:1,0 -f S16_LE -r 16000 -c 1 frage.wav # manuell
Lautsprecher/Kopfhörer (Ausgabe) wählen:
aplay -D hw:0,0 -f S16_LE -r 24000 -c 1 antwort.pcm
Bluetooth / Standardgerät (PipeWire/PulseAudio): Gerät am System koppeln und als
Standard setzen — dann nutzen arecord/aplay/voice_loop.py automatisch dieses
Gerät. Grafisch mit pavucontrol, per Kommandozeile z. B. mit wpctl status /
wpctl set-default <id>.
Gateway-Endpunkt-Auswahl (Routing-Ebene, vorbereitet):
curl -s $URL/api/devices | jq '{inputs:[.inputs[].kind], outputs:[.outputs[].kind]}'
# Auswahl mitgeben (wird validiert; echtes Geräte-Audio folgt erst mit echten Treibern):
curl -s -X POST $URL/api/sessions/oma-anna/route \
-H 'Content-Type: application/json' \
-d '{"input_endpoint":"bluetooth","output_endpoint":"local-default"}' | jq
Ein unbekannter Endpunkt führt zu HTTP 422. Roadmap: echte Geräte-Endpunkte
(PipeWire/Bluetooth/Handy) sind der nächste Ausbauschritt.
B3. Sprache wechseln
Global DEFAULT_LANGUAGE=de in .env, pro Nutzer via PUT /api/me/prefs, pro Session
via Route, oder pro Aufruf {"text":"…","language":"en"}.
Teil C — Praxis-Tests & Reaktionszeiten
C1. Funktioniert alles? (echter Live-Check)
make smoke
Prüft LLM, TTS und STT live gegen OpenRouter (geringe Kosten) und meldet pro Modul
[OK]/[FAIL] — inkl. TTS→STT-Round-Trip. Braucht OPENROUTER_API_KEY.
C2. Reaktionszeiten messen
Pro Aufruf die Gesamtzeit anzeigen (curl -w):
# Sprachausgabe (TTS):
curl -s -o gruss.pcm -w "TTS: %{time_total}s, %{size_download} Bytes\n" \
-X POST $URL/api/speak -H 'Content-Type: application/json' \
-d '{"text":"Guten Tag, wie kann ich Ihnen helfen?"}'
# Transkription (STT):
curl -s -o /dev/null -w "STT: %{time_total}s\n" \
-X POST $URL/api/transcribe -F "file=@frage.wav" -F "language=de"
# Chat-Antworttext (LLM, TTS auf Stub isoliert):
curl -s -o /dev/null -w "LLM: %{time_total}s\n" \
-X POST "$URL/api/chat?debug=true" -H 'Content-Type: application/json' \
-d '{"text":"Sag einen kurzen Gruss.","tts_provider":"piper"}'
Durchschnitt der Pipeline-Stufen serverseitig:
curl -s $URL/api/metrics | jq '.timers | to_entries
| map(select(.key|test("stage_duration")))
| map({stufe:.key, sekunden:.value.avg})'
Gemessene Richtwerte (Profil cloud, gegen OpenRouter, Stand 2026-06-17):
| Stufe | Modell | ~Zeit |
|---|---|---|
| STT | whisper-large-v3 |
~1,2 s |
| LLM | gemini-3.1-flash-lite |
~0,7 s |
| TTS | gemini-3.1-flash-tts |
~1,9 s |
| Sprach-Round-Trip (STT→LLM→TTS) | — | ~4 s |
Werte schwanken mit Netz, Textlänge, Modell und Region; der erste Aufruf ist oft langsamer (Verbindungsaufbau). Mit
stream/audio_stream(Teil A5) sinkt die wahrgenommene Wartezeit deutlich, weil schon vor Fertigstellung Text/Audio kommt.
C3. Welche Konstellation für welchen Use-Case?
| Use-Case | Empfehlung | Begründung |
|---|---|---|
| Senioren-Standard (kein KI-Rechner zuhause) | Profil cloud |
beste Qualität/Latenz ohne lokale Hardware (~4 s Round-Trip) |
| Datenschutz / offline | local-dev |
alles lokal — benötigt echte lokale Modelle (heute Platzhalter) |
| Kosten/Ausfallsicherheit | hybrid + *_FALLBACK |
teure Teile lokal, Rest Cloud; automatischer Fallback |
Empfehlung für den Einstieg: cloud verwenden, Antwortzeiten mit C2 prüfen, dann
bei Bedarf einzelne Module umstellen (Teil B1).
C4. Empfohlene Top-Konstellation (reproduzierbar)
Bewährte Konstellation mit sehr guter Sprachqualität (beherrscht u. a. Plattdeutsch) —
alles remote über OpenRouter (Profil cloud), nichts lokal:
| Stufe | Modell | Anbieter |
|---|---|---|
| STT | openai/whisper-large-v3 |
OpenRouter (remote) |
| LLM | google/gemini-3.1-flash-lite |
OpenRouter (remote) |
| TTS | google/gemini-3.1-flash-tts-preview (Stimme Zephyr) |
OpenRouter (remote) |
So reproduzierst du sie — in .env:
VA_PROFILE=cloud
OPENROUTER_STT_MODEL=openai/whisper-large-v3
OPENROUTER_LLM_MODEL=google/gemini-3.1-flash-lite
OPENROUTER_TTS_MODEL=google/gemini-3.1-flash-tts-preview
OPENROUTER_TTS_VOICE=Zephyr
Key via Umgebung (OPENROUTER_API_KEY). Dann ohne Provider-Overrides starten:
make run
python scripts/voice_loop.py --recorder arecord --device plughw:5,0 --session test
Grobe Kosten (Daumenwert, am OpenRouter-Dashboard verifizieren — Preise ändern sich):
| Stufe | Annahme | ~Kosten/Runde |
|---|---|---|
| STT (Whisper) | ~$0,006/Audio-Min, ~15 s | ~0,15 ¢ |
| LLM (Flash-Lite) | ~800 in / ~150 out Tokens | ~0,01 ¢ (vernachlässigbar) |
| TTS (Flash-TTS) | ~$0,01–0,03/Audio-Min, ~30 s | ~0,5–1,5 ¢ |
| Summe | ≈ 1–2 ¢ pro Sprech-Runde |
→ grob ~20–40 ¢ pro 10-Minuten-Gespräch, ~1–2 $/Stunde. Kostentreiber ist das Audio (TTS, dann STT); das LLM ist nahezu kostenlos. Verlässliche Zahlen liefert das OpenRouter-Dashboard (Activity/Usage).
Betrieb & Verwaltung
9. Authentifizierung, Kontingent & Notfall
Auth (Mehrbenutzer): Standard AUTH_ENABLED=true → geschützte Endpunkte brauchen
ein Bearer-Token pro Nutzer. (In der mitgelieferten .env ist es für die
Entwicklung auf false — dann ohne Token.)
export ADMIN_API_KEY=ein-langes-geheimnis # Server muss damit laufen
# Nutzer anlegen (Token erscheint NUR einmal):
curl -s -X POST $URL/api/admin/users \
-H "X-Admin-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' -d '{"display_name":"Oma Anna"}' | jq
# Mit Token nutzen:
TOKEN=<token-von-oben>
curl -s $URL/api/me -H "Authorization: Bearer $TOKEN" | jq
Langzeit-Erinnerungen (dauerhafte Fakten, gelten über alle Gespräche):
curl -s -X POST $URL/api/me/memories -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"content":"Mag morgens Kamillentee."}' | jq
curl -s $URL/api/me/memories -H "Authorization: Bearer $TOKEN" | jq
Tageskontingent (Kostenbremse) in .env: DAILY_REQUEST_LIMIT=200 (0 = unbegrenzt).
Überschreitung → HTTP 429. Notfall-Eingaben werden nie blockiert.
Notfall-Eskalation: Erkennt der Dienst ein Notlagen-Signal, protokolliert er es,
macht es sichtbar (X-Emergency / emergency-Event) und ruft optional einen Webhook
auf (EMERGENCY_WEBHOOK_URL). ⚠️ Nur eine Heuristik — kein Ersatz für einen echten
Notruf; erkannte Texte sind sensibel (Datenschutz/Einwilligung beachten).
10. Resilienz & Metriken
# Fallback je Modul (in .env): Provider fällt aus -> nächster übernimmt
LLM_FALLBACK=local-openai-compatible
# Metriken (Requests, Latenzen, Stufen, Fallback/Fehler):
curl -s $URL/api/metrics | jq
curl -s "$URL/api/metrics?format=prometheus" # Prometheus-Text (kein jq)
11. Fehlerbehebung
| Symptom | Ursache | Lösung |
|---|---|---|
OPENROUTER_API_KEY is empty |
Key nicht in der Umgebung | export OPENROUTER_API_KEY=…, neues Terminal / source ~/.bashrc |
| HTTP 401 (Bearer/Invalid token) | Auth an, Token fehlt/falsch | Token im Header, oder AUTH_ENABLED=false für dev |
HTTP 401 bei /api/admin/users |
falscher/fehlender Admin-Key | X-Admin-Key = ADMIN_API_KEY |
HTTP 403 bei ?session_id=… |
Session gehört anderem Nutzer | eigene session_id verwenden |
| HTTP 429 | Tageskontingent erreicht | DAILY_REQUEST_LIMIT erhöhen / Folgetag |
| HTTP 422 „Unbekannter Provider/Endpunkt" | Tippfehler | gültige Werte via curl -s $URL/api/config | jq |
| HTTP 502 bei STT/TTS | Cloud-Fehler/Format | make smoke ausführen; Modellnamen in .env prüfen |
VA_PROFILE wirkt nicht |
DEFAULT_*_PROVIDER in .env überschreibt |
diese Zeilen in .env auskommentieren |
Address already in use |
Port belegt | anderen PORT setzen |
pw_context_connect() failed / arecord: Fehler beim Öffnen des Gerätes |
PipeWire-Client- bzw. ALSA-default-Pfad gestört |
direktes Gerät nehmen: arecord -l, dann --recorder arecord --device plughw:2,0 |
| Keine Aufnahme/Wiedergabe | Werkzeug/Gerät fehlt | arecord -L / aplay -L; Pakete pipewire/alsa-utils/ffmpeg; Default via wpctl status |
| Profil greift nicht | config/voice-assistant.toml fehlt |
aus *.example.toml kopieren (Abschnitt 2) |
Logs erscheinen im Terminal von make run; mehr Details mit LOG_LEVEL=debug in .env.
12. Automatisierte Tests
make test # offline (mit Platzhaltern) — schnell, kostenlos
make smoke # echter Live-Check gegen OpenRouter (LLM/TTS/STT)