3. 语音算法概览

做语音算法联调,最怕什么?

不是单个模块调不好,而是几个模块串起来之后,声音反而变差了。我见过太多团队,AEC单独测指标漂亮,AGC单独跑也稳,一联调就炸——回声没消干净,AGC又把残留噪声抬起来了。

说白了,Pipeline的联调顺序,决定了整个系统的上限。

3.1 经典四模块Pipeline架构

先看一张整体框架图,后面我们逐层拆解。

语音算法Pipeline联调架构 麦克风输入 AEC 回声消除 参考信号 + 近端信号 AGC 自动增益控制 目标电平 -18dBFS NS 噪声抑制 稳态/非稳态噪声 VAD 语音活动检测 输出:语音/非语音标志 联调核心原则 1. 先消回声,再调音量 2. 先降噪,再判VAD 3. 每个模块输出对齐帧长 纯净语音输出

这个顺序不是随便排的。我刚开始做联调时,试过把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。一步一步来,出了问题也好定位。