Konkreter Provider hinter dem provider-agnostischen EMERGENCY_WEBHOOK_URL: ein kleiner FastAPI-Dienst (emergency_bridge/), der das Notruf-Payload der App entgegennimmt und pro Telefonnummer SMS und/oder TTS-Anruf über die seven.io-API auslöst. - main.py: POST /notruf (Bearer-Token-Prüfung), GET /health; seven.io SMS (/api/sms) + Voice (/api/voice, Text als SSML, XML-escaped), best effort mit Statuscode-100-Auswertung; Kanäle/Texte kommen aus dem Payload - Betrieb: eigener systemd-Service auf 127.0.0.1:8090, läuft in der vorhandenen venv; .env.example + README mit Installations- und Verdrahtungsschritten - Tests: SMS+Anruf-Fan-out, SSML-Escaping, Token-Auth, Kanal-Filter (5 Tests) - DEPLOYMENT.md: optionaler Abschnitt 2.9 mit Verweis auf die Bridge Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
58 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.
Das Feld available.cartesia_unsupported_languages (z. B. ["ar","it","ru","pl",…],
abgeleitet aus _UNSUPPORTED_LANGS in app/providers/tts/cartesia.py) speist im UI
einen Hinweis: Wählt der Nutzer Cartesia und eine feste Sprache, die Cartesia
nicht spricht, erscheint „Cartesia spricht nicht — es wird automatisch die
lokale Stimme (Piper) verwendet." Das macht den tts_fallback = "piper"-Mechanismus
sichtbar, statt lautlos eine andere Stimme zu liefern.
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): nl → ar, 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): nl → ar, 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_VOICE — nl 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) ResolvedRoute — voice_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, 16 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) | ♂ (kein ♀ verfügbar) | medium |
pl_PL-gosia-medium |
Polnisch | ♀ | medium |
pl_PL-darkman-medium |
Polnisch | ♂ | medium |
ar_JO-kareem-medium |
Arabisch (JO) | ♂ (kein ♀ verfügbar) | medium |
ru_RU-irina-medium |
Russisch | ♀ | medium |
ru_RU-ruslan-medium |
Russisch | ♂ | medium |
zh_CN-huayan-medium |
Chinesisch | ♀ (kein ♂ verfügbar) | medium |
Das Skript scripts/download_voices.py übernimmt den Download automatisch:
cd /opt/voice-assistant
sudo -u voice python scripts/download_voices.py
Es prüft, welche Dateien bereits vorhanden sind, und lädt nur fehlende nach.
Mit --force werden alle Stimmen neu heruntergeladen.
Manuell (Fallback ohne Python):
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
# Notruf-Benachrichtigung per E-Mail (Testphase). OHNE SMTP_HOST kein Versand.
# ACHTUNG: keine Inline-Kommentare hinter den Werten — der Loader nimmt die ganze
# Zeile als Wert. SMTP_FROM muss dem SMTP_USER gehören (sonst 553 Sender rejected).
EMERGENCY_CONTACT_EMAIL=dieter.schlueter@linix.de
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
SMTP_STARTTLS=true
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"
# WICHTIG: Piper als TTS-Fallback. Greift, wenn Cartesia eine Sprache nicht kann
# (IT/RU/AR/PL). Ohne diese Zeile schlägt TTS für diese Sprachen still fehl,
# statt auf die lokale Piper-Stimme auszuweichen.
tts_fallback = "piper"
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.7.1 Admin-Rechte: Log lesen & Gateway neu starten
Der Dienst läuft als System-Dienst unter dem User voice. Damit das Admin-Panel
(a) den Live-Log anzeigen und (b) das Gateway neu starten kann, braucht voice zwei
eng begrenzte Rechte. Ohne diese beiden Schritte bleiben der Log-Tab leer und der
Neustart-Button wirkungslos.
# (a) Log lesen: voice darf das System-Journal lesen
sudo usermod -aG systemd-journal voice
# (b) Neustart: voice darf AUSSCHLIESSLICH den eigenen Dienst neu starten
sudo tee /etc/sudoers.d/voice-assistant > /dev/null <<'SUDO'
# Erlaubt dem voice-User ausschließlich den Neustart des eigenen Dienstes
# (für den "Gateway neu starten"-Button im Admin-Panel). Sonst nichts.
voice ALL=(root) NOPASSWD: /usr/bin/systemctl restart voice-assistant.service
SUDO
sudo chmod 0440 /etc/sudoers.d/voice-assistant
sudo visudo -cf /etc/sudoers.d/voice-assistant # validieren
# Dienst neu starten, damit der Prozess die neue Gruppe übernimmt
sudo systemctl restart voice-assistant
Rückgängig: sudo gpasswd -d voice systemd-journal bzw. sudo rm /etc/sudoers.d/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
# Admin-Live-Log (WebSocket) — hinter Authelia, mit WS-Upgrade.
location = /api/admin/log {
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;
}
# Admin-API — ZUSÄTZLICH hinter Authelia (Doppelschutz). Identität via Remote-User.
location /api/admin/ {
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 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;
}
# Sprach-/Chat-WebSockets — KEIN Authelia. Auth via Token-Cookie (Senioren-Link).
# Remote-User HART LEEREN: sonst könnte ein Client per Header eine Identität
# vortäuschen (die App vertraut Remote-User von der Proxy-IP 127.0.0.1).
location /ws/ {
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 "";
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# App (Senioren) — KEIN Authelia. Auth via Token-Cookie / Ein-Klick-Link
# (https://voice.jamulix.de/?k=<token>). Remote-User HART LEEREN (Anti-Spoofing).
location / {
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 "";
}
}
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
2.9 Notruf-Bridge (optional, SMS/Anruf via seven.io)
Damit der Notruf zusätzlich zur E-Mail SMS und Sprachanruf auslöst, läuft ein
kleiner Webhook-Empfänger (emergency_bridge/), der über
seven.io sendet. Vollständige Anleitung:
emergency_bridge/README.md. Kurz:
# 1. Konfig (API-Key + BRIDGE_TOKEN setzen)
sudo cp /opt/voice-assistant/emergency_bridge/emergency-bridge.env.example \
/etc/voice-assistant/emergency-bridge.env
sudo chown voice:voice /etc/voice-assistant/emergency-bridge.env
sudo chmod 600 /etc/voice-assistant/emergency-bridge.env
sudo editor /etc/voice-assistant/emergency-bridge.env
# 2. Service
sudo cp /opt/voice-assistant/emergency_bridge/emergency-bridge.service \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now emergency-bridge
curl -s localhost:8090/health # -> {"status":"ok","configured":true}
Anschließend in /etc/voice-assistant/voice-assistant.env ergänzen und
sudo systemctl restart voice-assistant:
EMERGENCY_WEBHOOK_URL=http://127.0.0.1:8090/notruf
EMERGENCY_WEBHOOK_TOKEN=<derselbe Wert wie BRIDGE_TOKEN>
EMERGENCY_CONTACT_PHONE=+49XXXXXXXXXX
Ohne diese Schritte bleibt der Webhook-Kanal inaktiv; der Notruf nutzt dann nur E-Mail.
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
Zuletzt vollständig getestet: 2026-06-25 — pytest 213 passed ✓, Piper alle 10 Sprachen ✓, Cartesia DE/EN/FR/ES/PT/ZH ✓
4.0 Automatische Test-Suite (PFLICHT vor jedem Deploy)
Die pytest-Suite läuft offline und deterministisch (keine echten Netz-/API-Aufrufe, keine Stimm-Modelle nötig). Vor jedem Deploy ausführen — muss grün sein:
cd /opt/voice-assistant
sudo -u voice .venv/bin/pip install -q -e '.[test]' # einmalig: pytest installieren
sudo -u voice .venv/bin/python -m pytest -q
# Erwartet: "213 passed"
Automatischer Schutz vor jedem Push (empfohlen): Ein versionierter
pre-push-Hook lässt die Suite vor jedem git push laufen und bricht bei roten
Tests ab. Nach einem frischen Clone einmalig installieren:
cd /opt/voice-assistant
sudo -u voice bash scripts/install-hooks.sh
Der Hook liegt unter scripts/git-hooks/pre-push (versioniert); .git/hooks/
selbst wird von Git nicht versioniert, daher der Installer. Im Notfall umgehen:
git push --no-verify.
Abdeckung u. a.: Stimm-/Geschlechts-Routing (voice_for_route), Piper Multi-Speaker
(#speaker_id) und Gender-Code-Auflösung, Cartesia Voice-Map + nicht unterstützte
Sprachen, Cartesia→Piper-Fallback. Die Fixture in tests/conftest.py neutralisiert
die Deployment-Fallback-Ketten (*_fallback), damit Tests hermetisch bleiben.
4.1 Manuelle Smoke-Checks
# 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 | 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:
error.metadata.retry_after_secondsim JSON-Body (zuverlässigster Wert)- HTTP-Header
Retry-After(Sekunden) - HTTP-Header
X-RateLimit-Reset(Millisekunden-Timestamp) - 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).