my_voice_assistant_v3_jamulix/DEPLOYMENT.md
dschlueter 69990fb871 docs: Piper-Test auf AR, PT und ZH erweitern
Verifikationsabschnitt: Piper-Test deckt jetzt alle 10 Sprachen ab.
AR/PT/ZH haben kein Geschlechtspaar — liefern dieselbe Stimme für ♂+♀
(kareem, faber, huayan). Kommentare im Test machen das explizit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 23:16:59 +02:00

52 KiB

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:

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:

  syncPresetUI();
}

Füge direkt danach (vor // Markiert das aktive Ton-Preset) diese beiden Funktionen ein:

// 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:

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:

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

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

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:

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

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

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

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";:

span.dir = "auto";

1.4 app/web/index.html — Sprachen und Geschlechts-UI

A) words-lang-Select (Wörter-Trainer): nlar, PT und PL ergänzen

<option value="it">Italienisch (it)</option>
<option value="pt">Portugiesisch (pt)</option>
<option value="pl">Polnisch (pl)</option>
<option value="ar">Arabisch (ar)</option>

B) lang-sel-Select (Sprach-Auswahl oben): nlar, PT und PL ergänzen

<option value="it">🇮🇹 IT</option>
<option value="pt">🇧🇷 PT</option>
<option value="pl">🇵🇱 PL</option>
<option value="ar">🇸🇦 AR</option>

C) Texteingabe-Feld: automatische Schreibrichtung

<input id="prompt" type="text" autocomplete="off" dir="auto" placeholder="Nachricht …" >

D) Gender-Buttons in der Sidebar

Direkt vor <!-- Aktionen --> 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):

<!-- Geschlechtspräferenz (wirkt bei Cartesia und Piper mit m+f Varianten) -->
<div id="gender-pref-row">
  <p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-1.5 px-1">Stimme</p>
  <div class="flex gap-2">
    <button type="button" id="gender-m"
      class="gender-btn flex-1 rounded-lg border border-slate-200 dark:border-slate-700 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:hover:bg-slate-700/50">
      ♂ Männlich
    </button>
    <button type="button" id="gender-f"
      class="gender-btn flex-1 rounded-lg border border-slate-200 dark:border-slate-700 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:hover:bg-slate-700/50">
      ♀ Weiblich
    </button>
  </div>
</div>

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

@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

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:

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

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

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

ROUTE_KEYS = (
    "input_endpoint",
    "output_endpoint",
    "stt_provider",
    "llm_provider",
    "tts_provider",
    "language",
    "language_mode",
    "voice_gender",       # neu
)

B) LANG_TO_PIPER_VOICEnl entfernen, neue Sprachen ergänzen

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.

# 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:

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:

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:

voice_for_route(route.tts_provider, route.language, route.voice_gender)

F) ResolvedRoutevoice_gender ergänzen

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

"voice_gender": self.voice_gender,

G) CartesiaTTSProvider-Konstruktor — voices_str übergeben

In TTS_REGISTRY:

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

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

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

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

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

# Voice Assistant Gateway — jamulix.de
HOST=127.0.0.1
PORT=8003
OPENROUTER_API_KEY=<dein-openrouter-key>
CARTESIA_API_KEY=<dein-cartesia-key>
VA_PROFILE=jamulix
VA_CONFIG_FILE=/opt/voice-assistant/config/voice-assistant.toml
AUTH_ENABLED=true
ADMIN_API_KEY=<zufälliger-hex-key: openssl rand -hex 24>
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
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

# 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"
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:

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.

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

# 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 Stimmen testen (DE/EN/FR/ES/IT/RU/PL je ♂+♀; AR/ZH/PT nur eine Stimme)
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ść!"),
        ("ar","f","مرحباً!"),    ("ar","m","مرحباً!"),   # → immer kareem (kein ♀)
        ("pt","f","Bom dia!"),   ("pt","m","Bom dia!"),  # → immer faber (kein ♀)
        ("zh","f","你好!"),      ("zh","m","你好!"),    # → immer huayan (kein ♂)
    ]:
        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 — alle unterstützten Sprachen 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!"),
        ("pt","f","Bom dia!"),             ("pt","m","Bom dia!"),
        ("zh","f","你好!"),               ("zh","m","你好!"),
    ]:
        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

echo 'AUTHELIA_NOTIFIER_SMTP_PASSWORD=<passwort-für-valinix@linix.de>' \
  | 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):

notifier:
  smtp:
    address: 'submission://linix.de:587'
    username: valinix@linix.de
    sender: "Voice Assistant <valinix@linix.de>"
    tls:
      skip_verify: false
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:

sudo authelia crypto hash generate argon2 --password 'GewuenschtePasswort'
# Ausgabe: Digest: $argon2id$v=19$...

Schritt 2 — Eintrag in /etc/authelia/users_database.yml hinzufügen:

    username:
        password: $argon2id$v=19$...<hash>...
        displayname: Vorname Nachname
        email: name@example.com
        groups: []
        disabled: false

Normale User brauchen keine Gruppe (groups: []). Nur admins hat Sonderbedeutung.

Schritt 3:

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:

# 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.ä.)

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

// Vorher:
: (prefs.language || "flex");
// Nachher:
: (prefs.language || "de");

B) Bestehende User ohne Einstellungen in der DB korrigieren:

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

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:

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