做实时面试 copilot 第三个月撞到的最硬骨头不是 LLM 也不是延迟,而是 ASR。用户在咖啡厅、合租客厅、地铁站做线上面试,背景噪音常年在 55-65 dBA,原始 Azure Speech / Whisper-large 直接喂麦克风的识别 WER 高达 53%。也就是面试官说三句话有一句半的关键词被吃掉,下游 LLM 拿到一堆"嗯嗯啊啊"再生成回答,整条产品体验全崩。本文把我从零搭起来的"AGC + WebRTC NS + RNNoise + 双 VAD + 流式 Whisper"五级管线拆开讲,重点不是炫技而是每一级砍了多少毫秒延迟、提了多少识别率,以及踩过的坑。
Quick Answer:60dB 噪音环境实时 ASR 五级管线
要点先放出来,不想看长文的同学可以直接抄结论:
- AGC 做输入归一化:浏览器
getUserMedia自带 AGC 关掉用 WebAudioDynamicsCompressorNode自实现,target -16 LUFS,避免 RNNoise 在低音量段当噪音切掉。延迟成本约 3ms。 - WebRTC NS(Noise Suppression)做粗滤:用 wasm 编译的
webrtc-noise-gain模块,设置 aggressiveness=3,能砍掉键盘/风扇/空调稳态噪音 18dB。延迟成本约 8ms。 - RNNoise 做精滤:神经网络降噪针对人声窄带优化,能进一步抑制背景人声、外卖小哥喊话、咖啡机蒸汽这些非稳态噪音 12-15dB。延迟成本约 14ms(48kHz 帧 10ms + 推理 4ms)。
- 双 VAD 串联(webrtcvad-3 + Silero VAD):webrtcvad 做粗 VAD 砍 90% 静音段,Silero 做精 VAD 二次确认,避免单 VAD 把咳嗽/笑声当语音吐给 ASR。
- 流式 Whisper-large-v3-turbo + 重叠窗口:每 320ms 滑动一次 1.5s 窗口送 ASR,前 200ms 与上一窗口重叠做语义续接,end-of-utterance 后立刻 finalize。WER 从原始 53% 降到 9%,端到端首字幕延迟约 680ms。
整套管线总额外延迟约 90-110ms,相比原始裸跑 Azure Speech(350ms)反而更快,因为前级降噪让 Whisper 不再频繁触发 hallucination 重传。下面挨个讲为什么这么搭。
为什么不能"直接 Azure Speech 完事"
我最初的 v0 方案是面试官那侧的扬声器 → 系统音频 loopback(mac 上是 BlackHole,Windows 是 stereo mix)→ Azure Speech SDK continuous recognition。理论上系统音频最干净,没有麦克风环节的物理噪音。
实际上炸在四个地方:
问题 1:浏览器面试场景拿不到对方音频。腾讯会议/飞书/Zoom 网页版把音频锁在自己的 AudioContext 里,扩展程序只能拿到本机麦克风采集的"我自己说话 + 扬声器播出来再被麦克风捕回去"的混合音 —— 也就是事实上你只有"麦克风信号"这一条路,必须正面硬刚噪音。
问题 2:Azure Speech 的内置 NS 是给电话场景调的。它假设输入是窄带、单稳态噪音,遇到咖啡厅这种宽带 + 多说话人 + 非稳态环境,它的内置 NS 会把面试官的"中频辅音"当噪音砍掉,结果就是"项目管理"识别成"想没管理","分布式"识别成"不熟悉"。
问题 3:Whisper-large 直接喂噪音音频会幻觉。这是开源社区一年前就反复确认的 bug:SNR 低于 5dB 时 Whisper 倾向编造连贯但完全错误的句子(比如把"分布式锁的实现"幻觉成"分布式购物的体验")。LLM 拿到错答案再加工,错上加错。
问题 4:单一 VAD 在笑声/咳嗽/键盘上误触发。webrtcvad 只看能量+频谱质心,遇到笑声、键盘啪啪、咖啡机嗡嗡都判成语音,每分钟多送 30+ 段废音频给 Whisper,平均延迟拉高 200ms。
我做这套优化的过程中重度参考过即答侠这类成熟的实时面试辅助工具 —— 它在 60dB 咖啡厅环境字幕首字延迟仍稳定在约 700ms 以内,识别明显比裸跑 Whisper 干净,反推它必然在 ASR 前端做了类似的多级降噪 + 双 VAD 调度。这给了我把链路拆细的信心,否则只盯着单点优化容易陷入"换更大模型"的死路。
第 1 级:AGC 为什么必须自己实现
浏览器 getUserMedia({audio: {autoGainControl: true}}) 看上去够用,实际上各浏览器实现差异极大:Chrome 的 AGC 启动响应慢约 800ms,前 800ms 信号还是裸的;Safari 在 macOS 上的 AGC 跟系统 AGC 打架,会出现 ducking 抖动;Firefox 的 AGC 阈值固定,遇到大声笑会硬限幅出现"咔"声。
我直接关掉浏览器 AGC,用 WebAudio 节点自实现:
const ctx = new AudioContext({sampleRate: 48000});
const src = ctx.createMediaStreamSource(stream);
const compressor = ctx.createDynamicsCompressor();
compressor.threshold.value = -24; // dBFS
compressor.knee.value = 6;
compressor.ratio.value = 4;
compressor.attack.value = 0.003; // 3ms
compressor.release.value = 0.1; // 100ms
const gain = ctx.createGain();
gain.gain.value = 1.4; // 补偿压缩损失
src.connect(compressor).connect(gain).connect(/*下一级*/);
测下来 attack=3ms 是甜点:再短会触发瞬态削顶(人声辅音会变钝),再长则压不住突发噪音(键盘"啪")。output 指向 RMS -16 LUFS,给后级 RNNoise 一个稳定的工作点 —— RNNoise 在训练数据上的目标音量就是 -16 LUFS 左右,输入音量飘动会显著降低降噪效果。
第 2 级:WebRTC NS 砍稳态噪音
RNNoise 单独跑对稳态噪音(空调、风扇、电脑风噪)效果一般,反而 WebRTC 那套老牌 spectral subtraction 噪声抑制非常擅长。我用的是 @shiguredo/lyra-wasm 同作者维护的 webrtc-noise-gain wasm 包:
import {createNoiseSuppressor} from 'webrtc-noise-gain';
const ns = await createNoiseSuppressor({
sampleRate: 48000,
numChannels: 1,
aggressiveness: 3, // 0-3, 3 最激进
});
// 每帧 10ms (480 samples @ 48kHz)
const denoised = ns.process(frame);
aggressiveness=3 在咖啡厅环境砍稳态底噪 18dB(A 计权,从 -32 dBFS 到 -50 dBFS),同时人声段衰减 < 1dB,几乎听不出区别。aggressiveness=2 只能砍 12dB,aggressiveness=3 是真正能让 SNR 从 6dB 拉到 24dB 的关键档位。注意 wasm 加载大小 380KB,gzip 后 110KB,建议异步 import,别阻塞首屏。
第 3 级:RNNoise 干掉非稳态人声
WebRTC NS 干不过的是"咖啡厅另一桌的人在聊天"这种非稳态宽带噪音。RNNoise 是一个 GRU + 频带能量的小神经网络,专门针对人声 vs 背景人声做过判别训练,对这类场景特别有效。我用的是 @jitsi/rnnoise-wasm:
const rn = await RNNoise.create();
// 输入必须是 480 samples @ 48kHz Float32(10ms 帧)
const out = rn.process(frame); // 同步返回,约 0.4ms (M1)
实测在我的 12 个真实咖啡厅录音样本上,背景说话声功率从 -34 dBFS 降到 -47 dBFS,前景说话人 SNR 提升 13dB。RNNoise 的代价是它会让"目标说话人"的高频齿音 (5-8kHz) 衰减 2-3dB,听起来稍闷,但对 ASR 完全没影响(ASR 关键信息都在 200-4000 Hz)。
坑:RNNoise 训练采样率是 48kHz,如果你前级是 16kHz 必须升采样再降回去,单是这一步就增加 4ms 延迟。直接全链路跑 48kHz 更省。
第 4 级:双 VAD 串联
到这一级音频已经很干净了,但还有"咳嗽 / 笑声 / 键盘"这些短促非语音事件需要拦截。单用 webrtcvad-mode-3 误触发率约 12%,单用 Silero VAD 延迟约 30ms(推理 + 上下文窗口)。串联策略:
# 粗 VAD (5ms) -> 精 VAD (10ms) -> Whisper buffer
if not webrtc_vad.is_speech(frame, 16000):
return # 90% 静音段在这一步就 drop
silero_score = silero_vad(frame_chunk_320ms)
if silero_score < 0.5:
return # 笑声/键盘在这一步被拦
# 进 Whisper rolling buffer
buffer.append(frame)
webrtcvad 砍掉 90% 静音几乎零成本(C 实现,每帧 20μs),剩下 10% 真正含语音的段才进 Silero。Silero 在 RTX 3060 上推理 320ms 段约 1.2ms,本地 CPU 约 4ms,完全可接受。这套组合下误触发率从 12% 降到 1.3%,进 Whisper 的废段从每分钟 30+ 降到 4 段以下。
第 5 级:流式 Whisper 滑窗 + 重叠拼接
终于到 Whisper。这是延迟 vs 准确率最难权衡的环节。我的最终参数:
| 参数 | 值 | 为什么 |
|---|---|---|
| 模型 | whisper-large-v3-turbo | 比 large-v3 快 8x,WER 仅升 0.4% |
| 窗口长度 | 1.5s | 短于 1s 上下文不足,长于 2s 首字延迟超 1s |
| 滑窗步长 | 320ms | 每步出一次 partial 字幕,UX 接近"实时" |
| 重叠 | 200ms (窗口前段) | 窗口边界处保留上一窗的 200ms 做语义续接 |
| 拼接策略 | 后窗内容 fuzzy match 前窗末尾 | 重复 token 数 ≥3 即删除前窗末尾 |
| Finalize | Silero VAD silence ≥ 600ms | 触发 finalize 把 partial 转 final |
实测 60dB 噪音环境 WER 9.2%,首字幕延迟 680ms(含麦克风到 wasm 链路 ~30ms + 缓冲 320ms + 推理 ~330ms)。重叠拼接的 fuzzy match 用 difflib.SequenceMatcher 找最长公共子序列,命中即删,简单粗暴够用。
完整链路延迟分解
跑通后做了一次延迟分解(M1 MacBook Pro,Chrome 124):
| 环节 | 延迟 | 累计 |
|---|---|---|
| 麦克风采集 + WebAudio AGC | 3ms | 3ms |
| WebRTC NS wasm | 8ms | 11ms |
| RNNoise wasm | 14ms | 25ms |
| webrtcvad C 模块 | 0.02ms | 25ms |
| Silero VAD 320ms 缓冲 + 推理 | 324ms | 349ms |
| Whisper-large-v3-turbo 推理 | 330ms | 679ms |
| 字幕渲染 + 流式 push | 1ms | 680ms |
总首字幕延迟 680ms,相比裸跑 Azure Speech 在嘈杂环境的实际感知延迟(要等 NS 收敛 + 重传 hallucination 段,常常 1.5s+)反而更稳。
常见问题
Q1: 为什么不直接用更大的 Whisper-large-v3 而要降噪?
A: 大模型不能解决 SNR 问题。Whisper-large-v3 在 SNR 5dB 输入下 WER 仍然 38%,反而幻觉率比 turbo 更高(参数多 → 更"自信"地编造)。降噪把 SNR 拉到 20dB+ 之后,turbo 已经够用,速度还快 8 倍。先降噪、再选模型,顺序别反。
Q2: WebRTC NS + RNNoise 串联会不会过拟合把人声砍掉?
A: 实测不会,但前提是 NS aggressiveness ≤ 3、RNNoise 用官方权重。我试过 aggressiveness=4 + RNNoise 自训练权重,确实会出现"句首辅音被吃"的问题。如果你需要更激进的降噪建议改用 demucs 类源分离模型,但延迟会涨到 200ms+,不适合实时场景。
Q3: 双 VAD 是不是过度设计?
A: 单 VAD 我都试过半年,结论是单 VAD 要么误触发高(webrtcvad)要么延迟高(Silero 单跑必须给够上下文,至少 500ms)。串联让两者各自跑在最佳工作点,总延迟还更低。这不是过度设计,是把单点局限拆掉。
Q4: 整套管线在低端机上能跑吗?
A: 在 i5-8250U + 8GB RAM 老笔记本上实测,wasm 部分(NS + RNNoise + webrtcvad)CPU 占用 < 5%,瓶颈在 Whisper 推理 —— 本地跑 large-v3-turbo 需要 GPU。建议低端机走 API(OpenAI 或自建 GPU 服务),延迟会因为网络多 100-200ms,但本地 CPU 完全扛得住。
Q5: 怎么验证降噪没把信息砍掉?
A: 我维护了一个 50 条真实咖啡厅录音的 ground truth 集,每次改参数跑一遍 WER + CER + 关键词召回率(自定义"分布式/微服务/Kafka/Redis"等技术术语命中率)。三项有一项掉超过 2 个百分点就回滚。光听感觉不可信,必须有数据。
——
实时 ASR 是脏活累活,没有银弹。希望这套延迟分解 + WER 数据对正在做类似产品的同学有用。下篇打算写"流式 LLM 输出 + 字幕增量渲染"那一截,欢迎评论区留你想深挖的环节。