TLS Fallback (Bridge → RVS)
Audio-Rendering fuer App (Piper TTS via RVS) Chat-Persistenz (AsyncStorage, 500 Nachrichten)
This commit is contained in:
+106
-45
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user