diff --git a/.gitignore b/.gitignore index 479d860..e00fe4c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,3 @@ venv/ 3 TODO.md - -# Local voice models -config/voices/*.onnx -config/voices/*.onnx.json diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index c396874..1c5be23 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -135,7 +135,7 @@ pip install -e .[local] # installiert faster-whisper + piper-tts ### 2.2 Installation ```bash -cd my_voice_assistant_v3 +cd voice-assistant-scaffold python3 -m venv .venv source .venv/bin/activate pip install -U pip @@ -647,7 +647,7 @@ VA_PROFILE=hybrid make run # oder: systemctl --user restart voice-assis **Prüfen** (egal welche Richtung): ```bash -curl -s http://127.0.0.1:11434/v1/models | jq # Ollama (bzw. :8001 für llama.cpp) +curl http://127.0.0.1:11434/v1/models # Ollama (bzw. :8001 für llama.cpp) # danach im Admin → Status den LLM-Provider/das Modell kontrollieren oder kurz testen ``` @@ -776,16 +776,16 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr ┌─────────────────────────────────────────────────────────┐ │ 👄 Voice Assistant ............... [🇩🇪 ▾] [ ⋮ ] │ ├─────────────────────────────────────────────────────────┤ ┌─ Menü (⋮) ──────────────┐ -│ Angemeldet als ....................... Abmelden │ │ VORLESEN │ -├─────────────────────────────────────────────────────────┤ │ ▣ 📱 Im Gerät │ -│ Nachrichtenverlauf │ │ ▢ ⚡ Schnell │ -│ (eigene Nachrichten: blaue Blase rechts) │ │ ▢ ✨ Hohe Qualität │ -│ (Assistent: graue Blase links) │ │ ▢ ☁ Cloud │ +│ │ │ VORLESEN │ +│ Nachrichtenverlauf │ │ ▣ 📱 Im Gerät │ +│ (eigene Nachrichten: blaue Blase rechts) │ │ ▢ ☁ Server │ +│ (Assistent: graue Blase links) │ │ ▢ ✨ Beste Qualität │ │ │ │ ────────────────────── │ ├─────────────────────────────┬───────────────────────────┤ │ ✎ Neues Gespräch │ │ Texteingabe … [Senden] │ [🎤] │ │ 🌙 Tag-/Nachtmodus │ -└─────────────────────────────┴───────────────────────────┘ │ ⚙ Admin-Bereich │ - Statuszeile: „denkt …" / „verarbeite Sprache …" / leer └──────────────────────────┘ +└─────────────────────────────┴───────────────────────────┘ │ ────────────────────── │ + Statuszeile: „denkt …" / „verarbeite Sprache …" / leer │ Angemeldet · Admin · Ab │ + └──────────────────────────┘ ``` **Kopfzeile (immer schlank):** @@ -796,17 +796,14 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr | **Sprache ▾** | Antwortsprache (→ § 6.6): `🔄 Flex` = folgt der gesprochenen Sprache · feste Sprache (🇩🇪/🇬🇧/…) = Antwort + Stimme in dieser Sprache | | **⋮ Menü** | Öffnet die Einstellungen (unten) | -**Identitätsleiste (direkt unter der Kopfzeile):** links „Angemeldet als …" (SSO-Identität; -„Gast" ohne SSO), rechts **Abmelden** (Link zum SSO-Logout). - **Im ⋮-Menü:** | Element | Funktion | |---------|----------| -| **Vorlesen** | Wie wird die Antwort vorgelesen? `📱 Im Gerät` = das Handy liest selbst vor (kein Server-Audio → spart Daten; fällt auf Server zurück, wenn der Browser keine Stimmen hat) · `⚡ Schnell` = piper (lokal) · `✨ Hohe Qualität` = chatterbox (natürliche Stimme) · `☁ Cloud` = OpenRouter (Internet nötig). „Im Gerät" erscheint nur, wo der Browser es unterstützt. | +| **Vorlesen** | Wie wird die Antwort vorgelesen? `📱 Im Gerät` = das Handy liest selbst vor (kein Server-Audio → spart Daten) · `☁ Server` = piper (lokal, zuverlässig) · `✨ Beste Qualität` = chatterbox (natürliche Stimme). „Im Gerät" erscheint nur, wo der Browser es unterstützt. | | **✎ Neues Gespräch** | Frische Sitzung (Verlauf zurücksetzen) | | **🌙 Tag-/Nachtmodus** | Heller/dunkler Modus; folgt sonst dem Betriebssystem | -| **⚙ Admin-Bereich** | Öffnet das Admin-Panel — nur für Admin-Nutzer sichtbar (→ § 7.5) | +| **Konto** | „Angemeldet als …", **⚙ Admin** (nur Admins → § 7.5), **Abmelden** | **Unten:** @@ -1324,18 +1321,18 @@ Der Cache wird automatisch geleert. **Weg 2 — REST-API** (alle Sprachen, sofort wirksam): ```bash # Französischen Eintrag hinzufügen (kein Neustart nötig): -curl -s -X POST $URL/api/admin/pronunciation/fr \ - -H "X-Admin-Key: $ADMIN_API_KEY" \ +curl -X POST http://localhost:8080/api/admin/pronunciation/fr \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ - -d '{"section":"terms","key":"Schlüter","value":"Chluteur"}' | jq + -d '{"section":"terms","key":"Schlüter","value":"Chluteur"}' # Eintrag löschen: -curl -s -X DELETE $URL/api/admin/pronunciation/fr/terms/Schlüter \ - -H "X-Admin-Key: $ADMIN_API_KEY" | jq +curl -X DELETE http://localhost:8080/api/admin/pronunciation/fr/terms/Schlüter \ + -H "Authorization: Bearer " # Alle Einträge einer Sprache anzeigen: -curl -s $URL/api/admin/pronunciation/ru \ - -H "X-Admin-Key: $ADMIN_API_KEY" | jq +curl http://localhost:8080/api/admin/pronunciation/ru \ + -H "Authorization: Bearer " ``` **Weg 3 — YAML-Datei direkt editieren** (alle Sprachen): @@ -1356,7 +1353,7 @@ Spricht den Testsatz mit der aktuell eingestellten Stimme und Sprache. **Oder via curl:** ```bash -curl -s -X POST $URL/api/speak \ +curl -s -X POST http://localhost:8080/api/speak \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"text":"Hallo, ich bin Dieter Schlüter.","tts_provider":"piper","language":"fr"}' \ @@ -1411,12 +1408,12 @@ DEFAULT_LANGUAGE_MODE=fix # global: fix | flex (Admin: Tab „⚙ Einstellungen Pro Nutzer (dauerhaft): ```bash # Feste Sprache (Fix): -curl -s -X PUT $URL/api/me/prefs -H "Authorization: Bearer $TOKEN" \ - -H 'Content-Type: application/json' -d '{"language":"en","language_mode":"fix"}' | jq +curl -X PUT $URL/api/me/prefs -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"language":"en","language_mode":"fix"}' # Flex (Sprache folgt automatisch): -curl -s -X PUT $URL/api/me/prefs -H "Authorization: Bearer $TOKEN" \ - -H 'Content-Type: application/json' -d '{"language_mode":"flex"}' | jq +curl -X PUT $URL/api/me/prefs -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"language_mode":"flex"}' ``` Pro Aufruf: `{"text":"…","language":"en","language_mode":"fix"}` im Body. @@ -1897,7 +1894,7 @@ curl -s -X POST $URL/api/me/memories \ curl -s $URL/api/me/memories -H "Authorization: Bearer $TOKEN" | jq # Erinnerung löschen: -curl -s -X DELETE $URL/api/me/memories/ -H "Authorization: Bearer $TOKEN" | jq +curl -s -X DELETE $URL/api/me/memories/ -H "Authorization: Bearer $TOKEN" ``` In der Datenbank direkt ansehen/löschen (nötig z. B. wenn eine falsch extrahierte diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 4eb982d..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,69 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -> Sprache: Code-Kommentare, Doku und Commits sind auf Deutsch. Antworte und kommentiere auf Deutsch. - -## Befehle - -```bash -make install # venv anlegen + pip install -e .[test] -make run # Gateway (uvicorn --reload, Port aus .env, Standard 8080) -make test # gesamte Pytest-Suite (offline, ohne Netz/Kosten) -make smoke # echter End-to-End-Test gegen OpenRouter (kostet wenig) -make stop / restart # Gateway + llama.cpp stoppen / neu starten - -# Einzelner Test / einzelner Fall: -. .venv/bin/activate && pytest tests/test_routing.py -. .venv/bin/activate && pytest tests/test_routing.py::test_name -q -``` - -Profil beim Start wählen (überschreibt `.env`): `VA_PROFILE=cloud|hybrid|local-dev make run`. -Lokales LLM (llama.cpp-Docker) für `hybrid`/`local-dev`: `make llm-up` → `make llm-status` → `make llm-down`. -Backend wechseln (llama.cpp ↔ Ollama, teilen sich die GPU, nie gleichzeitig): `make llm-llamacpp` / `make llm-ollama`. - -Vor `make run`: wenn `.env` fehlt, wird sie aus `.env.example` erzeugt. Für Cloud/Hybrid `OPENROUTER_API_KEY` setzen. - -## Architektur (großes Bild) - -Modulares FastAPI-Gateway für einen seniorengerechten Sprachassistenten. Leitprinzip: **jede Achse (STT/LLM/TTS, Audio-I/O, lokal vs. remote) austauschbar, ohne Kern-Code zu ändern.** Ausführliche Referenz: `Docs/voice-assistant-architecture.md` und `BEDIENUNGSANLEITUNG.md`. - -### Zwei zentrale Mechanismen, die man verstehen muss - -1. **Geschichtete Konfiguration mit Profilen** (`app/config.py`). Präzedenz, höhere Ebene gewinnt: - `Defaults < config/voice-assistant.toml ([defaults]+[profiles.]) < ENV/.env < Nutzer-Prefs < Session-Route < Request`. - `VA_PROFILE` aktiviert ein Profil; `TomlProfileSource` hängt die TOML-Werte als pydantic-settings-Quelle **unter** ENV ein. **Secrets nie in die TOML** — nur in die Umgebung. Profile: `cloud` (alles OpenRouter), `hybrid` (LLM lokal), `local-dev` (alles lokal). - -2. **Route-Auflösung + Registries** (`app/dependencies.py`). `resolve_route(user, session_id, overrides)` liefert pro Aufruf eine `ResolvedRoute` (Endpunkte, STT/LLM/TTS-Provider, Sprache) nach obiger Präzedenz. Provider kommen aus `STT_REGISTRY`/`LLM_REGISTRY`/`TTS_REGISTRY` (`name -> factory(settings)`). **Neuen Provider = ein Registry-Eintrag + ABC-Implementierung** in `app/providers/{stt,llm,tts}/`. Unbekannter Name → `UnknownComponentError` → HTTP 422. - -### Speech-Pipeline (`app/pipeline/`, orchestriert in `app/core/orchestrator.py`) - -Kernidee: **semantische** Antwort ≠ **gesprochene** Antwort. Stufen: -`raw_transcript → cleaned_transcript (input_cleaner) → semantic_response (LLM) → spoken_response (spoken_response_adapter) → tts_ready_text (tts_normalizer) → TTS`. - -- `spoken_response_adapter`: macht LLM-Text sprechbar (Markdown raus, Listen → Ordinalwörter). -- `tts_normalizer`: füllt nur Phonemizer-Lücken (Ordinalia, Einheiten, Abkürzungen, YAML-Lexikon `config/pronunciation..yaml`). Stufe provider-abhängig (`TTS_NORMALIZE_LEVEL`): piper→`full`, Cloud-TTS→`light`. Deutsche Ordinalzahlen in `german_numbers.py`. -- `sentence_chunker`: inkrementelle Satzsegmentierung fürs satzweise Streaming-TTS; trennt bewusst **nicht** bei „1. Mai", „z. B." usw. - -Der Orchestrator schreibt synthetisiertes Audio **zusätzlich** in den Output-Endpunkt (`open→write_chunk→flush→close`) **und** gibt es als HTTP-Stream zurück. Bei `/api/chat` mit `session_id` lädt die API-Schicht den Verlauf aus dem Store, gibt ihn als `history` ans LLM und speichert beide Turns; ohne `session_id` zustandslos. - -### Querschnitt - -- **Persistenz** (`app/store.py`): `Store`-Interface + `SQLiteStore` (Nutzer, Sessions, Verlauf, Erinnerungen) — DB in `data/` (gitignored). -- **Auth** (`app/auth.py`): Bearer-Token (`require_user`) + Admin-Schutz (`require_admin`). `main.py` hat zwei Middlewares: Metriken + Web-UI-Gate (statische Seiten hinter Auth/SSO; `/api`, `/ws`, `/health` ausgenommen). `AUTH_ENABLED=false` → anonymer Nutzer (LAN-Dev). -- **Resilienz** (`app/providers/fallback.py`): Fallback-Ketten je Modul (`*_FALLBACK`). Metriken in `app/metrics.py` (`/api/metrics`, JSON + Prometheus). Tageskontingent `app/quota.py` (429). -- **Sicherheit** (`app/safety/`): zweistufige Notfall-Eskalation — schnelle Stichwort-Heuristik im Hot-Path (`emergency.py`) + LLM-Klassifikation als nicht-blockierender Hintergrund-Task (`llm_classifier.py`). -- **Auto-Erinnerungen** (`app/core/memory_extractor.py`): nach N Turns destilliert ein LLM dauerhafte Fakten als Hintergrund-Task. -- **Echtzeit** (`app/api/ws.py`): `/ws/chat` (Token-/Audio-Streaming) und `/ws/voice` (Audio→STT→Pipeline, VAD, Barge-in via `interrupt`). -- **Admin** (`app/api/admin.py`, `app/admin_llm.py`, `app/audit.py`, `app/runtime_config.py`): Live-Config, Backend-Wechsel, Gateway-Neustart, Lexika, Live-Log — schreibende Aktionen werden ins Audit-Log geschrieben. -- **Frontend** (`app/web/`): Tailwind ohne Build-Schritt, mobiltauglich. Geräte-TTS (Browser-SpeechSynthesis) mit Fallback auf Server-Audio. - -### Bewusste Platzhalter (nicht „kaputt") - -Audio-Endpunkte (`app/audio/endpoints/`) liefern leere Chunks — nur Auswahl/Lifecycle sind verdrahtet, kein echtes Hardware-I/O. `app/audio/transport_router.py` (Ebene 4 lokal/remote) existiert, ist aber noch nicht aktiv; lokal/remote trägt vorerst der Provider-Name. - -## Provider & lokale Module - -- Cloud: OpenRouter (STT/LLM/TTS). Lokales LLM: OpenAI-kompatibel (`local_openai_compatible.py`) gegen llama.cpp oder Ollama. -- Lokale KI ist **optional**: `pip install -e .[local]` (faster-whisper, piper). Beim Serverstart werden lokale Modelle vorgeladen (`app/core/warmup.py`). -- `chatterbox`-TTS (eigener GPU-HTTP-Dienst, Voice-Cloning) ist als wählbarer Provider angebunden (job-basiert `/speak`→`/status`→`/audio`). diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index 15b3e49..66b5315 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -170,6 +170,7 @@ ein No-op; `LoopbackOutput` sammelt die Chunks (testbar ohne Hardware). | `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) | +| `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig | | `GET /api/me` · `PUT /api/me/prefs` | aktueller Nutzer + dauerhafte Präferenzen | | `GET/POST/DELETE /api/me/memories` | Langzeit-Erinnerungen des Nutzers | | `GET /api/metrics` | Metriken (JSON / Prometheus) | @@ -180,32 +181,6 @@ 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. -### 7.1 Admin-API (alle hinter `require_admin`, Audit-geloggt) - -Trägt das Admin-Web-Panel (5 Bereiche + Übersicht-Dashboard). Schreibende Aktionen -werden ins Audit-Log geschrieben. - -| Methode & Pfad | Zweck | -|--------------------------------------------------|-------| -| `POST /api/admin/users` | Nutzer anlegen → Token einmalig | -| `GET /api/admin/users` | Nutzerliste | -| `PUT/DELETE /api/admin/users/{id}` | Nutzer ändern/löschen | -| `POST /api/admin/users/{id}/token` | Token neu ausstellen | -| `GET/POST/DELETE /api/admin/users/{id}/memories` | Erinnerungen je Nutzer | -| `GET /api/admin/users/{id}/sessions`·`/usage` | Sessions / Kontingent-Nutzung | -| `GET /api/admin/sessions/{id}/messages` | Gesprächsverlauf einsehen | -| `GET/PUT/DELETE /api/admin/config[/{key}]` | Live-Config lesen/setzen (z. B. `top_p`) | -| `GET /api/admin/llm/status` | LLM-/GPU-Status (read-only) | -| `POST /api/admin/llm/backend` | Backend wechseln (Ollama ↔ llama.cpp) | -| `POST /api/admin/gateway/restart` | Gateway aus dem Panel neu starten | -| `GET/POST/DELETE /api/admin/pronunciation/{lang}`| Aussprache-Lexika pflegen | -| `GET /api/admin/usage`·`/emergency-events` | Gesamt-Nutzung / Notfall-Ereignisse | -| `GET /api/admin/db-export` | SQLite-Export | -| `WS /api/admin/log` | Live-Log-Stream | - -Config-Änderungen sind als **Live** (sofort wirksam) oder **Restart** (Neustart nötig) -gekennzeichnet; der Backend-Wechsel und Live-Parameter wie `top_p` laufen ohne Neustart. - ## 8. Stand der Implementierung **Umgesetzt:** FastAPI-Gateway, alle o. g. REST-Endpunkte; OpenRouter-Adapter für @@ -220,15 +195,7 @@ Store, fließt ins LLM)**; **Langzeit-Erinnerungen pro Nutzer (als LLM-Kontext)* über WebSocket (`/ws/voice`: Audio rein → STT → Antwort-Pipeline) mit VAD-Aeusserungs- erkennung und Barge-in (`interrupt`)**; **Resilienz (Fallback-Ketten je Modul, In-Memory-Metriken `/api/metrics`), Tageskontingent pro Nutzer und heuristische -Notfall-Eskalation**; **Admin-Web-Panel (5 Bereiche + Übersicht-Dashboard) über die -Admin-API (§7.1) inkl. Backend-Wechsel, Gateway-Neustart, Live-Config-Parameter, -LLM-/GPU-Status, Aussprache-Lexika, Live-Log und Audit-Logging schreibender -Aktionen**; automatisierte Tests. - -**Web-/Mobil-Frontend:** schlankes Web-Interface unter `/` (Tailwind, kein Build), -mit **Geräte-TTS** (Browser-SpeechSynthesis auf Mobilgeräten; fällt auf Server-Audio -zurück, wenn keine lokalen Stimmen vorhanden) und **Ton-Presets** (Schnell → piper, -Hohe Qualität → chatterbox, Cloud → openrouter). Auth-Gate (Bearer-Token). +Notfall-Eskalation**; automatisierte Tests. **Echtes lokales STT & TTS:** `faster-whisper` (optionale Dependency `.[local]`, CTranslate2) transkribiert real; `piper` (in-process via piper-Python-API, Stimmmodell @@ -261,28 +228,23 @@ Verschlüsselung at-rest/in-transit, Löschkonzept, Einwilligung — „privacy ## 10. Verzeichnisstruktur (Ist) ```text -my_voice_assistant_v3/ +voice-assistant-scaffold/ ├── app/ │ ├── main.py # FastAPI-App + Router-Registrierung │ ├── config.py # Settings, TOML-Profile, Präzedenz │ ├── dependencies.py # Registries, ResolvedRoute, resolve_route, Store-/Router-Singleton │ ├── store.py # Persistenz: Store-Interface + SQLiteStore (Nutzer/Sessions/Verlauf) │ ├── auth.py # Bearer-Token-Auth (require_user) + Admin-Schutz -│ ├── audit.py # Audit-Log schreibender Admin-Aktionen -│ ├── admin_llm.py # LLM-/GPU-Status + Backend-Wechsel (Ollama ↔ llama.cpp) -│ ├── runtime_config.py # Live-Config (zur Laufzeit setzbare Parameter) │ ├── metrics.py # In-Memory-Metriken (Counter/Timer, JSON + Prometheus) │ ├── quota.py # Tageskontingent pro Nutzer (Kostenkontrolle) -│ ├── safety/ # emergency.py (Heuristik) + llm_classifier.py (Notfall-Klassifikation) +│ ├── safety/ # emergency.py: heuristische Notfall-Erkennung/-Eskalation │ ├── errors.py # RoutingError -> HTTP 422 │ ├── schemas.py # Pydantic-Modelle │ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me, ws -│ ├── core/ # orchestrator, memory_extractor (Auto-Erinnerungen), warmup (Modell-Vorladen) +│ ├── core/ # orchestrator │ ├── audio/ # router, transport_router, vad, endpoints/input|output/* │ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer, sentence_chunker, german_numbers -│ ├── providers/ # stt/ llm/ tts/ (openrouter + lokale + chatterbox) + fallback.py -│ ├── utils/ # Hilfsfunktionen -│ └── web/ # Web-/Admin-Frontend (Tailwind, kein Build) +│ └── providers/ # stt/ llm/ tts/ (openrouter + lokale Stubs) + fallback.py ├── config/ # voice-assistant.example.toml (+ lokale .toml, gitignored) ├── data/ # SQLite-DB (gitignored) ├── deploy/ # systemd unit + env-Beispiel diff --git a/README.md b/README.md index d28ed79..fc1fc9f 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,7 @@ Jede Achse — Hardware, Betrieb, Software — ist frei konfigurierbar, ohne Cod - **Gesprächsgedächtnis** pro Session (Verlauf) + Langzeit-Erinnerungen pro Nutzer (manuell + automatisch) - **WebSocket-Streaming:** Token-Streaming (LLM), Audio-Streaming (satzweises TTS), Sprach-Eingang, VAD, Barge-in - **Notfall-Eskalation:** zweistufig (Stichwörter + LLM-Klassifikation im Hintergrund) -- **Web-Interface** unter `/` (Tailwind, kein Build, mobiltauglich) mit Geräte-TTS (Browser-Sprachausgabe) und Ton-Presets (Schnell/Hohe Qualität/Cloud) -- **Admin-Panel** (5 Bereiche + Übersicht-Dashboard): Nutzer/Sessions, LLM-/GPU-Status, Backend-Wechsel + Gateway-Neustart, Live-Config, Aussprache-Lexika, Live-Log, Audit-Logging +- **Web-Interface** unter `/` (Tailwind, kein Build, mobiltauglich) - **Keine Secrets im Code** — API-Keys nur über die Umgebung --- @@ -194,7 +193,7 @@ make llm-status # Container- und HTTP-Status prüfen ## Projektstruktur (Kurzform) ```text -app/ Gateway: config, api/, core/, audio/, pipeline/, providers/, web/ (Frontend + Admin) +app/ Gateway: config, api/, core/, audio/, pipeline/, providers/ config/ voice-assistant.example.toml (lokale .toml ist gitignored) deploy/ systemd-Unit, nginx-Vorlage, env-Beispiel scripts/ voice_loop.py, chat_client.py, smoke_e2e.py, add_pronunciation.py diff --git a/app/api/chat.py b/app/api/chat.py index b0a3fa6..f56a0db 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -13,7 +13,6 @@ from app.dependencies import ( resolve_output_endpoint, get_store, piper_voice_for_language, - voice_for_route, ) from app.core.memory_extractor import maybe_schedule_extraction from app.quota import enforce_quota, record_usage, QuotaExceededError @@ -97,7 +96,7 @@ async def chat( raise HTTPException(status_code=429, detail=str(exc)) # Explizit angeforderte Stimme gewinnt; sonst folgt sie der Routensprache. - voice = payload.voice or voice_for_route(route.tts_provider, route.language, route.voice_gender) + voice = payload.voice or piper_voice_for_language(route.tts_provider, route.language) try: trace, audio = await orchestrator.chat_text( diff --git a/app/api/config.py b/app/api/config.py index c939955..6b1f1e5 100644 --- a/app/api/config.py +++ b/app/api/config.py @@ -1,4 +1,3 @@ -import httpx from fastapi import APIRouter from app.config import settings, active_profile @@ -13,60 +12,14 @@ from app.dependencies import ( router = APIRouter() -async def _chatterbox_reachable() -> bool: - try: - async with httpx.AsyncClient(timeout=1.5) as client: - r = await client.get(f"{settings.chatterbox_base_url.rstrip('/')}/status") - return r.status_code < 500 - except Exception: - return False - - -async def _cartesia_available() -> bool: - key = settings.cartesia_api_key.strip() - voice_id = settings.cartesia_voice_id.strip() - if not key or not voice_id: - return False - try: - async with httpx.AsyncClient(timeout=5.0) as client: - r = await client.get( - "https://api.cartesia.ai/voices", - headers={"X-API-Key": key, "Cartesia-Version": "2024-06-10"}, - ) - return r.status_code < 400 - except Exception: - return False - - -async def _openrouter_tts_available() -> bool: - """Prüft ob das konfigurierte TTS-Modell auf OpenRouter via /audio/speech erreichbar ist.""" - key = settings.openrouter_api_key.strip() - model = settings.openrouter_tts_model.strip() - if not key or not model: - return False - try: - async with httpx.AsyncClient(timeout=5.0) as client: - r = await client.post( - "https://openrouter.ai/api/v1/audio/speech", - headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, - json={"model": model, "input": "Test", "voice": settings.openrouter_tts_voice or "alloy", - "response_format": "mp3"}, - ) - # 200 = Audio OK, 4xx mit "does not exist" = Modell fehlt - if r.status_code == 200: - return True - body = r.text - return "does not exist" not in body and r.status_code < 500 - except Exception: - return False - - @router.get("/config") async def get_config(): - """Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine.""" + """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() - chatterbox_ok, openrouter_tts_ok, cartesia_ok = await _chatterbox_reachable(), await _openrouter_tts_available(), await _cartesia_available() return { "profile": active_profile(), "app_env": settings.app_env, @@ -77,9 +30,6 @@ async def get_config(): "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()], - "chatterbox_reachable": chatterbox_ok, - "openrouter_tts_available": openrouter_tts_ok, - "cartesia_available": cartesia_ok, }, "secrets": { "openrouter_api_key_set": bool(settings.openrouter_api_key.strip()), diff --git a/app/api/speak.py b/app/api/speak.py index e93428f..a39e8d1 100644 --- a/app/api/speak.py +++ b/app/api/speak.py @@ -12,7 +12,6 @@ from app.dependencies import ( resolve_output_endpoint, get_store, piper_voice_for_language, - voice_for_route, ) from app.quota import enforce_quota, record_usage, QuotaExceededError from app.schemas import SpeakRequest @@ -37,7 +36,7 @@ async def speak( try: route = resolve_route(user, session_id, overrides) # Explizit angefragte Stimme gewinnt; sonst folgt sie der Sprache (Piper-Parität zu /chat). - voice = payload.voice or voice_for_route(route.tts_provider, route.language, route.voice_gender) + voice = payload.voice or piper_voice_for_language(route.tts_provider, route.language) orchestrator = build_orchestrator(route) output = await resolve_output_endpoint(route) except SessionOwnershipError as exc: diff --git a/app/api/ws.py b/app/api/ws.py index 4199f5c..18d03ad 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -27,7 +27,6 @@ from app.dependencies import ( build_orchestrator, resolve_output_endpoint, piper_voice_for_language, - voice_for_route, ) from app.store import ANONYMOUS_USER_ID, SessionOwnershipError from app.audio.vad import EnergyVAD @@ -123,7 +122,7 @@ async def _run_turn( elif effective_voice is not None: voice = effective_voice else: - voice = voice_for_route(route.tts_provider, route.language, route.voice_gender) + voice = piper_voice_for_language(route.tts_provider, route.language) stream = bool(options.get("stream")) # text_only: Geräte-TTS (Web Speech API) spricht selbst -> kein Server-Audio erzeugen/senden. text_only = bool(options.get("text_only")) @@ -234,15 +233,15 @@ async def _voice_turn(websocket, store, user, session_id, audio, fmt, options): audio, fmt=fmt, language=None ) # Stimme folgt der erkannten Sprache (Fallback: Routensprache). - effective_voice = voice_for_route( - route.tts_provider, detected_language or route.language, route.voice_gender + effective_voice = piper_voice_for_language( + route.tts_provider, detected_language or route.language ) else: # Fix: konfigurierte Sprache an Whisper → Whisper übersetzt automatisch. transcript = await orchestrator.stt.transcribe(audio, fmt=fmt, language=route.language) detected_language = None # Stimme folgt der konfigurierten Sprache. - effective_voice = voice_for_route(route.tts_provider, route.language, route.voice_gender) + effective_voice = piper_voice_for_language(route.tts_provider, route.language) except Exception as exc: await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)}) return diff --git a/app/config.py b/app/config.py index bb2109a..14ce23f 100644 --- a/app/config.py +++ b/app/config.py @@ -141,11 +141,6 @@ class Settings(BaseSettings): piper_voice: str = "de_DE-thorsten-high" # Stimmmodell-Name (ohne .onnx) oder voller Pfad tts_sample_rate: int = 24000 # Ziel-Sample-Rate (das Gateway erwartet 24000 Hz) # --- Chatterbox-TTS (hohe Qualitaet + Voice-Cloning, eigener HTTP-Dienst) - - # --- Cartesia TTS (Cloud, sehr niedrige Latenz) ------------------------- - cartesia_api_key: str = "" - cartesia_voice_id: str = "" # UUID aus cartesia.ai/voices (Fallback) - cartesia_tts_model: str = "sonic-turbo" - cartesia_voices: str = "" # "lang:gender:uuid:name" je Zeile, # = Kommentar # sonic-2 | sonic-multilingual chatterbox_base_url: str = "http://127.0.0.1:9999" chatterbox_voice: str = "" # Pfad zu Referenz-WAV (Voice-Cloning) oder leer chatterbox_lang: str = "de" diff --git a/app/dependencies.py b/app/dependencies.py index b58407d..9179ae5 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -18,7 +18,6 @@ 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.cartesia import CartesiaTTSProvider from app.providers.tts.chatterbox import ChatterboxTTSProvider from app.providers.tts.piper import PiperTTSProvider from app.providers.fallback import ( @@ -81,10 +80,6 @@ TTS_REGISTRY = { s.chatterbox_timeout, voices_dir=s.chatterbox_voices_dir, ), - "cartesia": lambda s: CartesiaTTSProvider( - s.cartesia_api_key, s.cartesia_voice_id, s.cartesia_tts_model, s.tts_sample_rate, - voices_str=s.cartesia_voices, - ), "piper": lambda s: PiperTTSProvider( s.piper_bin, s.piper_voices_dir, s.piper_voice, s.tts_sample_rate ), @@ -156,74 +151,32 @@ ROUTE_KEYS = ( "tts_provider", "language", "language_mode", - "voice_gender", ) -# Stimm-Auswahl nach Sprache: (effektive) Sprache → beste Piper-Stimme (gender-agnostisch). +# Stimm-Auswahl nach Sprache: (effektive) Sprache → beste Piper-Stimme. +# Gilt in BEIDEN Modi — die gesprochene Stimme folgt immer der Antwortsprache. LANG_TO_PIPER_VOICE: dict[str, str] = { "de": "de_DE-thorsten-high", "en": "en_US-lessac-high", "fr": "fr_FR-siwis-medium", "es": "es_ES-sharvard-medium", "it": "it_IT-paola-medium", - "pt": "pt_BR-faber-medium", - "pl": "pl_PL-darkman-medium", - "ar": "ar_JO-kareem-medium", + "nl": "nl_NL-mls-medium", "ru": "ru_RU-irina-medium", "zh": "zh_CN-huayan-medium", "cmn": "zh_CN-huayan-medium", } -# Geschlechtsspezifische Piper-Stimmen für Sprachen mit m+f Varianten. -# Präzedenz: PIPER_VOICE_GENDERED > LANG_TO_PIPER_VOICE. -# ES sharvard ist ein Multi-Speaker-Modell: #0=M, #1=F (speaker_id_map: {'M':0,'F':1}) -PIPER_VOICE_GENDERED: dict[tuple[str, str], str] = { - ("de", "f"): "de_DE-kerstin-low", - ("de", "m"): "de_DE-thorsten-high", - ("en", "f"): "en_US-amy-medium", - ("en", "m"): "en_US-lessac-high", - ("fr", "f"): "fr_FR-siwis-medium", - ("fr", "m"): "fr_FR-tom-medium", - ("es", "f"): "es_ES-sharvard-medium#1", - ("es", "m"): "es_ES-sharvard-medium#0", - ("it", "f"): "it_IT-paola-medium", - ("it", "m"): "it_IT-riccardo-x_low", - ("ru", "f"): "ru_RU-irina-medium", - ("ru", "m"): "ru_RU-ruslan-medium", - ("pl", "f"): "pl_PL-gosia-medium", - ("pl", "m"): "pl_PL-darkman-medium", -} - def piper_voice_for_language(tts_provider: str, language: str | None) -> str | None: - """Kompatibilitäts-Alias für voice_for_route ohne gender.""" - return voice_for_route(tts_provider, language, "any") + """Liefert die zur Sprache passende Piper-Stimme (sonst None → Provider-Default). - -def voice_for_route( - tts_provider: str, language: str | None, voice_gender: str = "any" -) -> str | None: - """Liefert den voice-Parameter für synthesize() passend zum Provider. - - - piper: Piper-Stimmmodell-Name; geschlechtsspezifisch wenn Varianten vorhanden - - cartesia: Geschlechts-Code ("m"/"f"/"any") → CartesiaTTSProvider löst UUID auf - - andere: None (Provider nutzt seinen eigenen Default) + Nur für Piper sinnvoll: Cloud-TTS (OpenRouter) und Chatterbox haben eigene + Stimmnamen (z. B. "Zephyr") und steuern die Sprache anders. Bei None nimmt der + jeweilige Provider seine konfigurierte Default-Stimme. """ if tts_provider == "piper" and language: - lang = language.lower() - gender = (voice_gender or "any").lower() - # Geschlechtsspezifische Stimme: bevorzugte Richtung, dann Gegenteil - if gender in ("m", "f"): - candidates = [gender, "f" if gender == "m" else "m"] - else: # "any" → weiblich bevorzugt (konsistent mit Cartesia) - candidates = ["f", "m"] - for g in candidates: - voice = PIPER_VOICE_GENDERED.get((lang, g)) - if voice: - return voice - return LANG_TO_PIPER_VOICE.get(lang) - if tts_provider == "cartesia": - return voice_gender or "any" + return LANG_TO_PIPER_VOICE.get(language) return None @@ -236,7 +189,6 @@ class ResolvedRoute: tts_provider: str language: str language_mode: str = "fix" - voice_gender: str = "any" def as_dict(self) -> dict: return { @@ -247,7 +199,6 @@ class ResolvedRoute: "tts_provider": self.tts_provider, "language": self.language, "language_mode": self.language_mode, - "voice_gender": self.voice_gender, } diff --git a/app/providers/llm/openrouter.py b/app/providers/llm/openrouter.py index f29864a..408afa7 100644 --- a/app/providers/llm/openrouter.py +++ b/app/providers/llm/openrouter.py @@ -1,5 +1,3 @@ -import asyncio -import time from collections.abc import AsyncIterator import httpx @@ -11,37 +9,6 @@ from app.providers.llm.base import ( sse_delta, ) -_RETRY_STATUS = {404, 429, 500, 502, 503} - - -def _retry_wait(status: int, headers: httpx.Headers, attempt: int, body: str = "") -> float: - """Wartezeit: JSON retry_after_seconds > Retry-After > X-RateLimit-Reset > exponentiell.""" - if status == 429: - if body: - try: - import json as _json - d = _json.loads(body) - secs = d.get("error", {}).get("metadata", {}).get("retry_after_seconds") - if secs: - return max(1.0, min(float(secs) + 0.5, 120.0)) - except Exception: - pass - retry_after = headers.get("Retry-After") - if retry_after: - try: - return max(1.0, min(float(retry_after) + 0.5, 120.0)) - except ValueError: - pass - reset_ms = headers.get("X-RateLimit-Reset") - if reset_ms: - try: - wait = (int(reset_ms) / 1000.0) - time.time() + 0.5 - return max(1.0, min(wait, 120.0)) - except ValueError: - pass - return 30.0 - return 2.0 * attempt - SYSTEM_PROMPT = """ You are a voice assistant for spoken conversations with older adults. @@ -85,10 +52,9 @@ Always optimize your answer for listening, not for reading. class OpenRouterLLMProvider(LLMProvider): - def __init__(self, api_key: str, model: str, max_retries: int = 5): + def __init__(self, api_key: str, model: str): self.api_key = (api_key or "").strip() self.model = (model or "").strip() - self.max_retries = max(1, max_retries) def _build_messages( self, text: str, history: list[dict] | None, language: str | None = None @@ -108,6 +74,7 @@ class OpenRouterLLMProvider(LLMProvider): messages = [{"role": "system", "content": system_content}] if history: messages.extend(history) + # Sprach-Erinnerung direkt an der letzten Nutzer-Nachricht (schlägt History-Trägheit). messages.append({"role": "user", "content": with_lang_reminder(text.strip(), language)}) return messages @@ -122,52 +89,40 @@ class OpenRouterLLMProvider(LLMProvider): "model": self.model, "messages": self._build_messages(text, history, language=language), } + timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) - last_error: Exception | None = None - for attempt in range(1, self.max_retries + 1): - 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, - ) - if response.status_code in _RETRY_STATUS and attempt < self.max_retries: - last_error = RuntimeError( - f"OpenRouter LLM error {response.status_code}: {response.text}" - ) - await asyncio.sleep(_retry_wait(response.status_code, response.headers, attempt, response.text)) - continue - response.raise_for_status() - except httpx.HTTPStatusError as exc: - last_error = RuntimeError( - f"OpenRouter LLM error {exc.response.status_code}: {exc.response.text}" - ) - if exc.response.status_code in _RETRY_STATUS and attempt < self.max_retries: - await asyncio.sleep(_retry_wait(exc.response.status_code, exc.response.headers, attempt, exc.response.text)) - continue - raise last_error 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() + async with httpx.AsyncClient(timeout=timeout) as client: try: - content = data["choices"][0]["message"]["content"] - except (KeyError, IndexError, TypeError) as exc: - raise RuntimeError(f"Unexpected OpenRouter LLM response: {data}") from exc + 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 - if not content or not str(content).strip(): - raise RuntimeError("OpenRouter LLM returned empty content") + data = response.json() - return str(content).strip() + try: + content = data["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise RuntimeError(f"Unexpected OpenRouter LLM response: {data}") from exc - raise last_error or RuntimeError("OpenRouter LLM: all attempts failed") + if not content or not str(content).strip(): + raise RuntimeError("OpenRouter LLM returned empty content") + + return str(content).strip() async def stream( self, @@ -182,42 +137,30 @@ class OpenRouterLLMProvider(LLMProvider): "stream": True, } timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) - last_error: Exception | None = None - for attempt in range(1, self.max_retries + 1): - async with httpx.AsyncClient(timeout=timeout) as client: - try: - async with client.stream( - "POST", - "https://openrouter.ai/api/v1/chat/completions", - headers={ - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - }, - json=payload, - ) as response: - if response.status_code in _RETRY_STATUS and attempt < self.max_retries: - body = await response.aread() - last_error = RuntimeError( - f"OpenRouter LLM error {response.status_code}: " - f"{body.decode(errors='replace')}" - ) - await asyncio.sleep(_retry_wait(response.status_code, response.headers, attempt, body.decode(errors='replace'))) - continue - if response.status_code >= 400: - body = await response.aread() - raise RuntimeError( - f"OpenRouter LLM error {response.status_code}: " - f"{body.decode(errors='replace')}" - ) - async for line in response.aiter_lines(): - delta = sse_delta(line) - if delta: - yield delta - return - 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 + async with httpx.AsyncClient(timeout=timeout) as client: + try: + async with client.stream( + "POST", + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) as response: + if response.status_code >= 400: + body = await response.aread() + raise RuntimeError( + f"OpenRouter LLM error {response.status_code}: " + f"{body.decode(errors='replace')}" + ) + async for line in response.aiter_lines(): + delta = sse_delta(line) + if delta: + yield delta + 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 - raise last_error or RuntimeError("OpenRouter LLM: all attempts failed") diff --git a/app/providers/tts/cartesia.py b/app/providers/tts/cartesia.py deleted file mode 100644 index fbcbe21..0000000 --- a/app/providers/tts/cartesia.py +++ /dev/null @@ -1,142 +0,0 @@ -import httpx - -from app.providers.tts.base import TTSProvider - -_API_URL = "https://api.cartesia.ai/tts/bytes" -_API_VERSION = "2024-06-10" - -# Sprachen, die sonic-turbo unterstützt (Stand 2026-06). -_LANG_MAP = { - "de": "de", "en": "en", "fr": "fr", "es": "es", - "zh": "zh", "cmn": "zh", "pt": "pt", "ja": "ja", "ko": "ko", "hi": "hi", -} - -# sonic-turbo unterstützt diese Sprachen nicht — Fallback auf Piper empfohlen. -_UNSUPPORTED_LANGS = {"ar", "it", "ru", "nl", "pl", "sv", "tr"} - -_LANG_NAMES = { - "ar": "Arabisch", "it": "Italienisch", "ru": "Russisch", - "nl": "Niederländisch", "pl": "Polnisch", "sv": "Schwedisch", "tr": "Türkisch", -} - -_GENDER_CODES = {"m", "f", "any"} - - -def _parse_voice_map(voices_str: str) -> dict[tuple[str, str], dict]: - """Parst 'lang:gender:uuid:name'-Zeilen in ein (lang,gender)→{uuid,name}-Dict.""" - result = {} - for line in (voices_str or "").splitlines(): - line = line.strip() - if not line or line.startswith("#"): - continue - parts = line.split(":", 3) - if len(parts) != 4: - continue - lang, gender, uuid, name = [p.strip() for p in parts] - if lang and gender in ("m", "f") and uuid: - result[(lang, gender)] = {"uuid": uuid, "name": name} - return result - - -class CartesiaTTSProvider(TTSProvider): - def __init__( - self, - api_key: str, - voice_id: str, - model: str = "sonic-2", - sample_rate: int = 24000, - voices_str: str = "", - ): - self.api_key = (api_key or "").strip() - self.default_voice_id = (voice_id or "").strip() - self.model = (model or "sonic-2").strip() - self.sample_rate = int(sample_rate) - self.voice_map = _parse_voice_map(voices_str) - - def _resolve_voice_id(self, language: str | None, gender_hint: str | None) -> str: - """Wählt UUID aus Voice-Map. Fallback-Kette: f→m→any→default_voice_id.""" - lang = (language or "de").lower() - gender = (gender_hint or "any").strip().lower() - - # Präferenz-Reihenfolge: "any" → weiblich bevorzugt - if gender == "any" or gender not in ("m", "f"): - candidates = ["f", "m"] - elif gender == "f": - candidates = ["f", "m"] - else: - candidates = ["m", "f"] - - for g in candidates: - entry = self.voice_map.get((lang, g)) - if entry: - return entry["uuid"] - - return self.default_voice_id - - async def synthesize( - self, - text: str, - voice: str | None = None, - audio_format: str = "pcm", - language: str | None = None, - ) -> bytes: - if not self.api_key: - raise ValueError("CARTESIA_API_KEY is empty") - if not text or not text.strip(): - raise ValueError("TTS input text is empty") - - # voice ist entweder ein Geschlechts-Code ("m"/"f"/"any") oder eine direkte UUID - if voice and voice not in _GENDER_CODES: - voice_id = voice # direkte UUID-Übergabe - else: - voice_id = self._resolve_voice_id(language, voice) - - if not voice_id: - raise ValueError("CARTESIA_VOICE_ID ist nicht konfiguriert — bitte UUID in voice-assistant.toml eintragen") - - lang_key = (language or "de").lower() - if lang_key in _UNSUPPORTED_LANGS: - lang_label = _LANG_NAMES.get(lang_key, lang_key) - raise RuntimeError( - f"Cartesia unterstützt {lang_label} ({lang_key.upper()}) noch nicht als TTS-Sprache" - " — bitte Piper oder einen anderen Provider wählen" - ) - lang_code = _LANG_MAP.get(lang_key, "de") - - payload = { - "model_id": self.model, - "transcript": text.strip(), - "voice": {"mode": "id", "id": voice_id}, - "output_format": { - "container": "raw", - "encoding": "pcm_s16le", - "sample_rate": self.sample_rate, - }, - "language": lang_code, - } - - timeout = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0) - async with httpx.AsyncClient(timeout=timeout) as client: - try: - response = await client.post( - _API_URL, - headers={ - "X-API-Key": self.api_key, - "Cartesia-Version": _API_VERSION, - "Content-Type": "application/json", - }, - json=payload, - ) - response.raise_for_status() - except httpx.HTTPStatusError as exc: - raise RuntimeError( - f"Cartesia TTS error {exc.response.status_code}: {exc.response.text}" - ) from exc - except httpx.TimeoutException as exc: - raise RuntimeError("Cartesia TTS timeout") from exc - except httpx.HTTPError as exc: - raise RuntimeError(f"Cartesia TTS transport error: {exc}") from exc - - if not response.content: - raise RuntimeError("Cartesia TTS returned empty audio") - return response.content diff --git a/app/providers/tts/piper.py b/app/providers/tts/piper.py index f19cd04..e1d102c 100644 --- a/app/providers/tts/piper.py +++ b/app/providers/tts/piper.py @@ -104,17 +104,6 @@ class PiperTTSProvider(TTSProvider): except (OSError, KeyError, ValueError, TypeError): return self.target_rate # Konfig unlesbar -> Resampling überspringen - @staticmethod - def _parse_speaker(voice: str | None) -> tuple[str | None, int | None]: - """Trennt 'stimme#speaker_id' auf. Liefert (stimme, int_id) oder (stimme, None).""" - if voice and "#" in voice: - name, sid = voice.rsplit("#", 1) - try: - return name, int(sid) - except ValueError: - pass - return voice, None - async def synthesize( self, text: str, @@ -125,10 +114,9 @@ class PiperTTSProvider(TTSProvider): if not text or not text.strip(): raise ValueError("TTS input text is empty") - voice_name, speaker_id = self._parse_speaker(voice) - model, config = self._model_paths(voice_name) + model, config = self._model_paths(voice) pcm, native_rate = await asyncio.to_thread( - self._synthesize_sync, str(model), str(config), text.strip(), speaker_id + self._synthesize_sync, str(model), str(config), text.strip() ) if native_rate != self.target_rate: pcm = await asyncio.to_thread(_resample, pcm, native_rate, self.target_rate) @@ -137,30 +125,26 @@ class PiperTTSProvider(TTSProvider): return _wrap_wav(pcm, self.target_rate) return pcm - def _synthesize_sync(self, model: str, config: str, text: str, speaker_id: int | None = None) -> tuple[bytes, int]: + def _synthesize_sync(self, model: str, config: str, text: str) -> tuple[bytes, int]: if _PIPER_LIB: - from piper.config import SynthesisConfig - piper_voice = _load_voice(model) - syn_cfg = SynthesisConfig(speaker_id=speaker_id) if speaker_id is not None else None + voice = _load_voice(model) pcm = bytearray() rate = self.target_rate - for chunk in piper_voice.synthesize(text, syn_config=syn_cfg): + for chunk in voice.synthesize(text): pcm += chunk.audio_int16_bytes rate = chunk.sample_rate if not pcm: raise RuntimeError("Piper lieferte kein Audio") return bytes(pcm), rate # Fallback: piper-Binary (Subprozess pro Aufruf, langsamer). - return self._run_piper_binary(model, config, text, speaker_id) + return self._run_piper_binary(model, config, text) - def _run_piper_binary(self, model: str, config: str, text: str, speaker_id: int | None = None) -> tuple[bytes, int]: + def _run_piper_binary(self, model: str, config: str, text: str) -> tuple[bytes, int]: import subprocess if not (shutil.which(self.bin_path) or Path(self.bin_path).exists()): raise RuntimeError(f"Piper-Binary nicht gefunden: {self.bin_path}") cmd = [self.bin_path, "-m", model, "-c", config, "--output-raw"] - if speaker_id is not None: - cmd += ["--speaker", str(speaker_id)] proc = subprocess.run(cmd, input=text.encode("utf-8"), capture_output=True) if proc.returncode != 0: detail = proc.stderr.decode("utf-8", "replace").strip()[:300] diff --git a/app/web/app.js b/app/web/app.js index 234caa9..95c7cd3 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -20,29 +20,6 @@ function langOverrides() { } // Nutzer-Präferenzen als Request-Overrides für jeden Turn. -// Geschlechtspräferenz: 'm', 'f', oder 'any' (= weiblich bevorzugt) -let _voiceGender = 'f'; - -function applyGenderChoice(gender) { - // 'any' aus alten Prefs → weiblich als Standardwert - _voiceGender = (gender === 'm') ? 'm' : 'f'; - const btnM = document.getElementById('gender-m'); - const btnF = document.getElementById('gender-f'); - if (!btnM || !btnF) return; - const mActive = (_voiceGender === 'm'); - const fActive = (_voiceGender === 'f'); - // Aktiv: blauer Hintergrund + weiße Schrift - // Inaktiv: kein Hintergrund + dunkle Schrift (explizit, damit kein Vererbungsproblem) - btnM.classList.toggle('bg-blue-600', mActive); - btnM.classList.toggle('text-white', mActive); - btnM.classList.toggle('border-blue-600', mActive); - btnM.classList.toggle('text-slate-700', !mActive); - btnF.classList.toggle('bg-blue-600', fActive); - btnF.classList.toggle('text-white', fActive); - btnF.classList.toggle('border-blue-600', fActive); - btnF.classList.toggle('text-slate-700', !fActive); -} - function overrides() { const out = { ...langOverrides() }; if (isDeviceMode()) { @@ -50,7 +27,6 @@ function overrides() { } else if (ttsSel && ttsSel.value) { out.tts_provider = ttsSel.value; } - out.voice_gender = _voiceGender; return out; } @@ -69,7 +45,7 @@ async function savePrefs(obj) { const TTS_SUPPORTED = typeof window !== "undefined" && "speechSynthesis" in window; const LANG_BCP47 = { de: "de-DE", en: "en-US", fr: "fr-FR", es: "es-ES", - it: "it-IT", pt: "pt-BR", pl: "pl-PL", ar: "ar-SA", ru: "ru-RU", zh: "zh-CN", + it: "it-IT", nl: "nl-NL", ru: "ru-RU", zh: "zh-CN", }; function isMobile() { @@ -84,9 +60,7 @@ let _voices = []; function loadVoices() { _voices = TTS_SUPPORTED ? speechSynthesis.getVoices() : []; } if (TTS_SUPPORTED) { loadVoices(); - // Stimmen laden asynchron -> bei Eintreffen Preset-Zustand neu bewerten (entgrauen). - speechSynthesis.addEventListener("voiceschanged", () => { loadVoices(); refreshDevicePreset(); }); - setTimeout(() => refreshDevicePreset(), 1500); // Backstop, falls kein voiceschanged kommt + speechSynthesis.addEventListener("voiceschanged", loadVoices); } function pickVoice(bcp) { @@ -165,6 +139,13 @@ const PLAYBACK_KEY = "va-playback"; // geräte-lokal: "device" wenn Geräte-TTS // Vorrang: 1. lokale Wahl (localStorage), 2. Server-Pref (tts_provider), 3. Mobil-Default = Gerät. function applyPlaybackChoice(serverProvider) { if (!ttsSel) return; + // "Im Gerät"-Preset ausblenden, wenn der Browser keine Web Speech API hat. + if (!TTS_SUPPORTED) { + const opt = ttsSel.querySelector('option[value="device"]'); + if (opt) opt.remove(); + const btn = document.querySelector('.tts-preset[data-tts="device"]'); + if (btn) btn.classList.add("hidden"); + } const local = localStorage.getItem(PLAYBACK_KEY); if (local === "device" && TTS_SUPPORTED) { ttsSel.value = "device"; @@ -176,101 +157,15 @@ function applyPlaybackChoice(serverProvider) { } else if (!ttsSel.value) { ttsSel.value = "piper"; // Fallback: Server } - refreshDevicePreset(); // graut „Im Gerät" aus, wenn keine Stimmen + syncPresetUI -} - -// Graut „Im Gerät" aus, wenn keine Sprachstimmen vorhanden sind; entgraut automatisch, -// sobald welche da sind. Kein Aufzwingen: device wird nur (wieder) gewählt, wenn es die -// gespeicherte Präferenz ist. Ohne Stimmen NICHT auf der deaktivierten Option sitzen bleiben. -function refreshDevicePreset() { - const btn = document.querySelector('.tts-preset[data-tts="device"]'); - if (!btn || !ttsSel) return; - const ready = deviceVoicesReady(); - btn.disabled = !ready; - btn.classList.toggle("opacity-50", !ready); - btn.classList.toggle("cursor-not-allowed", !ready); - const sub = btn.querySelector(".tts-sub"); - if (sub) sub.textContent = ready - ? "Das Gerät liest vor · spart Daten" - : "keine Sprachstimmen im Browser"; - if (ready) { - if (localStorage.getItem(PLAYBACK_KEY) === "device" && ttsSel.value !== "device") { - ttsSel.value = "device"; // gespeicherte Präferenz wieder aktivieren - } - } else if (ttsSel.value === "device") { - ttsSel.value = "piper"; // Server statt deaktivierter Option (Präferenz bleibt) - } syncPresetUI(); } -// Graut "Hohe Qualität" (Chatterbox) aus, wenn kein GPU-Backend erreichbar ist. -async function refreshChatterboxPreset() { - const btn = document.querySelector('.tts-preset[data-tts="chatterbox"]'); - if (!btn || !ttsSel) return; - try { - const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json()); - const available = cfg.available?.chatterbox_reachable === true; - btn.disabled = !available; - btn.classList.toggle("opacity-50", !available); - btn.classList.toggle("cursor-not-allowed", !available); - const sub = btn.querySelector(".tts-sub"); - if (sub) sub.textContent = available - ? "Chatterbox · natürliche Stimme" - : "Chatterbox · kein GPU-Backend erreichbar"; - if (!available && ttsSel.value === "chatterbox") { - ttsSel.value = "piper"; - syncPresetUI(); - } - } catch (e) { /* ignorieren */ } -} - -// Graut "Cartesia" aus, wenn API-Key oder Voice-ID fehlt. -async function refreshCartesiaPreset() { - const btn = document.querySelector('.tts-preset[data-tts="cartesia"]'); - if (!btn || !ttsSel) return; - try { - const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json()); - const available = cfg.available?.cartesia_available === true; - btn.disabled = !available; - btn.classList.toggle("opacity-50", !available); - btn.classList.toggle("cursor-not-allowed", !available); - const sub = btn.querySelector(".tts-sub"); - if (sub) sub.textContent = available - ? "Cartesia Sonic · natürlich & schnell" - : "Cartesia · kein API-Key konfiguriert"; - if (!available && ttsSel.value === "cartesia") { - ttsSel.value = "piper"; - syncPresetUI(); - } - } catch (e) { /* ignorieren */ } -} - -// Graut "Cloud" (OpenRouter TTS) aus, wenn das TTS-Modell nicht verfügbar ist. -async function refreshOpenrouterTtsPreset() { - const btn = document.querySelector('.tts-preset[data-tts="openrouter"]'); - if (!btn || !ttsSel) return; - try { - const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json()); - const available = cfg.available?.openrouter_tts_available === true; - btn.disabled = !available; - btn.classList.toggle("opacity-50", !available); - btn.classList.toggle("cursor-not-allowed", !available); - const sub = btn.querySelector(".tts-sub"); - if (sub) sub.textContent = available - ? "OpenRouter · benötigt Internet" - : "OpenRouter · kein TTS-Modell verfügbar"; - if (!available && ttsSel.value === "openrouter") { - ttsSel.value = "piper"; - syncPresetUI(); - } - } catch (e) { /* ignorieren */ } -} - // Markiert das aktive Ton-Preset (Häkchen + Rahmen) passend zu #tts. function syncPresetUI() { const val = ttsSel ? ttsSel.value : ""; document.querySelectorAll(".tts-preset").forEach((btn) => { - const active = btn.dataset.tts === val; + const active = btn.dataset.tts === val || + (val === "openrouter" && btn.dataset.tts === "chatterbox"); // Cloud -> "Beste Qualität" btn.classList.toggle("ring-2", active); btn.classList.toggle("ring-blue-500", active); btn.classList.toggle("border-blue-500", active); @@ -328,10 +223,9 @@ function loadMe() { // Flex-Modus -> "flex", sonst die feste Sprache (Fallback: Flex). langSel.value = prefs.language_mode === "flex" ? "flex" - : (prefs.language || "de"); + : (prefs.language || "flex"); } applyPlaybackChoice(prefs.tts_provider); - applyGenderChoice(prefs.voice_gender || 'f'); return me; } catch (e) { $("#identity").textContent = "Verbindung fehlgeschlagen"; @@ -379,7 +273,6 @@ function addMessage(role, text, meta = {}) { div.className = BUBBLE[role] || BUBBLE.assistant; const span = document.createElement("span"); span.className = "bubble-text"; - span.dir = "auto"; span.textContent = text; div.appendChild(span); div._textEl = span; @@ -773,25 +666,7 @@ if (ttsSel) { }); } - -// Gender-Buttons -document.addEventListener("DOMContentLoaded", () => { - const btnM = document.getElementById("gender-m"); - const btnF = document.getElementById("gender-f"); - if (btnM) btnM.addEventListener("click", () => { - applyGenderChoice("m"); - savePrefs({ voice_gender: "m" }); - }); - if (btnF) btnF.addEventListener("click", () => { - applyGenderChoice("f"); - savePrefs({ voice_gender: "f" }); - }); -}); - loadMe(); -refreshChatterboxPreset(); -refreshCartesiaPreset(); -refreshOpenrouterTtsPreset(); // ═══════════════════════════════════════════════════════════════ // ADMIN PANEL @@ -1677,12 +1552,12 @@ const FIELD_META = { default_stt_provider: { ui: "combo", opts: ["openrouter","faster-whisper"], test: "stt" }, default_llm_provider: { ui: "combo", opts: ["openrouter","local-openai-compatible"], test: "llm" }, default_tts_provider: { ui: "combo", opts: ["openrouter","piper","chatterbox"], test: "tts" }, - default_language: { ui: "combo", opts: ["de","en","fr","es","it","pt","pl","ar"], - labels: { de:"Deutsch",en:"Englisch",fr:"Französisch",es:"Spanisch",it:"Italienisch",pt:"Portugiesisch",pl:"Polnisch",ar:"Arabisch" } }, + default_language: { ui: "combo", opts: ["de","en","fr","es","it","nl"], + labels: { de:"Deutsch",en:"Englisch",fr:"Französisch",es:"Spanisch",it:"Italienisch",nl:"Niederländisch" } }, openrouter_llm_model: { ui: "combo", opts: ["google/gemini-3.1-flash-lite","openai/gpt-4.1-mini","google/gemini-2.5-flash","anthropic/claude-haiku-4-5-20251001","meta-llama/llama-3.3-70b-instruct"], test: "llm" }, openrouter_tts_model: { ui: "combo", opts: ["google/gemini-3.1-flash-tts-preview","openai/gpt-4o-mini-tts"], test: "tts", testProvider: "openrouter" }, openrouter_tts_voice: { ui: "combo", opts: ["Zephyr","Puck","Charon","Kore","Fenrir","Leda","Orus","Aoede","Callirrhoe","Enceladus","Algieba","Despina","Achernar"], test: "tts", testProvider: "openrouter" }, - piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","de_DE-kerstin-low","en_US-lessac-high","en_US-amy-medium","en_US-ryan-high","en_GB-cori-high","fr_FR-siwis-medium","fr_FR-tom-medium","es_ES-sharvard-medium","it_IT-paola-medium","it_IT-riccardo-x_low","pt_BR-faber-medium","pl_PL-gosia-medium","pl_PL-darkman-medium","ar_JO-kareem-medium","ru_RU-irina-medium","ru_RU-ruslan-medium","zh_CN-huayan-medium"], test: "tts", testProvider: "piper" }, + piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","en_US-ryan-high","en_US-lessac-high","en_GB-cori-high","es_ES-sharvard-medium","fr_FR-siwis-medium","it_IT-paola-medium","nl_NL-mls-medium","ru_RU-irina-medium","zh_CN-huayan-medium"], test: "tts", testProvider: "piper" }, local_llm_system_prompt: { ui: "textarea", test: "llm" }, local_llm_temperature: { ui: "range", min: 0, max: 2, step: 0.1, test: "llm" }, local_llm_top_p: { ui: "range", min: 0, max: 1, step: 0.05, test: "llm" }, diff --git a/app/web/index.html b/app/web/index.html index ea91fc4..002d30f 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -163,9 +163,7 @@ - - - + @@ -227,9 +225,7 @@ - - - + @@ -237,18 +233,11 @@ class="h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 text-xl leading-none transition-colors">⋮ - -
- lade … - -
- @@ -266,53 +255,23 @@ class="tts-preset w-full flex items-center gap-3 rounded-lg border border-slate-200 dark:border-slate-700 px-3 py-2 text-left hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"> 📱 Im Gerät - Das Gerät liest vor · spart Daten + Das Gerät liest vor · spart Daten - - - - - - - -
-

Stimme

-
- -
@@ -328,11 +287,16 @@ 🌙 Tag-/Nachtmodus - + + + +
+

lade …

+
+ + +
@@ -342,7 +306,7 @@
-