fix(audio): playbackFinished-Listener feuern erst wenn AudioTrack wirklich durch ist

Race-Condition entdeckt im Log: nach jeder ARIA-Antwort lief
endConversation 5s nach TTS-Start (= "letzter Chunk eingetroffen"),
nicht wenn der AudioTrack-Hardware-Buffer wirklich am Ende war. ARIA
sprach also noch hoerbar, waehrend OpenWakeWord schon re-armte.

Folge: ARIAs eigene Stimme ging direkt nach AudioRecord.startRecording
ins Mikro. Die OpenWakeWord-Sessions von AudioRecord und AudioTrack
sind verschieden → AcousticEchoCanceler kann den Output nicht
subtrahieren (kein gemeinsamer Reference-Stream). Threshold +
Patience-State der Wake-Word-Inferenz wird durch ARIAs konstante
Audio-Eingabe verwirrt, der naechste echte "Computer"-Trigger geht
unter.

Fix: Listener-Fire aus handlePcmChunk(isFinal=true) raus, dafuer in
den schon existierenden PcmPlaybackFinished-Native-Event-Handler
rein. Die Kotlin-Seite emittiert das Event aus dem Writer-Thread-
finally-Block — also genau dann wenn AudioTrack alle Samples
durchgeschrieben hat.

Side-Effect: UI-Konsumenten von onPlaybackFinished sehen den
"finished"-State jetzt 1-2s spaeter (= ehrlicher zur Realitaet,
ist eigentlich eine UX-Verbesserung).
This commit is contained in:
2026-05-30 23:18:53 +02:00
parent 5e95eacd11
commit e9615d987e
+20 -6
View File
@@ -341,8 +341,21 @@ class AudioService {
try { try {
const emitter = new NativeEventEmitter(NativeModules.PcmStreamPlayer as any); const emitter = new NativeEventEmitter(NativeModules.PcmStreamPlayer as any);
emitter.addListener('PcmPlaybackFinished', () => { emitter.addListener('PcmPlaybackFinished', () => {
console.log('[Audio] PcmPlaybackFinished — Focus jetzt freigeben'); console.log('[Audio] PcmPlaybackFinished — AudioTrack drained');
this._releaseFocusDeferred(); this._releaseFocusDeferred();
// Erst HIER playbackFinished-Listener feuern — nicht schon beim
// Empfang des letzten PCM-Chunks (siehe handlePcmChunk). AudioTrack
// braucht nach end() noch 1-2s zum Drainen seines Hardware-Buffers.
// Wenn wir die Listener zu frueh feuern, re-armt OpenWakeWord
// waehrend ARIA noch hoerbar spricht → ARIAs Stimme verwirrt die
// Wake-Word-Detection (kein gemeinsames AEC zwischen AudioTrack-
// und AudioRecord-Session). Stefan-Reproduktion: nach jeder ARIA-
// Antwort schluckte das Wake-Word den naechsten Trigger.
import('./logger').then(m => m.reportAppDebug('audio.playback',
'PcmPlaybackFinished native event → fire listeners')).catch(()=>{});
this.playbackFinishedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackFinished cb err:', e); }
});
}); });
} catch (err) { } catch (err) {
console.warn('[Audio] PcmPlaybackFinished-Subscription fehlgeschlagen:', err); console.warn('[Audio] PcmPlaybackFinished-Subscription fehlgeschlagen:', err);
@@ -1368,12 +1381,13 @@ class AudioService {
// releasen den AudioFocus NICHT hier — der writer braucht u.U. noch // releasen den AudioFocus NICHT hier — der writer braucht u.U. noch
// 30+ Sekunden bis der Buffer wirklich abgespielt ist. Den release // 30+ Sekunden bis der Buffer wirklich abgespielt ist. Den release
// triggert das native Event "PcmPlaybackFinished" wenn AudioTrack // triggert das native Event "PcmPlaybackFinished" wenn AudioTrack
// wirklich am Ende ist (siehe ensurePlaybackFinishedListener). // wirklich am Ende ist (siehe Constructor-PcmPlaybackFinished-Handler).
//
// playbackFinishedListeners feuern AUCH erst dort — frueher feuerten
// sie hier (beim Eintreffen des letzten Chunks), das fuehrte zu
// einem Race: OpenWakeWord re-armte waehrend AudioTrack noch hoerbar
// ARIAs Stimme abspielte → naechstes Wake-Word ging unter.
try { await PcmStreamPlayer!.end(); } catch {} try { await PcmStreamPlayer!.end(); } catch {}
// playbackFinished-Listener informieren (UI-Logik)
this.playbackFinishedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackFinished cb err:', e); }
});
} }
this.pcmStreamActive = false; this.pcmStreamActive = false;