diff --git a/README.md b/README.md index 53f9c1b..87d5752 100644 --- a/README.md +++ b/README.md @@ -384,7 +384,7 @@ API-Endpoint fuer andere Services: `GET http://localhost:3001/api/session` - **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) +- **Anruf-Pause + Auto-Resume**: TTS verstummt bei klassischem Anruf oder VoIP-Call (WhatsApp/Signal/Discord). Nach dem Auflegen geht ARIA von der **genauen Stelle** weiter wo sie unterbrochen wurde — die App misst die Position vom Wiedergabe-Anfang und nutzt den WAV-Cache der Antwort - **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. - **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button @@ -864,7 +864,7 @@ docker exec aria-core ssh aria-wohnung hostname - [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix - [x] VAD-Stille-Toleranz einstellbar (1-8s) + adaptive Mikro-Baseline + Max-Aufnahme einstellbar (1-30 min) - [x] Barge-In: User kann ARIA waehrend Antwort unterbrechen, aria-core bekommt Kontext-Hint -- [x] Anruf-Pause: TTS verstummt bei eingehendem Anruf (PhoneStateListener) +- [x] Anruf-Pause + Auto-Resume: TTS verstummt bei Anruf, faehrt nach Auflegen ab der gemerkten Position fort (Date.now()-Tracking + WAV-Cache der Antwort) - [x] Settings-Sub-Screens: 8 Kategorien statt langer Liste - [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 diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index 2af339b..280fca3 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -269,6 +269,20 @@ class AudioService { private vadAdaptiveSilenceDb: number = VAD_SILENCE_FALLBACK_DB; private vadAdaptiveSpeechDb: number = VAD_SPEECH_FALLBACK_DB; + // Interruption-Tracking fuer Auto-Resume nach Anruf: + // - playbackStartTime: ms-Timestamp wenn AudioTrack tatsaechlich anfing + // abzuspielen (= _firePlaybackStarted) + // - currentPlaybackMsgId: welche Antwort lief gerade + // - pausedPosition / pausedMessageId: bei captureInterruption gemerkt + private playbackStartTime: number = 0; + private currentPlaybackMsgId: string = ''; + private pausedPosition: number = 0; // Sekunden in der Audio-Datei + private pausedMessageId: string = ''; + private resumeSound: Sound | null = null; // halten damit GC nicht zuschlaegt + // Leading-Silence wird im Native vor den Chunks geschrieben — beim + // Position-Berechnen vom playbackStarted abziehen + private readonly LEADING_SILENCE_SEC = 0.3; + constructor() { this.recorder = new AudioRecorderPlayer(); this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates @@ -341,6 +355,84 @@ class AudioService { this.stopPlayback(); } + /** Bei Anruf: aktuelle Wiedergabe-Position merken damit wir nach dem + * Auflegen von dort weitermachen koennen. Returnt Position in Sekunden + * oder 0 wenn nichts spielte. */ + captureInterruption(): number { + if (!this.playbackStartTime || !this.currentPlaybackMsgId) { + this.pausedPosition = 0; + this.pausedMessageId = ''; + return 0; + } + const elapsedMs = Date.now() - this.playbackStartTime; + const positionSec = Math.max(0, elapsedMs / 1000 - this.LEADING_SILENCE_SEC); + this.pausedPosition = positionSec; + this.pausedMessageId = this.currentPlaybackMsgId; + console.log('[Audio] captureInterruption: msgId=%s pos=%ss', + this.pausedMessageId, positionSec.toFixed(2)); + return positionSec; + } + + /** Nach Anruf-Ende: ab gemerkter Position weiterspielen. Wenn Cache noch + * nicht geschrieben (final kam waehrend Anruf vielleicht doch nicht), + * warten bis maxWaitMs und dann probieren. Returnt true wenn gestartet. */ + async resumeFromInterruption(maxWaitMs: number = 30000): Promise { + const msgId = this.pausedMessageId; + const position = this.pausedPosition; + if (!msgId) return false; + this.pausedMessageId = ''; // konsumieren + const cachePath = `${RNFS.DocumentDirectoryPath}/tts_cache/${msgId}.wav`; + const startTime = Date.now(); + while (Date.now() - startTime < maxWaitMs) { + try { + if (await RNFS.exists(cachePath)) { + return await this._playFromPathAtPosition(cachePath, position); + } + } catch {} + await new Promise(r => setTimeout(r, 500)); + } + console.warn('[Audio] resumeFromInterruption: WAV %s nicht binnen %dms verfuegbar', + msgId, maxWaitMs); + return false; + } + + private async _playFromPathAtPosition(path: string, positionSec: number): Promise { + try { + // Bestehende laufende Wiedergabe abbrechen damit wir sauber starten + if (this.resumeSound) { + try { this.resumeSound.stop(); this.resumeSound.release(); } catch {} + this.resumeSound = null; + } + const sound = await new Promise((resolve, reject) => { + const s = new Sound(path.replace(/^file:\/\//, ''), '', (err) => + err ? reject(err) : resolve(s)); + }); + // Audio-Focus anfordern damit Spotify pausiert + this._cancelDeferredFocusRelease(); + AudioFocus?.requestDuck().catch(() => {}); + this._firePlaybackStarted(); + this.isPlaying = true; + this.resumeSound = sound; + console.log('[Audio] Resume von Position %ss aus %s', + positionSec.toFixed(2), path); + sound.setCurrentTime(Math.max(0, positionSec)); + sound.play((success) => { + if (!success) console.warn('[Audio] Resume-Wiedergabe fehlgeschlagen'); + try { sound.release(); } catch {} + if (this.resumeSound === sound) this.resumeSound = null; + this.isPlaying = false; + this.playbackFinishedListeners.forEach(cb => { + try { cb(); } catch (e) { console.warn('[Audio] cb err:', e); } + }); + this._releaseFocusDeferred(); + }); + return true; + } catch (err: any) { + console.warn('[Audio] _playFromPathAtPosition fehlgeschlagen:', err?.message || err); + return false; + } + } + /** True wenn ARIA gerade was abspielt — egal ob WAV-Queue oder PCM-Stream. * Nuetzlich fuer "Barge-In": wenn der User spricht waehrend ARIA spricht, * soll die ARIA-Wiedergabe abgebrochen + die neue User-Message verarbeitet @@ -876,6 +968,9 @@ class AudioService { } private _firePlaybackStarted(): void { + // Tracking fuer Auto-Resume nach Anruf-Pause + this.playbackStartTime = Date.now(); + this.currentPlaybackMsgId = this.pcmMessageId || ''; this.playbackStartedListeners.forEach(cb => { try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); } }); diff --git a/android/src/services/phoneCall.ts b/android/src/services/phoneCall.ts index 91f41a8..0a8a739 100644 --- a/android/src/services/phoneCall.ts +++ b/android/src/services/phoneCall.ts @@ -176,6 +176,8 @@ class PhoneCallService { } private _haltForCall(toast: string): void { + // Position merken bevor wir den Stream killen — fuer Auto-Resume. + audioService.captureInterruption(); audioService.haltAllPlayback(toast); wakeWordService.pauseForCall().catch(() => {}); ToastAndroid.show(toast, ToastAndroid.SHORT); @@ -184,6 +186,14 @@ class PhoneCallService { private _resumeAfterCall(toast: string): void { wakeWordService.resumeFromCall().catch(() => {}); ToastAndroid.show(toast, ToastAndroid.SHORT); + // Auto-Resume: ab gemerkter Position weiterspielen wenn ARIA vor dem + // Anruf gerade redete. Wartet bis zu 30s auf den WAV-Cache (falls + // final-Marker erst nach dem Anruf-Ende kam). + audioService.resumeFromInterruption(30000).then(ok => { + if (ok) { + console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet'); + } + }).catch(() => {}); } } diff --git a/issue.md b/issue.md index 2effe47..0b9b79d 100644 --- a/issue.md +++ b/issue.md @@ -18,6 +18,7 @@ Wenn was anders ist, ist's ein Bug. | TTS zu Ende | nach 800ms resumed | (Conversation-Window)| (tts released) | | Eingehender Anruf (auch VoIP)| — | Mikro pausiert | aus | | Anruf vorbei | — | Mikro wieder armed | aktiv ('wake') | +| Anruf vorbei (Auto-Resume) | nach 800ms resumed | aus | aktiv ('tts') | Wichtige Mechanismen: - **Underrun-Schutz** im PcmStreamPlayer fuettert Stille rein wenn die @@ -32,6 +33,11 @@ Wichtige Mechanismen: zu/bereit"). - **Anruf-Erkennung** ueber TelephonyManager (klassisch) + AudioFocus- Loss-Listener mit Polling-Fallback (VoIP wie WhatsApp/Signal/Discord). +- **Auto-Resume nach Anruf**: beim Halt wird die Wiedergabe-Position + gemerkt (Date.now() - playbackStart - leadingSilence). Nach Auflegen + wartet die App bis zu 30s auf den WAV-Cache und spielt dann ab der + gemerkten Position weiter. Wenn das Telefonat länger als die Antwort + dauerte, ist der Cache schon fertig — instant Resume. ## Erledigt @@ -165,7 +171,6 @@ Wichtige Mechanismen: ### App Features - [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition) - [ ] 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 ### Architektur - [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)