my_voice_assistant_v2/app/providers/tts/openrouter.py

80 lines
3.1 KiB
Python
Raw Permalink Normal View History

import asyncio
import httpx
from app.providers.tts.base import TTSProvider
# Preview-TTS-Modelle liefern gelegentlich HTTP 200 mit LEEREM Body oder ein
# transientes 5xx. Solche Aussetzer kurz wiederholen, statt die Runde abzubrechen.
_MAX_ATTEMPTS = 3
_RETRY_BACKOFF = 0.6 # Sekunden, linear ansteigend
class OpenRouterTTSProvider(TTSProvider):
def __init__(self, api_key: str, model: str, voice: str):
self.api_key = (api_key or "").strip()
self.model = (model or "").strip()
self.voice = (voice or "").strip()
async def synthesize(
self,
text: str,
voice: str | None = None,
audio_format: str = "pcm",
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes Web-UI / TTS: - Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten. Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung. - Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht, Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe. - Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü. - "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit). - Dark-Mode: lesbare <option>-Popups (Kontrast-Fix). - Favicon (SVG + PNG-Fallbacks) aus mund.png. TTS-Backend: - Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der Sprache; Chatterbox mehrsprachig + cross-lingual. - Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY), loudness-normalisiert. LLM-Sprache: - Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung + Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit). Admin / Auth: - Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung. - Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen. - Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist. - Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität. Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
language: str | None = None, # Cloud-TTS steuert Sprache über Stimme/Modell
) -> bytes:
if not self.api_key:
raise ValueError("OPENROUTER_API_KEY is empty")
if not self.model:
raise ValueError("OPENROUTER_TTS_MODEL is empty")
if not text or not text.strip():
raise ValueError("TTS input text is empty")
effective_voice = (voice or self.voice).strip()
if not effective_voice:
raise ValueError("TTS voice is required for OpenRouter TTS")
payload = {
"model": self.model,
"input": text.strip(),
"voice": effective_voice,
"response_format": audio_format,
}
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
last_error = "OpenRouter TTS returned empty audio content"
for attempt in range(_MAX_ATTEMPTS):
try:
response = await client.post(
"https://openrouter.ai/api/v1/audio/speech",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=payload,
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
# 4xx (z. B. ungueltige Stimme) ist nicht transient -> sofort melden.
if status < 500:
raise RuntimeError(
f"OpenRouter TTS error {status}: {exc.response.text}"
) from exc
last_error = f"OpenRouter TTS error {status}: {exc.response.text}"
except httpx.TimeoutException:
last_error = "OpenRouter TTS timeout"
except httpx.HTTPError as exc:
raise RuntimeError(f"OpenRouter TTS transport error: {exc}") from exc
else:
if response.content:
return response.content
# HTTP 200 mit leerem Body -> transienter Aussetzer, erneut versuchen.
if attempt < _MAX_ATTEMPTS - 1:
await asyncio.sleep(_RETRY_BACKOFF * (attempt + 1))
raise RuntimeError(last_error)