diff --git a/README.md b/README.md index 275bf1a..cc30907 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,7 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session` - **Wake-Word** (on-device, openWakeWord ONNX): "Hey Jarvis", "Alexa", "Hey Mycroft", "Hey Rhasspy" — Mikrofon hoert passiv mit, Konversation startet beim Schluesselwort. Komplett on-device via ONNX Runtime, kein API-Key, kein Cloud-Roundtrip, Audio verlaesst das Geraet nicht. - **VAD (Voice Activity Detection)**: Adaptive Schwelle (Baseline aus ersten 500ms Mic-Pegel + 6dB Offset). Konfigurierbare Stille-Toleranz (1.0–8.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme einstellbar (1–30 min, Default 5 min) - **Barge-In**: Wenn du waehrend ARIAs Antwort eine neue Sprach-/Text-Nachricht reinschickst, wird sie unterbrochen + bekommt den Hint "das ist eine Korrektur" +- **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert - **Anruf-Pause**: TTS verstummt automatisch wenn das Telefon klingelt (READ_PHONE_STATE Permission) - **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt - **STT (Speech-to-Text)**: 16kHz mono → Bridge → Gamebox-Whisper (CUDA) → Text im Chat. Fast in Echtzeit. @@ -849,6 +850,7 @@ docker exec aria-core ssh aria-wohnung hostname - [x] APK ABI-Split arm64-v8a: 35 MB statt 136 MB - [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] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen - [x] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon diff --git a/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt b/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt index 88897da..481fac9 100644 --- a/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt +++ b/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt @@ -8,6 +8,9 @@ import android.content.pm.PackageManager import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder +import android.media.audiofx.AcousticEchoCanceler +import android.media.audiofx.AutomaticGainControl +import android.media.audiofx.NoiseSuppressor import android.util.Log import androidx.core.content.ContextCompat import com.facebook.react.bridge.Promise @@ -70,6 +73,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa private val running = AtomicBoolean(false) private var captureThread: Thread? = null + // Audio-Effects: Echo-Cancellation (gegen ARIAs eigene TTS-Stimme die sonst + // das Wake-Word triggern wuerde) + Noise-Suppression. Per VOICE_COMMUNICATION + // Audio-Source schon vorhanden, aber explizites Aktivieren ist robuster. + private var aec: AcousticEchoCanceler? = null + private var ns: NoiseSuppressor? = null + private var agc: AutomaticGainControl? = null + // Inferenz-State private val melBuffer: ArrayList = ArrayList(256) // Liste von 32-dim Frames private var melProcessedIdx: Int = 0 @@ -146,8 +156,12 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa AudioFormat.ENCODING_PCM_16BIT, ).coerceAtLeast(CHUNK_SAMPLES * 2 * 4) + // VOICE_COMMUNICATION-Source: aktiviert auf den meisten Android-Geraeten + // automatisch Echo-Cancellation + Noise-Suppression. Wichtig damit + // ARIAs eigene Stimme nicht das Wake-Word triggert wenn parallel + // zur TTS-Wiedergabe gelauscht wird. val record = AudioRecord( - MediaRecorder.AudioSource.MIC, + MediaRecorder.AudioSource.VOICE_COMMUNICATION, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, @@ -159,6 +173,27 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa return } audioRecord = record + + // Audio-Effects ZUSAETZLICH explizit aktivieren — manche Geraete + // benoetigen das, obwohl VOICE_COMMUNICATION es eigentlich schon + // mitbringt. Failure ist nicht kritisch (continue ohne Effects). + try { + if (AcousticEchoCanceler.isAvailable()) { + aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true } + Log.i(TAG, "AEC aktiviert (enabled=${aec?.enabled})") + } + } catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") } + try { + if (NoiseSuppressor.isAvailable()) { + ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true } + } + } catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") } + try { + if (AutomaticGainControl.isAvailable()) { + agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true } + } + } catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") } + resetInferenceState() running.set(true) record.startRecording() @@ -179,6 +214,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa } } + private fun releaseAudioEffects() { + try { aec?.release() } catch (_: Exception) {} + try { ns?.release() } catch (_: Exception) {} + try { agc?.release() } catch (_: Exception) {} + aec = null; ns = null; agc = null + } + @ReactMethod fun stop(promise: Promise) { running.set(false) @@ -189,6 +231,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa try { audioRecord?.stop() } catch (_: Exception) {} try { audioRecord?.release() } catch (_: Exception) {} audioRecord = null + releaseAudioEffects() Log.i(TAG, "Lauschen gestoppt") promise.resolve(true) } @@ -201,6 +244,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa try { audioRecord?.stop() } catch (_: Exception) {} try { audioRecord?.release() } catch (_: Exception) {} audioRecord = null + releaseAudioEffects() disposeSessions() promise.resolve(true) } diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 34bfc36..fb26333 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -545,9 +545,43 @@ const ChatScreen: React.FC = () => { } }); + // Barge-In via Wake-Word: User sagt "Computer" waehrend ARIA spricht. + // Wake-Word-Service hat bei TTS-Start parallel zu lauschen begonnen + // (mit AcousticEchoCanceler damit ARIAs eigene Stimme nicht triggert). + const unsubBarge = wakeWordService.onBargeIn(async () => { + console.log('[Chat] Barge-In via Wake-Word — TTS abbrechen + neue Aufnahme'); + audioService.haltAllPlayback('barge-in via wake-word'); + setAgentActivity({ activity: 'idle', tool: '' }); + rvs.send('cancel_request' as any, {}); + // Kurze Pause damit halt durchgreift, dann neue Aufnahme starten + await new Promise(r => setTimeout(r, 150)); + const windowMs = await loadConvWindowMs(); + const started = await audioService.startRecording(true, windowMs); + if (started) { + ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT); + playWakeReadySound().catch(() => {}); + } + }); + + // TTS-Lifecycle: solange ARIA spricht und Wake-Word verfuegbar ist, + // parallel mitlauschen — User kann "Computer" sagen statt manuell tappen. + const unsubTtsStart = audioService.onPlaybackStarted(() => { + if (wakeWordService.isConversing() && wakeWordService.hasWakeWord()) { + wakeWordService.startBargeListening().catch(() => {}); + } + }); + const unsubTtsEnd = audioService.onPlaybackFinished(() => { + // Vor naechster Aufnahme: barge-listening aus damit der AudioRecorder + // das Mikro greifen kann. + wakeWordService.stopBargeListening().catch(() => {}); + }); + return () => { unsubWake(); unsubSilence(); + unsubBarge(); + unsubTtsStart(); + unsubTtsEnd(); }; }, [wakeWordActive]); diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index c499662..59c9c2a 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -668,6 +668,7 @@ class AudioService { } this._cancelDeferredFocusRelease(); AudioFocus?.requestDuck().catch(() => {}); + this._firePlaybackStarted(); } } @@ -782,6 +783,7 @@ class AudioService { // Callback wenn alle Audio-Teile abgespielt sind private playbackFinishedListeners: (() => void)[] = []; + private playbackStartedListeners: (() => void)[] = []; onPlaybackFinished(callback: () => void): () => void { this.playbackFinishedListeners.push(callback); @@ -790,6 +792,21 @@ class AudioService { }; } + /** Callback wenn ARIAs TTS-Wiedergabe startet — fuer Wake-Word-parallel- + * Listening waehrend ARIA spricht (Barge-In via "Computer" sagen). */ + onPlaybackStarted(callback: () => void): () => void { + this.playbackStartedListeners.push(callback); + return () => { + this.playbackStartedListeners = this.playbackStartedListeners.filter(cb => cb !== callback); + }; + } + + private _firePlaybackStarted(): void { + this.playbackStartedListeners.forEach(cb => { + try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); } + }); + } + /** Naechstes Audio aus der Queue abspielen */ private async _playNext(): Promise { if (this.audioQueue.length === 0) { @@ -802,10 +819,11 @@ class AudioService { return; } - // Beim ersten Playback-Start: andere Apps ducken + // Beim ersten Playback-Start: andere Apps ducken + Listener informieren if (!this.isPlaying) { this._cancelDeferredFocusRelease(); AudioFocus?.requestDuck().catch(() => {}); + this._firePlaybackStarted(); } this.isPlaying = true; diff --git a/android/src/services/wakeword.ts b/android/src/services/wakeword.ts index 3b51de8..9dbb227 100644 --- a/android/src/services/wakeword.ts +++ b/android/src/services/wakeword.ts @@ -72,6 +72,11 @@ class WakeWordService { private state: WakeWordState = 'off'; private wakeCallbacks: WakeWordCallback[] = []; private stateCallbacks: StateCallback[] = []; + /** Barge-In-Callbacks: feuern wenn Wake-Word WAEHREND ARIA spricht erkannt + * wird. ChatScreen reagiert mit TTS-stop + neuer Aufnahme. */ + private bargeCallbacks: WakeWordCallback[] = []; + /** True solange Wake-Word parallel zu TTS aktiv ist. */ + private bargeListening: boolean = false; private keyword: WakeKeyword = DEFAULT_KEYWORD; private nativeReady: boolean = false; @@ -191,18 +196,28 @@ class WakeWordService { if (this.nativeReady && OpenWakeWord) { try { await OpenWakeWord.stop(); } catch {} } + this.bargeListening = false; this.setState('off'); } /** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */ private async onWakeDetected(): Promise { - console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword); - // KEIN Toast hier — der Toast "sprich jetzt" kommt erst wenn das Mikro - // wirklich offen ist (audioService meldet 'recording'-State). So weiss - // der User exakt ab wann er reden darf. + console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)', + this.keyword, this.state, this.bargeListening); if (this.nativeReady && OpenWakeWord) { try { await OpenWakeWord.stop(); } catch {} } + this.bargeListening = false; + // Wenn wir bereits in 'conversing' sind und der Trigger waehrend ARIAs TTS + // kam (Barge-In via Wake-Word), feuern wir einen separaten Callback damit + // ChatScreen das TTS abbrechen + neue Aufnahme starten kann. Sonst normal. + if (this.state === 'conversing') { + this.bargeCallbacks.forEach(cb => { + try { cb(); } catch (e) { console.warn('[WakeWord] barge cb err:', e); } + }); + // Kein erneutes setState — wir bleiben in 'conversing'. + return; + } this.setState('conversing'); setTimeout(() => { if (this.state === 'conversing') { @@ -211,6 +226,35 @@ class WakeWordService { }, 200); } + /** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann + * "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im + * Native-Modul verhindert dass ARIAs eigene Stimme triggert. + * Voraussetzung: AudioRecorder muss frei sein (Recording aus). Wenn der + * AudioRecorder gerade laeuft, hat der Vorrang — Wake-Word geht nicht. */ + async startBargeListening(): Promise { + if (!this.nativeReady || !OpenWakeWord) return; + if (this.state !== 'conversing') return; + if (this.bargeListening) return; + try { + await OpenWakeWord.start(); + this.bargeListening = true; + console.log('[WakeWord] Barge-Listening aktiv (parallel zu TTS)'); + } catch (err) { + console.warn('[WakeWord] Barge-Listening start fehlgeschlagen:', err); + } + } + + /** Barge-Listening wieder aus — z.B. wenn der AudioRecorder fuer die + * naechste Aufnahme das Mikro braucht. */ + async stopBargeListening(): Promise { + if (!this.bargeListening) return; + if (this.nativeReady && OpenWakeWord) { + try { await OpenWakeWord.stop(); } catch {} + } + this.bargeListening = false; + console.log('[WakeWord] Barge-Listening aus'); + } + /** Konversation beenden — User hat im Window nichts gesagt. * Mit Wake-Word: zurueck zu 'armed' (Listener wieder an). * Ohne: zurueck zu 'off'. @@ -270,6 +314,19 @@ class WakeWordService { }; } + /** Subscribe auf Barge-In-Events: Wake-Word erkannt waehrend ARIA noch + * spricht. ChatScreen sollte dann TTS abbrechen + neue Aufnahme starten. */ + onBargeIn(callback: WakeWordCallback): () => void { + this.bargeCallbacks.push(callback); + return () => { + this.bargeCallbacks = this.bargeCallbacks.filter(cb => cb !== callback); + }; + } + + isBargeListening(): boolean { + return this.bargeListening; + } + onStateChange(callback: StateCallback): () => void { this.stateCallbacks.push(callback); return () => { diff --git a/issue.md b/issue.md index 09ce109..830d9d8 100644 --- a/issue.md +++ b/issue.md @@ -107,6 +107,7 @@ - [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 ## Offen @@ -117,7 +118,6 @@ - [ ] 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 -- [ ] Wake-Word parallel zu TTS lauschen (mit AcousticEchoCanceler) — aktuell muss man warten bis ARIA fertig ist oder manuell den Voice-Button tappen fuer Barge-In ### Architektur - [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)