diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index c4f1c8c..dca71f7 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -1731,6 +1731,13 @@ const ChatScreen: React.FC = () => { // das Mikro greifen kann. wakeWordService.stopBargeListening().catch(() => {}); }); + // Aus der TTS-Queue nachgespielte (zweite) Antwort: ihren WAV-Cache-Pfad an + // die Bubble haengen, damit der Mund-Button/Play sie auch abspielen kann. + const unsubPcmCached = audioService.onPcmCached((messageId, audioPath) => { + if (!messageId || !audioPath) return; + setMessages(prev => prev.map(m => + m.messageId === messageId ? { ...m, audioPath } : m)); + }); return () => { unsubWake(); @@ -1739,6 +1746,7 @@ const ChatScreen: React.FC = () => { unsubPassive(); unsubTtsStart(); unsubTtsEnd(); + unsubPcmCached(); }; }, [wakeWordActive]); diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index e7b72f4..e618a1d 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -284,6 +284,13 @@ class AudioService { private pcmSampleRate: number = 24000; private pcmChannels: number = 1; private pcmBuffer: string[] = []; // base64-chunks zum spaeteren WAV-Build + // ── TTS-Abspiel-Queue: zwei back-to-back-Antworten sollen sich NICHT + // gegenseitig abschneiden. Eine neue hoerbare Antwort, die reinkommt waehrend + // eine andere noch HOERBAR spielt, wird gepuffert und nach PcmPlaybackFinished + // nachgespielt (statt via start()→stopInternal() die laufende zu cutten). ── + private pcmAudiblePlaying: boolean = false; // eine hoerbare Antwort spielt (bis PcmPlaybackFinished) + private pcmPlayingMsgId: string = ''; // deren messageId + private pcmPendingStreams: Array<{ messageId: string; sampleRate: number; channels: number; chunks: string[]; final: boolean }> = []; private pcmBytesCollected: number = 0; private readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB @@ -374,6 +381,16 @@ class AudioService { const emitter = new NativeEventEmitter(NativeModules.PcmStreamPlayer as any); emitter.addListener('PcmPlaybackFinished', () => { console.log('[Audio] PcmPlaybackFinished — AudioTrack drained'); + this.pcmAudiblePlaying = false; + this.pcmPlayingMsgId = ''; + // TTS-Abspiel-Queue: steht eine naechste Antwort bereit? Dann NICHT + // "fertig" melden (kein Wake-Word-Re-Arm / Conversation-Ende) — ARIA + // spricht gleich weiter. Die naechste gepufferte Antwort direkt spielen. + if (this.pcmPendingStreams.length > 0) { + this._promoteNextPendingStream().catch(err => + console.warn('[Audio] promote next pending stream err:', err)); + return; + } this._releaseFocusDeferred(); // Erst HIER playbackFinished-Listener feuern — nicht schon beim // Empfang des letzten PCM-Chunks (siehe handlePcmChunk). AudioTrack @@ -1401,6 +1418,23 @@ class AudioService { const base64 = payload.base64 || ''; const isFinal = !!payload.final; + // ── TTS-Abspiel-Queue ── + // Kommt eine NEUE hoerbare Antwort rein, waehrend eine andere noch hoerbar + // spielt? Dann NICHT starten (start()→stopInternal() wuerde die laufende + // abschneiden) — puffern und nach deren PcmPlaybackFinished nachspielen. + if (!silent && this.pcmAudiblePlaying && messageId && messageId !== this.pcmPlayingMsgId) { + let entry = this.pcmPendingStreams.find(e => e.messageId === messageId); + if (!entry) { + entry = { messageId, sampleRate, channels, chunks: [], final: false }; + this.pcmPendingStreams.push(entry); + console.log('[Audio] TTS-Queue: Antwort %s wird gepuffert (spielt gerade %s)', + messageId, this.pcmPlayingMsgId); + } + if (base64) entry.chunks.push(base64); + if (isFinal) entry.final = true; + return ''; // Live-Player nicht anfassen + } + // Neuer Stream? (messageId Wechsel oder nicht aktiv) if (!this.pcmStreamActive || this.pcmMessageId !== messageId) { if (this.pcmStreamActive && !silent) { @@ -1448,6 +1482,8 @@ class AudioService { this._cancelDeferredFocusRelease(); AudioFocus?.requestDuck().catch(() => {}); this._firePlaybackStarted(); + this.pcmAudiblePlaying = true; + this.pcmPlayingMsgId = messageId; } } @@ -1490,6 +1526,73 @@ class AudioService { return ''; } + /** Naechste gepufferte TTS-Antwort abspielen (TTS-Abspiel-Queue). Wird nach + * PcmPlaybackFinished der vorherigen aufgerufen — so sprechen zwei + * back-to-back-Antworten NACHEINANDER statt sich abzuschneiden. */ + private async _promoteNextPendingStream(): Promise { + const entry = this.pcmPendingStreams.shift(); + if (!entry) return; + // Inzwischen global gemutet / im Anruf / vom User gestoppt? Dann NICHT + // hoerbar abspielen — nur cachen und die naechste promoten. + const mutedNow = this._muted || this._pausedForCall || + (!!this._stoppedMessageId && this._stoppedMessageId === entry.messageId); + console.log('[Audio] TTS-Queue: spiele gepufferte Antwort %s (%d chunks, final=%s, muted=%s)', + entry.messageId, entry.chunks.length, entry.final, mutedNow); + // SOFORT als "spielt" markieren (vor jedem await) — sonst koennte ein + // gleichzeitig eintreffender Chunk einer DRITTEN Antwort in der await-Luecke + // einen konkurrierenden Stream starten statt zu puffern. + this.pcmPlayingMsgId = entry.messageId; + this.pcmAudiblePlaying = !mutedNow; + // Cache-State fuer den WAV-Build (Mund-Button-Replay) setzen. + this.pcmMessageId = entry.messageId; + this.pcmSampleRate = entry.sampleRate; + this.pcmChannels = entry.channels; + this.pcmBuffer = entry.chunks.slice(); + this.pcmBytesCollected = entry.chunks.reduce((n, c) => n + Math.floor(c.length * 0.75), 0); + this.pcmStreamActive = true; + if (!mutedNow && PcmStreamPlayer) { + try { + const prerollSec = await loadPrerollSec(); + await PcmStreamPlayer.start(entry.sampleRate, entry.channels, prerollSec); + this._cancelDeferredFocusRelease(); + AudioFocus?.requestDuck().catch(() => {}); + this._firePlaybackStarted(); + this.pcmAudiblePlaying = true; + this.pcmPlayingMsgId = entry.messageId; + for (const c of entry.chunks) { + try { await PcmStreamPlayer.writeChunk(c); } catch (err) { console.warn('[Audio] promote writeChunk', err); } + } + if (entry.final) { try { await PcmStreamPlayer.end(); } catch {} } + } catch (err) { + console.error('[Audio] TTS-Queue promote start fehlgeschlagen:', err); + this.pcmAudiblePlaying = false; + this.pcmPlayingMsgId = ''; + } + } + // War die Antwort schon komplett (final) da: WAV cachen + State wie im + // Normalpfad zuruecksetzen. Bei NICHT-final laeuft der Rest live ueber + // _handlePcmChunkImpl (messageId == pcmPlayingMsgId → Normalpfad). + if (entry.final) { + this.pcmStreamActive = false; + if (this.pcmBuffer.length > 0) { + const audioPath = await this._savePcmBufferAsWav(entry.messageId).catch(() => ''); + if (audioPath) { + this.pcmCachedListeners.forEach(cb => { + try { cb(entry.messageId, audioPath); } catch (e) { console.warn('[Audio] pcmCached cb err:', e); } + }); + } + } + this.pcmBuffer = []; + this.pcmBytesCollected = 0; + this.pcmMessageId = ''; + // Nicht hoerbar abgespielt (gemutet)? Dann feuert PcmPlaybackFinished nicht + // → die naechste gepufferte Antwort selbst nachziehen (Kette). + if (!this.pcmAudiblePlaying) { + await this._promoteNextPendingStream(); + } + } + } + /** Gesammelte PCM-Chunks als WAV speichern. Gibt file:// Pfad zurueck. */ private async _savePcmBufferAsWav(messageId: string): Promise { try { @@ -1578,6 +1681,19 @@ class AudioService { // Callback wenn alle Audio-Teile abgespielt sind private playbackFinishedListeners: (() => void)[] = []; private playbackStartedListeners: (() => void)[] = []; + // Feuert wenn eine aus der TTS-Queue NACHgespielte Antwort ihren WAV-Cache + // geschrieben hat — der Normalpfad meldet den Pfad ueber den handlePcmChunk- + // Rueckgabewert, gepufferte (zweite) Antworten koennen das aber nicht (ihre + // Chunks returnen '' waehrend sie warten). Damit setzt die App auch fuer die + // nachgespielte Antwort m.audioPath (Mund-Button-Replay). + private pcmCachedListeners: Array<(messageId: string, audioPath: string) => void> = []; + + onPcmCached(callback: (messageId: string, audioPath: string) => void): () => void { + this.pcmCachedListeners.push(callback); + return () => { + this.pcmCachedListeners = this.pcmCachedListeners.filter(cb => cb !== callback); + }; + } onPlaybackFinished(callback: () => void): () => void { this.playbackFinishedListeners.push(callback); @@ -1759,6 +1875,10 @@ class AudioService { } // AudioTrack IMMER hart stoppen (idempotent) — auch im Drain-Fall. PcmStreamPlayer?.stop().catch(() => {}); + // Wartende TTS-Antworten verwerfen (Mund-Button = still sein). + this.pcmPendingStreams = []; + this.pcmAudiblePlaying = false; + this.pcmPlayingMsgId = ''; stopBackgroundAudio().catch(() => {}); this._cancelDeferredFocusRelease(); AudioFocus?.release().catch(() => {}); @@ -1770,7 +1890,8 @@ class AudioService { // Kick-Cycle anstossen — Re-Renders triggern setMuted oft mehrfach hinter- // einander, und jeder weitere Kick lässt Spotify nochmal kurz pausieren. const hasAnything = !!(this.currentSound || this.resumeSound || this.preloadedSound - || this.pcmStreamActive || this.audioQueue.length || this.isPlaying); + || this.pcmStreamActive || this.audioQueue.length || this.isPlaying + || this.pcmPendingStreams.length); if (!hasAnything) return; console.log('[Audio] stopPlayback: currentSound=%s queue=%d pcm=%s', this.currentSound ? 'aktiv' : 'null', this.audioQueue.length, this.pcmStreamActive); @@ -1804,6 +1925,11 @@ class AudioService { this.pcmBuffer = []; this.pcmBytesCollected = 0; this.pcmMessageId = ''; + // TTS-Abspiel-Queue verwerfen — harter Stop/Abbruch/Barge-In soll auch + // wartende Antworten fallenlassen (sonst sprechen sie nach dem Stop weiter). + this.pcmPendingStreams = []; + this.pcmAudiblePlaying = false; + this.pcmPlayingMsgId = ''; // Audio-Focus sofort freigeben — User hat explizit abgebrochen. // Unser Focus war TRANSIENT, Spotify resumed darum automatisch beim // Abandon. Den frueheren kickReleaseMedia haben wir entfernt: er