Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c43a18402 | |||
| 5bdcc3c65b | |||
| 52795530f9 | |||
| 2eb0b4df90 | |||
| 0c18090351 | |||
| d6b54d3247 | |||
| ead28cf09a | |||
| f682aad4ff | |||
| e0c1a4bcd5 | |||
| a648dad96d | |||
| da5579038e |
@@ -568,8 +568,7 @@ aria-data/
|
||||
│ └── diag-state/ ← Diagnostic persistenter State
|
||||
│
|
||||
│ (im Shared Volume /shared/config/):
|
||||
│ ├── voice_config.json ← TTS-Einstellungen (Stimme, Speed, Engine)
|
||||
│ ├── highlight_triggers.json ← Highlight-Trigger Woerter
|
||||
│ ├── voice_config.json ← TTS-Einstellungen (Stimme, Speed, F5-TTS-Tuning)
|
||||
│ └── chat_backup.jsonl ← Nachrichten-Backup (on-the-fly)
|
||||
│
|
||||
└── ssh/ ← SSH Keys fuer VM-Zugriff
|
||||
@@ -816,7 +815,7 @@ docker exec aria-core ssh aria-wohnung hostname
|
||||
- [x] SSH-Zugriff auf VM (aria-wohnung)
|
||||
- [x] Diagnostic Web-UI + Einstellungen
|
||||
- [x] Session-Verwaltung + Chat-History
|
||||
- [x] Stimmen-Einstellungen (Ramona/Thorsten, Speed, Highlight-Trigger) — durch XTTS v2 Voice Cloning ersetzt
|
||||
- [x] Stimmen-Einstellungen (frueher Piper Ramona/Thorsten, Highlight-Trigger) — durch XTTS, dann F5-TTS Voice Cloning ersetzt
|
||||
- [x] Piper komplett entfernt — nur noch XTTS v2 als TTS (Gaming-PC)
|
||||
- [x] Streaming TTS: PCM-Chunks direkt in AudioTrack, nahtlose Wiedergabe
|
||||
- [x] TTS satzweise fuer lange Texte
|
||||
@@ -851,6 +850,9 @@ docker exec aria-core ssh aria-wohnung hostname
|
||||
- [x] Sprachnachrichten-Bubble: audioRequestId statt Substring-Match — keine vertauschten Bubbles mehr bei parallelen Aufnahmen
|
||||
- [x] Bereit-Sound (Airplane Ding-Dong) wenn Mikro nach Wake-Word offen ist — akustische Bestaetigung, in Settings abschaltbar
|
||||
- [x] Wake-Word parallel zu TTS mit AcousticEchoCanceler — "Computer" sagen waehrend ARIA spricht stoppt sie und oeffnet Mikro
|
||||
- [x] GPS-Position mit Nachrichten mitsenden (Toggle in Settings) — ARIA nutzt sie nur bei standortbezogenen Fragen, im Chat sichtbar nur in ihrer Antwort
|
||||
- [x] Sprachnachrichten ohne STT-Result werden nach Timeout automatisch entfernt (skaliert mit Aufnahmedauer)
|
||||
- [x] Background Audio Service: TTS, Wake-Word-Lauschen + Aufnahme laufen auch bei minimierter App weiter (Foreground-Service mit mediaPlayback|microphone, dynamische Notification)
|
||||
- [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen
|
||||
- [x] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon
|
||||
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 709
|
||||
versionName "0.0.7.9"
|
||||
versionCode 802
|
||||
versionName "0.0.8.2"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- Anruf-State lesen damit TTS bei klingelndem Telefon pausiert -->
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<!-- Foreground-Service damit TTS auch bei minimierter App weiterlaeuft.
|
||||
FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der
|
||||
Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word,
|
||||
Aufnahme im Gespraechsmodus). -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
@@ -37,5 +45,10 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name=".AriaPlaybackService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback|microphone" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
* Foreground-Service der den App-Prozess waehrend TTS-Wiedergabe am Leben
|
||||
* haelt — Android killt sonst den Prozess sobald die App im Hintergrund ist
|
||||
* und ARIA verstummt mitten im Satz.
|
||||
*
|
||||
* Notification ist persistent (ongoing) waehrend der Service laeuft.
|
||||
* Tap auf die Notification bringt MainActivity zurueck nach vorne.
|
||||
*
|
||||
* foregroundServiceType="mediaPlayback" ist Pflicht ab Android 14, sonst
|
||||
* wirft startForeground() eine SecurityException.
|
||||
*/
|
||||
class AriaPlaybackService : Service() {
|
||||
companion object {
|
||||
private const val TAG = "AriaPlaybackService"
|
||||
private const val CHANNEL_ID = "aria_playback"
|
||||
private const val NOTIFICATION_ID = 1042
|
||||
const val EXTRA_REASON = "reason" // "tts" | "wake" | "rec" | ""
|
||||
}
|
||||
|
||||
private var currentReason: String = ""
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ensureNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val reason = intent?.getStringExtra(EXTRA_REASON) ?: ""
|
||||
currentReason = reason
|
||||
Log.i(TAG, "Foreground-Service start/update (reason=$reason)")
|
||||
try {
|
||||
startForeground(NOTIFICATION_ID, buildNotification(reason))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "startForeground fehlgeschlagen", e)
|
||||
stopSelf()
|
||||
}
|
||||
// START_NOT_STICKY: wenn Android den Service killt, NICHT automatisch
|
||||
// wieder starten — die App entscheidet wann der Service noetig ist.
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.i(TAG, "Foreground-Service gestoppt")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun ensureNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val nm = getSystemService(NotificationManager::class.java) ?: return
|
||||
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"ARIA Audio-Wiedergabe",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Notification waehrend ARIA spricht (haelt die App im Hintergrund am Leben)"
|
||||
setShowBadge(false)
|
||||
}
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(reason: String): Notification {
|
||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
else
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
val pendingIntent = PendingIntent.getActivity(this, 0, launchIntent, pendingFlags)
|
||||
|
||||
val (title, body) = when (reason) {
|
||||
"tts" -> "ARIA spricht" to "Antwort wird abgespielt — antippen oeffnet die App"
|
||||
"rec" -> "ARIA hoert zu" to "Sprachaufnahme laeuft — antippen oeffnet die App"
|
||||
"wake" -> "ARIA bereit" to "Wake-Word lauscht passiv — antippen oeffnet die App"
|
||||
else -> "ARIA aktiv" to "Hintergrund-Modus — antippen oeffnet die App"
|
||||
}
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
|
||||
/**
|
||||
* RN-Bridge fuer den AriaPlaybackService.
|
||||
*
|
||||
* Wird vom JS waehrend einer TTS-Wiedergabe gestartet damit Android den
|
||||
* App-Prozess nicht killt wenn die App im Hintergrund ist (= ARIA spricht
|
||||
* weiter, auch wenn Stefan die App minimiert hat).
|
||||
*
|
||||
* Service stoppt entweder explizit per stop() oder wird von Android
|
||||
* mitgekillt wenn der Prozess weg ist (was bei Foreground-Service nur
|
||||
* passiert wenn der User die App force-stopped).
|
||||
*/
|
||||
class BackgroundAudioModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
override fun getName() = "BackgroundAudio"
|
||||
|
||||
companion object { private const val TAG = "BackgroundAudio" }
|
||||
|
||||
@ReactMethod
|
||||
fun start(reason: String, promise: Promise) {
|
||||
try {
|
||||
val ctx = reactApplicationContext
|
||||
val intent = Intent(ctx, AriaPlaybackService::class.java)
|
||||
intent.putExtra(AriaPlaybackService.EXTRA_REASON, reason ?: "")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ctx.startForegroundService(intent)
|
||||
} else {
|
||||
ctx.startService(intent)
|
||||
}
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "start fehlgeschlagen: ${e.message}")
|
||||
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun stop(promise: Promise) {
|
||||
try {
|
||||
val ctx = reactApplicationContext
|
||||
ctx.stopService(Intent(ctx, AriaPlaybackService::class.java))
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "stop fehlgeschlagen: ${e.message}")
|
||||
promise.reject("STOP_FAILED", e.message ?: "Unbekannter Fehler", e)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod fun addListener(eventName: String) {}
|
||||
@ReactMethod fun removeListeners(count: Int) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class BackgroundAudioPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(BackgroundAudioModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ class MainApplication : Application(), ReactApplication {
|
||||
add(PcmStreamPlayerPackage())
|
||||
add(OpenWakeWordPackage())
|
||||
add(PhoneCallPackage())
|
||||
add(BackgroundAudioPackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = "index"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.0.7.9",
|
||||
"version": "0.0.8.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ScrollView,
|
||||
Modal,
|
||||
ToastAndroid,
|
||||
AppState,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import RNFS from 'react-native-fs';
|
||||
@@ -27,6 +28,10 @@ import audioService from '../services/audio';
|
||||
import wakeWordService from '../services/wakeword';
|
||||
import phoneCallService from '../services/phoneCall';
|
||||
import { playWakeReadySound } from '../services/wakeReadySound';
|
||||
import {
|
||||
acquireBackgroundAudio,
|
||||
releaseBackgroundAudio,
|
||||
} from '../services/backgroundAudio';
|
||||
import updateService from '../services/updater';
|
||||
import VoiceButton from '../components/VoiceButton';
|
||||
import FileUpload, { FileData } from '../components/FileUpload';
|
||||
@@ -142,9 +147,10 @@ const ChatScreen: React.FC = () => {
|
||||
return `msg_${Date.now()}_${messageIdCounter.current}`;
|
||||
};
|
||||
|
||||
// TTS-Settings beim Mount + bei Screen-Fokus neu laden (damit Settings-Toggle sofort greift)
|
||||
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
|
||||
// sofort greift, ohne Context- oder Event-System)
|
||||
useEffect(() => {
|
||||
const loadTtsSettings = async () => {
|
||||
const loadSettings = async () => {
|
||||
const enabled = await AsyncStorage.getItem('aria_tts_enabled');
|
||||
setTtsDeviceEnabled(enabled !== 'false'); // default true
|
||||
const muted = await AsyncStorage.getItem('aria_tts_muted');
|
||||
@@ -152,10 +158,11 @@ const ChatScreen: React.FC = () => {
|
||||
const voice = await AsyncStorage.getItem('aria_xtts_voice');
|
||||
localXttsVoiceRef.current = voice || '';
|
||||
ttsSpeedRef.current = await loadTtsSpeed();
|
||||
const gps = await AsyncStorage.getItem('aria_gps_enabled');
|
||||
setGpsEnabled(gps === 'true');
|
||||
};
|
||||
loadTtsSettings();
|
||||
// Poll alle 2s um Settings-Aenderung mitzubekommen (einfache Loesung ohne Context)
|
||||
const interval = setInterval(loadTtsSettings, 2000);
|
||||
loadSettings();
|
||||
const interval = setInterval(loadSettings, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
@@ -171,6 +178,11 @@ const ChatScreen: React.FC = () => {
|
||||
// 'armed' oder 'off' fallen, darf Spotify wieder.
|
||||
if (s === 'conversing') audioService.acquireConversationFocus();
|
||||
else audioService.releaseConversationFocus();
|
||||
// Foreground-Service-Slot 'wake' — solange das Ohr ueberhaupt aktiv ist
|
||||
// (armed oder conversing), soll der App-Prozess im Hintergrund am Leben
|
||||
// bleiben damit Mikro-Lauschen + Aufnahme weiterlaufen.
|
||||
if (s !== 'off') acquireBackgroundAudio('wake').catch(() => {});
|
||||
else releaseBackgroundAudio('wake').catch(() => {});
|
||||
});
|
||||
return () => unsub();
|
||||
}, []);
|
||||
@@ -182,6 +194,31 @@ const ChatScreen: React.FC = () => {
|
||||
return () => { phoneCallService.stop().catch(() => {}); };
|
||||
}, []);
|
||||
|
||||
// App-Resume: kurzer Wake-Word-Cooldown — beim Wechsel Background→Foreground
|
||||
// gibt's haeufig Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack re-route)
|
||||
// die openWakeWord sonst faelschlich als Wake-Word interpretiert.
|
||||
useEffect(() => {
|
||||
let lastState: string = AppState.currentState;
|
||||
const sub = AppState.addEventListener('change', (next) => {
|
||||
if (lastState !== 'active' && next === 'active') {
|
||||
wakeWordService.setResumeCooldown(1500);
|
||||
}
|
||||
lastState = next;
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
|
||||
// Recording-State an Background-Service-Slot 'rec' koppeln — damit das Mikro
|
||||
// auch im Hintergrund weiter aufnehmen darf (Android killt den App-Prozess
|
||||
// sonst und die Aufnahme bricht ab).
|
||||
useEffect(() => {
|
||||
const unsub = audioService.onStateChange((s) => {
|
||||
if (s === 'recording') acquireBackgroundAudio('rec').catch(() => {});
|
||||
else releaseBackgroundAudio('rec').catch(() => {});
|
||||
});
|
||||
return () => unsub();
|
||||
}, []);
|
||||
|
||||
// ttsCanPlayRef live aktuell halten — Closure in onMessage unten liest
|
||||
// darueber statt direkt ttsDeviceEnabled/ttsMuted (sonst stale).
|
||||
useEffect(() => {
|
||||
@@ -566,12 +603,16 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
// TTS-Lifecycle: solange ARIA spricht und Wake-Word verfuegbar ist,
|
||||
// parallel mitlauschen — User kann "Computer" sagen statt manuell tappen.
|
||||
// PLUS: Foreground-Service-Slot 'tts' belegen damit Android den App-
|
||||
// Prozess nicht killt wenn die App im Hintergrund ist.
|
||||
const unsubTtsStart = audioService.onPlaybackStarted(() => {
|
||||
acquireBackgroundAudio('tts').catch(() => {});
|
||||
if (wakeWordService.isConversing() && wakeWordService.hasWakeWord()) {
|
||||
wakeWordService.startBargeListening().catch(() => {});
|
||||
}
|
||||
});
|
||||
const unsubTtsEnd = audioService.onPlaybackFinished(() => {
|
||||
releaseBackgroundAudio('tts').catch(() => {});
|
||||
// Vor naechster Aufnahme: barge-listening aus damit der AudioRecorder
|
||||
// das Mikro greifen kann.
|
||||
wakeWordService.stopBargeListening().catch(() => {});
|
||||
@@ -768,6 +809,17 @@ const ChatScreen: React.FC = () => {
|
||||
...(location && { location }),
|
||||
});
|
||||
scheduleStaleAudioCleanup(audioRequestId, result.durationMs);
|
||||
|
||||
// Manueller Mikro-Stop waehrend Wake-Word-Konversation: User hat explizit
|
||||
// den Knopf gedrueckt → er moechte nicht in den automatischen Multi-Turn-
|
||||
// Modus, sondern nach ARIAs Antwort zurueck zu passivem Wake-Word-Lauschen.
|
||||
// Bei VAD-Auto-Stop (Wake-Word-Pfad) laeuft das ueber den silence-callback
|
||||
// und endet mit resume() — der manuelle Stop hier ist der "ich bin fertig"-
|
||||
// Knopf.
|
||||
if (wakeWordService.isConversing()) {
|
||||
console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed');
|
||||
await wakeWordService.endConversation();
|
||||
}
|
||||
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
|
||||
|
||||
// Datei auswaehlen → zur Pending-Liste hinzufuegen
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Platform,
|
||||
ToastAndroid,
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import RNFS from 'react-native-fs';
|
||||
@@ -39,6 +40,10 @@ import {
|
||||
MAX_RECORDING_MIN_SEC,
|
||||
MAX_RECORDING_MAX_SEC,
|
||||
MAX_RECORDING_STORAGE_KEY,
|
||||
VAD_SILENCE_DB_DEFAULT,
|
||||
VAD_SILENCE_DB_MIN,
|
||||
VAD_SILENCE_DB_MAX,
|
||||
VAD_SILENCE_DB_OVERRIDE_KEY,
|
||||
TTS_SPEED_DEFAULT,
|
||||
TTS_SPEED_MIN,
|
||||
TTS_SPEED_MAX,
|
||||
@@ -124,6 +129,9 @@ const SettingsScreen: React.FC = () => {
|
||||
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
|
||||
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
|
||||
const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
|
||||
// null = automatisch (adaptive Baseline), sonst manueller dB-Override
|
||||
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
|
||||
const [showVadInfo, setShowVadInfo] = useState(false);
|
||||
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
|
||||
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
|
||||
const [wakeStatus, setWakeStatus] = useState<string>('');
|
||||
@@ -159,6 +167,9 @@ const SettingsScreen: React.FC = () => {
|
||||
AsyncStorage.getItem('aria_tts_enabled').then(saved => {
|
||||
if (saved !== null) setTtsEnabled(saved === 'true');
|
||||
});
|
||||
AsyncStorage.getItem('aria_gps_enabled').then(saved => {
|
||||
if (saved !== null) setGpsEnabled(saved === 'true');
|
||||
});
|
||||
AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => {
|
||||
if (saved != null) {
|
||||
const n = parseFloat(saved);
|
||||
@@ -191,6 +202,14 @@ const SettingsScreen: React.FC = () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => {
|
||||
if (saved != null && saved !== '') {
|
||||
const n = parseFloat(saved);
|
||||
if (isFinite(n) && n >= VAD_SILENCE_DB_MIN && n <= VAD_SILENCE_DB_MAX) {
|
||||
setVadSilenceDb(n);
|
||||
}
|
||||
}
|
||||
});
|
||||
AsyncStorage.getItem(TTS_SPEED_STORAGE_KEY).then(saved => {
|
||||
if (saved != null) {
|
||||
const n = parseFloat(saved);
|
||||
@@ -437,7 +456,7 @@ const SettingsScreen: React.FC = () => {
|
||||
|
||||
const handleGPSToggle = useCallback((value: boolean) => {
|
||||
setGpsEnabled(value);
|
||||
// In Produktion: Wert in AsyncStorage persistieren
|
||||
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// --- XTTS Voice ---
|
||||
@@ -661,7 +680,11 @@ const SettingsScreen: React.FC = () => {
|
||||
<View style={styles.toggleInfo}>
|
||||
<Text style={styles.toggleLabel}>GPS-Position mitsenden</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Standort wird automatisch an Nachrichten angehaengt
|
||||
Position (lat/lon) wird mit jeder Nachricht an ARIA mitgeschickt.
|
||||
Sie sieht's nur intern und nutzt es bei standortbezogenen Fragen
|
||||
("wo bin ich?", "Wetter hier?"), erwaehnt es sonst nicht.
|
||||
Im Chat-Verlauf bleibt die Bubble unveraendert — nur ARIAs
|
||||
Antwort kann darauf eingehen.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
@@ -775,8 +798,94 @@ const SettingsScreen: React.FC = () => {
|
||||
<Text style={styles.prerollButtonText}>+1m</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: 24, gap: 8}}>
|
||||
<Text style={styles.toggleLabel}>Stille-Pegel (dB)</Text>
|
||||
<TouchableOpacity onPress={() => setShowVadInfo(true)} style={styles.infoBtn}>
|
||||
<Text style={styles.infoBtnText}>i</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={styles.toggleHint}>
|
||||
Welcher Mikro-Pegel als "Stille" gilt. Standard: automatisch (Baseline aus
|
||||
den ersten 500ms). Manuell setzen wenn Auto nicht zuverlaessig greift.
|
||||
</Text>
|
||||
<View style={styles.prerollRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = vadSilenceDb == null
|
||||
? VAD_SILENCE_DB_DEFAULT - 1
|
||||
: Math.max(VAD_SILENCE_DB_MIN, vadSilenceDb - 1);
|
||||
setVadSilenceDb(next);
|
||||
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
|
||||
}}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>−1</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.prerollValue}>
|
||||
{vadSilenceDb == null ? 'auto' : `${vadSilenceDb} dB`}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.prerollButton}
|
||||
onPress={() => {
|
||||
const next = vadSilenceDb == null
|
||||
? VAD_SILENCE_DB_DEFAULT + 1
|
||||
: Math.min(VAD_SILENCE_DB_MAX, vadSilenceDb + 1);
|
||||
setVadSilenceDb(next);
|
||||
AsyncStorage.setItem(VAD_SILENCE_DB_OVERRIDE_KEY, String(next));
|
||||
}}
|
||||
>
|
||||
<Text style={styles.prerollButtonText}>+1</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{vadSilenceDb != null && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setVadSilenceDb(null);
|
||||
AsyncStorage.removeItem(VAD_SILENCE_DB_OVERRIDE_KEY);
|
||||
}}
|
||||
style={{alignSelf: 'center', marginTop: 8, paddingVertical: 6, paddingHorizontal: 12}}
|
||||
>
|
||||
<Text style={{color: '#0096FF', fontSize: 13}}>↻ Auf automatisch zuruecksetzen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={showVadInfo}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowVadInfo(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalCard}>
|
||||
<Text style={styles.modalTitle}>Stille-Pegel (dB)</Text>
|
||||
<Text style={styles.modalText}>
|
||||
Lautstaerken werden in Dezibel (dB) gemessen — negative Werte, je
|
||||
hoeher (naeher an 0), desto lauter.{'\n\n'}
|
||||
<Text style={{fontWeight: '700'}}>Standard:</Text> automatisch.
|
||||
Die App misst die ersten 500ms Hintergrundpegel und setzt die
|
||||
Stille-Schwelle auf Baseline + 6 dB. Funktioniert in den meisten
|
||||
Umgebungen.{'\n\n'}
|
||||
<Text style={{fontWeight: '700'}}>Manuell:</Text> Pegel unter dem
|
||||
eingestellten Wert gilt als "Stille" → Aufnahme stoppt.{'\n\n'}
|
||||
<Text style={{fontWeight: '700'}}>Faustregel:</Text>{'\n'}
|
||||
• <Text style={{color: '#FFD60A'}}>−45 dB</Text> sehr empfindlich (stoppt schnell, auch bei Atmen){'\n'}
|
||||
• <Text style={{color: '#34C759'}}>−38 dB</Text> ausgewogen (typische Bueroumgebung){'\n'}
|
||||
• <Text style={{color: '#FF6B6B'}}>−25 dB</Text> unempfindlich (laute Umgebung, nur klare Sprache zaehlt){'\n\n'}
|
||||
<Text style={{color: '#8888AA'}}>Niedrigere Zahl (z.B. −50) = sensibler.{'\n'}
|
||||
Hoehere Zahl (z.B. −20) = robuster gegen Hintergrundlaerm,
|
||||
braucht aber lautere Sprache.</Text>
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.connectButton, {marginTop: 16, alignSelf: 'stretch'}]}
|
||||
onPress={() => setShowVadInfo(false)}
|
||||
>
|
||||
<Text style={styles.connectButtonText}>OK</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</>)}
|
||||
|
||||
{/* === Wake-Word (komplett on-device, openWakeWord) === */}
|
||||
@@ -1628,6 +1737,48 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
infoBtn: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#0096FF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
infoBtnText: {
|
||||
color: '#0096FF',
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 16,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
modalCard: {
|
||||
backgroundColor: '#1E1E2E',
|
||||
borderRadius: 14,
|
||||
padding: 20,
|
||||
maxWidth: 460,
|
||||
width: '100%',
|
||||
},
|
||||
modalTitle: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 12,
|
||||
},
|
||||
modalText: {
|
||||
color: '#E0E0F0',
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
|
||||
keywordChip: {
|
||||
backgroundColor: '#1E1E2E',
|
||||
borderWidth: 1,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Platform, PermissionsAndroid, NativeModules, ToastAndroid } from 'react
|
||||
import Sound from 'react-native-sound';
|
||||
import RNFS from 'react-native-fs';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { acquireBackgroundAudio, releaseBackgroundAudio, stopBackgroundAudio } from './backgroundAudio';
|
||||
import AudioRecorderPlayer, {
|
||||
AudioEncoderAndroidType,
|
||||
AudioSourceAndroidType,
|
||||
@@ -84,6 +85,29 @@ const VAD_SPEECH_OFFSET_DB = 12; // sicheres Speech = Baseline + 12dB
|
||||
const VAD_BASELINE_SAMPLES = 5; // 5 × 100ms = 500ms Baseline
|
||||
const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — laenger = keine Huestler/Klopfer mehr
|
||||
|
||||
// Override fuer die Stille-Schwelle — wenn gesetzt, wird die adaptive Baseline
|
||||
// ignoriert. Nuetzlich wenn die adaptive Logik in spezifischen Umgebungen
|
||||
// nicht zuverlaessig greift. Range -55..-15 dB. Speech-Schwelle wird auf
|
||||
// override+10 dB gesetzt (Speech muss klar lauter als Stille sein).
|
||||
export const VAD_SILENCE_DB_DEFAULT = -38; // wenn User Manuell-Modus waehlt
|
||||
export const VAD_SILENCE_DB_MIN = -55; // sehr empfindlich, fast jeder Pegel ist "Sprache"
|
||||
export const VAD_SILENCE_DB_MAX = -15; // sehr unempfindlich, nur lautes Reden gilt
|
||||
export const VAD_SILENCE_DB_OVERRIDE_KEY = 'aria_vad_silence_db_override';
|
||||
|
||||
/** Liefert den manuellen Override-Wert oder null wenn "automatisch". */
|
||||
export async function loadVadSilenceDbOverride(): Promise<number | null> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY);
|
||||
if (raw == null || raw === '') return null;
|
||||
const n = parseFloat(raw);
|
||||
if (!isFinite(n)) return null;
|
||||
if (n < VAD_SILENCE_DB_MIN || n > VAD_SILENCE_DB_MAX) return null;
|
||||
return n;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// VAD-Stille (in Sekunden) — wie lange Sprechpause toleriert wird, bevor
|
||||
// die Aufnahme automatisch beendet wird. Einstellbar in den App-Settings.
|
||||
export const VAD_SILENCE_DEFAULT_SEC = 2.8;
|
||||
@@ -367,6 +391,12 @@ class AudioService {
|
||||
|
||||
this.recordingPath = `${RNFS.CachesDirectoryPath}/aria_recording_${Date.now()}.mp4`;
|
||||
|
||||
// Foreground-Service VOR dem AudioRecord starten — sonst blockt Android
|
||||
// den Background-Mic-Zugriff (foregroundServiceType=microphone muss zum
|
||||
// Zeitpunkt des startRecorder() schon aktiv sein, sonst greifen die
|
||||
// Background-Mic-Restrictions ab Android 11+).
|
||||
await acquireBackgroundAudio('rec');
|
||||
|
||||
// Aufnahme mit Metering starten
|
||||
await this.recorder.startRecorder(this.recordingPath, {
|
||||
AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
|
||||
@@ -388,11 +418,22 @@ class AudioService {
|
||||
if (db > -100) {
|
||||
this.vadBaselineSamples.push(db);
|
||||
if (this.vadBaselineSamples.length === VAD_BASELINE_SAMPLES) {
|
||||
const avg = this.vadBaselineSamples.reduce((a, b) => a + b, 0) / VAD_BASELINE_SAMPLES;
|
||||
this.vadAdaptiveSilenceDb = avg + VAD_SILENCE_OFFSET_DB;
|
||||
this.vadAdaptiveSpeechDb = avg + VAD_SPEECH_OFFSET_DB;
|
||||
const msg = `VAD: ambient=${avg.toFixed(0)}dB stille>${this.vadAdaptiveSilenceDb.toFixed(0)}dB`;
|
||||
console.log('[Audio] %s speech>%s', msg, this.vadAdaptiveSpeechDb.toFixed(1));
|
||||
// Minimum statt Mittelwert: robust gegen Spike-Samples (z.B. wenn
|
||||
// der User direkt nach Wake-Word sofort spricht oder das Wake-Word-
|
||||
// Echo noch im Mikro ist). Min ist der ruhigste Moment.
|
||||
const lowest = Math.min(...this.vadBaselineSamples);
|
||||
const rawSilence = lowest + VAD_SILENCE_OFFSET_DB;
|
||||
const rawSpeech = lowest + VAD_SPEECH_OFFSET_DB;
|
||||
// Cap auf einen vernuenftigen Bereich:
|
||||
// - Silence-Schwelle nicht ueber -28dB (sonst zaehlt Hintergrund-
|
||||
// geraeusch dauerhaft als "Sprache" → VAD feuert nie)
|
||||
// - Silence-Schwelle nicht unter -50dB (sonst zu strikt)
|
||||
this.vadAdaptiveSilenceDb = Math.max(-50, Math.min(rawSilence, -28));
|
||||
this.vadAdaptiveSpeechDb = Math.max(-40, Math.min(rawSpeech, -18));
|
||||
const msg = `VAD: ambient=${lowest.toFixed(0)}dB stille>${this.vadAdaptiveSilenceDb.toFixed(0)}dB`;
|
||||
console.log('[Audio] %s speech>%s (raw silence=%s speech=%s)',
|
||||
msg, this.vadAdaptiveSpeechDb.toFixed(1),
|
||||
rawSilence.toFixed(1), rawSpeech.toFixed(1));
|
||||
try { ToastAndroid.show(msg, ToastAndroid.SHORT); } catch {}
|
||||
}
|
||||
}
|
||||
@@ -425,11 +466,22 @@ class AudioService {
|
||||
this.speechDetected = false;
|
||||
this.speechStartTime = 0;
|
||||
// VAD-Adaptive zurueckgesetzt: Baseline wird in den ersten 500ms neu
|
||||
// gemessen. Bis dahin gelten die Fallback-Schwellen — die sind etwas
|
||||
// empfindlicher als die alten Werte (-38 statt -45 fuer Stille).
|
||||
// gemessen. Bis dahin gelten die Fallback-Schwellen.
|
||||
this.vadBaselineSamples = [];
|
||||
this.vadAdaptiveSilenceDb = VAD_SILENCE_FALLBACK_DB;
|
||||
this.vadAdaptiveSpeechDb = VAD_SPEECH_FALLBACK_DB;
|
||||
|
||||
// Manueller Override aus Settings — wenn gesetzt, wird die adaptive
|
||||
// Baseline-Messung uebersteuert. User-Wahl gewinnt vor Auto-Magic.
|
||||
const dbOverride = await loadVadSilenceDbOverride();
|
||||
if (dbOverride != null) {
|
||||
this.vadAdaptiveSilenceDb = dbOverride;
|
||||
this.vadAdaptiveSpeechDb = dbOverride + 10; // Speech klar ueber Stille
|
||||
this.vadBaselineSamples = new Array(VAD_BASELINE_SAMPLES).fill(0); // Baseline-Sammeln deaktivieren
|
||||
const msg = `VAD: manuell stille>${dbOverride}dB`;
|
||||
console.log('[Audio] %s', msg);
|
||||
try { ToastAndroid.show(msg, ToastAndroid.SHORT); } catch {}
|
||||
}
|
||||
this.setState('recording');
|
||||
|
||||
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
|
||||
@@ -887,6 +939,9 @@ class AudioService {
|
||||
|
||||
/** Laufende Wiedergabe stoppen + Queue leeren */
|
||||
stopPlayback(): void {
|
||||
// Foreground-Service auch stoppen — sonst bleibt die Notification haengen
|
||||
// wenn Wiedergabe abgebrochen wird (Anruf, Cancel, Barge-In).
|
||||
stopBackgroundAudio().catch(() => {});
|
||||
this.audioQueue = [];
|
||||
this.isPlaying = false;
|
||||
if (this.currentSound) {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Background-Audio: ARIAs TTS, Mic-Aufnahme und Wake-Word-Lauschen sollen
|
||||
* auch bei minimierter App weiterlaufen. Wir starten dafuer einen Foreground-
|
||||
* Service mit foregroundServiceType=mediaPlayback|microphone, der eine
|
||||
* persistente Notification zeigt waehrend irgendein Audio-Slot aktiv ist.
|
||||
*
|
||||
* Mehrere Komponenten koennen den Service unabhaengig "halten":
|
||||
* - 'tts' : ARIA spricht
|
||||
* - 'rec' : Aufnahme laeuft
|
||||
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
|
||||
*
|
||||
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
|
||||
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
|
||||
* den hoechstprioren Slot an (tts > rec > wake).
|
||||
*/
|
||||
|
||||
import { NativeModules } from 'react-native';
|
||||
|
||||
interface BackgroundAudioNative {
|
||||
start(reason: string): Promise<boolean>;
|
||||
stop(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
|
||||
|
||||
type Slot = 'tts' | 'rec' | 'wake';
|
||||
|
||||
const slots = new Set<Slot>();
|
||||
|
||||
// Prioritaet fuer den Notification-Text — hoechste zuerst.
|
||||
const PRIORITY: Slot[] = ['tts', 'rec', 'wake'];
|
||||
|
||||
function topReason(): string {
|
||||
for (const s of PRIORITY) {
|
||||
if (slots.has(s)) return s;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function applyState(): Promise<void> {
|
||||
if (!BackgroundAudio) return;
|
||||
if (slots.size === 0) {
|
||||
try { await BackgroundAudio.stop(); } catch {}
|
||||
console.log('[BackgroundAudio] Service gestoppt (keine Slots)');
|
||||
return;
|
||||
}
|
||||
const reason = topReason();
|
||||
try {
|
||||
await BackgroundAudio.start(reason);
|
||||
console.log('[BackgroundAudio] Service aktiv (slot=%s, slots=%s)',
|
||||
reason, [...slots].join('+'));
|
||||
} catch (err: any) {
|
||||
console.warn('[BackgroundAudio] start fehlgeschlagen:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireBackgroundAudio(slot: Slot): Promise<void> {
|
||||
if (slots.has(slot)) return;
|
||||
slots.add(slot);
|
||||
await applyState();
|
||||
}
|
||||
|
||||
export async function releaseBackgroundAudio(slot: Slot): Promise<void> {
|
||||
if (!slots.has(slot)) return;
|
||||
slots.delete(slot);
|
||||
await applyState();
|
||||
}
|
||||
|
||||
export function backgroundAudioActive(): boolean {
|
||||
return slots.size > 0;
|
||||
}
|
||||
|
||||
// --- Legacy API (nur tts-Slot) — fuer Aufruf-Sites die noch nichts vom Slot-
|
||||
// system wissen. Mappt auf den 'tts'-Slot. ---
|
||||
export const startBackgroundAudio = () => acquireBackgroundAudio('tts');
|
||||
export const stopBackgroundAudio = () => releaseBackgroundAudio('tts');
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ToastAndroid,
|
||||
} from 'react-native';
|
||||
import audioService from './audio';
|
||||
import wakeWordService from './wakeword';
|
||||
|
||||
interface PhoneCallNative {
|
||||
start(): Promise<boolean>;
|
||||
@@ -91,16 +92,25 @@ class PhoneCallService {
|
||||
|
||||
private _onStateChanged(state: PhoneState): void {
|
||||
if (state === this.lastState) return;
|
||||
console.log('[PhoneCall] State: %s → %s', this.lastState, state);
|
||||
const prev = this.lastState;
|
||||
console.log('[PhoneCall] State: %s → %s', prev, state);
|
||||
this.lastState = state;
|
||||
if (state === 'ringing' || state === 'offhook') {
|
||||
audioService.haltAllPlayback(`Telefon-State: ${state}`);
|
||||
// Wake-Word + Aufnahme pausieren: Telefonie-App belegt das Mikro
|
||||
// waehrend des Anrufs, plus ARIA soll nicht im Telefonat zuhoeren.
|
||||
wakeWordService.pauseForCall().catch(() => {});
|
||||
ToastAndroid.show(
|
||||
state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert',
|
||||
ToastAndroid.SHORT,
|
||||
);
|
||||
} else if (state === 'idle' && prev !== 'idle') {
|
||||
// Auflegen: Wake-Word reaktivieren wenn vor dem Anruf aktiv war.
|
||||
// TTS kommt nicht automatisch zurueck (Stream weg) — User kann
|
||||
// ARIAs letzte Antwort per Play-Button nochmal hoeren.
|
||||
wakeWordService.resumeFromCall().catch(() => {});
|
||||
ToastAndroid.show('Anruf beendet — ARIA wieder aktiv', ToastAndroid.SHORT);
|
||||
}
|
||||
// idle: nichts automatisch — User soll nichts unbeabsichtigt re-triggern
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { acquireBackgroundAudio } from './backgroundAudio';
|
||||
|
||||
type WakeWordCallback = () => void;
|
||||
type StateCallback = (state: WakeWordState) => void;
|
||||
@@ -77,6 +78,14 @@ class WakeWordService {
|
||||
private bargeCallbacks: WakeWordCallback[] = [];
|
||||
/** True solange Wake-Word parallel zu TTS aktiv ist. */
|
||||
private bargeListening: boolean = false;
|
||||
/** Anruf-Pause: state wird gemerkt damit nach Auflegen wiederhergestellt wird. */
|
||||
private callPaused: boolean = false;
|
||||
private preCallState: WakeWordState = 'off';
|
||||
/** Cooldown nach App-Resume: kurze Phase in der Wake-Word-Detections
|
||||
* ignoriert werden. Beim Wechsel von Background nach Vordergrund gibt's
|
||||
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
|
||||
* der openWakeWord faelschlich triggern kann. */
|
||||
private cooldownUntilMs: number = 0;
|
||||
|
||||
private keyword: WakeKeyword = DEFAULT_KEYWORD;
|
||||
private nativeReady: boolean = false;
|
||||
@@ -157,6 +166,10 @@ class WakeWordService {
|
||||
/** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
|
||||
async start(): Promise<boolean> {
|
||||
if (this.state !== 'off') return true;
|
||||
// Foreground-Service VOR dem Mic-Zugriff hochziehen damit Background-
|
||||
// Lauschen funktioniert (Android braucht foregroundServiceType=microphone
|
||||
// aktiv zum Zeitpunkt des AudioRecord.startRecording).
|
||||
await acquireBackgroundAudio('wake');
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try {
|
||||
await OpenWakeWord.start();
|
||||
@@ -200,8 +213,22 @@ class WakeWordService {
|
||||
this.setState('off');
|
||||
}
|
||||
|
||||
/** Cooldown setzen — alle Wake-Word-Detections in den naechsten ms ignorieren.
|
||||
* Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen
|
||||
* die openWakeWord faelschlich als Trigger interpretiert. */
|
||||
setResumeCooldown(ms: number = 1500): void {
|
||||
this.cooldownUntilMs = Date.now() + ms;
|
||||
console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
|
||||
}
|
||||
|
||||
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
|
||||
private async onWakeDetected(): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (now < this.cooldownUntilMs) {
|
||||
const left = this.cooldownUntilMs - now;
|
||||
console.log('[WakeWord] Trigger ignoriert (Cooldown noch %dms aktiv — wahrscheinlich App-Resume-Spike)', left);
|
||||
return;
|
||||
}
|
||||
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
|
||||
this.keyword, this.state, this.bargeListening);
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
@@ -255,6 +282,43 @@ class WakeWordService {
|
||||
console.log('[WakeWord] Barge-Listening aus');
|
||||
}
|
||||
|
||||
/** Bei eingehendem Anruf: Wake-Word + Aufnahme stoppen, Pre-Call-State
|
||||
* merken. Telefonie-App belegt das Mikro waehrend des Anrufs, plus ARIA
|
||||
* soll nicht in laufende Telefonate reinhoeren. */
|
||||
async pauseForCall(): Promise<void> {
|
||||
if (this.callPaused) return;
|
||||
this.preCallState = this.state;
|
||||
if (this.state === 'off') {
|
||||
this.callPaused = true; // merken dass wir pausiert wurden
|
||||
return;
|
||||
}
|
||||
this.callPaused = true;
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try { await OpenWakeWord.stop(); } catch {}
|
||||
}
|
||||
this.bargeListening = false;
|
||||
console.log('[WakeWord] Anruf — Wake-Word pausiert (war: %s)', this.preCallState);
|
||||
}
|
||||
|
||||
/** Nach Auflegen: Pre-Call-State wiederherstellen. Aktive Konversation
|
||||
* geht zu armed zurueck (User soll nicht in einen halben Dialog springen). */
|
||||
async resumeFromCall(): Promise<void> {
|
||||
if (!this.callPaused) return;
|
||||
const restoreTo = this.preCallState;
|
||||
this.callPaused = false;
|
||||
this.preCallState = 'off';
|
||||
console.log('[WakeWord] Anruf zu Ende — restore state=%s', restoreTo);
|
||||
if (restoreTo === 'off') return;
|
||||
// Aktive Konversation war wahrscheinlich durch haltAllPlayback eh abgebrochen,
|
||||
// sicher zu armed degraden.
|
||||
if (restoreTo === 'conversing') this.setState('armed');
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try { await OpenWakeWord.start(); } catch (err) {
|
||||
console.warn('[WakeWord] Restore-Start fehlgeschlagen:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Konversation beenden — User hat im Window nichts gesagt.
|
||||
* Mit Wake-Word: zurueck zu 'armed' (Listener wieder an).
|
||||
* Ohne: zurueck zu 'off'.
|
||||
|
||||
@@ -54,13 +54,6 @@ Fuer Web-Anfragen: **WebFetch** oder **Bash mit curl**. Niemals sagen "ich habe
|
||||
|
||||
## Stimme
|
||||
|
||||
| Stimme | Modell | Wann |
|
||||
|--------|--------|------|
|
||||
| **Ramona** (weiblich) | `de_DE-ramona-low` | Alltag, Antworten, Gespraeche (Standard) |
|
||||
| **Thorsten** (maennlich, tief) | `de_DE-thorsten-high` | Epische Momente, Alarme, besondere Ereignisse |
|
||||
|
||||
**Thorsten spricht bei:**
|
||||
- Build erfolgreich deployed
|
||||
- Ticket geloest / Aufgabe abgeschlossen
|
||||
- Kritischer Alarm (Server down, Sicherheitswarnung)
|
||||
- Wenn Stefan sagt "So soll es sein"
|
||||
TTS laeuft ueber F5-TTS (Voice Cloning, Gaming-PC). Stefan kann eigene
|
||||
Stimmen aus Audio-Samples klonen (Diagnostic → Stimmen → Stimme klonen)
|
||||
und in App + Diagnostic auswaehlen.
|
||||
|
||||
@@ -80,10 +80,8 @@ Wenn ein Tool nicht klappt, probiere die Alternative. Niemals sagen "ich habe ke
|
||||
|
||||
## Stimme
|
||||
|
||||
| Stimme | Modell | Wann |
|
||||
|--------|--------|------|
|
||||
| **Ramona** (weiblich) | `de_DE-ramona-low` | Alltag, Antworten, Gespraeche (Standard) |
|
||||
| **Thorsten** (maennlich, tief) | `de_DE-thorsten-high` | Epische Momente, Alarme, besondere Ereignisse |
|
||||
TTS laeuft ueber F5-TTS auf der Gamebox (Voice Cloning). Stefan kann
|
||||
eigene Stimmen aus Audio-Samples klonen und in App/Diagnostic auswaehlen.
|
||||
|
||||
## Gedaechtnis (Memory)
|
||||
|
||||
@@ -147,4 +145,4 @@ Danach den Eintrag in `memory/MEMORY.md` (Index) verlinken.
|
||||
### Netzwerk
|
||||
- **aria-net:** Internes Docker-Netz (proxy, aria-core)
|
||||
- **RVS:** Rendezvous-Server im Rechenzentrum — Relay fuer die Android-App
|
||||
- **Bridge:** Voice Bridge (Whisper STT + Piper TTS) — teilt Netzwerk mit aria-core
|
||||
- **Bridge:** Voice Bridge (orchestriert STT/TTS via Gamebox-Bridges) — teilt Netzwerk mit aria-core
|
||||
|
||||
+50
-31
@@ -1,17 +1,13 @@
|
||||
"""
|
||||
ARIA Voice Bridge — Hauptmodul.
|
||||
|
||||
Verbindet die Android App (via RVS) mit ARIA-Core und bietet
|
||||
lokale Spracheingabe (Wake-Word + Whisper STT) und Sprachausgabe (Piper TTS).
|
||||
Verbindet die Android App (via RVS) mit ARIA-Core. Spracheingabe laeuft
|
||||
ueber die whisper-bridge (Gamebox, faster-whisper auf CUDA), Sprachausgabe
|
||||
ueber die f5tts-bridge (Voice Cloning, satzweises PCM-Streaming).
|
||||
|
||||
Nachrichtenfluss:
|
||||
App → RVS → Bridge → aria-core
|
||||
aria-core → Bridge → RVS → App
|
||||
→ Lautsprecher (TTS)
|
||||
|
||||
Stimmen:
|
||||
- Ramona (de_DE-ramona-low) — Alltag, Gespraeche
|
||||
- Thorsten (de_DE-thorsten-high) — epische Momente, Alarme
|
||||
aria-core → Bridge → f5tts-bridge → PCM → RVS → App
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -493,7 +489,7 @@ class ARIABridge:
|
||||
self.current_mode = self._load_persisted_mode()
|
||||
self.running = False
|
||||
|
||||
# Komponenten (TTS: immer XTTS remote, Piper wurde entfernt)
|
||||
# Komponenten (TTS: F5-TTS remote auf der Gamebox, lokales TTS wurde entfernt)
|
||||
self.tts_enabled = True
|
||||
self.xtts_voice = ""
|
||||
self._f5tts_config: dict = {}
|
||||
@@ -1028,6 +1024,31 @@ class ARIABridge:
|
||||
except Exception as e:
|
||||
logger.debug("[session] Diagnostic nicht erreichbar (%s) — nutze '%s'", e, self._session_key)
|
||||
|
||||
def _build_core_text(self, text: str, interrupted: bool = False,
|
||||
location: Optional[dict] = None) -> str:
|
||||
"""Baut den Text fuer aria-core mit allen relevanten Hints (Barge-In,
|
||||
GPS-Position). Hints sind in eckigen Klammern, der eigentliche User-
|
||||
Text folgt unverandert."""
|
||||
parts: list[str] = []
|
||||
if interrupted:
|
||||
parts.append(
|
||||
"[Hinweis: Stefan hat dich gerade unterbrochen waehrend du noch "
|
||||
"gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, "
|
||||
"Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.]"
|
||||
)
|
||||
if location and isinstance(location, dict):
|
||||
lat = location.get("lat")
|
||||
lon = location.get("lon") or location.get("lng")
|
||||
if lat is not None and lon is not None:
|
||||
parts.append(
|
||||
f"[Stefans aktuelle GPS-Position: {float(lat):.6f}, {float(lon):.6f}. "
|
||||
f"Nutze die nur wenn die Frage sich auf seinen Standort bezieht. "
|
||||
f"Erwaehne sie nicht von dir aus, ausser er fragt explizit danach.]"
|
||||
)
|
||||
if parts:
|
||||
return " ".join(parts) + " " + text
|
||||
return text
|
||||
|
||||
def _build_pending_files_message(self, user_text: str) -> str:
|
||||
"""Baut eine Anweisung an aria-core aus den gepufferten Files + optionalem
|
||||
User-Text. user_text leer → 'warte auf Anweisung'-Variante."""
|
||||
@@ -1236,6 +1257,7 @@ class ARIABridge:
|
||||
self._next_speed_override = None
|
||||
if text:
|
||||
interrupted = bool(payload.get("interrupted", False))
|
||||
location = payload.get("location") or None
|
||||
# Wenn Files gerade gepuffert sind (Bild + Text gleichzeitig
|
||||
# gesendet), mergen wir sie zu einer einzigen Anfrage statt
|
||||
# zwei separater send_to_core-Calls.
|
||||
@@ -1243,15 +1265,11 @@ class ARIABridge:
|
||||
if merged:
|
||||
logger.info("[rvs] App-Chat (mit Anhaengen): '%s'", text[:80])
|
||||
else:
|
||||
core_text = (
|
||||
f"[Hinweis: Stefan hat dich gerade unterbrochen waehrend du noch "
|
||||
f"gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, "
|
||||
f"Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.] "
|
||||
f"{text}"
|
||||
if interrupted else text
|
||||
)
|
||||
logger.info("[rvs] App-Chat%s: '%s'",
|
||||
" [BARGE-IN]" if interrupted else "", text[:80])
|
||||
core_text = self._build_core_text(text, interrupted, location)
|
||||
logger.info("[rvs] App-Chat%s%s: '%s'",
|
||||
" [BARGE-IN]" if interrupted else "",
|
||||
" [GPS]" if location else "",
|
||||
text[:80])
|
||||
await self.send_to_core(core_text, source="app" + (" [barge-in]" if interrupted else ""))
|
||||
return
|
||||
|
||||
@@ -1511,11 +1529,14 @@ class ARIABridge:
|
||||
self._next_speed_override = None
|
||||
interrupted = bool(payload.get("interrupted", False))
|
||||
audio_request_id = payload.get("audioRequestId", "") or ""
|
||||
logger.info("[rvs] Audio empfangen: %s, %dms, %dKB%s%s",
|
||||
location = payload.get("location") or None
|
||||
logger.info("[rvs] Audio empfangen: %s, %dms, %dKB%s%s%s",
|
||||
mime_type, duration_ms, len(audio_b64) // 1365,
|
||||
" [BARGE-IN]" if interrupted else "",
|
||||
" [GPS]" if location else "",
|
||||
f" reqId={audio_request_id[:16]}" if audio_request_id else "")
|
||||
asyncio.create_task(self._process_app_audio(audio_b64, mime_type, interrupted, audio_request_id))
|
||||
asyncio.create_task(self._process_app_audio(
|
||||
audio_b64, mime_type, interrupted, audio_request_id, location))
|
||||
|
||||
elif msg_type == "stt_response":
|
||||
# Antwort der whisper-bridge auf unseren stt_request
|
||||
@@ -1573,7 +1594,8 @@ class ARIABridge:
|
||||
|
||||
async def _process_app_audio(self, audio_b64: str, mime_type: str,
|
||||
interrupted: bool = False,
|
||||
audio_request_id: str = "") -> None:
|
||||
audio_request_id: str = "",
|
||||
location: Optional[dict] = None) -> None:
|
||||
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal.
|
||||
|
||||
interrupted=True wenn der User waehrend ARIA noch sprach/dachte aufgenommen hat
|
||||
@@ -1583,7 +1605,10 @@ class ARIABridge:
|
||||
|
||||
audio_request_id: Korrelations-ID die die App im audio-Event mitschickt — wird
|
||||
unveraendert ans STT-Result zurueckgegeben damit die App die EXAKT richtige
|
||||
'wird verarbeitet'-Bubble ersetzen kann (auch bei mehreren parallelen Aufnahmen)."""
|
||||
'wird verarbeitet'-Bubble ersetzen kann (auch bei mehreren parallelen Aufnahmen).
|
||||
|
||||
location: Optional GPS-Position {lat, lon} — wird als Hinweis-Praefix mitgegeben
|
||||
damit ARIA bei standortbezogenen Fragen sie nutzen kann."""
|
||||
# Erst Remote versuchen
|
||||
text = await self._stt_remote(audio_b64, mime_type)
|
||||
if text is None:
|
||||
@@ -1595,15 +1620,9 @@ class ARIABridge:
|
||||
|
||||
if text.strip():
|
||||
logger.info("[rvs] STT Ergebnis: '%s'", text[:80])
|
||||
# Barge-In-Hinweis: gibt ARIA den Kontext dass sie unterbrochen wurde
|
||||
# und dies eine Korrektur/Aenderung der vorherigen Anweisung sein kann.
|
||||
core_text = (
|
||||
f"[Hinweis: Stefan hat dich gerade unterbrochen waehrend du noch "
|
||||
f"gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, "
|
||||
f"Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.] "
|
||||
f"{text}"
|
||||
if interrupted else text
|
||||
)
|
||||
# Hints (Barge-In, GPS) als Praefix vorschalten — gemeinsamer Helper
|
||||
# mit dem chat-Pfad damit das Verhalten konsistent ist.
|
||||
core_text = self._build_core_text(text, interrupted, location)
|
||||
# ERST an aria-core senden (wichtigster Schritt)
|
||||
await self.send_to_core(core_text, source="app-voice" + (" [barge-in]" if interrupted else ""))
|
||||
# STT-Text an RVS senden (fuer Anzeige in App + Diagnostic)
|
||||
|
||||
+1
-42
@@ -665,24 +665,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highlight-Trigger -->
|
||||
<div class="settings-section">
|
||||
<h2>Highlight-Trigger</h2>
|
||||
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
|
||||
Woerter die automatisch die Highlight-Stimme (Thorsten) ausloesen.
|
||||
Eines pro Zeile. Aenderungen werden in der Bridge gespeichert.
|
||||
</div>
|
||||
<div class="card" style="max-width:500px;">
|
||||
<textarea id="highlight-triggers" rows="8" style="width:100%;box-sizing:border-box;background:#1E1E2E;border:1px solid #2A2A3E;border-radius:6px;padding:8px;color:#fff;font-size:13px;font-family:monospace;resize:vertical;"
|
||||
placeholder="Lade..."></textarea>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<button class="btn" onclick="saveHighlightTriggers()" style="flex:1;">Speichern</button>
|
||||
<button class="btn secondary" onclick="loadHighlightTriggers()" style="flex:1;">Neu laden</button>
|
||||
</div>
|
||||
<div id="trigger-status" style="font-size:11px;color:#555570;margin-top:6px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tool-Berechtigungen -->
|
||||
<div class="settings-section">
|
||||
<h2>Tool-Berechtigungen</h2>
|
||||
@@ -956,14 +938,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'trigger_list') {
|
||||
const textarea = document.getElementById('highlight-triggers');
|
||||
textarea.value = (msg.triggers || []).join('\n');
|
||||
document.getElementById('trigger-status').textContent = msg.triggers.length + ' Trigger geladen';
|
||||
document.getElementById('trigger-status').style.color = '#8888AA';
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'service_status') {
|
||||
updateServiceStatus(msg.payload || {});
|
||||
return;
|
||||
@@ -1958,20 +1932,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Highlight-Trigger ────────────────────────
|
||||
function loadHighlightTriggers() {
|
||||
send({ action: 'get_triggers' });
|
||||
}
|
||||
function saveHighlightTriggers() {
|
||||
const text = document.getElementById('highlight-triggers').value;
|
||||
const triggers = text.split('\n').map(t => t.trim()).filter(t => t.length > 0);
|
||||
send({ action: 'save_triggers', triggers });
|
||||
document.getElementById('trigger-status').textContent = 'Gespeichert (' + triggers.length + ' Trigger)';
|
||||
document.getElementById('trigger-status').style.color = '#34C759';
|
||||
}
|
||||
// Beim Tab-Wechsel zu Einstellungen: Trigger laden
|
||||
const origSwitchMainTab = typeof switchMainTab === 'function' ? switchMainTab : null;
|
||||
|
||||
// ── Modus-Wechsel ────────────────────────────
|
||||
// Kanonische IDs (matchen bridge/modes.py canonical_id + android ModeSelector)
|
||||
const MODE_LABELS = { normal: 'Normal', nicht_stoeren: 'Nicht stoeren', fluester: 'Fluestern', hangar: 'Hangar', gaming: 'Gaming' };
|
||||
@@ -2456,9 +2416,8 @@
|
||||
document.querySelectorAll('.main-nav-btn').forEach(b => {
|
||||
if (b.textContent.trim().toLowerCase().includes(tab === 'main' ? 'main' : 'einstellung')) b.classList.add('active');
|
||||
});
|
||||
// Einstellungen: Config + Trigger + QR laden
|
||||
// Einstellungen: Config + QR laden
|
||||
if (tab === 'settings') {
|
||||
loadHighlightTriggers();
|
||||
send({ action: 'get_voice_config' });
|
||||
loadRuntimeConfig();
|
||||
loadOnboardingQR();
|
||||
|
||||
@@ -1475,10 +1475,6 @@ wss.on("connection", (ws) => {
|
||||
} catch {}
|
||||
sendToRVS_raw({ type: "config", payload: voiceConfig, timestamp: Date.now() });
|
||||
log("info", "server", `Voice-Config gespeichert: xttsVoice=${voiceConfig.xttsVoice || "default"}, whisper=${voiceConfig.whisperModel || "-"}`);
|
||||
} else if (msg.action === "get_triggers") {
|
||||
handleGetTriggers(ws);
|
||||
} else if (msg.action === "save_triggers") {
|
||||
handleSaveTriggers(ws, msg.triggers || []);
|
||||
} else if (msg.action === "test_tts") {
|
||||
handleTestTTS(ws, msg.text || "Test");
|
||||
} else if (msg.action === "preview_voice") {
|
||||
@@ -1629,31 +1625,6 @@ function handleGetVoiceConfig(clientWs) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Highlight-Trigger (legacy UI — wird nicht mehr ausgewertet seit Piper raus) ─
|
||||
const TRIGGERS_FILE = "/shared/config/highlight_triggers.json";
|
||||
|
||||
async function handleGetTriggers(clientWs) {
|
||||
try {
|
||||
const triggers = fs.existsSync(TRIGGERS_FILE)
|
||||
? JSON.parse(fs.readFileSync(TRIGGERS_FILE, "utf-8"))
|
||||
: [];
|
||||
clientWs.send(JSON.stringify({ type: "trigger_list", triggers }));
|
||||
} catch (err) {
|
||||
clientWs.send(JSON.stringify({ type: "trigger_list", triggers: [], error: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveTriggers(clientWs, triggers) {
|
||||
try {
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
fs.writeFileSync(TRIGGERS_FILE, JSON.stringify(triggers, null, 2));
|
||||
log("info", "server", `${triggers.length} Highlight-Trigger gespeichert`);
|
||||
clientWs.send(JSON.stringify({ type: "trigger_list", triggers }));
|
||||
} catch (err) {
|
||||
log("error", "server", `Trigger speichern fehlgeschlagen: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── TTS Diagnose (XTTS) ───────────────────────────────
|
||||
// ── Voice Preview ────────────────────────────────────────
|
||||
// Sammelt audio_pcm Chunks einer Preview-Anfrage, baut am Ende eine WAV
|
||||
|
||||
@@ -2,6 +2,41 @@
|
||||
|
||||
## Erledigt
|
||||
|
||||
### Bugs / Fixes
|
||||
|
||||
- [x] Diagnostic: "ARIA denkt..." bleibt nicht mehr stehen
|
||||
- [x] App: "ARIA denkt..." Indicator + Abbrechen-Button (Bridge spiegelt agent_activity via RVS)
|
||||
- [x] Textnachrichten werden von ARIA beantwortet (Bridge chat handler fix)
|
||||
- [x] Voice-Auswahl funktioniert wieder: speaker_wav als Basename statt Pfad fuer daswer123 local-Mode
|
||||
- [x] Diagnostic-Voice-Wechsel resettet alle App-lokalen Voice-Overrides via type "config"
|
||||
- [x] Streaming TTS Stop-Race: Writer wartet auf playbackHeadPosition vor stop()/release() — keine abgeschnittenen Saetze mehr
|
||||
- [x] App: Audioausgabe hoert nicht mehr mitten im Satz auf (playbackHeadPosition wait + Stop-Race fix)
|
||||
- [x] AudioFocus.release wartet auf echten Playback-Ende — kein Volume-Hochfahren mehr mid-Antwort
|
||||
- [x] App Mute-/Auto-Playback-Bug: Closure-Bug geloest (ttsCanPlayRef live-gespiegelt, nicht mehr stale)
|
||||
- [x] App Zombie-Recording: Ohr-aus kill laufende Aufnahme damit der Aufnahme-Button weiter funktioniert
|
||||
- [x] Whisper transkribiert Voice-Uploads nicht mehr mit hardcoded "small" — aktuelles Modell wird behalten, kein unnoetiger Modell-Swap
|
||||
- [x] RVS/WebSocket maxPayload 50MB: voice_upload mit WAV als base64 sprengt kein Frame-Limit mehr
|
||||
- [x] Wake-Word Embedding rank-4 Fix (Pipeline-Bug der das Triggern verhinderte) + Frame-Count aus Modell-Metadaten lesen
|
||||
- [x] PCM-Underrun-Schutz: Stille-Fill in Render-Pausen verhindert Spotify-Auto-Resume nach 10s Stillstand
|
||||
- [x] Conversation-Focus-Lifecycle: AudioFocus haengt am Wake-Word-State 'conversing' statt an einzelnen Streams — Spotify bleibt durchgehend gepaust, auch zwischen mehreren Antworten
|
||||
- [x] Voice-Override behaelt Stimme ueber alle TTS-Calls einer Antwort (vorher: nach erstem TTS-Call zurueck auf Default)
|
||||
- [x] Sprachnachricht-Bubble defensiv: STT-Result fuegt neue Bubble hinzu wenn Placeholder fehlt (Race-Schutz)
|
||||
- [x] Bild + Text als EINE Anfrage: Bridge buffert files 800ms, merged mit folgendem chat-Text zu einem send_to_core (statt zwei getrennten ARIA-Antworten)
|
||||
- [x] Diagnostic→App: persistente RVS-Connection statt frische pro Send (Race-Probleme mit Zombie-WS geloest)
|
||||
- [x] Textauswahl in Bubbles wieder funktional (nested Text+onPress raus, dataDetectorType="all" macht Links automatisch klickbar)
|
||||
- [x] **Placeholder-Race bei parallelen Sprachnachrichten geloest**: jede Aufnahme bekommt eine eindeutige audioRequestId, Bridge gibt sie ans STT-Result zurueck — App matcht jetzt punktgenau die richtige Bubble statt per Substring
|
||||
- [x] Mikro-Offen-Toast "🎤 sprich jetzt" erscheint erst wenn audioService.startRecording wirklich erfolgreich war (statt ~400ms vorher beim Wake-Word-Detect)
|
||||
- [x] Sprachnachrichten ohne STT-Result werden nach 60s+Aufnahmedauer automatisch entfernt (sicher genug fuer 5-30min-Aufnahmen, schnell genug fuer leere Wake-Word-Echos)
|
||||
- [x] VAD adaptive Baseline robuster: minimum statt avg + Cap auf -50dB bis -28dB (Stille) / -40dB bis -18dB (Speech) — keine "tote" VAD-Konfiguration mehr bei lauter Umgebung oder Wake-Word-Echo
|
||||
- [x] Push-to-Talk raus, nur noch Tap-to-Talk (verhinderte Touch-Race-Probleme)
|
||||
- [x] Manueller Mikro-Stop beendet Wake-Word-Konversation: Tap auf Mikro-Knopf waehrend conversing → audio raus + zurueck zu armed (= Wake-Word lauscht wieder, kein Auto-Mikro nach ARIAs Antwort). VAD-Auto-Stop bleibt bei Multi-Turn
|
||||
- [x] **Wake-Word pausiert bei Anruf**: phoneCall ruft pauseForCall (openWakeWord.stop) bei RINGING/OFFHOOK, resumeFromCall bei IDLE. Pre-Call-State wird gemerkt — armed bleibt armed, conversing degraded zu armed (User soll nicht in halbem Dialog landen)
|
||||
- [x] **App-Resume-Cooldown**: Wechsel von Background → Foreground triggert keinen falschen Wake-Word-Trigger mehr. AppState-Listener setzt 1.5s Cooldown in dem onWakeDetected-Events ignoriert werden (Audio-Pegel-Spike beim AudioFocus-Switch sonst als Wake-Word interpretiert)
|
||||
- [x] Background-Mikro robust: acquireBackgroundAudio('rec'/'wake') wird jetzt VOR AudioRecord.startRecording gerufen — Foreground-Service mit foregroundServiceType=microphone muss aktiv sein bevor das Mikro greift, sonst blockiert Android ab 11+ den Background-Zugriff
|
||||
- [x] **Stille-Pegel manuell setzbar** (Settings → Spracheingabe): Override-Wert in dB von -55 bis -15, default "automatisch". Info-Button mit Modal erklaert die Skala (niedriger = sensibler, hoeher = robuster gegen Hintergrundlaerm). Bei manuell gesetztem Wert wird die adaptive Baseline ignoriert
|
||||
|
||||
### App Features
|
||||
|
||||
- [x] Bildupload funktioniert (Shared Volume /shared/uploads/)
|
||||
- [x] Sprachnachrichten werden als Text angezeigt (STT → Chat-Bubble)
|
||||
- [x] Cache leeren + Auto-Download von Anhaengen
|
||||
@@ -11,13 +46,9 @@
|
||||
- [x] Ohr-Button → Gespraechsmodus (Auto-Aufnahme nach ARIA-Antwort)
|
||||
- [x] Play-Button in ARIA-Nachrichten fuer Sprachwiedergabe
|
||||
- [x] Chat-Suche in der App (Lupe in Statusleiste)
|
||||
- [x] Watchdog mit Container-Restart (2min Warnung → 5min doctor --fix → 8min Restart)
|
||||
- [x] Abbrechen-Button im Diagnostic Chat
|
||||
- [x] Nachrichten Backup on-the-fly (/shared/config/chat_backup.jsonl)
|
||||
- [x] Grosse Nachrichten satzweise aufteilen fuer TTS
|
||||
- [x] RVS Nachrichten vom Smartphone gehen durch
|
||||
- [x] Stimmen-Einstellungen (Ramona/Thorsten, Speed pro Stimme — durch XTTS/F5-TTS ersetzt)
|
||||
- [x] Highlight-Trigger konfigurierbar in Diagnostic
|
||||
- [x] Highlight-Trigger konfigurierbar in Diagnostic (spaeter komplett entfernt — war Piper-Relikt)
|
||||
- [x] XTTS v2 Integration (Gaming-PC, GPU, Voice Cloning) — durch F5-TTS ersetzt
|
||||
- [x] XTTS Voice Cloning (Audio-Samples hochladen, eigene Stimme)
|
||||
- [x] TTS Engine waehlbar (Piper/XTTS) — Piper raus, XTTS raus, jetzt nur F5-TTS
|
||||
@@ -25,16 +56,12 @@
|
||||
- [x] Auto-Update: APK-Installation via FileProvider
|
||||
- [x] Auto-Update: "Auf Updates pruefen" Button in App-Einstellungen
|
||||
- [x] Audio-Queue (sequentielle Wiedergabe, kein Ueberlappen)
|
||||
- [x] Textnachrichten werden von ARIA beantwortet (Bridge chat handler fix)
|
||||
- [x] Mehrere Anhaenge + Text vor dem Senden (Pending-Vorschau)
|
||||
- [x] Paste-Support fuer Bilder in Diagnostic Chat
|
||||
- [x] Markdown-Bereinigung fuer TTS (fett, kursiv, code, links, etc.)
|
||||
- [x] SSH Volume read-write fuer Proxy (kein -F Workaround mehr)
|
||||
- [x] Diagnostic: Sessions als Markdown exportieren (Download-Button)
|
||||
- [x] Speech Gate: Aufnahme wird verworfen wenn keine Sprache erkannt
|
||||
- [x] Session-Persistenz: Gewaehlte Session bleibt ueber Container-Restarts erhalten
|
||||
- [x] Diagnostic: "ARIA denkt..." bleibt nicht mehr stehen
|
||||
- [x] App: "ARIA denkt..." Indicator + Abbrechen-Button (Bridge spiegelt agent_activity via RVS)
|
||||
- [x] Whisper STT: Model-Auswahl in Diagnostic (tiny/base/small/medium/large-v3), Hot-Reload
|
||||
- [x] App: Audio-Aufnahme explizit 16kHz mono (spart Resample, optimal fuer Whisper)
|
||||
- [x] Streaming TTS: PCM-Stream → AudioTrack MODE_STREAM, keine WAV-Gaps
|
||||
@@ -51,14 +78,11 @@
|
||||
- [x] Disk-Voll Banner in Diagnostic: rotes Overlay + copy-baren Cleanup-Befehlen (safe + aggressiv)
|
||||
- [x] cleanup.sh: kombinierter Docker-Aufraeum-Befehl (safe / --full)
|
||||
- [x] Streaming TTS Pre-Roll: AudioTrack play() startet erst wenn 2.5s gepuffert sind
|
||||
- [x] Streaming TTS Stop-Race: Writer wartet auf playbackHeadPosition vor stop()/release() — keine abgeschnittenen Saetze mehr
|
||||
- [x] Leading-Silence (200ms) am Stream-Anfang — AudioTrack faehrt sauber an
|
||||
- [x] Pre-Roll-Buffer einstellbar in App-Settings (1.0-6.0s, Default 3.5s)
|
||||
- [x] Fade-In auf erstem PCM-Chunk (120ms) — versteckt XTTS/F5-TTS Warmup-Glitches
|
||||
- [x] Decimal-zu-Worte fuer TTS (0.1 → null komma eins, mit IP-Schutz-Lookahead)
|
||||
- [x] Generic Acronym-Buchstabieren (XTTS → X T T S, USB → U S B, ueber expliziter Liste)
|
||||
- [x] Voice-Auswahl funktioniert wieder: speaker_wav als Basename statt Pfad fuer daswer123 local-Mode
|
||||
- [x] Diagnostic-Voice-Wechsel resettet alle App-lokalen Voice-Overrides via type "config"
|
||||
- [x] voice_preload/voice_ready: Stille Mini-Render bei Voice-Wechsel + Toast/Status "bereit"
|
||||
- [x] Whisper STT auf die Gamebox ausgelagert (faster-whisper CUDA, float16) — neuer aria-whisper-bridge Container
|
||||
- [x] aria-bridge: STT primaer remote (Gamebox), Fallback lokal nach 45s Timeout
|
||||
@@ -66,48 +90,40 @@
|
||||
- [x] **F5-TTS ersetzt XTTS komplett** — neuer aria-f5tts-bridge Container, Voice Cloning, satzweises Streaming
|
||||
- [x] Voice-Upload mit Whisper-Auto-Transkription — User muss keinen Referenz-Text eintippen
|
||||
- [x] Audio-Pause statt Ducking: Spotify/YouTube pausieren komplett waehrend TTS (TRANSIENT statt MAY_DUCK)
|
||||
- [x] AudioFocus.release wartet auf echten Playback-Ende — kein Volume-Hochfahren mehr mid-Antwort
|
||||
- [x] VAD-Stille einstellbar in App-Settings (1.0-8.0s, Default 2.8s)
|
||||
- [x] MAX_RECORDING auf 120s — laengere Erklaerungen moeglich
|
||||
- [x] App: Audioausgabe hoert nicht mehr mitten im Satz auf (playbackHeadPosition wait + Stop-Race fix)
|
||||
- [x] F5-TTS: Referenz-WAV-Preprocessing — Loudness-Normalisierung -16 LUFS + Silence-Trim + 10s Clip fuer konsistente Cloning-Quali
|
||||
- [x] F5-TTS: deutsches Fine-Tune (aihpi/F5-TTS-German, Vocos-Variante) via hf:// Pfad in Diagnostic konfigurierbar
|
||||
- [x] Whisper transkribiert Voice-Uploads nicht mehr mit hardcoded "small" — aktuelles Modell wird behalten, kein unnoetiger Modell-Swap
|
||||
- [x] RVS/WebSocket maxPayload 50MB: voice_upload mit WAV als base64 sprengt kein Frame-Limit mehr
|
||||
- [x] Dynamischer STT-Timeout in aria-bridge: 300s waehrend whisper-bridge 'loading', 45s wenn 'ready'
|
||||
- [x] service_status Broadcasts: f5tts/whisper melden Lade-Status, Banner in Diagnostic (unten rechts) + App (oben)
|
||||
- [x] config_request Pattern: Bridges fragen beim Connect die aktuelle Voice-Config an, aria-bridge antwortet
|
||||
- [x] F5-TTS Tuning via Diagnostic (Modell-ID, Checkpoint, cfg_strength, nfe_step) statt ENV-Vars — Hot-Reload bei Modell-Wechsel
|
||||
- [x] Conversation-Window: Gespraechsmodus endet nach X Sekunden Stille (1.0-20.0s, Default 8s, einstellbar in Settings)
|
||||
- [x] Porcupine Wake-Word-Integration in der App (Built-In Keywords + Custom spaeter, per Geraet einstellbar)
|
||||
- [x] Porcupine Wake-Word-Integration in der App (durch openWakeWord ersetzt)
|
||||
- [x] HF-Cache als Bind-Mount statt Docker Volume — kein .vhdx-Bloat auf Docker Desktop / Windows
|
||||
- [x] cleanup-windows.ps1 / .bat: VHDX-Cleanup via diskpart (ohne Hyper-V) mit Self-Elevation
|
||||
- [x] App Mute-/Auto-Playback-Bug: Closure-Bug geloest (ttsCanPlayRef live-gespiegelt, nicht mehr stale)
|
||||
- [x] App Zombie-Recording: Ohr-aus kill laufende Aufnahme damit der Aufnahme-Button weiter funktioniert
|
||||
- [x] App Text-Rendering: Nachrichten selektierbar + Autolink fuer URLs/E-Mails/Telefonnummern (Browser/Mail/Dialer)
|
||||
- [x] TTS-Wiedergabegeschwindigkeit pro Geraet einstellbar (Settings → 0.5-2.0x in 0.1-Schritten, Default 1.0)
|
||||
- [x] Diagnostic: Voice-Preview-Modal (Play-Icon vor Delete-X, Textfeld mit Default, WAV im Browser abspielen)
|
||||
- [x] **Wake-Word komplett on-device via openWakeWord (ONNX Runtime)** — Porcupine raus, kein API-Key/keine Lizenzgebuehren mehr. Mitgelieferte Keywords: hey_jarvis, computer, alexa, hey_mycroft, hey_rhasspy
|
||||
- [x] Wake-Word Embedding rank-4 Fix (Pipeline-Bug der das Triggern verhinderte) + Frame-Count aus Modell-Metadaten lesen
|
||||
- [x] APK ABI-Split auf arm64-v8a — von ~136 MB auf ~35 MB, Auto-Update-Downloads aufs Phone deutlich kleiner
|
||||
- [x] PCM-Underrun-Schutz: Stille-Fill in Render-Pausen verhindert Spotify-Auto-Resume nach 10s Stillstand
|
||||
- [x] Conversation-Focus-Lifecycle: AudioFocus haengt am Wake-Word-State 'conversing' statt an einzelnen Streams — Spotify bleibt durchgehend gepaust, auch zwischen mehreren Antworten
|
||||
- [x] PhoneStateListener: TTS pausiert bei eingehendem Anruf (READ_PHONE_STATE Permission)
|
||||
- [x] Voice-Override behaelt Stimme ueber alle TTS-Calls einer Antwort (vorher: nach erstem TTS-Call zurueck auf Default)
|
||||
- [x] Sprachnachricht-Bubble defensiv: STT-Result fuegt neue Bubble hinzu wenn Placeholder fehlt (Race-Schutz)
|
||||
- [x] Bild + Text als EINE Anfrage: Bridge buffert files 800ms, merged mit folgendem chat-Text zu einem send_to_core (statt zwei getrennten ARIA-Antworten)
|
||||
- [x] Diagnostic-Chat: bubblige Formatierung, mehrzeiliges Eingabefeld (textarea, Enter sendet, Shift+Enter neue Zeile)
|
||||
- [x] Diagnostic→App: persistente RVS-Connection statt frische pro Send (Race-Probleme mit Zombie-WS geloest)
|
||||
- [x] Adaptive VAD-Schwelle: Baseline aus den ersten 500ms Mic-Pegel, Stille = baseline+6dB / Sprache = baseline+12dB. Funktioniert in lauten wie leisen Umgebungen
|
||||
- [x] Adaptive VAD-Schwelle: Baseline aus den ersten 500ms Mic-Pegel, Stille = baseline+6dB / Sprache = baseline+12dB
|
||||
- [x] Max-Aufnahmedauer konfigurierbar in Settings (1-30 min, Default 5 min) — laengere Diktate moeglich
|
||||
- [x] Barge-In: User kann ARIA waehrend Antwort/Tool-Use unterbrechen, alte Aktivitaet wird abgebrochen, Bridge gibt aria-core einen Kontext-Hint dass es eine Korrektur ist
|
||||
- [x] Push-to-Talk raus, nur noch Tap-to-Talk (verhinderte Touch-Race-Probleme)
|
||||
- [x] Settings-Sub-Screens: 8 Kategorien (Verbindung, Allgemein, Spracheingabe, Wake-Word, Sprachausgabe, Speicher, Protokoll, Ueber) statt langer Liste
|
||||
- [x] Textauswahl in Bubbles wieder funktional (nested Text+onPress raus, dataDetectorType="all" macht Links automatisch klickbar)
|
||||
- [x] **Placeholder-Race bei parallelen Sprachnachrichten geloest**: jede Aufnahme bekommt eine eindeutige audioRequestId, Bridge gibt sie ans STT-Result zurueck — App matcht jetzt punktgenau die richtige Bubble statt per Substring "Spracheingabe wird verarbeitet"
|
||||
- [x] Mikro-Offen-Toast "🎤 sprich jetzt" erscheint erst wenn audioService.startRecording wirklich erfolgreich war (statt ~400ms vorher beim Wake-Word-Detect)
|
||||
- [x] **Bereit-Sound (Airplane Ding-Dong) wenn Mikro nach Wake-Word offen** — akustische Bestaetigung statt nur Toast. Toggle in Settings → Wake-Word, default aktiv
|
||||
- [x] **Wake-Word parallel zu TTS** mit AcousticEchoCanceler: User sagt "Computer" waehrend ARIA spricht → TTS verstummt sofort, neue Aufnahme startet. Native AEC verhindert dass ARIAs eigene Stimme das Wake-Word triggert. Audio-Source ist VOICE_COMMUNICATION + zusaetzlich AEC/NS/AGC-Effekte aktiviert
|
||||
- [x] **Wake-Word parallel zu TTS** mit AcousticEchoCanceler: User sagt "Computer" waehrend ARIA spricht → TTS verstummt sofort, neue Aufnahme startet
|
||||
- [x] **GPS-Position mitsenden**: Toggle in Settings → Allgemein → Standort, persistiert in AsyncStorage. Wenn aktiv wird lat/lon mit jeder chat/audio-Message mitgegeben. Bridge prefixed den Text fuer aria-core mit GPS-Hint (mit Anweisung dass die Position nur bei Bedarf erwaehnt wird)
|
||||
- [x] **Background Audio Service**: TTS, Wake-Word-Lauschen UND Aufnahme laufen auch bei minimierter App weiter. Foreground-Service mit foregroundServiceType=mediaPlayback|microphone, persistente Notification mit dynamischem Text ("ARIA spricht" / "ARIA hoert zu" / "ARIA bereit")
|
||||
|
||||
### Infrastruktur
|
||||
|
||||
- [x] Watchdog mit Container-Restart (2min Warnung → 5min doctor --fix → 8min Restart)
|
||||
- [x] Nachrichten Backup on-the-fly (/shared/config/chat_backup.jsonl)
|
||||
- [x] RVS Nachrichten vom Smartphone gehen durch
|
||||
- [x] SSH Volume read-write fuer Proxy (kein -F Workaround mehr)
|
||||
|
||||
## Offen
|
||||
|
||||
@@ -115,7 +131,6 @@
|
||||
|
||||
### App Features
|
||||
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
|
||||
- [ ] Background Audio Service (TTS auch bei minimierter App)
|
||||
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
|
||||
- [ ] Pause+Resume bei Anruf: aktuell wird der TTS-Stream bei Klingeln hart gestoppt, schoener waere Pause + Resume nach Auflegen
|
||||
|
||||
|
||||
Reference in New Issue
Block a user