feat(web): chunked TTS satzweise abspielen + Barge-in während Wiedergabe

Die satzweise vom Server gestreamten TTS-Chunks (audio-Event + Binär-Frame)
wurden im Frontend nur gepuffert und erst beim done-Event komplett abgespielt
— der Chunked-TTS-Aufwand brachte so keinen Nutzen.

Jetzt:
- scheduleChunk() hängt jeden ankommenden PCM-Chunk lückenlos an den bisher
  geplanten Stream (Web-Audio-Scheduling über streamPlayTime). Die
  Sprachausgabe beginnt, sobald der erste Satz synthetisiert ist.
- case "audio" markiert den folgenden Binär-Frame als Streaming-Chunk.
- done spielt den Gesamtpuffer nur noch ab, wenn nicht bereits gestreamt wurde
  (kein doppeltes Abspielen); Replay-Cache bleibt befüllt.
- Die Chunk-Quellen liegen in activeSources -> Barge-in (stopAudio) stoppt auch
  laufende Streaming-Wiedergabe; streamPlayTime wird dabei zurückgesetzt.
- Geräte-TTS-Modus überspringt die Chunks unverändert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-26 10:18:31 +02:00
commit 5919a74cff

View file

@ -311,6 +311,7 @@ function closeMenu() {
let busy = false;
let activeWs = null; // aktive WS-Verbindung (fuer Barge-in von aussen erreichbar)
let activeSources = []; // laufende AudioBufferSourceNodes (stoppbar per Barge-in)
let streamPlayTime = 0; // naechster Startzeitpunkt fuer gapless Streaming-Wiedergabe
// Session-ID pro Nutzer (sonst "gehoert einem anderen Nutzer"-Konflikt). Wird aus
// /api/me abgeleitet; bis dahin null -> ensureSession() wartet auf loadMe().
@ -528,6 +529,7 @@ function replayBubble(div) {
function stopAudio() {
activeSources.forEach((s) => { try { s.stop(); } catch (e) {} });
activeSources = [];
streamPlayTime = 0; // Streaming-Uhr verwerfen -> naechster Turn startet sofort
if (TTS_SUPPORTED) { try { speechSynthesis.cancel(); } catch (e) {} } // Geräte-TTS stoppen
}
@ -577,6 +579,29 @@ function playPcm(chunks, sampleRate, onended) {
src.start();
}
// Inkrementelle Wiedergabe: einen einzelnen PCM-Chunk (chunked TTS, satzweise) lueckenlos
// an den bisher geplanten Stream anhaengen, statt bis zum done-Event zu puffern. So
// beginnt die Sprachausgabe, sobald der erste Satz fertig synthetisiert ist.
function scheduleChunk(arrayBuffer, sampleRate) {
const n = Math.floor(arrayBuffer.byteLength / 2);
if (!n) return;
const view = new DataView(arrayBuffer);
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === "suspended") audioCtx.resume();
const buf = audioCtx.createBuffer(1, n, sampleRate || 24000);
const ch = buf.getChannelData(0);
for (let i = 0; i < n; i++) ch[i] = view.getInt16(i * 2, true) / 32768;
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(audioCtx.destination);
activeSources.push(src); // stoppbar per Barge-in (stopAudio)
src.onended = () => { activeSources = activeSources.filter((s) => s !== src); };
// Ab jetzt oder ab dem Ende des zuletzt geplanten Chunks fortsetzen (gapless).
const startAt = Math.max(audioCtx.currentTime, streamPlayTime);
src.start(startAt);
streamPlayTime = startAt + buf.duration;
}
// ---------- WS-Turn (Text und Sprache teilen die Event-Logik) ----------
function wsUrl(path) {
const proto = location.protocol === "https:" ? "wss" : "ws";
@ -592,6 +617,9 @@ function runTurn(path, onopen) {
const pcm = [];
let answerEl = null;
let sampleRate = 24000;
let expectChunk = false; // naechster Binaer-Frame ist ein Streaming-Chunk (audio-Event ging voraus)
let streamed = false; // mind. ein Chunk wurde schon inkrementell abgespielt
streamPlayTime = 0; // Streaming-Uhr fuer diesen Turn zuruecksetzen
// Sprache dieses Turns (für den Replay je Bubble) = feste Routensprache.
let routeLang = null;
const turnLang = () => routeLang || null;
@ -601,13 +629,24 @@ function runTurn(path, onopen) {
ws.onclose = () => { activeWs = null; resolve(); };
ws.onmessage = (event) => {
if (typeof event.data !== "string") { pcm.push(event.data); return; }
if (typeof event.data !== "string") {
pcm.push(event.data); // immer cachen (Replay je Bubble)
if (expectChunk) { // satzweiser Chunk -> sofort lueckenlos abspielen
expectChunk = false;
if (!isDeviceMode()) { scheduleChunk(event.data, sampleRate); streamed = true; }
}
return;
}
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
switch (msg.type) {
case "ack":
routeLang = (msg.route && msg.route.language) || null;
break;
case "audio":
// Ankuendigung: der naechste Binaer-Frame ist ein satzweiser TTS-Chunk.
expectChunk = true;
break;
case "transcript":
if (msg.text) addMessage("user", msg.text, { lang: turnLang() });
break;
@ -638,7 +677,8 @@ function runTurn(path, onopen) {
// Geräte-Modus: kein Server-Audio -> nichts abspielen/cachen (Gerät hat schon gesprochen).
if (!isDeviceMode()) {
if (answerEl && answerEl._replay) answerEl._replay.chunks = pcm.slice(); // Replay-Cache
playPcm(pcm, sampleRate);
// Bei chunked TTS laeuft die Wiedergabe schon (scheduleChunk) -> nicht erneut starten.
if (!streamed) playPcm(pcm, sampleRate);
}
statusEl.textContent = "";
scrollToBottom();