TLS Fallback (Bridge → RVS)

Audio-Rendering fuer App (Piper TTS via RVS)
Chat-Persistenz (AsyncStorage, 500 Nachrichten)
This commit is contained in:
duffyduck 2026-03-10 18:40:03 +01:00
parent b5f1bf6d2c
commit e951fc712f
80 changed files with 261 additions and 63 deletions

View File

@ -9,6 +9,9 @@ ARIA_AUTH_TOKEN=change-me-to-a-long-random-string
RVS_HOST=rvs.example.de
RVS_PORT=443
RVS_TLS=true
# Bei TLS-Fehler automatisch auf ws:// (ohne TLS) fallback?
# true = Fallback erlaubt, false = nur mit TLS verbinden
RVS_TLS_FALLBACK=true
RVS_TOKEN=
# Gitea (for release.sh — Kennwort wird interaktiv abgefragt)

View File

@ -57,6 +57,23 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
- `WakeWordDetector` umgebaut — sucht Custom-Modell `/voices/wake_aria.onnx`, Fallback auf eingebautes `hey_jarvis`
- Alter Code crashte: `wakeword_models=["aria"]` erwartet Dateipfad, kein Keyword
**TLS Fallback (Bridge → RVS)**
- Bridge versucht zuerst `wss://` (TLS), bei `ssl.SSLError` automatisch Fallback auf `ws://`
- Konfigurierbar über `RVS_TLS_FALLBACK=true` in `.env`
- Loggt deutlich wenn TLS gewollt aber nicht verfügbar ist
**Audio-Rendering für App (Piper TTS via RVS)**
- Bridge rendert Piper TTS → WAV → base64, sendet Text UND Audio gleichzeitig über RVS
- App spielt Audio ab und zeigt Text parallel — Modus entscheidet ob Sprache oder nur Text
- Voice Engine initialisiert IMMER (auch ohne Soundkarte in der VM)
- STT/Wake-Word nur wenn Audio-Hardware vorhanden — graceful degradation
- Neue Dependency: `react-native-fs` (base64 → temp WAV → Sound abspielen)
**Chat-Persistenz (Android App)**
- Chat-Verlauf wird in AsyncStorage gespeichert (letzte 500 Nachrichten)
- Beim App-Start automatisch geladen — Konversation bleibt erhalten
- Linearer 1:1 Chat, keine Threads
**Neues Script: `get-voices.sh`**
- Lädt Piper Stimmen (Ramona + Thorsten) von HuggingFace herunter
- Neuer Installationsschritt in README

View File

@ -252,6 +252,7 @@ services:
- RVS_HOST=${RVS_HOST:-}
- RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true}
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
- RVS_TOKEN=${RVS_TOKEN:-}
restart: unless-stopped
networks:

View File

@ -27,6 +27,8 @@ import com.imagepicker.ImagePickerPackage;
import com.zoontek.rnpermissions.RNPermissionsPackage;
// react-native-camera-kit
import com.rncamerakit.RNCameraKitPackage;
// @react-native-async-storage/async-storage
import com.reactnativecommunity.asyncstorage.AsyncStoragePackage;
public class PackageList {
private Application application;
@ -79,7 +81,8 @@ public class PackageList {
new GeolocationPackage(),
new ImagePickerPackage(),
new RNPermissionsPackage(),
new RNCameraKitPackage()
new RNCameraKitPackage(),
new AsyncStoragePackage()
));
}
}

File diff suppressed because one or more lines are too long

View File

@ -301,6 +301,19 @@
symbolFile="R.txt"
externalAnnotations="annotations.zip"
proguardRules="proguard.txt"/>
<library
name="/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/android@@:react-native-async-storage_async-storage::release"
jars="/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/node_modules/@react-native-async-storage/async-storage/android/build/.transforms/2be425a3c2bdc5ca7e7f9aab0d2b9fd2/transformed/out/jars/classes.jar:/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/node_modules/@react-native-async-storage/async-storage/android/build/.transforms/2be425a3c2bdc5ca7e7f9aab0d2b9fd2/transformed/out/jars/libs/R.jar"
resolved="AriaCockpit:react-native-async-storage_async-storage:unspecified"
folder="/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/node_modules/@react-native-async-storage/async-storage/android/build/.transforms/2be425a3c2bdc5ca7e7f9aab0d2b9fd2/transformed/out"
manifest="AndroidManifest.xml"
resFolder="res"
assetsFolder="assets"
lintJar="lint.jar"
publicResources="public.txt"
symbolFile="R.txt"
externalAnnotations="annotations.zip"
proguardRules="proguard.txt"/>
<library
name="org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.21@jar"
jars="/home/duffy/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-jdk7/1.8.21/7473b8cd3c0ef9932345baf569bc398e8a717046/kotlin-stdlib-jdk7-1.8.21.jar"

View File

@ -301,6 +301,19 @@
symbolFile="R.txt"
externalAnnotations="annotations.zip"
proguardRules="proguard.txt"/>
<library
name="/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/android@@:react-native-async-storage_async-storage::release"
jars="/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/node_modules/@react-native-async-storage/async-storage/android/build/.transforms/2be425a3c2bdc5ca7e7f9aab0d2b9fd2/transformed/out/jars/classes.jar:/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/node_modules/@react-native-async-storage/async-storage/android/build/.transforms/2be425a3c2bdc5ca7e7f9aab0d2b9fd2/transformed/out/jars/libs/R.jar"
resolved="AriaCockpit:react-native-async-storage_async-storage:unspecified"
folder="/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/node_modules/@react-native-async-storage/async-storage/android/build/.transforms/2be425a3c2bdc5ca7e7f9aab0d2b9fd2/transformed/out"
manifest="AndroidManifest.xml"
resFolder="res"
assetsFolder="assets"
lintJar="lint.jar"
publicResources="public.txt"
symbolFile="R.txt"
externalAnnotations="annotations.zip"
proguardRules="proguard.txt"/>
<library
name="org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.21@jar"
jars="/home/duffy/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-jdk7/1.8.21/7473b8cd3c0ef9932345baf569bc398e8a717046/kotlin-stdlib-jdk7-1.8.21.jar"

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
#Mon Mar 09 00:30:03 CET 2026
#Tue Mar 10 18:02:18 CET 2026
base.2=/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/android/app/build/intermediates/dex/release/mergeDexRelease/classes2.dex
path.2=classes2.dex
base.1=/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/android/app/build/intermediates/global_synthetics_dex/release/classes.dex

View File

@ -12,6 +12,7 @@ MERGED from [:react-native-community_geolocation] /home/duffy/Dokumente/programm
MERGED from [:react-native-image-picker] /home/duffy/Dokumente/programmierung/ARIA-AGENT/android/node_modules/react-native-image-picker/android/build/intermediates/merged_manifest/release/AndroidManifest.xml:2:1-19:12
MERGED from [:react-native-permissions] /home/duffy/Dokumente/programmierung/ARIA-AGENT/android/node_modules/react-native-permissions/android/build/intermediates/merged_manifest/release/AndroidManifest.xml:2:1-7:12
MERGED from [:react-native-camera-kit] /home/duffy/Dokumente/programmierung/ARIA-AGENT/android/node_modules/react-native-camera-kit/android/build/intermediates/merged_manifest/release/AndroidManifest.xml:2:1-11:12
MERGED from [:react-native-async-storage_async-storage] /home/duffy/Dokumente/programmierung/ARIA-AGENT/android/node_modules/@react-native-async-storage/async-storage/android/build/intermediates/merged_manifest/release/AndroidManifest.xml:2:1-7:12
MERGED from [com.facebook.react:react-android:0.73.4] /home/duffy/.gradle/caches/transforms-3/158b871a21f85526704b0e97f3449ad0/transformed/jetified-react-android-0.73.4-release/AndroidManifest.xml:2:1-12:12
MERGED from [com.facebook.fresco:fresco:3.1.3] /home/duffy/.gradle/caches/transforms-3/73ba53becf6a818dacbfe76ccb7dfd5a/transformed/jetified-fresco-3.1.3/AndroidManifest.xml:2:1-7:12
MERGED from [com.facebook.fresco:imagepipeline-okhttp3:3.1.3] /home/duffy/.gradle/caches/transforms-3/78816c54b596e77b24ddd4d407d9d3f7/transformed/jetified-imagepipeline-okhttp3-3.1.3/AndroidManifest.xml:2:1-7:12
@ -220,6 +221,8 @@ MERGED from [:react-native-permissions] /home/duffy/Dokumente/programmierung/ARI
MERGED from [:react-native-permissions] /home/duffy/Dokumente/programmierung/ARIA-AGENT/android/node_modules/react-native-permissions/android/build/intermediates/merged_manifest/release/AndroidManifest.xml:5:5-44
MERGED from [:react-native-camera-kit] /home/duffy/Dokumente/programmierung/ARIA-AGENT/android/node_modules/react-native-camera-kit/android/build/intermediates/merged_manifest/release/AndroidManifest.xml:5:5-44
MERGED from [:react-native-camera-kit] /home/duffy/Dokumente/programmierung/ARIA-AGENT/android/node_modules/react-native-camera-kit/android/build/intermediates/merged_manifest/release/AndroidManifest.xml:5:5-44
MERGED from [:react-native-async-storage_async-storage] /home/duffy/Dokumente/programmierung/ARIA-AGENT/android/node_modules/@react-native-async-storage/async-storage/android/build/intermediates/merged_manifest/release/AndroidManifest.xml:5:5-44
MERGED from [:react-native-async-storage_async-storage] /home/duffy/Dokumente/programmierung/ARIA-AGENT/android/node_modules/@react-native-async-storage/async-storage/android/build/intermediates/merged_manifest/release/AndroidManifest.xml:5:5-44
MERGED from [com.facebook.react:react-android:0.73.4] /home/duffy/.gradle/caches/transforms-3/158b871a21f85526704b0e97f3449ad0/transformed/jetified-react-android-0.73.4-release/AndroidManifest.xml:10:5-44
MERGED from [com.facebook.react:react-android:0.73.4] /home/duffy/.gradle/caches/transforms-3/158b871a21f85526704b0e97f3449ad0/transformed/jetified-react-android-0.73.4-release/AndroidManifest.xml:10:5-44
MERGED from [com.facebook.fresco:fresco:3.1.3] /home/duffy/.gradle/caches/transforms-3/73ba53becf6a818dacbfe76ccb7dfd5a/transformed/jetified-fresco-3.1.3/AndroidManifest.xml:5:5-44

View File

@ -8,6 +8,7 @@
"name": "aria-cockpit",
"version": "0.1.0",
"dependencies": {
"@react-native-async-storage/async-storage": "^1.21.0",
"@react-native-community/geolocation": "^3.2.1",
"@react-navigation/bottom-tabs": "^6.5.11",
"@react-navigation/native": "^6.1.9",
@ -2848,6 +2849,18 @@
"node": ">= 8"
}
},
"node_modules/@react-native-async-storage/async-storage": {
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.24.0.tgz",
"integrity": "sha512-W4/vbwUOYOjco0x3toB8QCr7EjIP6nE9G7o8PMguvvjYT5Awg09lyV4enACRx4s++PPulBiBSjL0KTFx2u0Z/g==",
"license": "MIT",
"dependencies": {
"merge-options": "^3.0.4"
},
"peerDependencies": {
"react-native": "^0.0.0-0 || >=0.60 <1.0"
}
},
"node_modules/@react-native-community/cli": {
"version": "12.3.2",
"resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-12.3.2.tgz",
@ -7682,6 +7695,15 @@
"node": ">=8"
}
},
"node_modules/is-plain-obj": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-plain-object": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
@ -9137,6 +9159,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/merge-options": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
"license": "MIT",
"dependencies": {
"is-plain-obj": "^2.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",

View File

@ -22,7 +22,8 @@
"react-native-image-picker": "^7.1.0",
"react-native-permissions": "^4.1.4",
"react-native-camera-kit": "^13.0.0",
"@react-native-async-storage/async-storage": "^1.21.0"
"@react-native-async-storage/async-storage": "^1.21.0",
"react-native-fs": "^2.20.0"
},
"devDependencies": {
"typescript": "^5.3.3",

View File

@ -17,6 +17,7 @@ import {
StyleSheet,
Modal,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import VoiceButton from '../components/VoiceButton';
@ -41,6 +42,11 @@ interface ChatMessage {
attachments?: Attachment[];
}
// --- Konstanten ---
const CHAT_STORAGE_KEY = 'aria_chat_messages';
const MAX_STORED_MESSAGES = 500;
// --- Komponente ---
const ChatScreen: React.FC = () => {
@ -60,10 +66,26 @@ const ChatScreen: React.FC = () => {
return `msg_${Date.now()}_${messageIdCounter.current}`;
};
// GPS-Einstellung aus Settings laden (vereinfacht)
// Chat-Verlauf aus AsyncStorage laden
useEffect(() => {
// In Produktion: AsyncStorage oder Context verwenden
// Hier Platzhalter - GPS Toggle kommt aus SettingsScreen
const loadMessages = async () => {
try {
const stored = await AsyncStorage.getItem(CHAT_STORAGE_KEY);
if (stored) {
const parsed: ChatMessage[] = JSON.parse(stored);
setMessages(parsed);
// ID-Counter auf hoechsten Wert setzen um Kollisionen zu vermeiden
const maxId = parsed.reduce((max, msg) => {
const num = parseInt(msg.id.split('_').pop() || '0', 10);
return num > max ? num : max;
}, 0);
messageIdCounter.current = maxId;
}
} catch (err) {
console.error('[Chat] Fehler beim Laden des Verlaufs:', err);
}
};
loadMessages();
}, []);
// RVS-Nachrichten abonnieren
@ -99,6 +121,15 @@ const ChatScreen: React.FC = () => {
};
}, []);
// Chat-Verlauf in AsyncStorage speichern (letzte N Nachrichten)
useEffect(() => {
if (messages.length === 0) return;
const toStore = messages.slice(-MAX_STORED_MESSAGES);
AsyncStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(toStore)).catch(err =>
console.error('[Chat] Fehler beim Speichern:', err),
);
}, [messages]);
// Auto-Scroll bei neuen Nachrichten
useEffect(() => {
if (messages.length > 0) {

View File

@ -7,6 +7,7 @@
import { Platform, PermissionsAndroid } from 'react-native';
import Sound from 'react-native-sound';
import RNFS from 'react-native-fs';
// --- Typen ---
@ -127,18 +128,20 @@ class AudioService {
/** Base64-kodiertes Audio abspielen (z.B. TTS-Antwort von ARIA) */
async playAudio(base64Data: string): Promise<void> {
if (!base64Data) return;
// Laufende Wiedergabe stoppen
this.stopPlayback();
try {
// Base64-Daten in temporaere Datei schreiben und abspielen
// In Produktion: react-native-fs + Sound kombinieren
const tmpPath = `${Platform.OS === 'android' ? '/data/user/0/' : ''}aria_tts_temp.wav`;
// Base64 → temporaere WAV-Datei → Sound abspielen
const tmpPath = `${RNFS.CachesDirectoryPath}/aria_tts_${Date.now()}.wav`;
await RNFS.writeFile(tmpPath, base64Data, 'base64');
// Platzhalter: Sound aus Datei laden
this.currentSound = new Sound(tmpPath, '', (error) => {
if (error) {
console.error('[Audio] Fehler beim Laden:', error);
RNFS.unlink(tmpPath).catch(() => {});
return;
}
this.currentSound?.play((success) => {
@ -149,6 +152,8 @@ class AudioService {
}
this.currentSound?.release();
this.currentSound = null;
// Temp-Datei aufraeumen
RNFS.unlink(tmpPath).catch(() => {});
});
});
} catch (err) {

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:

View File

@ -57,6 +57,7 @@ services:
- RVS_HOST=${RVS_HOST:-}
- RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true}
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
- RVS_TOKEN=${RVS_TOKEN:-}
restart: unless-stopped
networks: