From 5919a74cff63b082d4223c155d7f3e63c7728358 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 26 Jun 2026 10:18:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20chunked=20TTS=20satzweise=20abspie?= =?UTF-8?q?len=20+=20Barge-in=20w=C3=A4hrend=20Wiedergabe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/web/app.js | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/app/web/app.js b/app/web/app.js index 423def2..0dbf06b 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -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();