「跟 AI 语音聊天」听起来很简单,实际上是一台六段流水线。任何一段卡住,用户体验就是「它没反应」。这篇把链路拆开,一段一段讲坑。
你的声音
→ [1 录音采集]
→ [2 静音检测 VAD]
→ [3 语音识别 ASR]
→ [4 大模型 LLM]
→ [5 语音合成 TTS]
→ [6 播放输出]
→ 你听到回复
最简单的是浏览器 MediaRecorder,但它有几个硬伤:输出的是 webm/opus 容器,服务端还得转码;延迟高;不能拿到裸 PCM。
要低延迟就得用 AudioWorklet,在音频线程里直接拿 Float32 采样:
// audio-worklet-processor.js
class PCMProcessor extends AudioWorkletProcessor {
process(inputs) {
const ch = inputs[0]?.[0];
if (ch) {
// 转成 16bit PCM 推给主线程
const pcm = new Int16Array(ch.length);
for (let i = 0; i < ch.length; i++) {
pcm[i] = Math.max(-1, Math.min(1, ch[i])) * 0x7FFF;
}
this.port.postMessage(pcm);
}
return true;
}
}
registerProcessor('pcm-processor', PCMProcessor);
服务端拿到的是裸 PCM,采样率 16k、16bit、单声道 —— 这是几乎所有 ASR 引擎的通用输入格式。
没有 VAD,你只能靠「固定录音 3 秒」或者「按一下停」,体验很僵。
浏览器端跑 silero-vad(ONNX 模型,几百 KB)效果很好:
// 检测到说话开始 → 打断当前播放(关键!)
vad.on('speechStart', () => {
stopAllAudio(); // 停掉正在播的 TTS
sendInterruptSignal(); // 通知后端取消本轮生成
});
// 检测到说话结束 → 立即送识别,别等静默超时
vad.on('speechEnd', () => flushAudioBuffer());
speechEnd 立刻 flush,用户说完就有反应,不用等。
非流式的问题:用户说完 → 等 1 秒识别 → 等 2 秒生成 → 等 1 秒合成 = 4 秒空白。用户早就以为它死了。
解法是全链路流式 + 句子分割:LLM 吐出第一个句子就立刻送去合成,不等全文生成完。
llm_stream.on('token', (t) => {
buffer += t;
if (/[。!?\n]/.test(t)) { // 遇到句末标点
ttsQueue.push(buffer); // 立刻送 TTS
buffer = '';
}
});
// 效果:首句回复的音频,比全文生成完再合成,快 2-3 秒
播放侧有个经典问题:浏览器自动播放策略。必须在用户手势(点击)里「解锁」AudioContext,否则后面的音频全被静音。
function unlockAudio() {
const ctx = new AudioContext();
ctx.resume(); // 必须在用户交互里调用
const buf = ctx.createBuffer(1, 1, 22050);
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
src.start(0); // 播一个无声 buffer 解锁
}
这段踩坑经历值得单独说。
某天用户反馈「你一直没回复」。查日志:
sendVoiceMessageWeixin: success to=xxx
voice upload done filekey=xxx size=264672 playtime=44112
服务端明明白白写着 success,文件也上传了。但用户就是收不到。
排查结论:
sendMessageWeixin —— 能收到 ✅sendVoiceMessageWeixin —— API 返回成功,但客户端不渲染 ❌原因是语音条对编码格式有要求(部分平台需要 AMR/silk),MP3 上传成功了但渲染不出来。用户看到的只是一条灰色空气泡。
success。任何「服务端说成功」的环节,都要有一个端到端的用户侧验证。
// 语音发送失败 → 自动回落纯文字
try {
await sendVoice(audioPath);
} catch (e) {
await sendText(fallbackText); // 至少让用户看到内容
}
更进一步:干脆语音 + 文字双发。用户能听就听,听不了还能看。别让用户只收到一个空气泡。
□ 麦克风权限已获取
□ AudioWorklet 采样率与 ASR 要求匹配(16k)
□ VAD 模型加载成功,speechEnd 能触发
□ ASR 返回首字延迟 < 1s
□ LLM 流式输出开启,句子分割生效
□ AudioContext 已在用户手势中解锁
□ TTS 音频时长合理(没被截断)
□ 端到端实测:用户能听到 👈 最重要
□ 失败有降级通道(回落文字)
语音对话的难点从来不是「某个环节不会做」,而是六个环节串起来,每一段都在偷偷加延迟,任何一段静默失败,用户都只感知到一件事:它没反应。
把这六个环节分别量化(延迟多少、成功率多少),再给关键路径加降级 —— 语音体验就能从「能用」变成「好用」。👻