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_HOST=rvs.example.de
RVS_PORT=443 RVS_PORT=443
RVS_TLS=true 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= RVS_TOKEN=
# Gitea (for release.sh — Kennwort wird interaktiv abgefragt) # 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` - `WakeWordDetector` umgebaut — sucht Custom-Modell `/voices/wake_aria.onnx`, Fallback auf eingebautes `hey_jarvis`
- Alter Code crashte: `wakeword_models=["aria"]` erwartet Dateipfad, kein Keyword - 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`** **Neues Script: `get-voices.sh`**
- Lädt Piper Stimmen (Ramona + Thorsten) von HuggingFace herunter - Lädt Piper Stimmen (Ramona + Thorsten) von HuggingFace herunter
- Neuer Installationsschritt in README - Neuer Installationsschritt in README

View File

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

View File

@ -27,6 +27,8 @@ import com.imagepicker.ImagePickerPackage;
import com.zoontek.rnpermissions.RNPermissionsPackage; import com.zoontek.rnpermissions.RNPermissionsPackage;
// react-native-camera-kit // react-native-camera-kit
import com.rncamerakit.RNCameraKitPackage; import com.rncamerakit.RNCameraKitPackage;
// @react-native-async-storage/async-storage
import com.reactnativecommunity.asyncstorage.AsyncStoragePackage;
public class PackageList { public class PackageList {
private Application application; private Application application;
@ -79,7 +81,8 @@ public class PackageList {
new GeolocationPackage(), new GeolocationPackage(),
new ImagePickerPackage(), new ImagePickerPackage(),
new RNPermissionsPackage(), 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" symbolFile="R.txt"
externalAnnotations="annotations.zip" externalAnnotations="annotations.zip"
proguardRules="proguard.txt"/> 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 <library
name="org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.21@jar" 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" 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" symbolFile="R.txt"
externalAnnotations="annotations.zip" externalAnnotations="annotations.zip"
proguardRules="proguard.txt"/> 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 <library
name="org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.21@jar" 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" 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 base.2=/home/duffy/Dokumente/programmierung/ARIA-AGENT/android/android/app/build/intermediates/dex/release/mergeDexRelease/classes2.dex
path.2=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 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-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-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-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.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: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 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-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-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.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 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", "name": "aria-cockpit",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@react-native-async-storage/async-storage": "^1.21.0",
"@react-native-community/geolocation": "^3.2.1", "@react-native-community/geolocation": "^3.2.1",
"@react-navigation/bottom-tabs": "^6.5.11", "@react-navigation/bottom-tabs": "^6.5.11",
"@react-navigation/native": "^6.1.9", "@react-navigation/native": "^6.1.9",
@ -2848,6 +2849,18 @@
"node": ">= 8" "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": { "node_modules/@react-native-community/cli": {
"version": "12.3.2", "version": "12.3.2",
"resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-12.3.2.tgz", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-12.3.2.tgz",
@ -7682,6 +7695,15 @@
"node": ">=8" "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": { "node_modules/is-plain-object": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", "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==", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT" "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": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "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-image-picker": "^7.1.0",
"react-native-permissions": "^4.1.4", "react-native-permissions": "^4.1.4",
"react-native-camera-kit": "^13.0.0", "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": { "devDependencies": {
"typescript": "^5.3.3", "typescript": "^5.3.3",

View File

@ -17,6 +17,7 @@ import {
StyleSheet, StyleSheet,
Modal, Modal,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs'; import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio'; import audioService from '../services/audio';
import VoiceButton from '../components/VoiceButton'; import VoiceButton from '../components/VoiceButton';
@ -41,6 +42,11 @@ interface ChatMessage {
attachments?: Attachment[]; attachments?: Attachment[];
} }
// --- Konstanten ---
const CHAT_STORAGE_KEY = 'aria_chat_messages';
const MAX_STORED_MESSAGES = 500;
// --- Komponente --- // --- Komponente ---
const ChatScreen: React.FC = () => { const ChatScreen: React.FC = () => {
@ -60,10 +66,26 @@ const ChatScreen: React.FC = () => {
return `msg_${Date.now()}_${messageIdCounter.current}`; return `msg_${Date.now()}_${messageIdCounter.current}`;
}; };
// GPS-Einstellung aus Settings laden (vereinfacht) // Chat-Verlauf aus AsyncStorage laden
useEffect(() => { useEffect(() => {
// In Produktion: AsyncStorage oder Context verwenden const loadMessages = async () => {
// Hier Platzhalter - GPS Toggle kommt aus SettingsScreen 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 // 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 // Auto-Scroll bei neuen Nachrichten
useEffect(() => { useEffect(() => {
if (messages.length > 0) { if (messages.length > 0) {

View File

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

View File

@ -17,10 +17,12 @@ Stimmen:
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import base64
import json import json
import logging import logging
import os import os
import signal import signal
import ssl
import sys import sys
import tempfile import tempfile
import wave 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_HOST = os.getenv("RVS_HOST", "") # z.B. rvs.hackersoft.de
RVS_PORT = os.getenv("RVS_PORT", "443") # Port des RVS RVS_PORT = os.getenv("RVS_PORT", "443") # Port des RVS
RVS_TLS = os.getenv("RVS_TLS", "true") # true = wss://, false = ws:// 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) RVS_TOKEN = os.getenv("RVS_TOKEN", "") # Pairing-Token (gleich wie in der App)
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small") WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de") WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de")
@ -311,30 +314,34 @@ class WakeWordDetector:
self.wake_word_key: str = "" self.wake_word_key: str = ""
def initialize(self) -> None: 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...") logger.info("Lade Wake-Word-Modell...")
custom_path = Path(self.CUSTOM_MODEL_PATH) # Alle eingebauten Modelle laden (ohne kwargs — Kompatibilitaet!)
if custom_path.exists(): self.model = WakeWordModel()
# Custom "aria" Modell vorhanden
self.model = WakeWordModel( # Verfuegbare Modelle ermitteln
wakeword_models=[str(custom_path)], available = list(self.model.models.keys()) if hasattr(self.model, 'models') else []
) logger.info("Verfuegbare Wake-Words: %s", ", ".join(available) if available else "(keine)")
self.wake_word_key = custom_path.stem
logger.info("Custom Wake-Word-Modell geladen: %s", custom_path) # Bestes Modell auswaehlen
else: if self.FALLBACK_MODEL in available:
# Fallback auf eingebautes Modell
self.model = WakeWordModel()
self.wake_word_key = self.FALLBACK_MODEL self.wake_word_key = self.FALLBACK_MODEL
logger.warning( elif available:
"Kein Custom-Modell (%s) — nutze Fallback '%s'", self.wake_word_key = available[0]
self.CUSTOM_MODEL_PATH, else:
self.FALLBACK_MODEL, self.wake_word_key = self.FALLBACK_MODEL
)
logger.info( logger.info("Wake-Word aktiv: '%s'", self.wake_word_key)
"Tipp: Custom Wake-Word trainieren → " logger.info(
"https://github.com/dscripka/openWakeWord#training-new-models" "Tipp: Custom 'aria' Wake-Word trainieren → "
) "https://github.com/dscripka/openWakeWord#training-new-models"
)
def detect(self, audio_chunk: np.ndarray) -> bool: def detect(self, audio_chunk: np.ndarray) -> bool:
"""Prueft ob das Wake-Word im Audio-Chunk enthalten ist. """Prueft ob das Wake-Word im Audio-Chunk enthalten ist.
@ -351,10 +358,10 @@ class WakeWordDetector:
prediction = self.model.predict(audio_chunk) prediction = self.model.predict(audio_chunk)
# openwakeword gibt Scores pro Modell zurueck # openwakeword gibt Scores pro Modell zurueck
score = prediction.get(self.wake_word_key, 0) for key, score in prediction.items():
if score > self.THRESHOLD: if score > self.THRESHOLD:
logger.info("Wake-Word erkannt! (Score: %.2f)", score) logger.info("Wake-Word '%s' erkannt! (Score: %.2f)", key, score)
return True return True
return False return False
@ -402,13 +409,20 @@ class ARIABridge:
rvs_host = self.config.get("RVS_HOST", RVS_HOST) rvs_host = self.config.get("RVS_HOST", RVS_HOST)
rvs_port = self.config.get("RVS_PORT", RVS_PORT) rvs_port = self.config.get("RVS_PORT", RVS_PORT)
rvs_tls = self.config.get("RVS_TLS", RVS_TLS).lower() == "true" 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) self.rvs_token = self.config.get("RVS_TOKEN", RVS_TOKEN)
# URL zusammenbauen # URLs zusammenbauen (primaer + fallback)
if rvs_host: if rvs_host:
proto = "wss" if rvs_tls else "ws" proto = "wss" if rvs_tls else "ws"
self.rvs_url = f"{proto}://{rvs_host}:{rvs_port}" 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: else:
self.rvs_url = "" self.rvs_url = ""
self.rvs_url_fallback = ""
self.current_mode = Mode.NORMAL self.current_mode = Mode.NORMAL
self.running = False self.running = False
@ -425,21 +439,30 @@ class ARIABridge:
self.ws_rvs: Optional[websockets.WebSocketClientProtocol] = None self.ws_rvs: Optional[websockets.WebSocketClientProtocol] = None
def initialize(self) -> 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("=" * 50)
logger.info("ARIA Voice Bridge startet...") logger.info("ARIA Voice Bridge startet...")
logger.info("=" * 50) logger.info("=" * 50)
# PulseAudio-Server pruefen # Voice-Engine IMMER laden — rendert Audio fuer die App (auch ohne Soundkarte)
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")
self.voice_engine.initialize() 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("Alle Komponenten initialisiert")
logger.info("aria-core: %s", self.ws_url) logger.info("aria-core: %s", self.ws_url)
@ -516,20 +539,39 @@ class ARIABridge:
"timestamp": int(asyncio.get_event_loop().time() * 1000), "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) # Antwort an die App weiterleiten (als Chat-Nachricht)
await self._send_to_rvs({ await self._send_to_rvs({
"type": "chat", "type": "chat",
"payload": { "payload": {
"text": text, "text": text,
"sender": "aria", "sender": "aria",
"voice": requested_voice or self.voice_engine.select_voice(text), "voice": voice_name,
}, },
"timestamp": int(asyncio.get_event_loop().time() * 1000), "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): 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: else:
logger.info("[core] TTS unterdrueckt (Modus: %s)", self.current_mode.config.name) 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: async def connect_to_rvs(self) -> None:
"""Persistente WebSocket-Verbindung zum RVS mit Auto-Reconnect. """Persistente WebSocket-Verbindung zum RVS mit Auto-Reconnect.
Authentifiziert sich mit dem gleichen Token wie die App. Bei TLS-Fehler wird automatisch auf ws:// gefallbackt
Nachrichten von der App werden an aria-core weitergeleitet. (wenn RVS_TLS_FALLBACK=true).
""" """
if not self.rvs_url or not self.rvs_token: if not self.rvs_url or not self.rvs_token:
logger.info("[rvs] Nicht konfiguriert — ueberspringe") logger.info("[rvs] Nicht konfiguriert — ueberspringe")
return return
retry_delay = 2 retry_delay = 2
url = f"{self.rvs_url}?token={self.rvs_token}" current_url = self.rvs_url
using_fallback = False
while self.running: while self.running:
try: 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: async with websockets.connect(url) as ws:
self.ws_rvs = ws self.ws_rvs = ws
retry_delay = 2 retry_delay = 2
@ -588,6 +632,16 @@ class ARIABridge:
logger.warning("[rvs] Verbindung verloren") logger.warning("[rvs] Verbindung verloren")
except ConnectionRefusedError: except ConnectionRefusedError:
logger.warning("[rvs] Nicht erreichbar") 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: except Exception:
logger.exception("[rvs] WebSocket-Fehler") logger.exception("[rvs] WebSocket-Fehler")
finally: finally:
@ -762,15 +816,22 @@ class ARIABridge:
# ── Run & Shutdown ─────────────────────────────────────── # ── Run & Shutdown ───────────────────────────────────────
async def run(self) -> None: 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 self.running = True
tasks = [ tasks = [
asyncio.create_task(self.connect_to_core()), asyncio.create_task(self.connect_to_core()),
asyncio.create_task(self.connect_to_rvs()), 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: try:
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
except asyncio.CancelledError: except asyncio.CancelledError:

View File

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