TLS Fallback (Bridge → RVS)

Audio-Rendering fuer App (Piper TTS via RVS)
Chat-Persistenz (AsyncStorage, 500 Nachrichten)
This commit is contained in:
2026-03-10 18:40:03 +01:00
parent b5f1bf6d2c
commit e951fc712f
80 changed files with 261 additions and 63 deletions
+106 -45
View File
@@ -17,10 +17,12 @@ Stimmen:
from __future__ import annotations
import asyncio
import base64
import json
import logging
import os
import signal
import ssl
import sys
import tempfile
import wave
@@ -53,6 +55,7 @@ CORE_WS_URL = os.getenv("ARIA_CORE_WS", "ws://aria:8080")
RVS_HOST = os.getenv("RVS_HOST", "") # z.B. rvs.hackersoft.de
RVS_PORT = os.getenv("RVS_PORT", "443") # Port des RVS
RVS_TLS = os.getenv("RVS_TLS", "true") # true = wss://, false = ws://
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true") # Bei TLS-Fehler ws:// versuchen
RVS_TOKEN = os.getenv("RVS_TOKEN", "") # Pairing-Token (gleich wie in der App)
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de")
@@ -311,30 +314,34 @@ class WakeWordDetector:
self.wake_word_key: str = ""
def initialize(self) -> None:
"""Laedt das Wake-Word-Modell."""
"""Laedt das Wake-Word-Modell.
Hinweis: WakeWordModel() wird OHNE Argumente aufgerufen.
Aeltere openwakeword-Versionen leiten unbekannte kwargs
an AudioFeatures weiter, was zum Crash fuehrt.
"""
logger.info("Lade Wake-Word-Modell...")
custom_path = Path(self.CUSTOM_MODEL_PATH)
if custom_path.exists():
# Custom "aria" Modell vorhanden
self.model = WakeWordModel(
wakeword_models=[str(custom_path)],
)
self.wake_word_key = custom_path.stem
logger.info("Custom Wake-Word-Modell geladen: %s", custom_path)
else:
# Fallback auf eingebautes Modell
self.model = WakeWordModel()
# Alle eingebauten Modelle laden (ohne kwargs — Kompatibilitaet!)
self.model = WakeWordModel()
# Verfuegbare Modelle ermitteln
available = list(self.model.models.keys()) if hasattr(self.model, 'models') else []
logger.info("Verfuegbare Wake-Words: %s", ", ".join(available) if available else "(keine)")
# Bestes Modell auswaehlen
if self.FALLBACK_MODEL in available:
self.wake_word_key = self.FALLBACK_MODEL
logger.warning(
"Kein Custom-Modell (%s) — nutze Fallback '%s'",
self.CUSTOM_MODEL_PATH,
self.FALLBACK_MODEL,
)
logger.info(
"Tipp: Custom Wake-Word trainieren → "
"https://github.com/dscripka/openWakeWord#training-new-models"
)
elif available:
self.wake_word_key = available[0]
else:
self.wake_word_key = self.FALLBACK_MODEL
logger.info("Wake-Word aktiv: '%s'", self.wake_word_key)
logger.info(
"Tipp: Custom 'aria' Wake-Word trainieren → "
"https://github.com/dscripka/openWakeWord#training-new-models"
)
def detect(self, audio_chunk: np.ndarray) -> bool:
"""Prueft ob das Wake-Word im Audio-Chunk enthalten ist.
@@ -351,10 +358,10 @@ class WakeWordDetector:
prediction = self.model.predict(audio_chunk)
# openwakeword gibt Scores pro Modell zurueck
score = prediction.get(self.wake_word_key, 0)
if score > self.THRESHOLD:
logger.info("Wake-Word erkannt! (Score: %.2f)", score)
return True
for key, score in prediction.items():
if score > self.THRESHOLD:
logger.info("Wake-Word '%s' erkannt! (Score: %.2f)", key, score)
return True
return False
@@ -402,13 +409,20 @@ class ARIABridge:
rvs_host = self.config.get("RVS_HOST", RVS_HOST)
rvs_port = self.config.get("RVS_PORT", RVS_PORT)
rvs_tls = self.config.get("RVS_TLS", RVS_TLS).lower() == "true"
self.rvs_tls_fallback = self.config.get("RVS_TLS_FALLBACK", RVS_TLS_FALLBACK).lower() == "true"
self.rvs_token = self.config.get("RVS_TOKEN", RVS_TOKEN)
# URL zusammenbauen
# URLs zusammenbauen (primaer + fallback)
if rvs_host:
proto = "wss" if rvs_tls else "ws"
self.rvs_url = f"{proto}://{rvs_host}:{rvs_port}"
# Fallback-URL (ohne TLS) nur wenn TLS aktiv und Fallback erlaubt
if rvs_tls and self.rvs_tls_fallback:
self.rvs_url_fallback = f"ws://{rvs_host}:{rvs_port}"
else:
self.rvs_url_fallback = ""
else:
self.rvs_url = ""
self.rvs_url_fallback = ""
self.current_mode = Mode.NORMAL
self.running = False
@@ -425,21 +439,30 @@ class ARIABridge:
self.ws_rvs: Optional[websockets.WebSocketClientProtocol] = None
def initialize(self) -> None:
"""Initialisiert alle Komponenten."""
"""Initialisiert alle Komponenten.
Audio-Komponenten (TTS, STT, Wake-Word) sind optional —
wenn kein Audio-Geraet vorhanden ist (z.B. VM ohne Soundkarte),
laeuft die Bridge trotzdem als reiner RVS-Relay.
"""
logger.info("=" * 50)
logger.info("ARIA Voice Bridge startet...")
logger.info("=" * 50)
# PulseAudio-Server pruefen
pulse_server = os.getenv("PULSE_SERVER")
if pulse_server:
logger.info("PulseAudio Server: %s", pulse_server)
else:
logger.warning("Kein PULSE_SERVER gesetzt — verwende Standard-Audio")
# Voice-Engine IMMER laden — rendert Audio fuer die App (auch ohne Soundkarte)
self.voice_engine.initialize()
self.stt_engine.initialize()
self.wake_word.initialize()
# Audio-Hardware pruefen (fuer lokales Mikro/Lautsprecher)
self.audio_available = False
try:
sd.query_devices()
self.audio_available = True
logger.info("Audio-Geraet gefunden — Wake-Word und lokale TTS aktiv")
self.stt_engine.initialize()
self.wake_word.initialize()
except (sd.PortAudioError, Exception):
logger.warning("Kein Audio-Geraet — Wake-Word und lokale TTS deaktiviert")
logger.info("Piper TTS rendert Audio fuer die App (via RVS)")
logger.info("Alle Komponenten initialisiert")
logger.info("aria-core: %s", self.ws_url)
@@ -516,20 +539,39 @@ class ARIABridge:
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
# Stimme auswaehlen
voice_name = requested_voice or self.voice_engine.select_voice(text)
# Antwort an die App weiterleiten (als Chat-Nachricht)
await self._send_to_rvs({
"type": "chat",
"payload": {
"text": text,
"sender": "aria",
"voice": requested_voice or self.voice_engine.select_voice(text),
"voice": voice_name,
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
# Sprachausgabe lokal (wenn Modus es erlaubt)
# TTS-Audio rendern und an die App senden (wenn Modus es erlaubt)
if should_speak(self.current_mode, is_critical):
self.voice_engine.speak(text, requested_voice)
audio_data = self.voice_engine.synthesize(text, voice_name)
if audio_data:
audio_b64 = base64.b64encode(audio_data).decode("ascii")
await self._send_to_rvs({
"type": "audio",
"payload": {
"base64": audio_b64,
"mimeType": "audio/wav",
"voice": voice_name,
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
logger.info("[core] TTS-Audio gesendet: %d bytes (%s)", len(audio_data), voice_name)
# Lokal abspielen (nur wenn Soundkarte vorhanden)
if self.audio_available:
self.voice_engine.speak(text, requested_voice)
else:
logger.info("[core] TTS unterdrueckt (Modus: %s)", self.current_mode.config.name)
@@ -557,19 +599,21 @@ class ARIABridge:
async def connect_to_rvs(self) -> None:
"""Persistente WebSocket-Verbindung zum RVS mit Auto-Reconnect.
Authentifiziert sich mit dem gleichen Token wie die App.
Nachrichten von der App werden an aria-core weitergeleitet.
Bei TLS-Fehler wird automatisch auf ws:// gefallbackt
(wenn RVS_TLS_FALLBACK=true).
"""
if not self.rvs_url or not self.rvs_token:
logger.info("[rvs] Nicht konfiguriert — ueberspringe")
return
retry_delay = 2
url = f"{self.rvs_url}?token={self.rvs_token}"
current_url = self.rvs_url
using_fallback = False
while self.running:
try:
logger.info("[rvs] Verbinde: %s", self.rvs_url)
url = f"{current_url}?token={self.rvs_token}"
logger.info("[rvs] Verbinde: %s", current_url)
async with websockets.connect(url) as ws:
self.ws_rvs = ws
retry_delay = 2
@@ -588,6 +632,16 @@ class ARIABridge:
logger.warning("[rvs] Verbindung verloren")
except ConnectionRefusedError:
logger.warning("[rvs] Nicht erreichbar")
except (ssl.SSLError, OSError) as e:
# TLS-Fehler — Fallback auf ws:// versuchen
if not using_fallback and self.rvs_url_fallback:
logger.warning("[rvs] TLS-Fehler: %s", e)
logger.warning("[rvs] TLS gewollt aber nicht verfuegbar — Fallback auf ws://")
current_url = self.rvs_url_fallback
using_fallback = True
retry_delay = 1 # Sofort versuchen
else:
logger.error("[rvs] SSL-Fehler (kein Fallback): %s", e)
except Exception:
logger.exception("[rvs] WebSocket-Fehler")
finally:
@@ -762,15 +816,22 @@ class ARIABridge:
# ── Run & Shutdown ───────────────────────────────────────
async def run(self) -> None:
"""Startet die Bridge mit allen drei Verbindungen parallel."""
"""Startet die Bridge mit allen Verbindungen parallel.
Ohne Audio-Geraet laeuft nur core + rvs (reiner Relay-Modus).
"""
self.running = True
tasks = [
asyncio.create_task(self.connect_to_core()),
asyncio.create_task(self.connect_to_rvs()),
asyncio.create_task(self.audio_loop()),
]
if self.audio_available:
tasks.append(asyncio.create_task(self.audio_loop()))
else:
logger.info("Audio-Loop deaktiviert — kein Audio-Geraet")
try:
await asyncio.gather(*tasks)
except asyncio.CancelledError: