diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index c396874..1473538 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -804,6 +804,7 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr | 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. | +| **♂ Männlich / ♀ Weiblich** | Geschlechtspräferenz der Stimme. Radiobuttons — immer genau einer aktiv. Standard: ♀. Wirkt bei Piper (wo ♂+♀-Varianten vorhanden: DE, EN, FR, ES, IT, RU, PL) und bei Cartesia (DE, EN, FR, ES, ZH, PT). Sprachen ohne Geschlechtspaar (AR, ZH, PT bei Piper) ignorieren die Einstellung. Präferenz wird dauerhaft gespeichert. | | **✎ 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) | @@ -1136,20 +1137,31 @@ Stimmmodell-Dateien (`.onnx` + `.onnx.json`) liegen im `PIPER_VOICES `rhasspy/piper-voices` → die zwei Dateien in das Verzeichnis kopieren, dann `PIPER_VOICE` setzen und Server neu starten. -Aktuell installierte Stimmen (Stand 2026-06-19): +Aktuell installierte Stimmen (Stand 2026-06-24): -| `PIPER_VOICE` | Sprache | Qualität | Phoneme | Hinweis | +Die Geschlechtspräferenz (♂/♀-Buttons in der Sidebar) wählt automatisch aus den +verfügbaren Varianten. Sprachen mit nur einer Stimme ignorieren die Einstellung. + +| `PIPER_VOICE` | Sprache | Qualität | Geschlecht | Hinweis | |---|---|---|---|---| -| `de_DE-thorsten-high` | Deutsch | **high** | 154 | **Default**, männlich | -| `en_US-ryan-high` | Englisch (US) | **high** | 130 | männlich | -| `en_US-lessac-high` | Englisch (US) | **high** | 154 | weiblich | -| `en_GB-cori-high` | Englisch (GB) | **high** | 157 | weiblich, britischer Akzent | -| `es_ES-sharvard-medium` | Spanisch | medium* | 154 | männlich | -| `fr_FR-siwis-medium` | Französisch | medium* | 154 | weiblich, korrekte Nasalvokale | -| `it_IT-paola-medium` | Italienisch | medium* | 154 | weiblich | -| `nl_NL-mls-medium` | Niederländisch | medium* | 159 | mehrere Sprecher | -| `ru_RU-irina-medium` | Russisch | medium* | 151 | weiblich | -| `zh_CN-huayan-medium` | Chinesisch (Mandarin) | medium* | 152 | weiblich | +| `de_DE-thorsten-high` | Deutsch | **high** | ♂ | **Default** | +| `de_DE-kerstin-low` | Deutsch | low | ♀ | | +| `en_US-lessac-high` | Englisch (US) | **high** | ♂ | | +| `en_US-amy-medium` | Englisch (US) | medium* | ♀ | | +| `en_US-ryan-high` | Englisch (US) | **high** | ♂ | alternativ | +| `en_GB-cori-high` | Englisch (GB) | **high** | ♀ | britischer Akzent | +| `fr_FR-siwis-medium` | Französisch | medium* | ♀ | korrekte Nasalvokale | +| `fr_FR-tom-medium` | Französisch | medium* | ♂ | | +| `es_ES-sharvard-medium` | Spanisch | medium* | ♂+♀ | Multi-Speaker-Modell: `#0`=♂, `#1`=♀ | +| `it_IT-paola-medium` | Italienisch | medium* | ♀ | | +| `it_IT-riccardo-x_low` | Italienisch | x_low | ♂ | | +| `pt_BR-faber-medium` | Portugiesisch (BR) | medium* | ♂ | | +| `pl_PL-gosia-medium` | Polnisch | medium* | ♀ | | +| `pl_PL-darkman-medium` | Polnisch | medium* | ♂ | | +| `ar_JO-kareem-medium` | Arabisch (JO) | medium* | ♂ | jordanischer Dialekt | +| `ru_RU-irina-medium` | Russisch | medium* | ♀ | | +| `ru_RU-ruslan-medium` | Russisch | medium* | ♂ | | +| `zh_CN-huayan-medium` | Chinesisch (Mandarin) | medium* | ♀ | | \* Für diese Sprachen existiert keine `high`-Variante in piper — `medium` ist das Maximum. @@ -1233,7 +1245,9 @@ config/ pronunciation.fr.yaml # Französisch pronunciation.es.yaml # Spanisch pronunciation.it.yaml # Italienisch - pronunciation.nl.yaml # Niederländisch + pronunciation.pt.yaml # Portugiesisch + pronunciation.pl.yaml # Polnisch + pronunciation.ar.yaml # Arabisch pronunciation.ru.yaml # Russisch pronunciation.zh.yaml # Chinesisch ``` @@ -1279,7 +1293,7 @@ Original: "Schlüter" DE: kein Eintrag nötig (nativ) FR: "Chluteur" → ch=/ʃ/ u=/y/ (= ü!) eur=/œʁ/ → /ʃlytœʁ/ ≈ /ʃlyːtɐ/ EN: "Schlueter" → espeak-en liest "ue" als /uː/ → /ˈʃluːtər/ ✓ -NL: "Schluuter" → nl "uu"=/yː/ (= ü) sch=/sx/ +PT: "Schluter" → pt "u"=/u/ "e"=/ɨ/ → /ʃlu.tɨɾ/ (EP) oder /ʃlu.tɛɾ/ (BP) RU: "Шлютер" → Kyrillisch für exakte Phoneme (Latein wird schlecht gelesen) ZH: "施吕特" → 施=Shī=/ʃɨ/ 吕=lǚ=/ly/ (≈ lü!) 特=tè=/tɛ/ ``` @@ -1293,13 +1307,17 @@ ZH: "施吕特" → 施=Shī=/ʃɨ/ 吕=lǚ=/ly/ (≈ lü!) 特=tè **Häufige Klangäquivalente je Sprache:** -| Laut | DE | EN | FR | NL | RU | ZH | +| Laut | DE | EN | FR | ES/PT | RU | ZH | |------|----|----|----|----|----|----| -| /ʃ/ | sch | sh | ch | sch (≈) | Ш | sh → 施/书 | -| /y/ (= ü) | ü | — | u | uu | Ю/Ю | ü → 吕/绿 | -| /x/ (= ch) | ch | kh | — | g/ch | Х | h → 哈 | +| /ʃ/ | sch | sh | ch | sh | Ш | sh → 施/书 | +| /y/ (= ü) | ü | — | u | — | Ю | ü → 吕/绿 | +| /x/ (= ch) | ch | kh | — | j | Х | h → 哈 | | /ts/ | z | ts | ts | ts | Ц | ts → 茨 | +> **Arabisch (AR):** espeak-ng liest arabischen Text direkt in korrekter Aussprache — +> Einträge in `pronunciation.ar.yaml` daher immer in **arabischer Schrift** (كذلك). +> Lateinische Umschriften werden schlecht gelesen. + **Sonderfall Russisch und Chinesisch:** espeak-ng liest lateinische Buchstaben in russischem / chinesischem Modus schlecht. Immer Kyrillisch (RU) bzw. Hanzi (ZH) verwenden: @@ -1385,15 +1403,17 @@ gesteuert. Es kennt zwei Arten von Werten: | Auswahl | Bedeutung | Whisper (STT) | LLM-Antwort + Stimme | |---------|-----------|---------------|----------------------| | **🔄 Flex** | keine feste Sprache — folgt automatisch der gesprochenen | erkennt die Sprache, **übersetzt nicht** | in der **erkannten** Sprache (blaue Blase zeigt das Original) | -| **🇩🇪 / 🇬🇧 / … (feste Sprache)** | „Fix": System bleibt bei dieser Sprache | bekommt die feste Sprache → **übersetzt** die Eingabe | immer in der **festen** Sprache, egal worin gefragt wurde | +| **🇩🇪 🇬🇧 🇫🇷 🇪🇸 🇮🇹 🇧🇷 🇵🇱 🇸🇦 🇷🇺 🇨🇳** | „Fix": System bleibt bei dieser Sprache | bekommt die feste Sprache → **übersetzt** die Eingabe | immer in der **festen** Sprache, egal worin gefragt wurde | **Beispiele** (Eingabe auf Französisch gesprochen): - **Flex** → blaue Blase: französischer Originaltext · Antwort + Stimme: Französisch. - **🇩🇪 DE** → blaue Blase: deutsche Übersetzung · Antwort + Stimme: Deutsch. -Die vorlesende Piper-Stimme folgt immer der Antwortsprache automatisch (z. B. `thorsten` -für Deutsch, `siwis` für Französisch — Zuordnung → `LANG_TO_PIPER_VOICE`). Bei Text-Chat +Die vorlesende Piper-Stimme folgt immer der Antwortsprache automatisch (z. B. `thorsten`/`kerstin` +für Deutsch, `siwis`/`tom` für Französisch — Zuordnung → `LANG_TO_PIPER_VOICE` und +`PIPER_VOICE_GENDERED`). Die aktive Geschlechtspräferenz (♂/♀-Buttons) bestimmt, welche +Variante gewählt wird. Bei Text-Chat im Flex-Modus (keine Audio-Erkennung möglich) bekommt das LLM **keine** Sprachvorgabe und antwortet von selbst in der Sprache der Eingabe; als Fallback gilt `DEFAULT_LANGUAGE`. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..7b72b7f --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,1475 @@ +# Deployment-Prompt: Voice Assistant auf jamulix.de + +Dieses Dokument beschreibt alle Änderungen, die am Repository +`my_voice_assistant_v3` für den Betrieb auf **jamulix.de** vorgenommen wurden. +Ziel: Ein zweites Claude-Instanz (oder ein Mensch) soll die Änderungen exakt +reproduzieren können. + +--- + +## Kontext + +- **Server:** jamulix.de, Ubuntu 24.04 LTS, 4 CPU-Kerne (QEMU), 3,8 GB RAM +- **Kein GPU** — Chatterbox ist nicht nutzbar +- **Installationspfad:** `/opt/voice-assistant` +- **Systembenutzer:** `voice` (GID 22, bereits vorhanden als Gruppe) +- **Konfigurationsverzeichnis:** `/etc/voice-assistant/` +- **Datenbank:** `/var/lib/voice-assistant/` +- **Profil:** `jamulix` (STT lokal, LLM Cloud, TTS lokal) +- **URL:** `https://voice.jamulix.de` (Subdomain, eigenes SSL-Zertifikat) +- **Authentifizierung:** Authelia 2FA (bereits auf Server installiert, läuft auf Port 9091) + +--- + +## Teil 1: Repository-Änderungen (Code) + +### 1.1 `app/api/config.py` — vollständig ersetzen + +Die originale Datei meldet keine Verfügbarkeit von Chatterbox oder Cloud-TTS. +Ersetze den gesamten Inhalt durch: + +```python +import httpx +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() + + +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 _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"}, + ) + 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.""" + route = resolve_route() + audio_router = get_audio_router() + chatterbox_ok, openrouter_tts_ok = await _chatterbox_reachable(), await _openrouter_tts_available() + 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()], + "chatterbox_reachable": chatterbox_ok, + "openrouter_tts_available": openrouter_tts_ok, + }, + "secrets": { + "openrouter_api_key_set": bool(settings.openrouter_api_key.strip()), + }, + } +``` + +**Zweck:** Das Frontend kann damit nicht verfügbare TTS-Optionen automatisch +ausgrauen, ohne dass der Nutzer erst auf einen Fehler stößt. + +--- + +### 1.2 `app/web/app.js` — Chatterbox/OpenRouter Verfügbarkeits-Refresh + +#### A) Zwei Funktionen nach `refreshDevicePreset()` einfügen + +Suche in `app.js` die Funktion `refreshDevicePreset()`. Sie endet mit: +```javascript + syncPresetUI(); +} +``` + +Füge **direkt danach** (vor `// Markiert das aktive Ton-Preset`) diese beiden +Funktionen ein: + +```javascript +// 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 "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 */ } +} +``` + +#### B) Zwei Aufrufe nach `loadMe()` einfügen + +Suche die Zeile `loadMe();`. Füge **direkt danach** ein: + +```javascript +refreshChatterboxPreset(); +refreshOpenrouterTtsPreset(); +``` + +--- + +### 1.3 `app/web/app.js` — Sprachen und Geschlechtspräferenz + +#### A) `LANG_BCP47`-Map anpassen + +`nl` entfernen, `pt` (Brasilianisch), `pl` (Polnisch) und `ar` (Arabisch) ergänzen: + +```javascript +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", +}; +``` + +#### B) Admin-Panel `default_language` anpassen + +```javascript +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" } }, +``` + +#### C) Admin-Panel `piper_voice` — vollständige Liste + +```javascript +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" }, +``` + +#### D) Geschlechtspräferenz — globale Variable und Hilfsfunktion + +Direkt vor `function overrides()` einfügen: + +```javascript +// Geschlechtspräferenz: 'm' oder 'f' — Radiobutton-Logik, immer genau einer aktiv +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: text-slate-700 explizit, damit kein Vererbungsproblem in Tag-Modus + 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); +} +``` + +#### E) `overrides()` — `voice_gender` mitsenden + +```javascript +function overrides() { + const out = { ...langOverrides() }; + if (isDeviceMode()) { + out.text_only = true; + } else if (ttsSel && ttsSel.value) { + out.tts_provider = ttsSel.value; + } + out.voice_gender = _voiceGender; + return out; +} +``` + +#### F) `loadMe()` — Präferenz laden + +In der `loadMe()`-Funktion direkt nach `applyPlaybackChoice(prefs.tts_provider)`: + +```javascript +applyGenderChoice(prefs.voice_gender || 'f'); +``` + +#### G) Gender-Button Event-Listener + +Nach dem `ttsSel`-Listener und vor `loadMe()` einfügen. +Radiobutton-Logik: Klick setzt immer direkt auf m oder f, kein Toggle zurück auf "any": + +```javascript +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" }); + }); +}); +``` + +#### H) Nachrichten-Bubbles: RTL-Unterstützung + +In `addMessage()` nach `span.className = "bubble-text";`: + +```javascript +span.dir = "auto"; +``` + +--- + +### 1.4 `app/web/index.html` — Sprachen und Geschlechts-UI + +#### A) `words-lang`-Select (Wörter-Trainer): `nl` → `ar`, PT und PL ergänzen + +```html + + + + +``` + +#### B) `lang-sel`-Select (Sprach-Auswahl oben): `nl` → `ar`, PT und PL ergänzen + +```html + + + + +``` + +#### C) Texteingabe-Feld: automatische Schreibrichtung + +```html + +``` + +#### D) Gender-Buttons in der Sidebar + +Direkt vor `` einfügen. +`text-slate-700` muss in der Basis-Klasse stehen, damit Tailwind JIT die Farbe generiert +(inaktive Buttons bekommen sie per JS, aktive `text-white` überschreibt sie): + +```html + +
+

Stimme

+
+ + +
+
+``` + +--- + +### 1.5 `app/providers/tts/piper.py` — Speaker-ID-Unterstützung ergänzen + +Piper-Modelle können mehrere Sprecher enthalten (z. B. `es_ES-sharvard-medium` mit +Speaker `M=0` und `F=1`). Die Stimmauswahl nutzt das Format `stimmname#speaker_id`, +z. B. `es_ES-sharvard-medium#0`. Drei Stellen in `piper.py` müssen angepasst werden: + +#### A) Neue statische Methode `_parse_speaker()` in `PiperTTSProvider` + +```python +@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 +``` + +#### B) `synthesize()` — `_parse_speaker` aufrufen + +```python +async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + 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) + pcm, native_rate = await asyncio.to_thread( + self._synthesize_sync, str(model), str(config), text.strip(), speaker_id + ) + ... +``` + +#### C) `_synthesize_sync()` — `speaker_id` via `SynthesisConfig` übergeben + +**Wichtig:** Die installierte `piper`-Library (Stand 2026-06) kennt kein `speaker_id`-Argument +direkt — der Parameter wird über `piper.config.SynthesisConfig` übergeben: + +```python +def _synthesize_sync(self, model, config, text, speaker_id=None): + 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 + pcm = bytearray() + rate = self.target_rate + for chunk in piper_voice.synthesize(text, syn_config=syn_cfg): + pcm += chunk.audio_int16_bytes + rate = chunk.sample_rate + if not pcm: + raise RuntimeError("Piper lieferte kein Audio") + return bytes(pcm), rate + return self._run_piper_binary(model, config, text, speaker_id) +``` + +#### D) `_run_piper_binary()` — `--speaker`-Flag ergänzen + +```python +def _run_piper_binary(self, model, config, text, speaker_id=None): + import subprocess + ... + 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) + ... +``` + +--- + +### 1.6 `app/providers/tts/cartesia.py` — vollständig ersetzen + +```python +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-turbo", + 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-turbo").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() + + # "any" und "f" → weiblich bevorzugt + if gender in ("any", "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 + 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 +``` + +**Wichtig:** Cartesia hat `sonic-2` und alle anderen älteren Modelle abgekündigt. +`sonic-turbo` ist das einzige aktuell funktionierende Modell (Stand 2026-06). +`sonic-turbo` unterstützt: DE, EN, FR, ES, ZH, PT, JA, KO, HI — aber **nicht** AR, IT, RU, PL, NL, SV, TR. + +--- + +### 1.7 `app/dependencies.py` — Routing-Erweiterungen + +#### A) `voice_gender` zu `ROUTE_KEYS` hinzufügen + +```python +ROUTE_KEYS = ( + "input_endpoint", + "output_endpoint", + "stt_provider", + "llm_provider", + "tts_provider", + "language", + "language_mode", + "voice_gender", # neu +) +``` + +#### B) `LANG_TO_PIPER_VOICE` — `nl` entfernen, neue Sprachen ergänzen + +```python +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", + "ru": "ru_RU-irina-medium", + "zh": "zh_CN-huayan-medium", + "cmn": "zh_CN-huayan-medium", +} +``` + +#### C) `PIPER_VOICE_GENDERED` neu anlegen (nach `LANG_TO_PIPER_VOICE`) + +ES sharvard ist ein Multi-Speaker-Modell (`speaker_id_map: {'M': 0, 'F': 1}`), deshalb +`#0`/`#1`-Suffix statt separater Dateien. ZH, PT, AR haben keinen gleichwertigen +Geschlechtspartner in der piper-voices-Bibliothek — dort greift `LANG_TO_PIPER_VOICE`. + +```python +# 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", +} +``` + +#### D) `piper_voice_for_language()` ersetzen durch `voice_for_route()` + +Die bisherige Funktion `piper_voice_for_language()` durch zwei Funktionen ersetzen: + +```python +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") + + +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) + """ + if tts_provider == "piper" and language: + lang = language.lower() + gender = (voice_gender or "any").lower() + 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 None +``` + +#### E) `voice_for_route` in `api/ws.py`, `api/chat.py`, `api/speak.py` einbinden + +In allen drei Dateien: +```python +from app.dependencies import ( + piper_voice_for_language, + voice_for_route, # neu importieren + ... +) +``` + +Alle Aufrufe von `piper_voice_for_language(route.tts_provider, route.language)` ersetzen durch: +```python +voice_for_route(route.tts_provider, route.language, route.voice_gender) +``` + +#### F) `ResolvedRoute` — `voice_gender` ergänzen + +```python +@dataclass +class ResolvedRoute: + input_endpoint: str + output_endpoint: str + stt_provider: str + llm_provider: str + tts_provider: str + language: str + language_mode: str = "fix" + voice_gender: str = "any" # neu +``` + +Und in `as_dict()`: +```python +"voice_gender": self.voice_gender, +``` + +#### G) CartesiaTTSProvider-Konstruktor — `voices_str` übergeben + +In `TTS_REGISTRY`: +```python +"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, +), +``` + +--- + +### 1.8 `app/config.py` — Cartesia-Felder ergänzen + +In der `Settings`-Klasse: + +```python +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 +``` + +--- + +## Teil 2: Deployment auf dem Server + +### 2.1 System-Benutzer und Verzeichnisse + +```bash +sudo useradd --system --no-create-home --shell /usr/sbin/nologin --gid voice voice +sudo mkdir -p /var/lib/voice-assistant/hf-cache +sudo mkdir -p /etc/voice-assistant +``` + +### 2.2 Repository klonen + +```bash +sudo git clone https://kitux.de/forgejo/dschlueter/my_voice_assistant_v3 /opt/voice-assistant +sudo chown -R voice:voice /opt/voice-assistant /var/lib/voice-assistant +``` + +### 2.3 Python-Umgebung + +```bash +cd /opt/voice-assistant +sudo -u voice python3 -m venv .venv +sudo -H -u voice .venv/bin/pip install -e ".[local]" +sudo -H -u voice .venv/bin/pip install faster-whisper piper-tts +``` + +### 2.4 Piper-Stimmen herunterladen (10 Sprachen, 13 Dateien) + +| Dateiname | Sprache | Geschlecht | Qualität | +|---|---|---|---| +| `de_DE-thorsten-high` | Deutsch | ♂ | high | +| `de_DE-kerstin-low` | Deutsch | ♀ | low | +| `en_US-lessac-high` | Englisch | ♂ | high | +| `en_US-amy-medium` | Englisch | ♀ | medium | +| `fr_FR-siwis-medium` | Französisch | ♀ | medium | +| `fr_FR-tom-medium` | Französisch | ♂ | medium | +| `es_ES-sharvard-medium` | Spanisch | ♂+♀ (Multi-Speaker: #0=M, #1=F) | medium | +| `it_IT-paola-medium` | Italienisch | ♀ | medium | +| `it_IT-riccardo-x_low` | Italienisch | ♂ | x_low | +| `pt_BR-faber-medium` | Portugiesisch (BR) | ♂ | medium | +| `pl_PL-gosia-medium` | Polnisch | ♀ | medium | +| `pl_PL-darkman-medium` | Polnisch | ♂ | medium | +| `ar_JO-kareem-medium` | Arabisch (JO) | ♂ | medium | +| `ru_RU-irina-medium` | Russisch | ♀ | medium | +| `ru_RU-ruslan-medium` | Russisch | ♂ | medium | +| `zh_CN-huayan-medium` | Chinesisch | ♀ | medium | + +```bash +BASE="https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0" +DIR="/opt/voice-assistant/config/voices" + +declare -A VOICES=( + ["de_DE-thorsten-high"]="de/de_DE/thorsten/high" + ["de_DE-kerstin-low"]="de/de_DE/kerstin/low" + ["en_US-lessac-high"]="en/en_US/lessac/high" + ["en_US-amy-medium"]="en/en_US/amy/medium" + ["fr_FR-siwis-medium"]="fr/fr_FR/siwis/medium" + ["fr_FR-tom-medium"]="fr/fr_FR/tom/medium" + ["es_ES-sharvard-medium"]="es/es_ES/sharvard/medium" + ["it_IT-paola-medium"]="it/it_IT/paola/medium" + ["it_IT-riccardo-x_low"]="it/it_IT/riccardo/x_low" + ["pt_BR-faber-medium"]="pt/pt_BR/faber/medium" + ["pl_PL-gosia-medium"]="pl/pl_PL/gosia/medium" + ["pl_PL-darkman-medium"]="pl/pl_PL/darkman/medium" + ["ar_JO-kareem-medium"]="ar/ar_JO/kareem/medium" + ["ru_RU-irina-medium"]="ru/ru_RU/irina/medium" + ["ru_RU-ruslan-medium"]="ru/ru_RU/ruslan/medium" + ["zh_CN-huayan-medium"]="zh/zh_CN/huayan/medium" +) + +for NAME in "${!VOICES[@]}"; do + PATH_PART="${VOICES[$NAME]}" + sudo -u voice wget -q "$BASE/$PATH_PART/$NAME.onnx" -O "$DIR/$NAME.onnx" & + sudo -u voice wget -q "$BASE/$PATH_PART/$NAME.onnx.json" -O "$DIR/$NAME.onnx.json" & +done +wait +``` + +### 2.5 Umgebungsvariablen `/etc/voice-assistant/voice-assistant.env` + +```ini +# Voice Assistant Gateway — jamulix.de +HOST=127.0.0.1 +PORT=8003 +OPENROUTER_API_KEY= +CARTESIA_API_KEY= +VA_PROFILE=jamulix +VA_CONFIG_FILE=/opt/voice-assistant/config/voice-assistant.toml +AUTH_ENABLED=true +ADMIN_API_KEY= +TRUSTED_AUTH_HEADER=Remote-User +TRUSTED_PROXY_IPS=127.0.0.1 +ADMIN_USERS=dschlueter +DB_PATH=/var/lib/voice-assistant/voice-assistant.db +HF_HOME=/var/lib/voice-assistant/hf-cache +SSO_LOGOUT_URL=https://auth.jamulix.de/?action=logout +``` + +```bash +sudo chmod 640 /etc/voice-assistant/voice-assistant.env +sudo chown root:voice /etc/voice-assistant/voice-assistant.env +``` + +### 2.6 TOML-Konfiguration `/opt/voice-assistant/config/voice-assistant.toml` + +```toml +# Konfiguration für jamulix.de +# Profil: jamulix — STT lokal (faster-whisper), LLM OpenRouter, TTS Piper/Cartesia + +[defaults] +default_language = "de" +default_input_endpoint = "local-default" +default_output_endpoint = "local-default" + +openrouter_llm_model = "mistralai/mistral-small-24b-instruct-2501" +llm_fallback = "openrouter" + +openrouter_tts_model = "openai/gpt-audio-mini" +openrouter_tts_voice = "alloy" +openrouter_stt_model = "openai/whisper-large-v3" + +piper_voices_dir = "/opt/voice-assistant/config/voices" +piper_voice = "de_DE-thorsten-high" + +# Cartesia Stimmen: Format lang:gender:uuid:name (# = Kommentar / Alternative) +# Modell: sonic-turbo (einziges aktuell verfügbares Modell, Stand 2026-06) +# Unterstützte Sprachen: DE, EN, FR, ES, ZH, PT — NICHT IT, RU, AR, PL +cartesia_voices = """ +de:m:b7187e84-fe22-4344-ba4a-bc013fcb533e:Sebastian +de:f:38aabb6a-f52b-4fb0-a3d1-988518f4dc06:Alina +en:m:4f7f1324-1853-48a6-b294-4e78e8036a83:Caspar +en:f:62ae83ad-4f6a-430b-af41-a9bede9286ca:Gemma +fr:m:0418348a-0ca2-4e90-9986-800fb8b3bbc0:Antoine +fr:f:7c58f4a4-a72c-42fa-a503-41b9408820f3:Amélie +es:m:2695b6b5-5543-4be1-96d9-3967fb5e7fec:Augustin (mx) +# es:m:13ff5deb-2591-42ad-a356-63a04e524411:Marcos (cast) +es:f:5c5ad5e7-1020-476b-8b91-fdcbe9cc313c:Daniela (mx) +# es:f:9d8c6b2e-0a23-4a15-ae1b-121d5b5af417:Nuria (cast) +pt:m:b603811e-54c2-4a0a-8854-09eab9ffa63f:Bruno +pt:f:c9611be8-aae9-4a93-bb1c-98dd6b7d52a4:Isabella +# it:m:408daed0-c597-4c27-aae8-fa0497d644bf:Matteo — sonic-turbo unterstützt IT nicht +# it:f:d718e944-b313-4998-b011-d1cc078d4ef3:Liv — sonic-turbo unterstützt IT nicht +# ar:m:40f9b5d1-bc79-43a6-b5cc-1c692b3b40d2:Zain — Cartesia unterstützt AR nicht +# ar:f:731ace69-ee17-41bc-8c6f-665c9f1db95c:Fatima — Cartesia unterstützt AR nicht +# ru:m:1e4176b1-3db9-44d6-a601-4fe68b041942:Sergei — sonic-turbo unterstützt RU nicht +# ru:f:064b17af-d36b-4bfb-b003-be07dba1b649:Tatiana — sonic-turbo unterstützt RU nicht +# pl:m — sonic-turbo unterstützt PL nicht (Piper: pl_PL-darkman-medium) +# pl:f — sonic-turbo unterstützt PL nicht (Piper: pl_PL-gosia-medium) +zh:m:eda5bbff-1ff1-4886-8ef1-4e69a77640a0:Kai +zh:f:7a5d4663-88ae-47b7-808e-8f9b9ee4127b:Hua +""" + +[profiles.jamulix] +default_stt_provider = "faster-whisper" +default_llm_provider = "openrouter" +default_tts_provider = "piper" + +[profiles.cloud] +default_stt_provider = "openrouter" +default_llm_provider = "openrouter" +default_tts_provider = "openrouter" +``` + +```bash +sudo chown voice:voice /opt/voice-assistant/config/voice-assistant.toml +``` + +### 2.7 systemd-Service installieren + +Die Datei `deploy/voice-assistant.service` aus dem Repository verwenden: + +```bash +sudo cp /opt/voice-assistant/deploy/voice-assistant.service \ + /etc/systemd/system/voice-assistant.service +sudo systemctl daemon-reload +sudo systemctl enable --now voice-assistant +``` + +### 2.8 nginx-Konfiguration `/etc/nginx/sites-available/voice.jamulix.de` + +**Wichtig:** `X-Forwarded-For` muss leer gesetzt werden (`""`), da uvicorn +sonst die echte Browser-IP als Client-Host interpretiert und der +`TRUSTED_PROXY_IPS=127.0.0.1`-Check fehlschlägt. + +```nginx +server { + listen 80; + listen [::]:80; + server_name voice.jamulix.de; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name voice.jamulix.de; + server_tokens off; + + ssl_certificate /etc/letsencrypt/live/voice.jamulix.de/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/voice.jamulix.de/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Authelia Auth-Check (intern) + location = /authelia-check { + internal; + proxy_pass http://127.0.0.1:9091/api/verify; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URL $scheme://$http_host$request_uri; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Uri $request_uri; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Method $request_method; + } + + # Health-Check (öffentlich) + location = /health { + proxy_pass http://127.0.0.1:8003/health; + } + + # WebSocket-Routen /ws/chat und /ws/voice + location /ws/ { + auth_request /authelia-check; + auth_request_set $user $upstream_http_remote_user; + error_page 401 =302 https://auth.jamulix.de/?rd=$scheme://$http_host$request_uri; + + proxy_pass http://127.0.0.1:8003; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For ""; + proxy_set_header Remote-User $user; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + # Alle anderen Routen — Authelia 2FA-Schutz + location / { + auth_request /authelia-check; + auth_request_set $user $upstream_http_remote_user; + error_page 401 =302 https://auth.jamulix.de/?rd=$scheme://$host$request_uri; + + proxy_pass http://127.0.0.1:8003; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For ""; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Remote-User $user; + } +} +``` + +```bash +sudo ln -sf /etc/nginx/sites-available/voice.jamulix.de \ + /etc/nginx/sites-enabled/voice.jamulix.de +sudo certbot --nginx -d voice.jamulix.de --non-interactive --agree-tos \ + -m dieter.schlueter@linix.de +sudo systemctl reload nginx +``` + +--- + +## Teil 3: Bekannte Fallstricke + +| Problem | Ursache | Fix | +|---|---|---| +| `PermissionError: /home/voice` | Kein Home-Verzeichnis für `voice`-User | `HF_HOME=/var/lib/voice-assistant/hf-cache` in env setzen | +| `{"detail":"Authentication required"}` | uvicorn liest `X-Forwarded-For` als Client-IP | `proxy_set_header X-Forwarded-For ""` in nginx | +| WebSocket 404 | nginx hatte keine `/ws/`-Location mit Upgrade-Headern | Separate `location /ws/` mit `Upgrade`/`Connection`-Headern | +| Piper findet Stimme nicht | `piper_voices_dir` zeigt auf falsches Verzeichnis | In TOML auf `/opt/voice-assistant/config/voices` setzen | +| OpenRouter Rate-Limit 429 | Free-Tier-Modelle sind gemeinsam genutzt | Bezahlmodell verwenden (`mistral-small-24b`) | +| Authelia `/api/authz/forward-auth` gibt 400 | Falscher Endpoint-Typ für nginx auth_request | `/api/verify` mit `X-Original-URL`-Header verwenden | +| Cartesia `Model sunsetted` | `sonic-2` und alle Datums-Varianten sind abgekündigt | Modell auf `sonic-turbo` setzen (in `config.py` und TOML) | +| Cartesia 400 bei IT/RU/AR/PL | `sonic-turbo` unterstützt diese Sprachen nicht | Für diese Sprachen Piper verwenden; Cartesia gibt klare Fehlermeldung | +| Gender-Button: weiße Schrift auf weißem Grund (Tag-Modus) | Tailwind JIT generiert `text-slate-700` nur, wenn die Klasse im HTML-Quelltext vorkommt — per JS dynamisch hinzugefügte Klassen werden nicht gescannt | `text-slate-700` in die Button-Basis-Klasse im HTML eintragen; `applyGenderChoice()` setzt sie explizit für den inaktiven Zustand | +| `PiperVoice.synthesize()` kennt kein `speaker_id`-Argument | Die piper-Python-Library erwartet `speaker_id` in `SynthesisConfig`, nicht als direkten Parameter | `from piper.config import SynthesisConfig; voice.synthesize(text, syn_config=SynthesisConfig(speaker_id=N))` | + +--- + +## Teil 4: Verifikation + +```bash +# Service läuft? +sudo systemctl status voice-assistant + +# Health-Check +curl -s https://voice.jamulix.de/health + +# Capability-Flags korrekt? +curl -s http://127.0.0.1:8003/api/config | python3 -c " +import json,sys; d=json.load(sys.stdin) +print('chatterbox_reachable: ', d['available']['chatterbox_reachable']) +print('openrouter_tts_available:', d['available']['openrouter_tts_available']) +" + +# WebSocket erreichbar? +curl -s --no-buffer \ + -H "Upgrade: websocket" -H "Connection: Upgrade" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ + -H "Sec-WebSocket-Version: 13" -H "Remote-User: dschlueter" \ + "http://127.0.0.1:8003/ws/voice?session_id=test" | head -1 +# Erwartet: HTTP/1.1 101 Switching Protocols + +# Piper — alle Geschlechtspaare testen (DE/EN/FR/ES/IT/RU/PL je ♂+♀) +cd /opt/voice-assistant && sudo -u voice .venv/bin/python - << 'EOF' +import asyncio +from app.providers.tts.piper import PiperTTSProvider +from app.dependencies import voice_for_route +from app.config import settings as cfg +p = PiperTTSProvider(cfg.piper_bin, cfg.piper_voices_dir, cfg.piper_voice, cfg.tts_sample_rate) +async def run(): + for lang, gender, text in [ + ("de","f","Guten Tag!"), ("de","m","Guten Tag!"), + ("en","f","Hello!"), ("en","m","Hello!"), + ("fr","f","Bonjour!"), ("fr","m","Bonjour!"), + ("es","f","¡Hola!"), ("es","m","¡Hola!"), + ("it","f","Ciao!"), ("it","m","Ciao!"), + ("ru","f","Привет!"), ("ru","m","Привет!"), + ("pl","f","Cześć!"), ("pl","m","Cześć!"), + ]: + voice = voice_for_route("piper", lang, gender) + audio = await p.synthesize(text, voice=voice, audio_format="pcm") + print(f"{'✓' if len(audio)>5000 else '✗'} {lang} {gender} {voice}") +asyncio.run(run()) +EOF + +# Cartesia — DE/EN/FR/ES je ♂+♀ testen +# Hinweis: env-Datei muss explizit geladen werden (wird sonst nur vom systemd-Service eingelesen) +cd /opt/voice-assistant && sudo -u voice env $(sudo cat /etc/voice-assistant/voice-assistant.env | grep -v '^#' | xargs) .venv/bin/python - << 'EOF' +import asyncio +from app.providers.tts.cartesia import CartesiaTTSProvider +from app.config import settings as cfg +p = CartesiaTTSProvider(cfg.cartesia_api_key, cfg.cartesia_voice_id, cfg.cartesia_tts_model, + cfg.tts_sample_rate, voices_str=cfg.cartesia_voices) +async def run(): + for lang, gender, text in [ + ("de","f","Guten Tag!"), ("de","m","Guten Tag!"), + ("en","f","Hello!"), ("en","m","Hello!"), + ("fr","f","Bonjour!"), ("fr","m","Bonjour!"), + ("es","f","¡Hola!"), ("es","m","¡Hola!"), + ]: + vid = p._resolve_voice_id(lang, gender) + name = next((v["name"] for (l,g),v in p.voice_map.items() if v["uuid"]==vid), "?") + audio = await p.synthesize(text, voice=gender, language=lang) + print(f"{'✓' if len(audio)>5000 else '✗'} {lang} {gender} {name}") +asyncio.run(run()) +EOF +``` + +--- + +## Teil 5: Authelia — E-Mail-Versand einrichten + +Authelia schreibt Benachrichtigungen (TOTP-Registrierung, Passwort-Reset) standardmäßig +in eine Log-Datei. Für echten E-Mail-Versand über `linix.de` als SMTP-Relay: + +### 5.1 SMTP-Passwort in `/etc/authelia/secrets.env` eintragen + +```bash +echo 'AUTHELIA_NOTIFIER_SMTP_PASSWORD=' \ + | sudo tee -a /etc/authelia/secrets.env > /dev/null +``` + +Die Datei wird bereits vom systemd-Service als `EnvironmentFile` geladen — +Authelia erkennt `AUTHELIA_NOTIFIER_SMTP_PASSWORD` automatisch. + +### 5.2 Notifier in `/etc/authelia/configuration.yml` umstellen + +Den Block `notifier:` ersetzen (war `filesystem`, jetzt `smtp`): + +```yaml +notifier: + smtp: + address: 'submission://linix.de:587' + username: valinix@linix.de + sender: "Voice Assistant " + tls: + skip_verify: false +``` + +```bash +sudo systemctl restart authelia +``` + +**Hinweis:** Port 587 mit STARTTLS (TLS 1.3). Port 465 ist auf linix.de nicht offen. + +--- + +## Teil 6: Authelia — Benutzerverwaltung + +### 6.1 Neuen User anlegen + +**Schritt 1 — Argon2id-Hash erzeugen:** +```bash +sudo authelia crypto hash generate argon2 --password 'GewuenschtePasswort' +# Ausgabe: Digest: $argon2id$v=19$... +``` + +**Schritt 2 — Eintrag in `/etc/authelia/users_database.yml` hinzufügen:** +```yaml + username: + password: $argon2id$v=19$...... + displayname: Vorname Nachname + email: name@example.com + groups: [] + disabled: false +``` +Normale User brauchen keine Gruppe (`groups: []`). Nur `admins` hat Sonderbedeutung. + +**Schritt 3:** +```bash +sudo systemctl restart authelia +``` + +Der neue User erhält beim ersten Login den TOTP-Einrichtungslink per Mail. + +### 6.2 Username nachträglich umbenennen + +Alle drei Stellen müssen konsistent geändert werden: + +```bash +# 1. YAML +sudo sed -i 's/^ altername:$/ neuername:/' /etc/authelia/users_database.yml + +# 2. SQLite — alle relevanten Tabellen +for TABLE in totp_configurations authentication_logs user_opaque_identifier \ + user_preferences identity_verification banned_user; do + sudo sqlite3 /var/lib/authelia/db.sqlite3 \ + "UPDATE $TABLE SET username='neuername' WHERE username='altername';" 2>/dev/null +done + +# 3. Voice-Assistant-Env (falls ADMIN_USERS betroffen) +sudo sed -i 's/altername/neuername/g' /etc/voice-assistant/voice-assistant.env + +sudo systemctl restart authelia voice-assistant +``` + +Das TOTP bleibt erhalten — kein Re-Enroll nötig. + +### 6.3 TOTP eines Users zurücksetzen (Handy verloren o.ä.) + +```bash +sudo sqlite3 /var/lib/authelia/db.sqlite3 \ + "DELETE FROM totp_configurations WHERE username='username';" +sudo systemctl restart authelia +``` +User erhält beim nächsten Login einen neuen Registrierungslink per Mail. + +--- + +## Teil 7: Initiale Authelia-User auf jamulix.de + +| Username | Displayname | E-Mail | Gruppe | +|---|---|---|---| +| `dschlueter` | Dieter Schlüter | dieter.schlueter@linix.de | admins | +| `valinix` | VA Linix | valinix@linix.de | — | + +--- + +## Teil 8: Frontend-Fixes + +### 8.1 Standard-Sprache DE statt Flex + +Neue User haben leere Einstellungen `{}` in der DB — das Frontend zeigt dann +„Flex" als Fallback. Zwei Fixes: + +**A) JS-Fallback in `app/web/app.js` ändern** (Zeile ~286): +```javascript +// Vorher: +: (prefs.language || "flex"); +// Nachher: +: (prefs.language || "de"); +``` + +**B) Bestehende User ohne Einstellungen in der DB korrigieren:** +```bash +sudo sqlite3 /var/lib/voice-assistant/voice-assistant.db \ + "UPDATE users SET prefs_json='{\"language\": \"de\", \"language_mode\": \"fix\", \"tts_provider\": \"piper\"}' WHERE prefs_json='{}';" +``` + +--- + +## Teil 9: OpenRouter LLM — Retry-Logik + +Free-Tier-Modelle auf OpenRouter sind oft rate-limited (429) oder temporär nicht +verfügbar (404). Die Datei `app/providers/llm/openrouter.py` wurde mit einer +Retry-Logik ausgestattet. + +### 9.1 Vollständiger Ersatz von `app/providers/llm/openrouter.py` + +```python +import asyncio +import time +from collections.abc import AsyncIterator + +import httpx + +from app.providers.llm.base import ( + LLMProvider, + lang_instruction, + with_lang_reminder, + 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. + +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, max_retries: int = 5): + 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 + ) -> list[dict]: + 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") + + system_content = SYSTEM_PROMPT + instr = lang_instruction(language) + if instr: + system_content = f"{SYSTEM_PROMPT}\n\n{instr}" + + messages = [{"role": "system", "content": system_content}] + if history: + messages.extend(history) + messages.append({"role": "user", "content": with_lang_reminder(text.strip(), language)}) + return messages + + async def complete( + self, + text: str, + history: list[dict] | None = None, + session_id: str | None = None, + language: str | None = None, + ) -> str: + payload = { + "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() + 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() + + raise last_error or RuntimeError("OpenRouter LLM: all attempts failed") + + async def stream( + self, + text: str, + history: list[dict] | None = None, + session_id: str | None = None, + language: str | None = None, + ) -> AsyncIterator[str]: + payload = { + "model": self.model, + "messages": self._build_messages(text, history, language=language), + "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 + + raise last_error or RuntimeError("OpenRouter LLM: all attempts failed") +``` + +Priorität der Wartezeit bei 429: +1. `error.metadata.retry_after_seconds` im JSON-Body (zuverlässigster Wert) +2. HTTP-Header `Retry-After` (Sekunden) +3. HTTP-Header `X-RateLimit-Reset` (Millisekunden-Timestamp) +4. Fallback: 30 Sekunden + +`max_retries` ist im Konstruktor konfigurierbar (Standard: 5). + +### 9.2 Empfohlenes LLM-Modell + +Für zuverlässigen Betrieb das **Bezahlmodell** verwenden: + +```toml +openrouter_llm_model = "mistralai/mistral-small-24b-instruct-2501" +``` + +Free-Tier-Modelle (`:free`-Suffix) sind für Produktion ungeeignet — hohe +Wartezeiten durch geteilte Kapazität (z.B. Venice-Backend: 8 req/min).