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:
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user