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)); }