From b8095688ba0c7fefe1498e082d920b949bb7653b Mon Sep 17 00:00:00 2001 From: Blizzard Date: Thu, 23 Jul 2026 09:01:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(voice):=20TTS=20=E5=8F=AA=E6=92=AD=E5=89=8D?= =?UTF-8?q?=E5=87=A0=E5=AD=97=E5=B0=B1=E6=96=AD=E2=80=94=E2=80=94tts=5Fend?= =?UTF-8?q?=20=E5=88=AB=E6=89=93=E6=96=AD=EF=BC=8C=E7=AD=89=E6=8E=92?= =?UTF-8?q?=E9=98=9F=E9=9F=B3=E9=A2=91=E6=94=BE=E5=AE=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 火山合成远快于真实语速:一句话的 PCM 秒级全推到客户端,被 nextStart 预约到未来时刻顺序播。此前 tts_end 直接 resetPlayback() stop 掉所有还 没播的 source,于是只听到前三四个字就断。 改:tts_end 走 drainThenReady()——不打断,按 nextStart 算剩余时长等排队 音频自然放完,末段结束再回 ready;speaking 态维持到真播完(HUD 频谱靠它)。 resetPlayback 只留给 barge-in/close,并清 drainTimer 防迟到定时器改状态。 Co-Authored-By: Claude Opus 4.8 --- sundynix-desktop/frontend/src/lib/voice.ts | 25 ++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/sundynix-desktop/frontend/src/lib/voice.ts b/sundynix-desktop/frontend/src/lib/voice.ts index f97d779..37b92a5 100644 --- a/sundynix-desktop/frontend/src/lib/voice.ts +++ b/sundynix-desktop/frontend/src/lib/voice.ts @@ -65,6 +65,7 @@ export class VoiceClient { private sources: AudioBufferSourceNode[] = []; private playAnalyser: AnalyserNode | null = null; private lvlBuf = new Uint8Array(512); // 复用的时域采样缓冲(level() 每帧读,别每帧新建) + private drainTimer: number | null = null; // tts_end 后等排队音频放完的定时器 constructor(cb: VoiceCallbacks) { this.cb = cb; @@ -126,8 +127,10 @@ export class VoiceClient { this.setState("speaking"); break; case "tts_end": - this.resetPlayback(); - this.setState("ready"); + // 别 resetPlayback!服务端"音频发完" ≠ 客户端"播完":音频按 nextStart 预约到未来时刻播, + // 而火山合成远快于真实语速,收到 tts_end 时大半音频还排在队列里没播。stop 掉就只剩前几个字。 + // 让排队音频自然放完,最后一段结束再回 ready。 + this.drainThenReady(); break; case "error": this.cb.onError?.(m.msg ?? "语音出错"); @@ -295,6 +298,10 @@ export class VoiceClient { } private resetPlayback(): void { + if (this.drainTimer !== null) { + window.clearTimeout(this.drainTimer); + this.drainTimer = null; + } this.sources.forEach((s) => { try { s.stop(); @@ -306,6 +313,20 @@ export class VoiceClient { this.nextStart = 0; } + // drainThenReady 处理 tts_end:不打断,等排队音频按调度自然放完,最后回 ready。 + // speaking 态要维持到真正播完(HUD 频谱靠它读 playAnalyser);播完把队列清干净。 + private drainThenReady(): void { + if (this.drainTimer !== null) window.clearTimeout(this.drainTimer); + const ctx = this.playCtx; + const remainMs = ctx ? Math.max(0, (this.nextStart - ctx.currentTime) * 1000) : 0; + this.drainTimer = window.setTimeout(() => { + this.drainTimer = null; + this.sources = []; + this.nextStart = 0; + if (this.state === "speaking") this.setState("ready"); // 期间被打断/新一轮改了态就不覆盖 + }, remainMs + 120); // +120ms 余量,等末尾 source 真正 onended + } + private send(m: { type: string; graph?: string }): void { if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(m)); }