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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-17 01:48:56 +02:00
commit 293ed257db
72 changed files with 2612 additions and 0 deletions

31
.env.example Normal file
View file

@ -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

21
.gitignore vendored Normal file
View file

@ -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

223
BEDIENUNGSANLEITUNG.md Normal file
View file

@ -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.

7
Dockerfile Normal file
View file

@ -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}"]

View file

@ -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.<aktiv>]`; `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
```

28
Makefile Normal file
View file

@ -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 .

130
README.md Normal file
View file

@ -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).

0
app/__init__.py Normal file
View file

0
app/api/__init__.py Normal file
View file

92
app/api/chat.py Normal file
View file

@ -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))

37
app/api/config.py Normal file
View file

@ -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()),
},
}

12
app/api/devices.py Normal file
View file

@ -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()],
}

7
app/api/health.py Normal file
View file

@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
async def health():
return {"status": "ok"}

10
app/api/sessions.py Normal file
View file

@ -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}

60
app/api/speak.py Normal file
View file

@ -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))

50
app/api/transcribe.py Normal file
View file

@ -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()}

0
app/audio/__init__.py Normal file
View file

View file

View file

View file

@ -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: ...

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

View file

@ -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: ...

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

38
app/audio/router.py Normal file
View file

@ -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")

View file

@ -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}")

146
app/config.py Normal file
View file

@ -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.<VA_PROFILE>].
- 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()

0
app/core/__init__.py Normal file
View file

105
app/core/orchestrator.py Normal file
View file

@ -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

View file

@ -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

187
app/dependencies.py Normal file
View file

@ -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)

13
app/errors.py Normal file
View file

@ -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."""

17
app/main.py Normal file
View file

@ -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")

0
app/pipeline/__init__.py Normal file
View file

View file

@ -0,0 +1,4 @@
class InputCleaner:
async def run(self, text: str) -> str:
cleaned = " ".join(text.strip().split())
return cleaned.replace(" äh ", " ").replace(" hm ", " ")

View file

@ -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

View file

@ -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

View file

View file

View file

@ -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: ...

View file

@ -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"]

View file

@ -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()

View file

View file

@ -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: ...

View file

@ -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]"

View file

@ -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", "")

View file

View file

@ -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: ...

View file

@ -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""

View file

@ -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

View file

@ -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""

71
app/schemas.py Normal file
View file

@ -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

0
app/utils/__init__.py Normal file
View file

76
chat_client.py Normal file
View file

@ -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")

View file

@ -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"

View file

@ -0,0 +1,3 @@
HOST=0.0.0.0
PORT=8080
OPENROUTER_API_KEY=

View file

@ -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

15
docker-compose.yml Normal file
View file

@ -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

28
pyproject.toml Normal file
View file

@ -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*"]

22
tests/conftest.py Normal file
View file

@ -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")

View file

@ -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"))

View file

@ -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()

View file

@ -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

View file

@ -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"}

46
tests/test_routing.py Normal file
View file

@ -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"