fix(voice): TTS 只播前几字就断——tts_end 别打断,等排队音频放完

火山合成远快于真实语速:一句话的 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 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-23 09:01:02 +08:00
parent 4153046e0e
commit b8095688ba
+23 -2
View File
@@ -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));
}