feat(queue): TTS-Abspiel-Queue — back-to-back-Antworten sprechen nacheinander

Bisher war die serialisierte Sprachausgabe zweier fast gleichzeitig fertiger
Antworten Timing-Glueck: PcmStreamPlayer.start() ruft stopInternal() (flush+
release), eine neue Antwort haette die laufende also abgeschnitten, sobald ihr
Audio waehrend der Wiedergabe der ersten ankam.

Jetzt echte Abspiel-Queue im audioService: kommt eine neue HOERBARE Antwort
waehrend eine andere noch hoerbar spielt (pcmAudiblePlaying bis
PcmPlaybackFinished, nicht nur bis Stream-Ende), werden ihre PCM-Chunks
gepuffert und erst nach dem Drain der laufenden nachgespielt. Bei wartender
Antwort meldet PcmPlaybackFinished NICHT 'fertig' (kein Wake-Word-Re-Arm).
Harter Stop/Barge-In/Mute verwirft die Queue. Race gegen gleichzeitige Chunks
einer dritten Antwort geschlossen (Flags vor await gesetzt). onPcmCached meldet
den WAV-Pfad nachgespielter Antworten fuer Mund-Button-Replay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 10:03:31 +02:00
co-authored by Claude Opus 4.8
parent e61a0ff871
commit fa871219ae
2 changed files with 135 additions and 1 deletions
+8
View File
@@ -1731,6 +1731,13 @@ const ChatScreen: React.FC = () => {
// das Mikro greifen kann. // das Mikro greifen kann.
wakeWordService.stopBargeListening().catch(() => {}); 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 () => { return () => {
unsubWake(); unsubWake();
@@ -1739,6 +1746,7 @@ const ChatScreen: React.FC = () => {
unsubPassive(); unsubPassive();
unsubTtsStart(); unsubTtsStart();
unsubTtsEnd(); unsubTtsEnd();
unsubPcmCached();
}; };
}, [wakeWordActive]); }, [wakeWordActive]);
+127 -1
View File
@@ -284,6 +284,13 @@ class AudioService {
private pcmSampleRate: number = 24000; private pcmSampleRate: number = 24000;
private pcmChannels: number = 1; private pcmChannels: number = 1;
private pcmBuffer: string[] = []; // base64-chunks zum spaeteren WAV-Build 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 pcmBytesCollected: number = 0;
private readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB private readonly PCM_MAX_CACHE_BYTES = 30 * 1024 * 1024; // 30MB
@@ -374,6 +381,16 @@ class AudioService {
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 — AudioTrack drained'); 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(); this._releaseFocusDeferred();
// Erst HIER playbackFinished-Listener feuern — nicht schon beim // Erst HIER playbackFinished-Listener feuern — nicht schon beim
// Empfang des letzten PCM-Chunks (siehe handlePcmChunk). AudioTrack // Empfang des letzten PCM-Chunks (siehe handlePcmChunk). AudioTrack
@@ -1401,6 +1418,23 @@ class AudioService {
const base64 = payload.base64 || ''; const base64 = payload.base64 || '';
const isFinal = !!payload.final; 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) // Neuer Stream? (messageId Wechsel oder nicht aktiv)
if (!this.pcmStreamActive || this.pcmMessageId !== messageId) { if (!this.pcmStreamActive || this.pcmMessageId !== messageId) {
if (this.pcmStreamActive && !silent) { if (this.pcmStreamActive && !silent) {
@@ -1448,6 +1482,8 @@ class AudioService {
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {}); AudioFocus?.requestDuck().catch(() => {});
this._firePlaybackStarted(); this._firePlaybackStarted();
this.pcmAudiblePlaying = true;
this.pcmPlayingMsgId = messageId;
} }
} }
@@ -1490,6 +1526,73 @@ class AudioService {
return ''; 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<void> {
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. */ /** Gesammelte PCM-Chunks als WAV speichern. Gibt file:// Pfad zurueck. */
private async _savePcmBufferAsWav(messageId: string): Promise<string> { private async _savePcmBufferAsWav(messageId: string): Promise<string> {
try { try {
@@ -1578,6 +1681,19 @@ class AudioService {
// Callback wenn alle Audio-Teile abgespielt sind // Callback wenn alle Audio-Teile abgespielt sind
private playbackFinishedListeners: (() => void)[] = []; private playbackFinishedListeners: (() => void)[] = [];
private playbackStartedListeners: (() => 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 { onPlaybackFinished(callback: () => void): () => void {
this.playbackFinishedListeners.push(callback); this.playbackFinishedListeners.push(callback);
@@ -1759,6 +1875,10 @@ class AudioService {
} }
// AudioTrack IMMER hart stoppen (idempotent) — auch im Drain-Fall. // AudioTrack IMMER hart stoppen (idempotent) — auch im Drain-Fall.
PcmStreamPlayer?.stop().catch(() => {}); PcmStreamPlayer?.stop().catch(() => {});
// Wartende TTS-Antworten verwerfen (Mund-Button = still sein).
this.pcmPendingStreams = [];
this.pcmAudiblePlaying = false;
this.pcmPlayingMsgId = '';
stopBackgroundAudio().catch(() => {}); stopBackgroundAudio().catch(() => {});
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
AudioFocus?.release().catch(() => {}); AudioFocus?.release().catch(() => {});
@@ -1770,7 +1890,8 @@ class AudioService {
// Kick-Cycle anstossen — Re-Renders triggern setMuted oft mehrfach hinter- // Kick-Cycle anstossen — Re-Renders triggern setMuted oft mehrfach hinter-
// einander, und jeder weitere Kick lässt Spotify nochmal kurz pausieren. // einander, und jeder weitere Kick lässt Spotify nochmal kurz pausieren.
const hasAnything = !!(this.currentSound || this.resumeSound || this.preloadedSound 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; if (!hasAnything) return;
console.log('[Audio] stopPlayback: currentSound=%s queue=%d pcm=%s', console.log('[Audio] stopPlayback: currentSound=%s queue=%d pcm=%s',
this.currentSound ? 'aktiv' : 'null', this.audioQueue.length, this.pcmStreamActive); this.currentSound ? 'aktiv' : 'null', this.audioQueue.length, this.pcmStreamActive);
@@ -1804,6 +1925,11 @@ class AudioService {
this.pcmBuffer = []; this.pcmBuffer = [];
this.pcmBytesCollected = 0; this.pcmBytesCollected = 0;
this.pcmMessageId = ''; 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. // Audio-Focus sofort freigeben — User hat explizit abgebrochen.
// Unser Focus war TRANSIENT, Spotify resumed darum automatisch beim // Unser Focus war TRANSIENT, Spotify resumed darum automatisch beim
// Abandon. Den frueheren kickReleaseMedia haben wir entfernt: er // Abandon. Den frueheren kickReleaseMedia haben wir entfernt: er