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:
parent
f30b9b56c7
commit
5919a74cff
1 changed files with 42 additions and 2 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue