3. 语音算法Pipeline:AEC、AGC、NS、VAD模块的联调顺序与数据流设计
做语音算法联调,最怕什么?
不是单个模块调不好,而是几个模块串起来之后,声音反而变差了。我见过太多团队,AEC单独测指标漂亮,AGC单独跑也稳,一联调就炸——回声没消干净,AGC又把残留噪声抬起来了。
说白了,Pipeline的联调顺序,决定了整个系统的上限。
3.1 经典四模块Pipeline架构
先看一张整体框架图,后面我们逐层拆解。
这个顺序不是随便排的。我刚开始做联调时,试过把AGC放在AEC前面,结果AGC把远端参考信号和近端信号一起放大,AEC的非线性失真直接爆炸。嗯,那次调试花了我整整两天。
3.2 各模块联调顺序详解
3.2.1 第一步:AEC 必须打头阵
为什么AEC必须放在最前面?
回声是语音通信里最破坏体验的问题。如果回声没消干净,后面的AGC和NS都会把残留回声当成有效信号来处理。你想想看,AGC会把残留回声放大,NS又没法区分回声和语音——结果就是双向通话时,对方听到自己的回声被放大了好几倍。
关键点:AEC需要两路输入——近端麦克风信号和远端参考信号。联调时务必确认参考信号延迟对齐,否则AEC会失效。
我在项目中遇到过一个问题:AEC在单端测试时效果很好,ERLE能到40dB以上。但联调后ERLE掉到了15dB。查了两天才发现,是音频驱动层给AEC的参考信号比近端信号晚了32个采样点。32个点,人耳听不出来,但AEC的NLMS自适应滤波器直接崩了。
3.2.2 第二步:AGC 做电平归一化
AEC处理完之后,信号的电平可能很不稳定。近端说话声音有大有小,AEC的残留信号电平也飘忽不定。这时候AGC上场,把信号拉到统一的目标电平。
我个人习惯的目标电平是-18dBFS到-16dBFS之间。为什么是这个范围?
- 太低(比如-26dBFS):后续NS和VAD的SNR会恶化,噪声抑制容易误判
- 太高(比如-10dBFS):给Codec编码留的headroom不够,容易削波
联调技巧:AGC的attack time和release time要跟AEC的收敛速度匹配。如果AGC响应太快,AEC还没收敛,AGC就把AEC的收敛过程放大了,造成"回声泵浦"效应。
3.2.3 第三步:NS 做噪声抑制
AGC把电平拉平之后,NS开始干活。这时候NS面对的输入信号,电平是稳定的,回声也基本消干净了,NS只需要专注处理背景噪声。
NS放在AGC后面还有一个好处:AGC已经做了电平归一化,NS的噪声阈值可以设成固定值,不用动态调整。这能省不少计算量。
我记得有一次联调,客户反馈在嘈杂环境下语音断断续续。排查后发现是NS的over-suppression导致语音被误杀。后来把NS的噪声估计更新速度调慢了一档,同时把AGC的max gain从12dB降到6dB,问题就解决了。
避坑指南:我曾经把NS放在AGC前面,结果NS把噪声压下去之后,AGC又把残留噪声抬起来了。NS白干了。所以顺序一定不能错。
3.3.4 第四步:VAD 做最终判决
VAD放在最后,是因为它需要最干净的信号来做判决。前面三个模块已经把回声、电平波动、背景噪声都处理好了,VAD的准确率才能上去。
VAD的输出不只是"有语音/无语音"这么简单。在实际联调中,VAD的flag会用来控制:
- Codec的DTX(不连续传输)开关
- AGC的冻结/更新状态
- NS的噪声估计更新策略
说白了,VAD是整个Pipeline的"交通警察"。它说"有语音",后面的模块就全力工作;它说"无语音",系统就进入省电模式。
3.3 数据流设计要点
联调顺序定下来之后,数据流设计就是另一个坑。我整理了一个表格,把每个模块的输入输出和帧长要求列清楚。
| 模块 | 输入 | 输出 | 帧长要求 | 延迟预算 |
|---|---|---|---|---|
| AEC | 近端信号 + 远端参考 | 回声消除后信号 | 10ms / 20ms | < 15ms |
| AGC | AEC输出 | 电平归一化信号 | 10ms / 20ms | < 5ms |
| NS | AGC输出 | 降噪后信号 | 10ms / 20ms | < 10ms |
| VAD | NS输出 | 语音/非语音标志 | 10ms / 20ms | < 5ms |
这里有个容易被忽略的点:帧长必须统一。如果AEC用10ms帧,AGC用20ms帧,数据流就乱套了。我建议整个Pipeline统一用10ms帧,延迟更低,而且跟大多数Codec的帧长兼容。
3.4 联调数据流伪代码
下面是一个简化版的联调数据流实现,展示了四个模块如何串起来。
// 语音算法Pipeline联调伪代码
// 帧长: 10ms, 采样率: 16kHz
void process_audio_pipeline(short *near_in, short *ref_in,
short *out, int frame_size) {
// 1. AEC: 回声消除
aec_process(near_in, ref_in, aec_out, frame_size);
// 2. AGC: 自动增益控制
// 目标电平: -18dBFS
agc_process(aec_out, agc_out, frame_size, -18.0f);
// 3. NS: 噪声抑制
ns_process(agc_out, ns_out, frame_size);
// 4. VAD: 语音活动检测
int vad_flag = vad_process(ns_out, frame_size);
// 根据VAD结果控制输出
if (vad_flag) {
// 有语音: 正常输出
memcpy(out, ns_out, frame_size * sizeof(short));
} else {
// 无语音: 输出静音或舒适噪声
generate_comfort_noise(out, frame_size);
}
}
联调建议:每个模块之间加一个环形缓冲区,用来处理帧对齐和延迟补偿。我习惯在每个模块的输入输出各加一个10ms的buffer,这样即使某个模块处理时间波动,也不会影响整体数据流。
3.5 常见联调问题与解决方案
最后分享几个我在联调中踩过的坑,希望能帮你少走弯路。
问题1:AEC+AGC联调出现"呼吸效应"
现象:背景噪声忽大忽小,像在呼吸。
原因:AGC的增益变化速度跟AEC的残留回声波动频率接近,形成低频振荡。
解决方案:把AGC的release time从50ms调到200ms,让增益变化更平滑。
问题2:NS+VAD联调导致语音段被切断
现象:说话时开头几个字被吃掉。
原因:NS的噪声估计更新太快,VAD的hangover时间太短。
解决方案:VAD的hangover从100ms延长到200ms,同时NS的噪声估计在VAD检测到语音时冻结更新。
问题3:全Pipeline联调后延迟超标
现象:端到端延迟从15ms飙到45ms。
原因:每个模块都加了10ms的lookahead,四个模块叠加就是40ms。
解决方案:把AEC和NS的lookahead合并,共享同一个延迟buffer。我试过之后,延迟降到了22ms。
联调这件事,说白了就是"先拆开调,再串起来测"。每个模块单独调好只是第一步,串起来之后的各种耦合效应才是真正的考验。我个人习惯是先做AEC+AGC联调,确认回声和电平都稳定了,再加入NS,最后加VAD。一步一步来,出了问题也好定位。