From 293ed257dbe4ddf1d79155b336db9636dfd51bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 01:48:56 +0200 Subject: [PATCH] Initial commit: Voice Assistant Gateway mit Konfig-/Routing-Fundament - FastAPI-Gateway mit REST-Endpunkten (chat/speak/transcribe/devices/sessions/config) - Geschichtete Konfiguration mit Profilen (local-dev/hybrid/cloud) via TOML + ENV - Registry-Pattern + einheitliche Route-Aufloesung (Default->Profil->ENV->Session->Request) - Device Router (strikt, Singleton) und Output-Lifecycle im Orchestrator - OpenRouter-Adapter (STT multipart/LLM/TTS) + lokale Provider-Stubs - Regelbasierte Pipeline (Cleaner/Spoken-Adapter/TTS-Normalizer) - 22 automatisierte Tests; Doku: README, BEDIENUNGSANLEITUNG, Architektur - Secrets ausschliesslich ueber Umgebung; .env und lokale config gitignored Co-Authored-By: Claude Opus 4.8 --- .env.example | 31 +++ .gitignore | 21 ++ BEDIENUNGSANLEITUNG.md | 223 +++++++++++++++++++ Dockerfile | 7 + Docs/voice-assistant-architecture.md | 220 ++++++++++++++++++ Makefile | 28 +++ README.md | 130 +++++++++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/chat.py | 92 ++++++++ app/api/config.py | 37 +++ app/api/devices.py | 12 + app/api/health.py | 7 + app/api/sessions.py | 10 + app/api/speak.py | 60 +++++ app/api/transcribe.py | 50 +++++ app/audio/__init__.py | 0 app/audio/endpoints/__init__.py | 0 app/audio/endpoints/input/__init__.py | 0 app/audio/endpoints/input/base.py | 17 ++ app/audio/endpoints/input/bluetooth.py | 23 ++ app/audio/endpoints/input/local_default.py | 24 ++ app/audio/endpoints/input/mobile_webrtc.py | 25 +++ app/audio/endpoints/input/mobile_ws.py | 24 ++ app/audio/endpoints/output/__init__.py | 0 app/audio/endpoints/output/base.py | 20 ++ app/audio/endpoints/output/bluetooth.py | 26 +++ app/audio/endpoints/output/local_default.py | 26 +++ app/audio/endpoints/output/loopback.py | 28 +++ app/audio/endpoints/output/mobile_webrtc.py | 27 +++ app/audio/endpoints/output/mobile_ws.py | 27 +++ app/audio/router.py | 38 ++++ app/audio/transport_router.py | 11 + app/config.py | 146 ++++++++++++ app/core/__init__.py | 0 app/core/orchestrator.py | 105 +++++++++ app/core/session_manager.py | 11 + app/dependencies.py | 187 ++++++++++++++++ app/errors.py | 13 ++ app/main.py | 17 ++ app/pipeline/__init__.py | 0 app/pipeline/input_cleaner.py | 4 + app/pipeline/spoken_response_adapter.py | 37 +++ app/pipeline/tts_normalizer.py | 59 +++++ app/providers/__init__.py | 0 app/providers/llm/__init__.py | 0 app/providers/llm/base.py | 5 + app/providers/llm/local_openai_compatible.py | 24 ++ app/providers/llm/openrouter.py | 101 +++++++++ app/providers/stt/__init__.py | 0 app/providers/stt/base.py | 5 + app/providers/stt/faster_whisper.py | 5 + app/providers/stt/openrouter.py | 46 ++++ app/providers/tts/__init__.py | 0 app/providers/tts/base.py | 5 + app/providers/tts/chatterbox.py | 5 + app/providers/tts/openrouter.py | 62 ++++++ app/providers/tts/piper.py | 5 + app/schemas.py | 71 ++++++ app/utils/__init__.py | 0 chat_client.py | 76 +++++++ config/voice-assistant.example.toml | 44 ++++ deploy/voice-assistant.env.example | 3 + deploy/voice-assistant.service | 15 ++ docker-compose.yml | 15 ++ pyproject.toml | 28 +++ tests/conftest.py | 22 ++ tests/test_audio_router.py | 43 ++++ tests/test_basic_layout.py | 7 + tests/test_config_profiles.py | 60 +++++ tests/test_endpoints_e2e.py | 96 ++++++++ tests/test_routing.py | 46 ++++ 72 files changed, 2612 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 BEDIENUNGSANLEITUNG.md create mode 100644 Dockerfile create mode 100644 Docs/voice-assistant-architecture.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/chat.py create mode 100644 app/api/config.py create mode 100644 app/api/devices.py create mode 100644 app/api/health.py create mode 100644 app/api/sessions.py create mode 100644 app/api/speak.py create mode 100644 app/api/transcribe.py create mode 100644 app/audio/__init__.py create mode 100644 app/audio/endpoints/__init__.py create mode 100644 app/audio/endpoints/input/__init__.py create mode 100644 app/audio/endpoints/input/base.py create mode 100644 app/audio/endpoints/input/bluetooth.py create mode 100644 app/audio/endpoints/input/local_default.py create mode 100644 app/audio/endpoints/input/mobile_webrtc.py create mode 100644 app/audio/endpoints/input/mobile_ws.py create mode 100644 app/audio/endpoints/output/__init__.py create mode 100644 app/audio/endpoints/output/base.py create mode 100644 app/audio/endpoints/output/bluetooth.py create mode 100644 app/audio/endpoints/output/local_default.py create mode 100644 app/audio/endpoints/output/loopback.py create mode 100644 app/audio/endpoints/output/mobile_webrtc.py create mode 100644 app/audio/endpoints/output/mobile_ws.py create mode 100644 app/audio/router.py create mode 100644 app/audio/transport_router.py create mode 100644 app/config.py create mode 100644 app/core/__init__.py create mode 100644 app/core/orchestrator.py create mode 100644 app/core/session_manager.py create mode 100644 app/dependencies.py create mode 100644 app/errors.py create mode 100644 app/main.py create mode 100644 app/pipeline/__init__.py create mode 100644 app/pipeline/input_cleaner.py create mode 100644 app/pipeline/spoken_response_adapter.py create mode 100644 app/pipeline/tts_normalizer.py create mode 100644 app/providers/__init__.py create mode 100644 app/providers/llm/__init__.py create mode 100644 app/providers/llm/base.py create mode 100644 app/providers/llm/local_openai_compatible.py create mode 100644 app/providers/llm/openrouter.py create mode 100644 app/providers/stt/__init__.py create mode 100644 app/providers/stt/base.py create mode 100644 app/providers/stt/faster_whisper.py create mode 100644 app/providers/stt/openrouter.py create mode 100644 app/providers/tts/__init__.py create mode 100644 app/providers/tts/base.py create mode 100644 app/providers/tts/chatterbox.py create mode 100644 app/providers/tts/openrouter.py create mode 100644 app/providers/tts/piper.py create mode 100644 app/schemas.py create mode 100644 app/utils/__init__.py create mode 100644 chat_client.py create mode 100644 config/voice-assistant.example.toml create mode 100644 deploy/voice-assistant.env.example create mode 100644 deploy/voice-assistant.service create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 tests/conftest.py create mode 100644 tests/test_audio_router.py create mode 100644 tests/test_basic_layout.py create mode 100644 tests/test_config_profiles.py create mode 100644 tests/test_endpoints_e2e.py create mode 100644 tests/test_routing.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5f554e4 --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Port bei Bedarf anpassen +APP_ENV=dev +HOST=0.0.0.0 +PORT=8080 +LOG_LEVEL=info + +# Secret nur ueber die Umgebung setzen (nicht hier eintragen), z. B. export in ~/.bashrc +OPENROUTER_API_KEY= + +# --- Zentrale Konfiguration / Profile ------------------------------------- +# Aktives Profil aus config/voice-assistant.toml waehlen: local-dev | hybrid | cloud +# (leer lassen = nur Defaults/ENV). Eigener Pfad via VA_CONFIG_FILE. +VA_PROFILE= +# VA_CONFIG_FILE=config/voice-assistant.toml + +# Hinweis zur Praezedenz: ENV gewinnt ueber die TOML-Datei. Die DEFAULT_*_PROVIDER- +# Zeilen unten ueberschreiben daher ein gesetztes VA_PROFILE. Wer profilbasiert +# umschalten will, sollte sie auskommentiert lassen. +OPENROUTER_STT_MODEL=openai/whisper-large-v3 +OPENROUTER_TTS_MODEL=openai/gpt-4o-mini-tts +OPENROUTER_TTS_VOICE=alloy +OPENROUTER_LLM_MODEL=openai/gpt-4.1-mini +DEFAULT_LANGUAGE=de +DEFAULT_INPUT_ENDPOINT=local-default +DEFAULT_OUTPUT_ENDPOINT=local-default +# DEFAULT_STT_PROVIDER=openrouter +# DEFAULT_LLM_PROVIDER=local-openai-compatible +# DEFAULT_TTS_PROVIDER=openrouter +LOCAL_LLM_BASE_URL=http://127.0.0.1:11434/v1 +LOCAL_LLM_API_KEY=dummy +LOCAL_LLM_MODEL=llama3.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8cfe85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Secrets / lokale Konfiguration +.env + +# Lokale/instanzspezifische Konfiguration (nur die *.example.toml wird versioniert) +config/voice-assistant.toml + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.pytest_cache/ + +# Lokale Tool-/Editor-Konfiguration +.claude/ + +# Editor-/Backup-Reste +*.bak +*.patch +*.orig diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md new file mode 100644 index 0000000..6f5d1ee --- /dev/null +++ b/BEDIENUNGSANLEITUNG.md @@ -0,0 +1,223 @@ +# Bedienungsanleitung — Voice Assistant Gateway + +Diese Anleitung führt Schritt für Schritt durch Installation, Start, Konfiguration +und Fehlerbehebung. Technische Hintergründe stehen im +[Architektur-Dokument](Docs/voice-assistant-architecture.md), eine kompakte +Übersicht im [README](README.md). + +--- + +## 1. Voraussetzungen + +- **Python 3.11 oder neuer** (`python3 --version`) +- Ein **OpenRouter-API-Key** — nur nötig, wenn ein Profil entfernte KI nutzt + (`hybrid`, `cloud`). Für rein lokalen Betrieb (`local-dev`) nicht erforderlich. +- Optional: Docker, falls im Container betrieben. + +--- + +## 2. Installation + +```bash +cd voice-assistant-scaffold +python3 -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -e .[test] +``` + +Danach die zentrale Konfigurationsdatei anlegen: + +```bash +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 und gehört **nicht** in eine Datei. +Dauerhaft am besten in `~/.bashrc`: + +```bash +echo 'export OPENROUTER_API_KEY=sk-or-v1-DEIN_KEY' >> ~/.bashrc +chmod 600 ~/.bashrc +source ~/.bashrc +``` + +Prüfen, ob er ankommt: + +```bash +echo ${OPENROUTER_API_KEY:0:8} # zeigt nur den Anfang +``` + +> **Sicherheit:** Den Key niemals in `.env` oder `config/*.toml` schreiben. Wird ein +> Key versehentlich öffentlich, im OpenRouter-Dashboard löschen (= widerrufen) und +> neu erzeugen. + +--- + +## 4. Betriebsart (Profil) wählen + +Profile bestimmen, welche KI-Module genutzt werden: + +| Profil | Bedeutung | Key nötig? | +|-------------|--------------------------------------------|------------| +| `local-dev` | alles lokal (eigene KI/Hardware) | nein | +| `hybrid` | STT/TTS über Cloud, Haupt-LLM lokal | ja | +| `cloud` | alles über OpenRouter (Standardbetrieb) | ja | + +Profil **einmalig** für einen Start: + +```bash +VA_PROFILE=cloud make run +``` + +Profil **dauerhaft** — in `.env` eintragen: + +``` +VA_PROFILE=cloud +``` + +> Hinweis: Stehen in `.env` noch `DEFAULT_STT_PROVIDER` / `DEFAULT_LLM_PROVIDER` / +> `DEFAULT_TTS_PROVIDER`, überschreiben diese das Profil. Für profilbasiertes +> Umschalten sollten sie auskommentiert sein. + +--- + +## 5. Starten und Stoppen + +```bash +make run +``` + +Standard-Adresse: `http://localhost:8080` (Port änderbar, siehe Abschnitt 8). +Beenden mit **Strg + C**. + +Schnelltest in einem zweiten Terminal: + +```bash +curl http://localhost:8080/health +# {"status":"ok"} + +curl http://localhost:8080/api/config +# zeigt aktives Profil und die aufgelöste Standard-Route +``` + +--- + +## 6. Tägliche Bedienung — typische Aufgaben + +### a) Text sprechen lassen (`/api/speak`) + +```bash +curl -X POST http://localhost:8080/api/speak \ + -H 'Content-Type: application/json' \ + -d '{"text":"Guten Morgen, wie geht es Ihnen?"}' \ + --output antwort.pcm +``` + +### b) Chatten (Text rein, gesprochene Antwort raus) (`/api/chat`) + +Nur den Trace als JSON ansehen (ohne Audio): + +```bash +curl -X POST "http://localhost:8080/api/chat?debug=true" \ + -H 'Content-Type: application/json' \ + -d '{"text":"Wie wird das Wetter morgen?"}' +``` + +Komfortabler mit dem mitgelieferten Client (spielt die Antwort ab): + +```bash +python chat_client.py "Erzähl mir einen guten Morgen-Spruch" +``` + +> `chat_client.py` erwartet den Dienst auf Port **8003** — bei Bedarf im Skript +> `GATEWAY_URL` anpassen oder den Dienst mit `PORT=8003 make run` starten. + +### c) Audio transkribieren (`/api/transcribe`) + +```bash +curl -X POST http://localhost:8080/api/transcribe \ + -F "file=@aufnahme.wav" -F "language=de" +``` + +### d) Gerät oder Provider einmalig umstellen (pro Aufruf) + +```bash +curl -X POST http://localhost:8080/api/speak \ + -H 'Content-Type: application/json' \ + -d '{"text":"Test","tts_provider":"piper","output_endpoint":"loopback"}' +``` + +### e) Präferenzen für eine Session festlegen + +```bash +# einmal setzen +curl -X POST http://localhost:8080/api/sessions/oma-anna/route \ + -H 'Content-Type: application/json' \ + -d '{"llm_provider":"openrouter","language":"de"}' + +# danach mit dieser Session nutzen +curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \ + -H 'Content-Type: application/json' -d '{"text":"Hallo!"}' +``` + +--- + +## 7. Verfügbare Geräte und Bausteine ansehen + +```bash +curl http://localhost:8080/api/devices # Audio-Endpunkte mit Fähigkeiten +curl http://localhost:8080/api/config # Profil, Route, Provider, Endpunkte +``` + +--- + +## 8. Port ändern + +```bash +PORT=8003 make run # einmalig +sed -i 's/^PORT=.*/PORT=8003/' .env # dauerhaft +``` + +--- + +## 9. Mit Docker betreiben + +```bash +export OPENROUTER_API_KEY=sk-or-v1-... +docker compose up --build +``` + +Der Key wird aus der Shell in den Container durchgereicht; fehlt er, bricht der +Start mit klarer Meldung ab. + +--- + +## 10. Fehlerbehebung + +| Symptom | Ursache | Lösung | +|---|---|---| +| `OPENROUTER_API_KEY is empty` / 401 | Key nicht in der Umgebung | `export OPENROUTER_API_KEY=…`, neues Terminal / `source ~/.bashrc` | +| HTTP **422** „Unbekannter …-Provider/Endpunkt" | Tippfehler in `*_provider` / `*_endpoint` | gültige Werte via `GET /api/config` prüfen | +| `VA_PROFILE` wirkt nicht | `DEFAULT_*_PROVIDER` in `.env` überschreibt es | diese Zeilen in `.env` auskommentieren | +| LLM-Timeout / Connection refused (lokal) | lokaler LLM-Server (Port 11434) läuft nicht | LLM-Server starten oder Profil `cloud` wählen | +| `Address already in use` | Port belegt | anderen `PORT` setzen (Abschnitt 8) | +| `chat_client.py` bekommt keine Antwort | Client nutzt Port 8003 | Dienst mit `PORT=8003` starten oder `GATEWAY_URL` anpassen | +| Profil greift nicht / Standardwerte | `config/voice-assistant.toml` fehlt | Datei aus `*.example.toml` kopieren (Abschnitt 2) | + +Logs erscheinen im Terminal, in dem `make run` läuft. Für mehr Details +`LOG_LEVEL=debug` in `.env` setzen. + +--- + +## 11. Tests ausführen + +```bash +make test +``` + +Alle Tests sollten grün sein. Schlägt etwas fehl, gibt die Ausgabe den genauen +Testnamen und die Ursache an. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b8729a8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /app +COPY pyproject.toml README.md ./ +COPY app ./app +RUN pip install --no-cache-dir -U pip && pip install --no-cache-dir .[test] +EXPOSE 8080 +CMD ["sh", "-c", "uvicorn app.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-8080}"] diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md new file mode 100644 index 0000000..97c2c09 --- /dev/null +++ b/Docs/voice-assistant-architecture.md @@ -0,0 +1,220 @@ +# Architektur: Modularer Voice-Assistent + +> Stand: aktueller Implementierungsstand des Gateways. Dieses Dokument beschreibt +> das Konzept, den umgesetzten Stand und die geplanten nächsten Schritte. + +## 1. Ziel & Leitidee + +Ein modularer, **cloud-first, aber hybrid betreibbarer** Sprachassistent für +Senioren — ein „digitaler Vertrauter", erreichbar von zuhause und unterwegs. + +Der Hauptbetrieb läuft in der Cloud / auf einem vHost (Senioren sollen keinen +teuren KI-Rechner zuhause brauchen). Gleichzeitig muss jede Achse frei wählbar +bleiben, je nach Installation und Entwicklungs-/Testbedarf: + +- **Hardware** — Audio-Eingabe und -Ausgabe (lokal, Bluetooth, Handy, Netzwerk) +- **Betrieb** — lokal, Cloud oder hybrid +- **Software** — lokale oder entfernte KI (STT / LLM / TTS), ganz oder teilweise + +Designziele: geringe Latenz, robuste Fallbacks, gute Debugbarkeit, klare Trennung +von **semantischer** und **gesprochener** Antwort, und vor allem **Austauschbarkeit**: +einzelne Module gegen andere tauschen, ohne das Programm umzuschreiben. + +## 2. Architekturprinzip — fünf Ebenen + +1. **Audio Endpoints** – konkrete Quellen/Ziele (lokal, Bluetooth, Handy, WebRTC) +2. **Device Router** – wählt passende Input-/Output-Endpunkte +3. **Speech Pipeline** – STT, Input-Cleaner, Dialog-LLM, Spoken-Response-Adapter, TTS-Normalizer, TTS +4. **Transport Router** – lokale vs. entfernte Ausführung einzelner Module +5. **Orchestrator** – Session, Turn-Taking, Fallbacks, Metrik + +Jede Ebene kommuniziert über wohldefinierte Interfaces (ABCs + Pydantic-Schemas), +nicht über konkrete Bibliotheken. + +## 3. Konfigurations- & Routing-Modell (umgesetzt) + +Das Herzstück der Austauschbarkeit. Jede Achse ist auf mehreren Ebenen +einstellbar; höhere Ebene gewinnt: + +``` +eingebaute Defaults < config/voice-assistant.toml (inkl. aktivem Profil) + < ENV / .env < Session-Route < Request +``` + +### 3.1 Zentrale Config + Profile + +Eine geschichtete zentrale Datei (`config/voice-assistant.toml`, Vorlage +`config/voice-assistant.example.toml`), gelesen über die stdlib (`tomllib`). +**Profile** bündeln Betriebsarten und werden per ENV `VA_PROFILE` aktiviert: + +| Profil | STT | LLM | TTS | +|-------------|----------------|--------------------------|------------| +| `local-dev` | faster-whisper | local-openai-compatible | piper | +| `hybrid` | openrouter | local-openai-compatible | openrouter | +| `cloud` | openrouter | openrouter | openrouter | + +Umgesetzt in `app/config.py`: `load_profile_config()` merged `[defaults]` + +`[profiles.]`; `TomlProfileSource` hängt diese Werte als Settings-Quelle +**unter** ENV ein (`settings_customise_sources`). `VA_PROFILE`/`VA_CONFIG_FILE` +werden aus echter Umgebung **oder** `.env` gelesen. + +> **Secrets gehören nie in die Config-Datei** — nur in die Umgebung +> (z. B. `OPENROUTER_API_KEY`). Die TOML-Datei darf versioniert/geteilt werden. + +### 3.2 Einheitliche Route-Auflösung + +`app/dependencies.py` löst pro Aufruf eine `ResolvedRoute` auf +(`resolve_route(session_id, overrides)`): Defaults < Session-Route < Request. +Die Route umfasst `input_endpoint`, `output_endpoint`, `stt_provider`, +`llm_provider`, `tts_provider`, `language`. + +### 3.3 Registry-Pattern (Provider austauschbar) + +`STT_REGISTRY` / `LLM_REGISTRY` / `TTS_REGISTRY` bilden `name -> factory(settings)`. +Ein neuer Provider = ein Eintrag, ohne Kern-Code zu ändern. Unbekannter Name → +`UnknownComponentError` → HTTP 422. + +### 3.4 Device Router + +`app/audio/router.py` wählt Endpunkte per `id`- oder `kind`-Match. Ein angefragter, +aber unbekannter Endpunkt wirft `UnknownEndpointError` (→ 422) — **kein** stiller +Default-Fallback. Ohne Wunsch greift der als `default` markierte Endpunkt. Der +Router ist ein Singleton (stabiler Zustand, z. B. `LoopbackOutput`). + +## 4. Datenmodelle & Interfaces + +Schemas in `app/schemas.py`, Interfaces als ABCs in den jeweiligen `base.py`. + +```python +class EndpointCapabilities(BaseModel): + id: str; kind: str + direction: Literal["input", "output"] + sample_rate: int = 16000; channels: int = 1 + latency_class: Literal["low", "medium", "high"] = "medium" + supports_aec: bool = False; supports_barge_in: bool = False + networked: bool = False; bluetooth: bool = False + mobile: bool = False; default: bool = False + +class AudioChunk(BaseModel): + data: bytes; sample_rate: int = 16000; channels: int = 1 + format: str = "wav"; timestamp_ms: int = 0 + +class PipelineTrace(BaseModel): + raw_transcript: str | None = None + cleaned_transcript: str | None = None + semantic_response: str | None = None + spoken_response: str | None = None + tts_ready_text: str | None = None +``` + +Provider-Interfaces: + +```python +class STTProvider(ABC): + async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: ... + +class LLMProvider(ABC): + async def complete(self, text: str, session_id: str | None = None) -> str: ... + +class TTSProvider(ABC): + async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: ... +``` + +Audio-Endpunkt-Interfaces: `AudioInputEndpoint` (`capabilities/open/read_chunk/close`), +`AudioOutputEndpoint` (`capabilities/open/write_chunk/flush/close`) — alle async. + +## 5. Speech Pipeline + +Trennung von **semantischer** und **gesprochener** Antwort ist zentral: eine +inhaltlich gute Antwort ist nicht automatisch gut hörbar. + +Stufen: `raw_transcript → cleaned_transcript → semantic_response → spoken_response → tts_ready_text`. + +- **Input Cleaner** (`pipeline/input_cleaner.py`) – konservative Bereinigung des STT-Texts (Füllwörter, Whitespace). Verändert die Nutzerintention nicht. +- **Dialog-LLM** – semantische Antwort; Persona/Sicherheitsregeln im System-Prompt (`providers/llm/openrouter.py`). +- **Spoken Response Adapter** (`pipeline/spoken_response_adapter.py`) – macht die Antwort sprechbar/seniorengerecht (Markdown raus, Listen → Sätze, Uhrzeiten erhalten). +- **TTS Normalizer** (`pipeline/tts_normalizer.py`) – Zahlen/Abkürzungen/Einheiten verbal ausformulieren, sprachabhängig (de/en). + +Empfehlung: nicht jede Zwischenstufe braucht ein großes LLM — Cleaner und +Normalizer überwiegend regelbasiert (so heute umgesetzt), Adapter promptbasiert. + +## 6. Orchestrator + +`app/core/orchestrator.py` verbindet Provider, Pipeline und Output-Endpunkt. + +- `chat_text(text, language, voice, output)` → `(trace, audio_bytes)` +- `speak_only(text, voice, language, output)` → `audio_bytes` +- `transcribe_only(audio_bytes, fmt, language, input)` → `trace` + +Das synthetisierte Audio wird **zusätzlich** durch den gewählten Output-Endpunkt +geschrieben (`open → write_chunk → flush → close`) und **gleichzeitig** als +HTTP-Stream zurückgegeben (additiv). Bei lokalen Geräten ist `write_chunk` heute +ein No-op; `LoopbackOutput` sammelt die Chunks (testbar ohne Hardware). + +## 7. FastAPI-Endpunkte (umgesetzt) + +| Methode & Pfad | Zweck | +|--------------------------------------|-------| +| `GET /health` | Liveness | +| `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` | bevorzugte Geräte/Provider/Sprache je Session | +| `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) | + +Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session** +(`?session_id=…`) und **Defaults/Profil** steuerbar. Verwendete Route erscheint als +`X-*`-Header bzw. im `?debug`-JSON. + +## 8. Stand der Implementierung + +**Umgesetzt:** FastAPI-Gateway, alle o. g. REST-Endpunkte; OpenRouter-Adapter für +STT (multipart), LLM und TTS; lokaler OpenAI-kompatibler LLM-Adapter; regelbasierte +Pipeline; geschichtete Config + Profile; Registry + einheitliche Route-Auflösung; +Device Router (strikt, Singleton); Output-Lifecycle; 22 automatisierte Tests. + +**Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`, +`mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind +verdrahtet, kein echtes Hardware-I/O. Lokale Provider `faster-whisper`, `piper`, +`chatterbox` sind Stubs. `transport_router.py` (Ebene 4) existiert, ist aber noch +nicht aktiv (lokal/remote trägt vorerst der Provider-Name). + +## 9. Roadmap / bewusste nächste Schritte + +Reihenfolge der Weiterentwicklung: + +1. **(erledigt)** Konfig- & Routing-Fundament: Profile, Device Router, Registry, Pro-Request-Override. +2. **Cloud-Fundament:** Authentifizierung + Mehrbenutzer + persistenter Session-/Profil-Store (heute ist `SessionManager` in-memory → nicht skalierend, ohne Auth). +3. **Konversationsgedächtnis:** Verlauf + Langzeit-Präferenzen (heute ist `llm.complete` zustandslos). +4. **Echtzeit:** Streaming-STT/TTS, WebSocket/WebRTC-Endpunkte real, Barge-in, Turn-Manager. +5. **Resilienz:** Fallback-Policy (remote KI fällt aus → lokaler/alternativer Provider), Metriken/Tracing. +6. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext). +7. **TransportRouter** als eigene lokal/remote-Achse aktivieren. + +**Datenschutz (querschnittlich, ab sofort mitdenken):** Senioren-Sprachdaten sind +hochsensibel (oft gesundheitsbezogen → DSGVO Art. 9). EU-Datenresidenz, +Verschlüsselung at-rest/in-transit, Löschkonzept, Einwilligung — „privacy by design". + +## 10. Verzeichnisstruktur (Ist) + +```text +voice-assistant-scaffold/ +├── app/ +│ ├── main.py # FastAPI-App + Router-Registrierung +│ ├── config.py # Settings, TOML-Profile, Präzedenz +│ ├── dependencies.py # Registries, ResolvedRoute, resolve_route, Singleton-Router +│ ├── errors.py # RoutingError -> HTTP 422 +│ ├── schemas.py # Pydantic-Modelle +│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config +│ ├── core/ # orchestrator, session_manager +│ ├── audio/ # router, transport_router, endpoints/input|output/* +│ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer +│ └── providers/ # stt/ llm/ tts/ (openrouter + lokale Stubs) +├── config/ # voice-assistant.example.toml (+ lokale .toml, gitignored) +├── deploy/ # systemd unit + env-Beispiel +├── tests/ # config-profile, routing, audio-router, e2e +├── Docs/ # dieses Dokument +├── Dockerfile, docker-compose.yml, Makefile, pyproject.toml +└── README.md, BEDIENUNGSANLEITUNG.md +``` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..35d0775 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +ifneq (,$(wildcard ./.env)) +include .env +export +endif + +PORT ?= 8080 +HOST ?= 0.0.0.0 + +.PHONY: ensure-env install run test docker-build + +ensure-env: + @if [ ! -f .env ] && [ -f .env.example ]; then \ + cp .env.example .env; \ + echo "Created .env from .env.example"; \ + fi + +install: ensure-env + python3 -m venv .venv + . .venv/bin/activate && pip install -U pip && pip install -e .[test] + +run: ensure-env + . .venv/bin/activate && uvicorn app.main:app --host $(HOST) --port $(PORT) --reload + +test: ensure-env + . .venv/bin/activate && pytest tests/ + +docker-build: + docker build -t voice-assistant-gateway . diff --git a/README.md b/README.md new file mode 100644 index 0000000..73ed35d --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# 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`](Docs/voice-assistant-architecture.md). +Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md). + +## Features + +- **Pipeline mit getrennter Semantik/Sprache:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS +- **Provider austauschbar** über Registry (OpenRouter remote; faster-whisper/piper/chatterbox als lokale Stubs) +- **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`) +- **Routing auf jeder Ebene:** Default → Profil → ENV → Session → Request +- **REST-API** für Chat, Transkription, Sprachausgabe, Geräte, Sessions, Config +- **Ohne Secrets im Code** — API-Keys nur über die Umgebung + +## Schnellstart + +```bash +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: + +```bash +curl http://localhost:8080/health +curl http://localhost:8080/api/config +``` + +## 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`): + +```bash +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=…`). Gesetzte `DEFAULT_*_PROVIDER`-Werte in `.env` +> überschreiben ein `VA_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`. + +## 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) | + +Beispiel (Sprachausgabe an den Test-Loopback, lokaler TTS-Stub): + +```bash +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. + +## Tests + +```bash +make test # oder: pytest -q +``` + +Abgedeckt: Config-Profile & Präzedenz, Route-Auflösung, Device Router, +End-to-End (Loopback, 422-Fälle, Session-/Request-Override, `/api/config`). + +## Port ändern + +```bash +PORT=8003 make run # einmalig +sed -i 's/^PORT=.*/PORT=8003/' .env # dauerhaft +PORT=8003 docker compose up # mit Docker +``` + +## Deployment + +- **Docker:** `docker compose up --build` (reicht `OPENROUTER_API_KEY` aus der Shell durch) +- **systemd:** Vorlagen unter `deploy/` (`voice-assistant.service`, `voice-assistant.env.example`) + +## Projektstruktur (Kurzform) + +```text +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). diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/chat.py b/app/api/chat.py new file mode 100644 index 0000000..300a500 --- /dev/null +++ b/app/api/chat.py @@ -0,0 +1,92 @@ +from io import BytesIO + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import JSONResponse, StreamingResponse + +from app.config import settings +from app.errors import RoutingError +from app.dependencies import ( + resolve_route, + build_orchestrator, + resolve_output_endpoint, +) +from app.schemas import ChatRequest + +router = APIRouter() + + +def _route_headers(route) -> dict: + return { + "X-Input-Endpoint": route.input_endpoint, + "X-Output-Endpoint": route.output_endpoint, + "X-STT-Provider": route.stt_provider, + "X-LLM-Provider": route.llm_provider, + "X-TTS-Provider": route.tts_provider, + } + + +@router.post("/chat") +async def chat( + payload: ChatRequest, + debug: bool = Query( + default=False, + description="Return JSON trace instead of audio response", + ), + session_id: str | None = Query( + default=None, + description="Optional session id to apply a stored route", + ), +): + overrides = { + "input_endpoint": payload.input_endpoint, + "output_endpoint": payload.output_endpoint, + "language": payload.language, + "stt_provider": payload.stt_provider, + "llm_provider": payload.llm_provider, + "tts_provider": payload.tts_provider, + } + route = resolve_route(session_id, overrides) + voice = payload.voice or settings.openrouter_tts_voice + + try: + orchestrator = build_orchestrator(route) + output = await resolve_output_endpoint(route) + except RoutingError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + try: + trace, audio = await orchestrator.chat_text( + payload.text, + language=route.language, + voice=voice, + output=output, + ) + + if debug: + return JSONResponse( + content={ + "ok": True, + "voice": voice, + "route": route.as_dict(), + "trace": { + "raw_transcript": trace.raw_transcript, + "cleaned_transcript": trace.cleaned_transcript, + "semantic_response": trace.semantic_response, + "spoken_response": trace.spoken_response, + "tts_ready_text": trace.tts_ready_text, + }, + } + ) + + headers = { + "Content-Language": route.language, + "X-Audio-Format": "pcm", + "X-Audio-Sample-Rate": "24000", + "X-Audio-Channels": "1", + "X-Audio-Sample-Width": "16", + **_route_headers(route), + } + return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers) + + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) diff --git a/app/api/config.py b/app/api/config.py new file mode 100644 index 0000000..6b1f1e5 --- /dev/null +++ b/app/api/config.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter + +from app.config import settings, active_profile +from app.dependencies import ( + resolve_route, + get_audio_router, + STT_REGISTRY, + LLM_REGISTRY, + TTS_REGISTRY, +) + +router = APIRouter() + + +@router.get("/config") +async def get_config(): + """Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine. + + Bewusst OHNE Secrets - API-Keys werden nur als 'gesetzt/nicht gesetzt' gemeldet. + """ + route = resolve_route() + audio_router = get_audio_router() + return { + "profile": active_profile(), + "app_env": settings.app_env, + "default_route": route.as_dict(), + "available": { + "stt_providers": sorted(STT_REGISTRY), + "llm_providers": sorted(LLM_REGISTRY), + "tts_providers": sorted(TTS_REGISTRY), + "input_endpoints": [c.model_dump() for c in await audio_router.list_inputs()], + "output_endpoints": [c.model_dump() for c in await audio_router.list_outputs()], + }, + "secrets": { + "openrouter_api_key_set": bool(settings.openrouter_api_key.strip()), + }, + } diff --git a/app/api/devices.py b/app/api/devices.py new file mode 100644 index 0000000..931740b --- /dev/null +++ b/app/api/devices.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter +from app.dependencies import get_audio_router + +router = APIRouter() + +@router.get("/devices") +async def list_devices(): + audio_router = get_audio_router() + return { + "inputs": [item.model_dump() for item in await audio_router.list_inputs()], + "outputs": [item.model_dump() for item in await audio_router.list_outputs()], + } diff --git a/app/api/health.py b/app/api/health.py new file mode 100644 index 0000000..60aeed4 --- /dev/null +++ b/app/api/health.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +router = APIRouter() + +@router.get("/health") +async def health(): + return {"status": "ok"} diff --git a/app/api/sessions.py b/app/api/sessions.py new file mode 100644 index 0000000..181ff9d --- /dev/null +++ b/app/api/sessions.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter +from app.schemas import SessionRouteRequest +from app.dependencies import session_manager + +router = APIRouter() + +@router.post("/sessions/{session_id}/route") +async def set_session_route(session_id: str, payload: SessionRouteRequest): + session = session_manager.update(session_id, payload.model_dump()) + return {"session_id": session_id, "route": session} diff --git a/app/api/speak.py b/app/api/speak.py new file mode 100644 index 0000000..4573de9 --- /dev/null +++ b/app/api/speak.py @@ -0,0 +1,60 @@ +from io import BytesIO + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import StreamingResponse + +from app.config import settings +from app.errors import RoutingError +from app.dependencies import ( + resolve_route, + build_orchestrator, + resolve_output_endpoint, +) +from app.schemas import SpeakRequest + +router = APIRouter() + + +@router.post("/speak") +async def speak( + payload: SpeakRequest, + session_id: str | None = Query( + default=None, + description="Optional session id to apply a stored route", + ), +): + overrides = { + "output_endpoint": payload.output_endpoint, + "language": payload.language, + "tts_provider": payload.tts_provider, + } + route = resolve_route(session_id, overrides) + voice = payload.voice or settings.openrouter_tts_voice + + try: + orchestrator = build_orchestrator(route) + output = await resolve_output_endpoint(route) + except RoutingError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + try: + audio = await orchestrator.speak_only( + payload.text, + voice=voice, + language=route.language, + output=output, + ) + + headers = { + "Content-Language": route.language, + "X-Audio-Format": "pcm", + "X-Audio-Sample-Rate": "24000", + "X-Audio-Channels": "1", + "X-Audio-Sample-Width": "16", + "X-Output-Endpoint": route.output_endpoint, + "X-TTS-Provider": route.tts_provider, + } + return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers) + + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) diff --git a/app/api/transcribe.py b/app/api/transcribe.py new file mode 100644 index 0000000..b727849 --- /dev/null +++ b/app/api/transcribe.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile + +from app.errors import RoutingError +from app.dependencies import ( + resolve_route, + build_orchestrator, + resolve_input_endpoint, +) + +router = APIRouter() + + +@router.post("/transcribe") +async def transcribe( + file: UploadFile = File(...), + language: str | None = Form(default=None), + input_endpoint: str | None = Form(default=None), + stt_provider: str | None = Form(default=None), + session_id: str | None = Query( + default=None, + description="Optional session id to apply a stored route", + ), +): + overrides = { + "input_endpoint": input_endpoint, + "language": language, + "stt_provider": stt_provider, + } + route = resolve_route(session_id, overrides) + + try: + orchestrator = build_orchestrator(route) + source = await resolve_input_endpoint(route) + except RoutingError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + content = await file.read() + suffix = (file.filename or "audio.wav").rsplit(".", 1)[-1].lower() + + try: + trace = await orchestrator.transcribe_only( + content, + fmt=suffix, + language=route.language, + input=source, + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) + + return {"route": route.as_dict(), "trace": trace.model_dump()} diff --git a/app/audio/__init__.py b/app/audio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/audio/endpoints/__init__.py b/app/audio/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/audio/endpoints/input/__init__.py b/app/audio/endpoints/input/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/audio/endpoints/input/base.py b/app/audio/endpoints/input/base.py new file mode 100644 index 0000000..8d21d0e --- /dev/null +++ b/app/audio/endpoints/input/base.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod +from app.schemas import AudioChunk, EndpointCapabilities + +class AudioInputEndpoint(ABC): + endpoint_id: str + + @abstractmethod + async def capabilities(self) -> EndpointCapabilities: ... + + @abstractmethod + async def open(self) -> None: ... + + @abstractmethod + async def read_chunk(self) -> AudioChunk: ... + + @abstractmethod + async def close(self) -> None: ... diff --git a/app/audio/endpoints/input/bluetooth.py b/app/audio/endpoints/input/bluetooth.py new file mode 100644 index 0000000..135b838 --- /dev/null +++ b/app/audio/endpoints/input/bluetooth.py @@ -0,0 +1,23 @@ +from app.audio.endpoints.input.base import AudioInputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class BluetoothInput(AudioInputEndpoint): + endpoint_id = "bt-headset-01" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="bluetooth", + direction="input", + latency_class="medium", + bluetooth=True, + ) + + async def open(self) -> None: + return None + + async def read_chunk(self) -> AudioChunk: + return AudioChunk(data=b"", format="wav", timestamp_ms=0) + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/input/local_default.py b/app/audio/endpoints/input/local_default.py new file mode 100644 index 0000000..c8a418c --- /dev/null +++ b/app/audio/endpoints/input/local_default.py @@ -0,0 +1,24 @@ +from app.audio.endpoints.input.base import AudioInputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class LocalDefaultInput(AudioInputEndpoint): + endpoint_id = "local-default-mic" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="local-default", + direction="input", + latency_class="low", + supports_barge_in=True, + default=True, + ) + + async def open(self) -> None: + return None + + async def read_chunk(self) -> AudioChunk: + return AudioChunk(data=b"", format="wav", timestamp_ms=0) + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/input/mobile_webrtc.py b/app/audio/endpoints/input/mobile_webrtc.py new file mode 100644 index 0000000..42b419e --- /dev/null +++ b/app/audio/endpoints/input/mobile_webrtc.py @@ -0,0 +1,25 @@ +from app.audio.endpoints.input.base import AudioInputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class MobileWebRTCInput(AudioInputEndpoint): + endpoint_id = "mobile-webrtc-client" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="mobile-webrtc", + direction="input", + latency_class="low", + networked=True, + mobile=True, + supports_barge_in=True, + ) + + async def open(self) -> None: + return None + + async def read_chunk(self) -> AudioChunk: + return AudioChunk(data=b"", format="wav", timestamp_ms=0) + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/input/mobile_ws.py b/app/audio/endpoints/input/mobile_ws.py new file mode 100644 index 0000000..eae733c --- /dev/null +++ b/app/audio/endpoints/input/mobile_ws.py @@ -0,0 +1,24 @@ +from app.audio.endpoints.input.base import AudioInputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class MobileWebSocketInput(AudioInputEndpoint): + endpoint_id = "mobile-ws-client" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="mobile-ws", + direction="input", + latency_class="medium", + networked=True, + mobile=True, + ) + + async def open(self) -> None: + return None + + async def read_chunk(self) -> AudioChunk: + return AudioChunk(data=b"", format="wav", timestamp_ms=0) + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/output/__init__.py b/app/audio/endpoints/output/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/audio/endpoints/output/base.py b/app/audio/endpoints/output/base.py new file mode 100644 index 0000000..31cd2d9 --- /dev/null +++ b/app/audio/endpoints/output/base.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod +from app.schemas import AudioChunk, EndpointCapabilities + +class AudioOutputEndpoint(ABC): + endpoint_id: str + + @abstractmethod + async def capabilities(self) -> EndpointCapabilities: ... + + @abstractmethod + async def open(self) -> None: ... + + @abstractmethod + async def write_chunk(self, chunk: AudioChunk) -> None: ... + + @abstractmethod + async def flush(self) -> None: ... + + @abstractmethod + async def close(self) -> None: ... diff --git a/app/audio/endpoints/output/bluetooth.py b/app/audio/endpoints/output/bluetooth.py new file mode 100644 index 0000000..d275045 --- /dev/null +++ b/app/audio/endpoints/output/bluetooth.py @@ -0,0 +1,26 @@ +from app.audio.endpoints.output.base import AudioOutputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class BluetoothOutput(AudioOutputEndpoint): + endpoint_id = "bt-speaker-01" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="bluetooth", + direction="output", + latency_class="medium", + bluetooth=True, + ) + + async def open(self) -> None: + return None + + async def write_chunk(self, chunk: AudioChunk) -> None: + return None + + async def flush(self) -> None: + return None + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/output/local_default.py b/app/audio/endpoints/output/local_default.py new file mode 100644 index 0000000..692ecbd --- /dev/null +++ b/app/audio/endpoints/output/local_default.py @@ -0,0 +1,26 @@ +from app.audio.endpoints.output.base import AudioOutputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class LocalDefaultOutput(AudioOutputEndpoint): + endpoint_id = "local-default-speaker" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="local-default", + direction="output", + latency_class="low", + default=True, + ) + + async def open(self) -> None: + return None + + async def write_chunk(self, chunk: AudioChunk) -> None: + return None + + async def flush(self) -> None: + return None + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/output/loopback.py b/app/audio/endpoints/output/loopback.py new file mode 100644 index 0000000..d8cdbd4 --- /dev/null +++ b/app/audio/endpoints/output/loopback.py @@ -0,0 +1,28 @@ +from app.audio.endpoints.output.base import AudioOutputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class LoopbackOutput(AudioOutputEndpoint): + endpoint_id = "loopback-output" + + def __init__(self): + self.chunks = [] + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="loopback", + direction="output", + latency_class="low", + ) + + async def open(self) -> None: + return None + + async def write_chunk(self, chunk: AudioChunk) -> None: + self.chunks.append(chunk) + + async def flush(self) -> None: + return None + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/output/mobile_webrtc.py b/app/audio/endpoints/output/mobile_webrtc.py new file mode 100644 index 0000000..d75feca --- /dev/null +++ b/app/audio/endpoints/output/mobile_webrtc.py @@ -0,0 +1,27 @@ +from app.audio.endpoints.output.base import AudioOutputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class MobileWebRTCOutput(AudioOutputEndpoint): + endpoint_id = "mobile-webrtc-client" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="mobile-webrtc", + direction="output", + latency_class="low", + networked=True, + mobile=True, + ) + + async def open(self) -> None: + return None + + async def write_chunk(self, chunk: AudioChunk) -> None: + return None + + async def flush(self) -> None: + return None + + async def close(self) -> None: + return None diff --git a/app/audio/endpoints/output/mobile_ws.py b/app/audio/endpoints/output/mobile_ws.py new file mode 100644 index 0000000..302920d --- /dev/null +++ b/app/audio/endpoints/output/mobile_ws.py @@ -0,0 +1,27 @@ +from app.audio.endpoints.output.base import AudioOutputEndpoint +from app.schemas import AudioChunk, EndpointCapabilities + +class MobileWebSocketOutput(AudioOutputEndpoint): + endpoint_id = "mobile-ws-client" + + async def capabilities(self) -> EndpointCapabilities: + return EndpointCapabilities( + id=self.endpoint_id, + kind="mobile-ws", + direction="output", + latency_class="medium", + networked=True, + mobile=True, + ) + + async def open(self) -> None: + return None + + async def write_chunk(self, chunk: AudioChunk) -> None: + return None + + async def flush(self) -> None: + return None + + async def close(self) -> None: + return None diff --git a/app/audio/router.py b/app/audio/router.py new file mode 100644 index 0000000..0c43247 --- /dev/null +++ b/app/audio/router.py @@ -0,0 +1,38 @@ +from app.errors import UnknownEndpointError + + +class AudioRouter: + def __init__(self, inputs, outputs): + self.inputs = inputs + self.outputs = outputs + + async def list_inputs(self): + return [await endpoint.capabilities() for endpoint in self.inputs] + + async def list_outputs(self): + return [await endpoint.capabilities() for endpoint in self.outputs] + + async def select_input(self, preferred: str | None = None): + return await self._select(self.inputs, preferred, direction="input") + + async def select_output(self, preferred: str | None = None): + return await self._select(self.outputs, preferred, direction="output") + + async def _select(self, endpoints, preferred: str | None, direction: str): + # Capabilities einmal sammeln (Basis fuer spaetere capability-basierte Auswahl). + pairs = [(endpoint, await endpoint.capabilities()) for endpoint in endpoints] + + if preferred: + for endpoint, caps in pairs: + if caps.id == preferred or caps.kind == preferred: + return endpoint + # Angefragter Endpunkt existiert nicht -> KEIN stiller Default-Fallback. + available = sorted({caps.id for _, caps in pairs} | {caps.kind for _, caps in pairs}) + raise UnknownEndpointError( + f"Unbekannter {direction}-Endpunkt {preferred!r}. Verfuegbar: {available}" + ) + + for endpoint, caps in pairs: + if caps.default: + return endpoint + raise RuntimeError(f"Kein {direction}-Standardendpunkt verfuegbar") diff --git a/app/audio/transport_router.py b/app/audio/transport_router.py new file mode 100644 index 0000000..061ed48 --- /dev/null +++ b/app/audio/transport_router.py @@ -0,0 +1,11 @@ +class TransportRouter: + def __init__(self, local_registry: dict, remote_registry: dict): + self.local_registry = local_registry + self.remote_registry = remote_registry + + def resolve(self, module_type: str, provider_name: str): + if provider_name in self.local_registry.get(module_type, {}): + return self.local_registry[module_type][provider_name] + if provider_name in self.remote_registry.get(module_type, {}): + return self.remote_registry[module_type][provider_name] + raise KeyError(f"Unknown provider: {module_type}/{provider_name}") diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..6d40398 --- /dev/null +++ b/app/config.py @@ -0,0 +1,146 @@ +import os +from pathlib import Path + +try: + import tomllib # Python >= 3.11 (stdlib) +except ModuleNotFoundError: # pragma: no cover - Fallback fuer aeltere Interpreter + import tomli as tomllib # type: ignore + +from pydantic.fields import FieldInfo +from pydantic_settings import ( + BaseSettings, + PydanticBaseSettingsSource, + SettingsConfigDict, +) + +BASE_DIR = Path(__file__).resolve().parent.parent +ENV_FILE = BASE_DIR / ".env" +DEFAULT_CONFIG_FILE = BASE_DIR / "config" / "voice-assistant.toml" + + +def _setting_lookup(key: str) -> str | None: + """Liest einen Steuer-Schluessel: echte Umgebung zuerst, dann die .env-Datei. + + Noetig fuer VA_PROFILE/VA_CONFIG_FILE, weil diese gebraucht werden, BEVOR + pydantic-settings die .env laedt - und .env-Werte sonst nicht in os.environ stehen. + """ + value = os.getenv(key) + if value is not None: + return value + try: + from dotenv import dotenv_values + except ModuleNotFoundError: # pragma: no cover + return None + if ENV_FILE.is_file(): + return dotenv_values(ENV_FILE).get(key) + return None + + +def _config_file_path() -> Path: + return Path(_setting_lookup("VA_CONFIG_FILE") or str(DEFAULT_CONFIG_FILE)) + + +def active_profile() -> str | None: + """Name des aktiven Profils (VA_PROFILE) aus Umgebung oder .env, falls gesetzt.""" + profile = _setting_lookup("VA_PROFILE") + return profile.strip() or None if profile else None + + +def load_profile_config() -> dict: + """Liest die zentrale TOML-Config und merged [defaults] + [profiles.]. + + - Fehlt die Datei, gilt ein leeres dict (nur ENV/Defaults greifen) - kein Fehler, + damit reine Cloud-Deployments ohne Datei (nur ENV) funktionieren. + - Ein gesetztes, aber unbekanntes VA_PROFILE ist ein Konfigurationsfehler. + """ + path = _config_file_path() + if not path.is_file(): + return {} + + with path.open("rb") as handle: + data = tomllib.load(handle) + + merged: dict = dict(data.get("defaults", {})) + + profile = active_profile() + if profile: + profiles = data.get("profiles", {}) + if profile not in profiles: + raise ValueError( + f"Unbekanntes VA_PROFILE {profile!r}. " + f"Verfuegbar: {sorted(profiles)}" + ) + merged.update(profiles[profile]) + + return merged + + +class TomlProfileSource(PydanticBaseSettingsSource): + """Settings-Quelle aus der zentralen TOML-Config (inkl. aktivem Profil). + + Liegt in der Praezedenz unter ENV/.env, aber ueber den eingebauten Defaults. + Es werden nur Schluessel durchgereicht, die auch als Settings-Feld existieren. + """ + + def __init__(self, settings_cls): + super().__init__(settings_cls) + raw = load_profile_config() + known = set(settings_cls.model_fields) + self._values = { + key.lower(): value + for key, value in raw.items() + if key.lower() in known + } + + def get_field_value(self, field: FieldInfo, field_name: str): + if field_name in self._values: + return self._values[field_name], field_name, False + return None, field_name, False + + def __call__(self) -> dict: + return dict(self._values) + + +class Settings(BaseSettings): + app_env: str = "dev" + host: str = "0.0.0.0" + port: int = 8080 + log_level: str = "info" + openrouter_api_key: str = "" + openrouter_stt_model: str = "openai/whisper-large-v3" + openrouter_tts_model: str = "openai/gpt-4o-mini-tts" + openrouter_tts_voice: str = "alloy" + openrouter_llm_model: str = "openai/gpt-4.1-mini" + default_language: str = "de" + default_input_endpoint: str = "local-default" + default_output_endpoint: str = "local-default" + default_stt_provider: str = "openrouter" + default_llm_provider: str = "local-openai-compatible" + default_tts_provider: str = "openrouter" + local_llm_base_url: str = "http://127.0.0.1:11434/v1" + local_llm_api_key: str = "dummy" + local_llm_model: str = "llama3.1" + model_config = SettingsConfigDict( + env_file=ENV_FILE, case_sensitive=False, extra="ignore" + ) + + @classmethod + def settings_customise_sources( + cls, + settings_cls, + init_settings, + env_settings, + dotenv_settings, + file_secret_settings, + ): + # Praezedenz (frueher = hoeher): init > ENV > .env > TOML/Profil > Defaults + return ( + init_settings, + env_settings, + dotenv_settings, + TomlProfileSource(settings_cls), + file_secret_settings, + ) + + +settings = Settings() diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py new file mode 100644 index 0000000..4ab6322 --- /dev/null +++ b/app/core/orchestrator.py @@ -0,0 +1,105 @@ +from app.schemas import AudioChunk, PipelineTrace + +# Festes Ausgabeformat der TTS-Stufe (s16le PCM, 24 kHz, mono). +TTS_AUDIO_FORMAT = "pcm" +TTS_SAMPLE_RATE = 24000 +TTS_CHANNELS = 1 + + +class Orchestrator: + def __init__(self, stt, llm, tts, input_cleaner, spoken_adapter, tts_normalizer): + self.stt = stt + self.llm = llm + self.tts = tts + self.input_cleaner = input_cleaner + self.spoken_adapter = spoken_adapter + self.tts_normalizer = tts_normalizer + + async def _emit_to_output(self, audio: bytes, output) -> None: + """Schreibt das synthetisierte Audio durch den gewaehlten Output-Endpunkt. + + Der HTTP-Stream bleibt davon unberuehrt (additiv). Bei lokalen Geraeten + ist write_chunk heute ein no-op; LoopbackOutput sammelt die Chunks. + """ + if output is None: + return + chunk = AudioChunk( + data=audio, + sample_rate=TTS_SAMPLE_RATE, + channels=TTS_CHANNELS, + format=TTS_AUDIO_FORMAT, + ) + await output.open() + try: + await output.write_chunk(chunk) + await output.flush() + finally: + await output.close() + + async def transcribe_only( + self, + audio_bytes: bytes, + fmt: str, + language: str | None = None, + input=None, + ): + trace = PipelineTrace() + # input dient hier nur der Validierung/Metadaten; das Audio kommt per Upload. + if input is not None: + await input.capabilities() + trace.raw_transcript = await self.stt.transcribe( + audio_bytes, + fmt=fmt, + language=language, + ) + trace.cleaned_transcript = await self.input_cleaner.run( + trace.raw_transcript or "" + ) + return trace + + async def speak_only( + self, + text: str, + voice: str | None = None, + language: str | None = None, + output=None, + ): + spoken = await self.spoken_adapter.run(text, language=language) + normalized = await self.tts_normalizer.run(spoken, language=language) + audio = await self.tts.synthesize(normalized, voice=voice) + await self._emit_to_output(audio, output) + return audio + + async def chat_text( + self, + text: str, + language: str | None = None, + voice: str | None = None, + output=None, + ): + trace = PipelineTrace() + + trace.raw_transcript = text + trace.cleaned_transcript = await self.input_cleaner.run(text or "") + + trace.semantic_response = await self.llm.complete( + trace.cleaned_transcript or "" + ) + if not trace.semantic_response: + raise RuntimeError("LLM returned an empty response") + + trace.spoken_response = await self.spoken_adapter.run( + trace.semantic_response, + language=language, + ) + trace.tts_ready_text = await self.tts_normalizer.run( + trace.spoken_response, + language=language, + ) + + audio = await self.tts.synthesize( + trace.tts_ready_text, + voice=voice, + ) + await self._emit_to_output(audio, output) + return trace, audio diff --git a/app/core/session_manager.py b/app/core/session_manager.py new file mode 100644 index 0000000..821c707 --- /dev/null +++ b/app/core/session_manager.py @@ -0,0 +1,11 @@ +class SessionManager: + def __init__(self): + self._sessions = {} + + def get(self, session_id: str) -> dict: + return self._sessions.setdefault(session_id, {}) + + def update(self, session_id: str, values: dict) -> dict: + session = self.get(session_id) + session.update({k: v for k, v in values.items() if v is not None}) + return session diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..1c05482 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,187 @@ +from dataclasses import dataclass + +from app.config import Settings, settings +from app.errors import UnknownComponentError +from app.audio.router import AudioRouter +from app.audio.endpoints.input.local_default import LocalDefaultInput +from app.audio.endpoints.input.bluetooth import BluetoothInput +from app.audio.endpoints.input.mobile_ws import MobileWebSocketInput +from app.audio.endpoints.input.mobile_webrtc import MobileWebRTCInput +from app.audio.endpoints.output.local_default import LocalDefaultOutput +from app.audio.endpoints.output.bluetooth import BluetoothOutput +from app.audio.endpoints.output.mobile_ws import MobileWebSocketOutput +from app.audio.endpoints.output.mobile_webrtc import MobileWebRTCOutput +from app.audio.endpoints.output.loopback import LoopbackOutput +from app.providers.stt.openrouter import OpenRouterSTTProvider +from app.providers.stt.faster_whisper import FasterWhisperProvider +from app.providers.llm.local_openai_compatible import LocalOpenAICompatibleLLM +from app.providers.llm.openrouter import OpenRouterLLMProvider +from app.providers.tts.openrouter import OpenRouterTTSProvider +from app.providers.tts.chatterbox import ChatterboxTTSProvider +from app.providers.tts.piper import PiperTTSProvider +from app.pipeline.input_cleaner import InputCleaner +from app.pipeline.spoken_response_adapter import SpokenResponseAdapter +from app.pipeline.tts_normalizer import TTSNormalizer +from app.core.orchestrator import Orchestrator +from app.core.session_manager import SessionManager + +session_manager = SessionManager() + +# --------------------------------------------------------------------------- +# Provider-Registries: Modul austauschbar via Name, ohne Kern-Code zu aendern. +# Ein neuer Provider = ein Eintrag. Unbekannter Name -> UnknownComponentError. +# --------------------------------------------------------------------------- +STT_REGISTRY = { + "openrouter": lambda s: OpenRouterSTTProvider(s.openrouter_api_key, s.openrouter_stt_model), + "faster-whisper": lambda s: FasterWhisperProvider(), +} + +LLM_REGISTRY = { + "openrouter": lambda s: OpenRouterLLMProvider(s.openrouter_api_key, s.openrouter_llm_model), + "local-openai-compatible": lambda s: LocalOpenAICompatibleLLM( + s.local_llm_base_url, s.local_llm_api_key, s.local_llm_model + ), +} + +TTS_REGISTRY = { + "openrouter": lambda s: OpenRouterTTSProvider( + s.openrouter_api_key, s.openrouter_tts_model, s.openrouter_tts_voice + ), + "chatterbox": lambda s: ChatterboxTTSProvider(), + "piper": lambda s: PiperTTSProvider(), +} + + +def _from_registry(registry: dict, name: str, kind: str, cfg: Settings): + try: + factory = registry[name] + except KeyError as exc: + raise UnknownComponentError( + f"Unbekannter {kind}-Provider {name!r}. Verfuegbar: {sorted(registry)}" + ) from exc + return factory(cfg) + + +def get_stt_provider(name: str | None = None, cfg: Settings = settings): + return _from_registry(STT_REGISTRY, name or cfg.default_stt_provider, "STT", cfg) + + +def get_llm_provider(name: str | None = None, cfg: Settings = settings): + return _from_registry(LLM_REGISTRY, name or cfg.default_llm_provider, "LLM", cfg) + + +def get_tts_provider(name: str | None = None, cfg: Settings = settings): + return _from_registry(TTS_REGISTRY, name or cfg.default_tts_provider, "TTS", cfg) + + +# --------------------------------------------------------------------------- +# Audio-Router: Modul-Singleton, damit zustandsbehaftete Endpunkte +# (z. B. LoopbackOutput.chunks) ueber Requests hinweg stabil bleiben. +# --------------------------------------------------------------------------- +_audio_router: AudioRouter | None = None + + +def get_audio_router() -> AudioRouter: + global _audio_router + if _audio_router is None: + _audio_router = AudioRouter( + inputs=[ + LocalDefaultInput(), + BluetoothInput(), + MobileWebSocketInput(), + MobileWebRTCInput(), + ], + outputs=[ + LocalDefaultOutput(), + BluetoothOutput(), + MobileWebSocketOutput(), + MobileWebRTCOutput(), + LoopbackOutput(), + ], + ) + return _audio_router + + +# --------------------------------------------------------------------------- +# Session-Routing und einheitliche Route-Aufloesung ueber alle Achsen. +# Praezedenz: Settings-Defaults < Session-Route < Request-Overrides. +# --------------------------------------------------------------------------- +ROUTE_KEYS = ( + "input_endpoint", + "output_endpoint", + "stt_provider", + "llm_provider", + "tts_provider", + "language", +) + + +@dataclass +class ResolvedRoute: + input_endpoint: str + output_endpoint: str + stt_provider: str + llm_provider: str + tts_provider: str + language: str + + def as_dict(self) -> dict: + return { + "input_endpoint": self.input_endpoint, + "output_endpoint": self.output_endpoint, + "stt_provider": self.stt_provider, + "llm_provider": self.llm_provider, + "tts_provider": self.tts_provider, + "language": self.language, + } + + +def get_session_route(session_id: str | None) -> dict: + """Liefert die gespeicherte Route einer Session (leeres dict ohne session_id).""" + return session_manager.get(session_id) if session_id else {} + + +def resolve_route( + session_id: str | None = None, + overrides: dict | None = None, + cfg: Settings = settings, +) -> ResolvedRoute: + """Loest die effektive Route aus Defaults, Session und Request-Overrides auf.""" + resolved = { + "input_endpoint": cfg.default_input_endpoint, + "output_endpoint": cfg.default_output_endpoint, + "stt_provider": cfg.default_stt_provider, + "llm_provider": cfg.default_llm_provider, + "tts_provider": cfg.default_tts_provider, + "language": cfg.default_language, + } + + session_route = get_session_route(session_id) + request_overrides = overrides or {} + + for layer in (session_route, request_overrides): + for key in ROUTE_KEYS: + value = layer.get(key) + if value is not None: + resolved[key] = value + + return ResolvedRoute(**resolved) + + +def build_orchestrator(route: ResolvedRoute, cfg: Settings = settings) -> Orchestrator: + return Orchestrator( + stt=get_stt_provider(route.stt_provider, cfg), + llm=get_llm_provider(route.llm_provider, cfg), + tts=get_tts_provider(route.tts_provider, cfg), + input_cleaner=InputCleaner(), + spoken_adapter=SpokenResponseAdapter(), + tts_normalizer=TTSNormalizer(), + ) + + +async def resolve_output_endpoint(route: ResolvedRoute): + return await get_audio_router().select_output(route.output_endpoint) + + +async def resolve_input_endpoint(route: ResolvedRoute): + return await get_audio_router().select_input(route.input_endpoint) diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000..b56f1c9 --- /dev/null +++ b/app/errors.py @@ -0,0 +1,13 @@ +class RoutingError(Exception): + """Basis fuer Fehler bei der Routing-/Komponentenauswahl. + + Wird in der API-Schicht zu HTTP 422 uebersetzt (Client-Konfigurationsfehler). + """ + + +class UnknownComponentError(RoutingError): + """Unbekannter Provider-Name fuer STT, LLM oder TTS.""" + + +class UnknownEndpointError(RoutingError): + """Ein angefragter Audio-Endpunkt (input/output) existiert nicht.""" diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..eadbef0 --- /dev/null +++ b/app/main.py @@ -0,0 +1,17 @@ +from fastapi import FastAPI +from app.api.health import router as health_router +from app.api.chat import router as chat_router +from app.api.transcribe import router as transcribe_router +from app.api.speak import router as speak_router +from app.api.devices import router as devices_router +from app.api.sessions import router as sessions_router +from app.api.config import router as config_router + +app = FastAPI(title="Voice Assistant Gateway") +app.include_router(health_router) +app.include_router(chat_router, prefix="/api") +app.include_router(transcribe_router, prefix="/api") +app.include_router(speak_router, prefix="/api") +app.include_router(devices_router, prefix="/api") +app.include_router(sessions_router, prefix="/api") +app.include_router(config_router, prefix="/api") diff --git a/app/pipeline/__init__.py b/app/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/pipeline/input_cleaner.py b/app/pipeline/input_cleaner.py new file mode 100644 index 0000000..6d06fb9 --- /dev/null +++ b/app/pipeline/input_cleaner.py @@ -0,0 +1,4 @@ +class InputCleaner: + async def run(self, text: str) -> str: + cleaned = " ".join(text.strip().split()) + return cleaned.replace(" äh ", " ").replace(" hm ", " ") diff --git a/app/pipeline/spoken_response_adapter.py b/app/pipeline/spoken_response_adapter.py new file mode 100644 index 0000000..7df2114 --- /dev/null +++ b/app/pipeline/spoken_response_adapter.py @@ -0,0 +1,37 @@ +import re + + +class SpokenResponseAdapter: + async def run(self, text: str, language: str = "de") -> str: + if not text: + return "" + + text = text.strip() + + # Markdown / Formatierung entfernen + text = re.sub(r"```[\s\S]*?```", " ", text) # code blocks + text = re.sub(r"`([^`]*)`", r"\1", text) # inline code + text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) # markdown links + text = re.sub(r"[*_~#>]+", " ", text) # markdown symbols + + # Listen entschärfen + text = re.sub(r"(?m)^\s*[-•]\s+", "", text) + text = re.sub(r"(?m)^\s*\d+\.\s+", "", text) + + # Mehrfache Leerzeichen / Zeilenumbrüche glätten + text = re.sub(r"\s+", " ", text).strip() + + # Für Voice natürlicher machen: Doppelpunkte/Semikolons etwas beruhigen, + # aber Uhrzeiten/Verhältnisse (10:30) nicht zerstören -> nur am Wortende ersetzen. + text = re.sub(r"[:;](?=\s|$)", ",", text) + + # Klammern meist nicht gut für TTS + text = text.replace("(", ", ") + text = text.replace(")", " ") + + # Abschlusspunktion sicherstellen + if text and not text.endswith((".", "!", "?")): + text += "." + + return text + diff --git a/app/pipeline/tts_normalizer.py b/app/pipeline/tts_normalizer.py new file mode 100644 index 0000000..34342e1 --- /dev/null +++ b/app/pipeline/tts_normalizer.py @@ -0,0 +1,59 @@ +import re + + +class TTSNormalizer: + async def run(self, text: str, language: str = "de") -> str: + if not text: + return "" + + normalized = text + + if language == "de": + replacements = { + "24/7": "vierundzwanzig sieben", + "&": " und ", + "%": " Prozent", + "€": " Euro", + "$": " Dollar", + "km/h": " Kilometer pro Stunde", + "z.B.": "zum Beispiel", + "bzw.": "beziehungsweise", + "u.a.": "unter anderem", + "ca.": "circa", + } + else: + replacements = { + "24/7": "twenty four seven", + "&": " and ", + "%": " percent", + "€": " euros", + "$": " dollars", + "km/h": " kilometers per hour", + "e.g.": "for example", + "i.e.": "that is", + } + + for old, new in replacements.items(): + normalized = normalized.replace(old, new) + + # Slashes zwischen Wörtern/Zahlen sprachfreundlicher machen + normalized = re.sub(r"(\w)/(\w)", r"\1 oder \2", normalized) + + # Datums-/Versions-/Bereichsstriche etwas entschärfen + normalized = normalized.replace("–", " bis ") + normalized = normalized.replace("—", ", ") + normalized = normalized.replace(" - ", ", ") + + # URLs und E-Mails nicht roh vorlesen + normalized = re.sub(r"https?://\S+", "Link", normalized) + normalized = re.sub(r"\b[\w\.-]+@[\w\.-]+\.\w+\b", "E-Mail-Adresse", normalized) + + # Mehrfache Leerzeichen glätten + normalized = re.sub(r"\s+", " ", normalized).strip() + + if normalized and not normalized.endswith((".", "!", "?")): + normalized += "." + + return normalized + + diff --git a/app/providers/__init__.py b/app/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/providers/llm/__init__.py b/app/providers/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/providers/llm/base.py b/app/providers/llm/base.py new file mode 100644 index 0000000..2e3517c --- /dev/null +++ b/app/providers/llm/base.py @@ -0,0 +1,5 @@ +from abc import ABC, abstractmethod + +class LLMProvider(ABC): + @abstractmethod + async def complete(self, text: str, session_id: str | None = None) -> str: ... diff --git a/app/providers/llm/local_openai_compatible.py b/app/providers/llm/local_openai_compatible.py new file mode 100644 index 0000000..147a404 --- /dev/null +++ b/app/providers/llm/local_openai_compatible.py @@ -0,0 +1,24 @@ +import httpx +from app.providers.llm.base import LLMProvider + +class LocalOpenAICompatibleLLM(LLMProvider): + def __init__(self, base_url: str, api_key: str, model: str): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.model = model + + async def complete(self, text: str, session_id: str | None = None) -> str: + payload = { + "model": self.model, + "messages": [{"role": "user", "content": text}], + "temperature": 0.3, + } + async with httpx.AsyncClient(timeout=120) as client: + response = await client.post( + f"{self.base_url}/chat/completions", + headers={"Authorization": f"Bearer {self.api_key}"}, + json=payload, + ) + response.raise_for_status() + data = response.json() + return data["choices"][0]["message"]["content"] diff --git a/app/providers/llm/openrouter.py b/app/providers/llm/openrouter.py new file mode 100644 index 0000000..66f507f --- /dev/null +++ b/app/providers/llm/openrouter.py @@ -0,0 +1,101 @@ +import httpx + +from app.providers.llm.base import LLMProvider + + +SYSTEM_PROMPT = """ +You are a voice assistant for spoken conversations with older adults. + +Speak naturally, clearly, and calmly. +Use short, simple sentences. +Prefer plain everyday language over technical wording. +Answer in the same language as the user, unless the user asks to switch languages. + +Important response rules: +- Output plain text only. +- No markdown. +- No bullet points. +- No numbered lists. +- No tables. +- No code. +- No emojis. +- No URLs unless the user explicitly asks for one. +- Do not use asterisks, hashtags, or formatting symbols. +- Do not write headings. +- Do not use long disclaimers. + +Voice style rules: +- Sound helpful, warm, and patient. +- Keep answers brief by default: 1 to 3 short sentences. +- If more detail is needed, explain step by step in natural spoken sentences. +- Ask at most one follow-up question at a time. +- If the answer contains several items, present them as natural speech, not as a list. +- Use wording that sounds good when spoken aloud. +- Avoid abbreviations when possible. +- Avoid symbols when words are better. +- Prefer complete spoken forms for dates, times, and numbers when useful. + +Safety and honesty rules: +- If you are unsure, say so briefly and clearly. +- Do not invent facts. +- If current real-world information is needed and unavailable, say that clearly. + +Always optimize your answer for listening, not for reading. +""".strip() + + +class OpenRouterLLMProvider(LLMProvider): + def __init__(self, api_key: str, model: str): + self.api_key = (api_key or "").strip() + self.model = (model or "").strip() + + async def complete(self, text: str, session_id: str | None = None) -> str: + if not self.api_key: + raise ValueError("OPENROUTER_API_KEY is empty") + if not self.model: + raise ValueError("OPENROUTER_LLM_MODEL is empty") + if not text or not text.strip(): + raise ValueError("LLM input text is empty") + + payload = { + "model": self.model, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": text.strip()}, + ], + } + + timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + try: + response = await client.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise RuntimeError( + f"OpenRouter LLM error {exc.response.status_code}: {exc.response.text}" + ) from exc + except httpx.TimeoutException as exc: + raise RuntimeError("OpenRouter LLM timeout") from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"OpenRouter LLM transport error: {exc}") from exc + + data = response.json() + + try: + content = data["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise RuntimeError(f"Unexpected OpenRouter LLM response: {data}") from exc + + if not content or not str(content).strip(): + raise RuntimeError("OpenRouter LLM returned empty content") + + return str(content).strip() + diff --git a/app/providers/stt/__init__.py b/app/providers/stt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/providers/stt/base.py b/app/providers/stt/base.py new file mode 100644 index 0000000..79a3844 --- /dev/null +++ b/app/providers/stt/base.py @@ -0,0 +1,5 @@ +from abc import ABC, abstractmethod + +class STTProvider(ABC): + @abstractmethod + async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: ... diff --git a/app/providers/stt/faster_whisper.py b/app/providers/stt/faster_whisper.py new file mode 100644 index 0000000..16fd21c --- /dev/null +++ b/app/providers/stt/faster_whisper.py @@ -0,0 +1,5 @@ +from app.providers.stt.base import STTProvider + +class FasterWhisperProvider(STTProvider): + async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: + return "[local transcription placeholder]" diff --git a/app/providers/stt/openrouter.py b/app/providers/stt/openrouter.py new file mode 100644 index 0000000..81a3f6a --- /dev/null +++ b/app/providers/stt/openrouter.py @@ -0,0 +1,46 @@ +import httpx + +from app.providers.stt.base import STTProvider + + +class OpenRouterSTTProvider(STTProvider): + def __init__(self, api_key: str, model: str): + self.api_key = (api_key or "").strip() + self.model = (model or "").strip() + + async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: + if not self.api_key: + raise ValueError("OPENROUTER_API_KEY is empty") + if not self.model: + raise ValueError("OPENROUTER_STT_MODEL is empty") + if not audio_bytes: + raise ValueError("STT input audio is empty") + + # OpenAI-kompatibler /audio/transcriptions-Endpunkt erwartet multipart/form-data + # mit binärem file-Feld, nicht JSON mit base64. + files = {"file": (f"audio.{fmt}", audio_bytes, f"audio/{fmt}")} + data: dict[str, str] = {"model": self.model} + if language: + data["language"] = language + + timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + try: + response = await client.post( + "https://openrouter.ai/api/v1/audio/transcriptions", + headers={"Authorization": f"Bearer {self.api_key}"}, + files=files, + data=data, + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise RuntimeError( + f"OpenRouter STT error {exc.response.status_code}: {exc.response.text}" + ) from exc + except httpx.TimeoutException as exc: + raise RuntimeError("OpenRouter STT timeout") from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"OpenRouter STT transport error: {exc}") from exc + + return response.json().get("text", "") diff --git a/app/providers/tts/__init__.py b/app/providers/tts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/providers/tts/base.py b/app/providers/tts/base.py new file mode 100644 index 0000000..8f17bae --- /dev/null +++ b/app/providers/tts/base.py @@ -0,0 +1,5 @@ +from abc import ABC, abstractmethod + +class TTSProvider(ABC): + @abstractmethod + async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: ... diff --git a/app/providers/tts/chatterbox.py b/app/providers/tts/chatterbox.py new file mode 100644 index 0000000..abdf298 --- /dev/null +++ b/app/providers/tts/chatterbox.py @@ -0,0 +1,5 @@ +from app.providers.tts.base import TTSProvider + +class ChatterboxTTSProvider(TTSProvider): + async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: + return b"" diff --git a/app/providers/tts/openrouter.py b/app/providers/tts/openrouter.py new file mode 100644 index 0000000..28186d1 --- /dev/null +++ b/app/providers/tts/openrouter.py @@ -0,0 +1,62 @@ +import httpx + +from app.providers.tts.base import TTSProvider + + +class OpenRouterTTSProvider(TTSProvider): + def __init__(self, api_key: str, model: str, voice: str): + self.api_key = (api_key or "").strip() + self.model = (model or "").strip() + self.voice = (voice or "").strip() + + async def synthesize( + self, + text: str, + voice: str | None = None, + audio_format: str = "pcm", + ) -> bytes: + if not self.api_key: + raise ValueError("OPENROUTER_API_KEY is empty") + if not self.model: + raise ValueError("OPENROUTER_TTS_MODEL is empty") + if not text or not text.strip(): + raise ValueError("TTS input text is empty") + + effective_voice = (voice or self.voice).strip() + if not effective_voice: + raise ValueError("TTS voice is required for OpenRouter TTS") + + payload = { + "model": self.model, + "input": text.strip(), + "voice": effective_voice, + "response_format": audio_format, + } + + timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + try: + response = await client.post( + "https://openrouter.ai/api/v1/audio/speech", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise RuntimeError( + f"OpenRouter TTS error {exc.response.status_code}: {exc.response.text}" + ) from exc + except httpx.TimeoutException as exc: + raise RuntimeError("OpenRouter TTS timeout") from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"OpenRouter TTS transport error: {exc}") from exc + + if not response.content: + raise RuntimeError("OpenRouter TTS returned empty audio content") + + return response.content + diff --git a/app/providers/tts/piper.py b/app/providers/tts/piper.py new file mode 100644 index 0000000..7a8e9b9 --- /dev/null +++ b/app/providers/tts/piper.py @@ -0,0 +1,5 @@ +from app.providers.tts.base import TTSProvider + +class PiperTTSProvider(TTSProvider): + async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: + return b"" diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..173957b --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,71 @@ +from typing import Literal +from pydantic import BaseModel, Field + + +class EndpointCapabilities(BaseModel): + id: str + kind: str + direction: Literal["input", "output"] + sample_rate: int = 16000 + channels: int = 1 + latency_class: Literal["low", "medium", "high"] = "medium" + supports_aec: bool = False + supports_barge_in: bool = False + networked: bool = False + bluetooth: bool = False + mobile: bool = False + default: bool = False + + +class AudioChunk(BaseModel): + data: bytes + sample_rate: int = 16000 + channels: int = 1 + format: str = "wav" + timestamp_ms: int = 0 + + +class PipelineTrace(BaseModel): + raw_transcript: str | None = None + cleaned_transcript: str | None = None + semantic_response: str | None = None + spoken_response: str | None = None + tts_ready_text: str | None = None + + +class SpeakRequest(BaseModel): + text: str = Field(min_length=1) + voice: str | None = None + language: str | None = None + output_endpoint: str | None = None + tts_provider: str | None = None + + +class ChatRequest(BaseModel): + text: str = Field(min_length=1) + input_endpoint: str | None = None + output_endpoint: str | None = None + language: str | None = None + voice: str | None = None + stt_provider: str | None = None + llm_provider: str | None = None + tts_provider: str | None = None + + +class SessionRouteRequest(BaseModel): + input_endpoint: str | None = None + output_endpoint: str | None = None + stt_provider: str | None = None + llm_provider: str | None = None + tts_provider: str | None = None + language: str | None = None + + +class RouteInfo(BaseModel): + input_endpoint: str + output_endpoint: str + stt_provider: str + llm_provider: str + tts_provider: str + language: str + diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat_client.py b/chat_client.py new file mode 100644 index 0000000..20ebb9e --- /dev/null +++ b/chat_client.py @@ -0,0 +1,76 @@ +import io +import wave +import requests +import soundfile as sf +import numpy as np +import subprocess +import sys + +GATEWAY_URL = "http://localhost:8003" +CHAT_ENDPOINT = f"{GATEWAY_URL}/api/chat" + +# feste Annahmen für Gemini 3.1 Flash TTS über OpenRouter +SAMPLE_RATE = 24000 +CHANNELS = 1 +SAMPLE_WIDTH = 2 # 16-bit PCM + + +def pcm_to_wav(pcm_bytes: bytes, wav_path: str) -> None: + """Rohes s16le-PCM in eine WAV-Datei schreiben.""" + with wave.open(wav_path, "wb") as wf: + wf.setnchannels(CHANNELS) + wf.setsampwidth(SAMPLE_WIDTH) + wf.setframerate(SAMPLE_RATE) + wf.writeframes(pcm_bytes) + + +def play_wav(wav_path: str) -> None: + """WAV-Datei abspielen (ffplay oder aplay/mpv, je nach System).""" + for cmd in ( + ["ffplay", "-nodisp", "-autoexit", wav_path], + ["aplay", wav_path], + ["mpv", wav_path], + ): + try: + subprocess.run(cmd, check=True) + return + except (FileNotFoundError, subprocess.CalledProcessError): + continue + print(f"Konnte keine geeignete Player-CLI finden für {wav_path}", file=sys.stderr) + + +def chat_and_play(text: str, language: str = "de") -> None: + payload = {"text": text, "language": language} + + resp = requests.post( + CHAT_ENDPOINT, + json=payload, + stream=True, + ) + + if not resp.ok: + print("HTTP", resp.status_code) + print(resp.text) + return + + pcm_bytes = b"".join(resp.iter_content(chunk_size=8192)) + + # Hinweis: Den Text-Trace (Transkript/Antwort) liefert /api/chat nur im JSON, + # wenn man ?debug=true anhängt - nicht als Header im Audio-Stream. + print("Audio-Format:", resp.headers.get("X-Audio-Format")) + print("Sample-Rate:", resp.headers.get("X-Audio-Sample-Rate")) + + wav_path = "chat_reply.wav" + pcm_to_wav(pcm_bytes, wav_path) + print(f"WAV gespeichert unter {wav_path}") + play_wav(wav_path) + + +if __name__ == "__main__": + if len(sys.argv) > 1: + user_text = " ".join(sys.argv[1:]) + else: + user_text = "Wie wird das Wetter morgen in Bünde?" + + chat_and_play(user_text, language="de") + diff --git a/config/voice-assistant.example.toml b/config/voice-assistant.example.toml new file mode 100644 index 0000000..3a5937c --- /dev/null +++ b/config/voice-assistant.example.toml @@ -0,0 +1,44 @@ +# Zentrale Konfiguration des Voice-Assistant-Gateways. +# +# WICHTIG: Secrets (API-Keys) gehoeren NICHT in diese Datei -> ausschliesslich +# ueber Umgebungsvariablen (z. B. OPENROUTER_API_KEY). +# +# Praezedenz (hoeher gewinnt): +# eingebaute Defaults < diese TOML-Datei < ENV/.env < Session-Route < Request +# +# Aktives Profil waehlen via ENV: VA_PROFILE=local-dev | hybrid | cloud +# Eigenen Pfad setzen via ENV: VA_CONFIG_FILE=/pfad/zu/voice-assistant.toml +# +# Diese Datei nach config/voice-assistant.toml kopieren und anpassen. + +# Basiswerte, die fuer alle Profile gelten (von Profilen ueberschreibbar). +[defaults] +default_language = "de" +default_input_endpoint = "local-default" +default_output_endpoint = "local-default" + +openrouter_stt_model = "openai/whisper-large-v3" +openrouter_tts_model = "openai/gpt-4o-mini-tts" +openrouter_tts_voice = "alloy" +openrouter_llm_model = "openai/gpt-4.1-mini" + +local_llm_base_url = "http://127.0.0.1:11434/v1" +local_llm_model = "llama3.1" + +# Reines lokales Setup (eigene Hardware/KI) - z. B. fuer Entwicklung/Offline-Test. +[profiles.local-dev] +default_stt_provider = "faster-whisper" +default_llm_provider = "local-openai-compatible" +default_tts_provider = "piper" + +# Hybrid: STT/TTS remote, Haupt-LLM lokal. +[profiles.hybrid] +default_stt_provider = "openrouter" +default_llm_provider = "local-openai-compatible" +default_tts_provider = "openrouter" + +# Voll-Cloud: alle KI-Module remote (Standard fuer den produktiven vHost-Betrieb). +[profiles.cloud] +default_stt_provider = "openrouter" +default_llm_provider = "openrouter" +default_tts_provider = "openrouter" diff --git a/deploy/voice-assistant.env.example b/deploy/voice-assistant.env.example new file mode 100644 index 0000000..ce273b5 --- /dev/null +++ b/deploy/voice-assistant.env.example @@ -0,0 +1,3 @@ +HOST=0.0.0.0 +PORT=8080 +OPENROUTER_API_KEY= diff --git a/deploy/voice-assistant.service b/deploy/voice-assistant.service new file mode 100644 index 0000000..aefca4b --- /dev/null +++ b/deploy/voice-assistant.service @@ -0,0 +1,15 @@ +[Unit] +Description=Voice Assistant Gateway +After=network.target + +[Service] +Type=simple +User=voice +WorkingDirectory=/opt/voice-assistant +EnvironmentFile=/etc/voice-assistant/voice-assistant.env +ExecStart=/opt/voice-assistant/.venv/bin/uvicorn app.main:app --host ${HOST} --port ${PORT} +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fd32a2a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + voice-assistant: + build: . + ports: + - "${PORT:-8080}:${PORT:-8080}" + env_file: + - .env + environment: + HOST: "${HOST:-0.0.0.0}" + PORT: "${PORT:-8080}" + # Secret aus der Shell-Umgebung durchreichen (nicht aus .env), z. B. export in ~/.bashrc + OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:?OPENROUTER_API_KEY ist nicht gesetzt}" + command: > + sh -c 'uvicorn app.main:app --host "$${HOST}" --port "$${PORT}"' + restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e1d220a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "voice-assistant-gateway" +version = "0.1.0" +description = "Modular voice assistant gateway with pluggable audio endpoints and provider adapters" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.116.0", + "uvicorn[standard]>=0.35.0", + "httpx>=0.28.0", + "pydantic>=2.11.0", + "pydantic-settings>=2.10.0", + "python-multipart>=0.0.20" +] + +[project.optional-dependencies] +test = [ + "pytest>=8.0" +] + +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] +exclude = ["deploy*", "tests*"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1879910 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +import pytest + +import app.dependencies as deps + + +@pytest.fixture(autouse=True) +def reset_state(): + """Isoliert den Singleton-Audio-Router (Loopback-Buffer) und Sessions je Test.""" + deps._audio_router = None + deps.session_manager._sessions.clear() + yield + deps._audio_router = None + deps.session_manager._sessions.clear() + + +def loopback_output(): + """Liefert den LoopbackOutput aus dem aktuellen Singleton-Router.""" + router = deps.get_audio_router() + for endpoint in router.outputs: + if type(endpoint).__name__ == "LoopbackOutput": + return endpoint + raise AssertionError("LoopbackOutput nicht gefunden") diff --git a/tests/test_audio_router.py b/tests/test_audio_router.py new file mode 100644 index 0000000..bfef57b --- /dev/null +++ b/tests/test_audio_router.py @@ -0,0 +1,43 @@ +import asyncio + +import pytest + +from app.audio.router import AudioRouter +from app.audio.endpoints.input.local_default import LocalDefaultInput +from app.audio.endpoints.input.bluetooth import BluetoothInput +from app.audio.endpoints.output.local_default import LocalDefaultOutput +from app.audio.endpoints.output.loopback import LoopbackOutput +from app.errors import UnknownEndpointError + + +def make_router(): + return AudioRouter( + inputs=[LocalDefaultInput(), BluetoothInput()], + outputs=[LocalDefaultOutput(), LoopbackOutput()], + ) + + +def test_select_default_output(): + router = make_router() + endpoint = asyncio.run(router.select_output()) + caps = asyncio.run(endpoint.capabilities()) + assert caps.default is True + assert caps.kind == "local-default" + + +def test_select_output_by_kind(): + router = make_router() + endpoint = asyncio.run(router.select_output("loopback")) + assert asyncio.run(endpoint.capabilities()).kind == "loopback" + + +def test_select_input_by_id(): + router = make_router() + endpoint = asyncio.run(router.select_input("local-default-mic")) + assert asyncio.run(endpoint.capabilities()).id == "local-default-mic" + + +def test_unknown_endpoint_raises(): + router = make_router() + with pytest.raises(UnknownEndpointError): + asyncio.run(router.select_output("does-not-exist")) diff --git a/tests/test_basic_layout.py b/tests/test_basic_layout.py new file mode 100644 index 0000000..3d45010 --- /dev/null +++ b/tests/test_basic_layout.py @@ -0,0 +1,7 @@ +from pathlib import Path + +def test_project_files_exist(): + root = Path(__file__).resolve().parents[1] + assert (root / "app" / "main.py").exists() + assert (root / "pyproject.toml").exists() + assert (root / "docker-compose.yml").exists() diff --git a/tests/test_config_profiles.py b/tests/test_config_profiles.py new file mode 100644 index 0000000..6932d6b --- /dev/null +++ b/tests/test_config_profiles.py @@ -0,0 +1,60 @@ +import pytest +from pydantic_settings import SettingsConfigDict + +from app import config as cfg + +EXAMPLE_TOML = str(cfg.BASE_DIR / "config" / "voice-assistant.example.toml") + + +class IsolatedSettings(cfg.Settings): + # .env ausblenden, damit nur TOML/Defaults/ENV-Monkeypatch zaehlen. + model_config = SettingsConfigDict(env_file=None, case_sensitive=False, extra="ignore") + + +def _clear_provider_env(monkeypatch): + for name in ("DEFAULT_STT_PROVIDER", "DEFAULT_LLM_PROVIDER", "DEFAULT_TTS_PROVIDER"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("VA_CONFIG_FILE", EXAMPLE_TOML) + + +def test_profile_local_dev(monkeypatch): + _clear_provider_env(monkeypatch) + monkeypatch.setenv("VA_PROFILE", "local-dev") + s = IsolatedSettings() + assert s.default_stt_provider == "faster-whisper" + assert s.default_llm_provider == "local-openai-compatible" + assert s.default_tts_provider == "piper" + + +def test_profile_cloud(monkeypatch): + _clear_provider_env(monkeypatch) + monkeypatch.setenv("VA_PROFILE", "cloud") + s = IsolatedSettings() + assert s.default_stt_provider == "openrouter" + assert s.default_llm_provider == "openrouter" + assert s.default_tts_provider == "openrouter" + + +def test_env_overrides_toml(monkeypatch): + _clear_provider_env(monkeypatch) + monkeypatch.setenv("VA_PROFILE", "local-dev") + monkeypatch.setenv("DEFAULT_LLM_PROVIDER", "openrouter") # ENV gewinnt ueber TOML + s = IsolatedSettings() + assert s.default_llm_provider == "openrouter" + assert s.default_tts_provider == "piper" # vom Profil, nicht ueberschrieben + + +def test_unknown_profile_raises(monkeypatch): + _clear_provider_env(monkeypatch) + monkeypatch.setenv("VA_PROFILE", "gibtsnicht") + with pytest.raises(ValueError): + IsolatedSettings() + + +def test_missing_config_file_falls_back_to_defaults(monkeypatch): + for name in ("DEFAULT_STT_PROVIDER", "DEFAULT_LLM_PROVIDER", "DEFAULT_TTS_PROVIDER"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("VA_CONFIG_FILE", "/nonexistent/voice-assistant.toml") + monkeypatch.delenv("VA_PROFILE", raising=False) + s = IsolatedSettings() + assert s.default_stt_provider == "openrouter" # eingebauter Field-Default diff --git a/tests/test_endpoints_e2e.py b/tests/test_endpoints_e2e.py new file mode 100644 index 0000000..17949da --- /dev/null +++ b/tests/test_endpoints_e2e.py @@ -0,0 +1,96 @@ +import json + +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.main import app +from tests.conftest import loopback_output + +client = TestClient(app) + + +def test_speak_loopback_collects_chunks(): + # piper-Stub liefert b"" -> kein Netzcall; Loopback sammelt den Chunk. + resp = client.post( + "/api/speak", + json={"text": "Hallo Welt", "tts_provider": "piper", "output_endpoint": "loopback"}, + ) + assert resp.status_code == 200 + assert resp.headers["X-Output-Endpoint"] == "loopback" + assert resp.headers["X-TTS-Provider"] == "piper" + assert len(loopback_output().chunks) == 1 + + +def test_unknown_endpoint_returns_422(): + resp = client.post( + "/api/speak", + json={"text": "x", "tts_provider": "piper", "output_endpoint": "gibtsnicht"}, + ) + assert resp.status_code == 422 + assert "gibtsnicht" in resp.json()["detail"] + + +def test_unknown_provider_returns_422(): + resp = client.post("/api/speak", json={"text": "x", "tts_provider": "gibtsnicht"}) + assert resp.status_code == 422 + + +def test_session_route_applies(): + client.post( + "/api/sessions/s1/route", + json={"tts_provider": "piper", "output_endpoint": "loopback"}, + ) + resp = client.post("/api/speak?session_id=s1", json={"text": "hallo"}) + assert resp.status_code == 200 + assert resp.headers["X-Output-Endpoint"] == "loopback" + assert resp.headers["X-TTS-Provider"] == "piper" + + +def test_chat_per_request_override_and_loopback(monkeypatch): + class StubLLM: + async def complete(self, text, session_id=None): + return "Mir geht es gut, danke." + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + return b"AUDIO" + + monkeypatch.setitem(deps.LLM_REGISTRY, "stub", lambda s: StubLLM()) + monkeypatch.setitem(deps.TTS_REGISTRY, "stub", lambda s: StubTTS()) + + resp = client.post( + "/api/chat?debug=true", + json={ + "text": "Wie geht es dir?", + "llm_provider": "stub", + "tts_provider": "stub", + "output_endpoint": "loopback", + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["route"]["llm_provider"] == "stub" + assert body["route"]["output_endpoint"] == "loopback" + assert body["trace"]["semantic_response"] == "Mir geht es gut, danke." + assert loopback_output().chunks[0].data == b"AUDIO" + + +def test_transcribe_local_provider(): + files = {"file": ("a.wav", b"RIFFdata", "audio/wav")} + data = {"stt_provider": "faster-whisper"} + resp = client.post("/api/transcribe", data=data, files=files) + assert resp.status_code == 200 + body = resp.json() + assert body["route"]["stt_provider"] == "faster-whisper" + assert body["trace"]["raw_transcript"] == "[local transcription placeholder]" + + +def test_config_endpoint_exposes_no_secrets(): + resp = client.get("/api/config") + assert resp.status_code == 200 + body = resp.json() + assert "piper" in body["available"]["tts_providers"] + assert "loopback" in {e["kind"] for e in body["available"]["output_endpoints"]} + # Keine echten Secrets im Body. + assert "sk-or-" not in json.dumps(body) + assert set(body["secrets"].keys()) == {"openrouter_api_key_set"} diff --git a/tests/test_routing.py b/tests/test_routing.py new file mode 100644 index 0000000..89f3c67 --- /dev/null +++ b/tests/test_routing.py @@ -0,0 +1,46 @@ +import pytest + +from app.config import Settings +from app.errors import UnknownComponentError +from app.dependencies import ( + resolve_route, + get_llm_provider, + get_tts_provider, + session_manager, +) + + +def test_default_route_from_settings(): + cfg = Settings() + route = resolve_route(cfg=cfg) + assert route.stt_provider == cfg.default_stt_provider + assert route.llm_provider == cfg.default_llm_provider + assert route.input_endpoint == cfg.default_input_endpoint + assert route.language == cfg.default_language + + +def test_request_overrides_win(): + route = resolve_route(overrides={"llm_provider": "openrouter", "output_endpoint": "loopback"}) + assert route.llm_provider == "openrouter" + assert route.output_endpoint == "loopback" + + +def test_session_then_request_precedence(): + session_manager.update("s_test", {"tts_provider": "piper", "language": "en"}) + route = resolve_route("s_test") + assert route.tts_provider == "piper" + assert route.language == "en" + + # Request schlaegt Session. + route2 = resolve_route("s_test", {"tts_provider": "chatterbox"}) + assert route2.tts_provider == "chatterbox" + assert route2.language == "en" + + +def test_registry_unknown_provider_raises(): + with pytest.raises(UnknownComponentError): + get_llm_provider("does-not-exist") + + +def test_registry_known_provider(): + assert type(get_tts_provider("piper")).__name__ == "PiperTTSProvider"