语音对话前端全链路:WebRTC 采集、流式 ASR 与 TTS 的工程落地
语音对话前端全链路:WebRTC 采集、流式 ASR 与 TTS 的工程落地
一、边说边听的难题:语音 AI 助手的实时性与打断困境
去年我们给一个车载语音助手做前端,验收时产品提了一条:"用户说话中途改主意,要能立刻打断 AI,AI 必须闭嘴听新指令。"这事我见过太多团队栽进去:把语音对话做成「录音→转写→大模型→合成→播放」的串行流水线。用户每说一句要等 AI 念完才能继续,体验像在对讲机里聊天。
语音 AI 助手与文本对话最大的不同,在于「说」与「听」必须并行。人类对话中打断是常态,平均每三次对话就有一次发生重叠语音。若助手还在播放上一句 TTS 时,用户已经开口补充,前端必须立刻停掉播放、切回采集状态、把新语音送进 ASR。任何一环延迟,用户就会重复喊"停、停、停"。
全链路由四段组成:WebRTC 采集音频流、ASR 实时转写为文本、大模型流式生成回复、TTS 流式合成并播放。任意一段阻塞,整条链路的实时性就崩塌。前端要同时管理录音状态、VAD(语音活动检测)静音判断、回声消除、打断重置,还要在弱网下做重试与降级。
更要命的是流式分块。ASR 不是等用户说完才返回,而是边说边吐增量文本;TTS 也不是等大模型写完整句才合成,而是拿到一个分句就立刻转音频。前端要把这些分块拼接、缓冲、按序播放,任何错序都会让用户听到「卡带式」的断续语音。于是语音对话前端的本质,是一个跨四段管道的状态机调度器。
二、全链路状态机与流式分块:ASR 与 TTS 的协同机制
整个对话周期可以建模为一个有限状态机:空闲(idle)、聆听中(listening)、思考中(thinking)、播报中(speaking)。状态转移由两类事件驱动:用户端的 VAD 信号(开始说话、静音超时)与服务端的流式消息(ASR 增量、LLM token、TTS 音频块)。
VAD 是状态机的核心传感器。它持续分析麦克风输入的能量与过零率,判断当前是否有人说话。开始说话时切到 listening,检测到一段静音(通常 600 到 800 毫秒)则认为句子结束,切到 thinking。静音阈值不能太短,否则说话中的正常停顿会被误判为结束;也不能太长,否则响应延迟被拉大。
打断处理依赖一个关键能力:回声消除。TTS 播放的音频会被麦克风重新采集,若不做消除,VAD 会误以为「用户在说话」从而自我打断。浏览器 WebRTC 的 getUserMedia 配合 echoCancellation 约束能消除一部分回声,但外放场景下仍需软件 AEC 兜底。检测到真实用户语音(能量持续高于阈值且非回声)时,立刻终止 TTS 播放、清空待播队列、切回 listening。
流式 ASR 通常基于 WebSocket 传输。客户端持续发送音频分块(16kHz PCM,每帧约 20 到 40 毫秒),服务端返回增量识别结果,带 isFinal 标记区分中间帧与句子边界。中间帧会反复修正前面的文本,前端要按句子 ID 做幂等替换,不能直接追加。
流式 TTS 则把大模型的文本输出按标点切分句,每拿到一个完整分句就请求合成,返回的音频块按序进入播放队列。浏览器 AudioContext 的调度机制能保证块间无缝衔接,前一块播完时下一块已经在缓冲区就绪。
综上,语音对话链路最脆弱处在打断清理:旧 TTS 音频块可能还在 WebSocket 管道里飞,前端必须用递增 sessionId 标记每次对话,过期 session 的音频块直接丢弃,否则用户会听到半句旧回复突然冒出来。把这条清理守住,对话才能真正「随时打断」。
三、生产级语音对话控制器实现
下面给出一个语音对话控制器的核心实现。它管理状态机、VAD 触发、流式 ASR 与 TTS 的分块处理,并内置打断清理与异常重试。
type DialogState = 'idle' | 'listening' | 'thinking' | 'speaking';
interface VoiceControllerOptions {
asrUrl: string; // 流式 ASR 的 WebSocket 地址
ttsUrl: string; // 流式 TTS 的 HTTP 地址
vadSilenceMs?: number; // 静音判定阈值,默认 700ms
retryLimit?: number; // 链路失败重试次数
}
export class VoiceDialogController {
private state: DialogState = 'idle';
private sessionId = 0; // 每次对话递增,用于过期数据丢弃
private audioCtx: AudioContext | null = null;
private mediaStream: MediaStream | null = null;
private asrSocket: WebSocket | null = null;
private ttsQueue: AudioBuffer[] = []; // 待播音频队列
private retryCount = 0;
constructor(private opts: VoiceControllerOptions) {}
async start() {
// 采集音频:开启回声消除与噪声抑制,这是语音对话能跑通的前提
this.mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
sampleRate: 16000,
},
});
this.audioCtx = new AudioContext();
this.transitionTo('listening');
this.connectASR();
this.startVAD();
}
private connectASR() {
// ASR 连接失败需重试,但重试要有上限,避免弱网下无限重连
this.asrSocket = new WebSocket(this.opts.asrUrl);
this.asrSocket.binaryType = 'arraybuffer';
this.asrSocket.onopen = () => {
this.retryCount = 0;
this.startStreamingAudio();
};
this.asrSocket.onmessage = (ev) => this.handleASRMessage(ev.data);
this.asrSocket.onclose = () => {
if (this.state !== 'idle' && this.retryCount < (this.opts.retryLimit ?? 3)) {
this.retryCount++;
// 指数退避重连,避免服务端刚恢复就被打爆
setTimeout(() => this.connectASR(), 300 * 2 ** this.retryCount);
}
};
this.asrSocket.onerror = () => this.asrSocket?.close();
}
private startStreamingAudio() {
if (!this.mediaStream || !this.asrSocket || !this.audioCtx) return;
// 用 Web Audio 的 ScriptProcessorNode 做分帧,每帧送 ASR
const source = this.audioCtx.createMediaStreamSource(this.mediaStream);
const processor = this.audioCtx.createScriptProcessor(4096, 1, 1);
processor.onaudioprocess = (e) => {
if (this.asrSocket?.readyState === WebSocket.OPEN) {
const pcm = e.inputBuffer.getChannelData(0);
// 转 16bit PCM,符合多数 ASR 服务端的输入约定
const buf = floatTo16BitPCM(pcm);
this.asrSocket.send(buf.buffer);
}
};
source.connect(processor);
processor.connect(this.audioCtx.destination);
}
private handleASRMessage(data: string) {
const msg = JSON.parse(data);
if (msg.isFinal && msg.text.trim()) {
this.transitionTo('thinking');
this.callLLM(msg.text);
}
}
private async callLLM(query: string) {
const currentSession = ++this.sessionId;
// 流式请求大模型,按句切分后送 TTS
const resp = await fetch('/api/llm/stream', {
method: 'POST',
body: JSON.stringify({ query }),
});
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let pending = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
pending += decoder.decode(value, { stream: true });
// 按标点切句,拿到完整分句立刻合成,不等整段写完
const sentences = pending.split(/(?<=[。!?,;])/);
pending = sentences.pop() ?? '';
for (const s of sentences) {
if (s.trim()) await this.requestTTS(s.trim(), currentSession);
}
}
}
private async requestTTS(text: string, session: number) {
try {
const resp = await fetch(this.opts.ttsUrl, {
method: 'POST',
body: JSON.stringify({ text, session }),
});
const arrBuf = await resp.arrayBuffer();
const audioBuf = await this.audioCtx!.decodeAudioData(arrBuf);
// 过期 session 的音频直接丢弃,这是打断不串音的关键
if (session !== this.session) return;
this.ttsQueue.push(audioBuf);
if (this.state !== 'speaking') this.playQueue(session);
} catch (err) {
// 单句合成失败不致命,跳过继续,避免整段对话卡死
console.warn('[tts]', err);
}
}
private playQueue(session: number) {
if (session !== this.session) return;
const buf = this.ttsQueue.shift();
if (!buf) {
// 队列空且仍在 speaking,说明还有未到的 TTS 块,保持状态
if (this.state === 'speaking') return;
this.transitionTo('idle');
return;
}
this.transitionTo('speaking');
const src = this.audioCtx!.createBufferSource();
src.buffer = buf;
src.connect(this.audioCtx!.destination);
src.onended = () => this.playQueue(session);
src.start();
}
private startVAD() {
// 简化 VAD:监测 RMS 能量,超阈值认为有人说话
// 生产实现建议用 rnnoise 或 @ricky0123/vad-web
let silenceTimer: number | undefined;
const analyser = this.audioCtx!.createAnalyser();
analyser.fftSize = 512;
const data = new Uint8Array(analyser.frequencyBinCount);
const tick = () => {
analyser.getByteFrequencyData(data);
const rms = data.reduce((a, b) => a + b * b, 0) / data.length;
if (rms > 100) {
// 检测到语音:若正在 speaking,触发打断
if (this.state === 'speaking') this.interrupt();
clearTimeout(silenceTimer);
} else if (this.state === 'listening') {
// 静音超时判定句子结束
clearTimeout(silenceTimer);
silenceTimer = window.setTimeout(() => {
if (this.state === 'listening') this.transitionTo('thinking');
}, this.opts.vadSilenceMs ?? 700);
}
requestAnimationFrame(tick);
};
tick();
}
private interrupt() {
// 打断核心:递增 session 让所有在途 TTS 作废,清空播放队列
this.sessionId++;
this.ttsQueue = [];
this.transitionTo('listening');
}
private transitionTo(next: DialogState) {
this.state = next;
}
stop() {
this.interrupt();
this.asrSocket?.close();
this.mediaStream?.getTracks().forEach((t) => t.stop());
this.audioCtx?.close();
this.transitionTo('idle');
}
}
function floatTo16BitPCM(input: Float32Array): Int16Array {
const out = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) {
const s = Math.max(-1, Math.min(1, input[i]));
out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
return out;
}
关键点有四处。其一,sessionId 递增机制是打断不串音的根本保障,过期 session 的 TTS 块到达即丢弃。其二,ASR WebSocket 断线做指数退避重连,弱网下不会无限打爆服务端。其三,大模型输出按标点切句后立刻送 TTS,不必等整段完成,首字延迟显著降低。其四,VAD 与 TTS 播放共享同一能量检测,检测到真实语音立刻 interrupt。
四、实时性与准确性的权衡:回声、打断与适用边界
语音对话前端不是全场景通吃。
回声消除是最大的工程坑。浏览器原生的 echoCancellation 在耳机场景下效果良好,一旦外放就会泄漏。某车载项目外放时 TTS 每播一句就被自己的声音打断,最后不得不再加一层基于参考信号的软件 AEC,工期多花了两周。若产品形态不强制外放,优先引导用户戴耳机,能省掉一半麻烦。
VAD 的静音阈值是体验的调节阀。设短了,用户思考时的停顿会被误判为句子结束,AI 抢答;设长了,响应延迟变大,用户觉得"慢半拍"。不同场景的最佳值差异巨大:指令式对话 400 毫秒即可,长陈述场景要 1000 毫秒以上。这个参数必须做成可配置,不能写死。
流式 TTS 的分句策略也有代价。按标点切句虽然快,但遇到长定语无标点句子,会等到大模型输出很久才出第一句音频,首字延迟反而劣化。部分实现改为按 token 数量切块,但又会把语义切半,TTS 合成的语调在断点处不自然。需在延迟与自然度间取舍。
适用边界:指令式助手、车载语音、智能硬件对话场景收益最高,这些场景对实时性与打断容忍度要求严苛。纯朗读类(如新闻播报)、低频问答类产品,用流式 TTS 收益有限,反而增加复杂度。移动端 Safari 对 AudioContext 与 WebSocket 的限制较多,需做兼容性兜底。
结论
语音对话前端的本质是跨四段管道的状态机调度,核心是「边说边听」与「打断即停」。落地建议:第一,用 VAD 驱动状态机,静音阈值按场景可配置。第二,ASR 走 WebSocket 流式,断线指数退避重连。第三,大模型输出按句切分送 TTS,降低首字延迟。第四,sessionId 递增机制保证打断时过期 TTS 块被丢弃,不串音。第五,回声消除优先靠原生能力,外放场景补软件 AEC。最终在实时性、准确性与复杂度之间取得平衡。这条路在指令式语音助手场景下能跑通,回报是值得的。
更多推荐

所有评论(0)