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

Merged
Blizzard merged 3 commits from feat/site into main 2026-07-24 08:55:31 +00:00
Showing only changes of commit b8095688ba - Show all commits
+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));
}