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:
@@ -65,6 +65,7 @@ export class VoiceClient {
|
|||||||
private sources: AudioBufferSourceNode[] = [];
|
private sources: AudioBufferSourceNode[] = [];
|
||||||
private playAnalyser: AnalyserNode | null = null;
|
private playAnalyser: AnalyserNode | null = null;
|
||||||
private lvlBuf = new Uint8Array(512); // 复用的时域采样缓冲(level() 每帧读,别每帧新建)
|
private lvlBuf = new Uint8Array(512); // 复用的时域采样缓冲(level() 每帧读,别每帧新建)
|
||||||
|
private drainTimer: number | null = null; // tts_end 后等排队音频放完的定时器
|
||||||
|
|
||||||
constructor(cb: VoiceCallbacks) {
|
constructor(cb: VoiceCallbacks) {
|
||||||
this.cb = cb;
|
this.cb = cb;
|
||||||
@@ -126,8 +127,10 @@ export class VoiceClient {
|
|||||||
this.setState("speaking");
|
this.setState("speaking");
|
||||||
break;
|
break;
|
||||||
case "tts_end":
|
case "tts_end":
|
||||||
this.resetPlayback();
|
// 别 resetPlayback!服务端"音频发完" ≠ 客户端"播完":音频按 nextStart 预约到未来时刻播,
|
||||||
this.setState("ready");
|
// 而火山合成远快于真实语速,收到 tts_end 时大半音频还排在队列里没播。stop 掉就只剩前几个字。
|
||||||
|
// 让排队音频自然放完,最后一段结束再回 ready。
|
||||||
|
this.drainThenReady();
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
this.cb.onError?.(m.msg ?? "语音出错");
|
this.cb.onError?.(m.msg ?? "语音出错");
|
||||||
@@ -295,6 +298,10 @@ export class VoiceClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resetPlayback(): void {
|
private resetPlayback(): void {
|
||||||
|
if (this.drainTimer !== null) {
|
||||||
|
window.clearTimeout(this.drainTimer);
|
||||||
|
this.drainTimer = null;
|
||||||
|
}
|
||||||
this.sources.forEach((s) => {
|
this.sources.forEach((s) => {
|
||||||
try {
|
try {
|
||||||
s.stop();
|
s.stop();
|
||||||
@@ -306,6 +313,20 @@ export class VoiceClient {
|
|||||||
this.nextStart = 0;
|
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 {
|
private send(m: { type: string; graph?: string }): void {
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(m));
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(m));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user