声学回声消除(AEC)基础:回声产生原理、线性AEC(NLMS算法)、双讲检测(DTD)、非线性处理(NLP)
好,咱们今天聊聊声学回声消除。这玩意儿,说白了就是让智能音箱在播放音乐的时候,还能听清你说话。我刚开始做这个模块的时候,觉得不就是个减法嘛,把喇叭发出去的声音减掉不就行了?后来被现实狠狠教育了一顿——哪有那么简单。
一、回声是怎么产生的?
先看物理过程。你对着音箱说“小爱同学”,音箱的麦克风把你的声音收进去。同时,音箱的喇叭正在播放《孤勇者》。麦克风不光收了你的声音,还把喇叭的声音也收进来了。这个喇叭的声音,就是回声。
回声路径长什么样?我画个简图给你看:
扬声器信号 x(n) ——> 声学空间(房间反射、延迟) ——> 麦克风接收 d(n)
↑
| 近端语音 s(n)
麦克风收到的信号 d(n) 其实是三部分的叠加:
- 近端语音 s(n):你说话的声音,这是我们要保留的
- 回声信号 y(n):喇叭播放的声音经过房间反射后传回麦克风
- 环境噪声 v(n):空调、风扇、马路上的车流声
公式写出来就是:d(n) = s(n) + y(n) + v(n)
我们的目标,就是把 y(n) 从 d(n) 里干掉,只留下 s(n) + v(n)。至于噪声,那是降噪模块的事,咱们先不管。
关键认知:回声消除不是把喇叭声音静音,而是让麦克风“听不见”喇叭的声音。这两个概念完全不同。
二、线性AEC:NLMS算法
线性AEC的核心思路,就是用一个自适应滤波器去模拟回声路径。我习惯用NLMS(归一化最小均方)算法,因为它实现简单,收敛也够快。
2.1 基本原理
想象一下,喇叭发出信号 x(n),经过房间反射后变成 y(n)。我们不知道房间的冲激响应 h(n) 是什么,但我们可以用一个滤波器 w(n) 去逼近它。滤波器的输出就是回声估计值 ŷ(n)。
误差信号 e(n) = d(n) - ŷ(n)。当滤波器收敛时,e(n) 就只剩下近端语音和噪声了。
NLMS的更新公式长这样:
w(n+1) = w(n) + μ * e(n) * x(n) / (||x(n)||² + δ)
其中:
- μ 是步长因子,控制收敛速度。我一般取 0.1 ~ 0.5
- δ 是一个很小的正数,防止分母为零
- ||x(n)||² 是输入信号的功率,做归一化用的
我的经验:步长 μ 别设太大。我在一个项目里图省事设了0.8,结果回声没消掉,反而把语音给消了。后来改成0.3,稳得很。
2.2 滤波器长度怎么选?
滤波器长度决定了能处理的回声延迟范围。一般来说:
| 应用场景 | 采样率 | 滤波器长度 | 可处理延迟 |
|---|---|---|---|
| 耳机通话 | 16kHz | 128 ~ 256 | 8 ~ 16ms |
| 智能音箱 | 16kHz | 512 ~ 1024 | 32 ~ 64ms |
| 会议系统 | 16kHz | 2048 ~ 4096 | 128 ~ 256ms |
智能音箱一般用512点就够了。太长的话,计算量上去了,收敛也慢。太短的话,长延迟的回声消不掉。
2.3 代码实现片段
// NLMS 单次迭代
float nlms_update(float *w, float *x, float d, int len, float mu, float delta) {
// 1. 计算估计值
float y_hat = 0;
for (int i = 0; i < len; i++) {
y_hat += w[i] * x[i];
}
// 2. 计算误差
float e = d - y_hat;
// 3. 计算输入信号功率
float power = 0;
for (int i = 0; i < len; i++) {
power += x[i] * x[i];
}
// 4. 更新滤波器系数
float norm = power + delta;
for (int i = 0; i < len; i++) {
w[i] += mu * e * x[i] / norm;
}
return e;
}
这段代码看着简单,但实际工程里坑不少。比如输入信号功率 power 如果一直很小,归一化因子会很大,导致滤波器发散。所以 δ 不能太小,我一般设 1e-6。
三、双讲检测(DTD)
双讲检测,英文叫 Double-Talk Detection。啥意思?就是你和音箱同时在说话的时候。这时候麦克风信号 d(n) 里既有回声又有你的语音,滤波器如果还继续更新,就会把语音当成回声给消掉。
我遇到过最惨的一次,客户反馈说“音箱把我说话的声音给吃了”。查了半天,就是DTD没做好,滤波器在双讲状态下还在疯狂更新。
3.1 常用检测方法
DTD的方法有好几种,我挑三个常用的说:
- 能量比较法:比较麦克风信号和参考信号的功率比。如果比值突然变大,说明有近端语音进来了。
- 相关法:计算参考信号和误差信号的互相关系数。双讲时相关系数会下降。
- Geigel算法:比较麦克风信号和参考信号的峰值幅度。简单粗暴,嵌入式上跑得快。
我个人偏爱Geigel算法,因为它计算量小,在Cortex-M4上都能跑得动。
// Geigel DTD 检测
int geigel_dtd(float *ref, float *mic, int len, float threshold) {
float ref_max = 0;
float mic_max = 0;
for (int i = 0; i < len; i++) {
if (fabs(ref[i]) > ref_max) ref_max = fabs(ref[i]);
if (fabs(mic[i]) > mic_max) mic_max = fabs(mic[i]);
}
// 如果麦克风信号远大于参考信号,判定为双讲
if (mic_max > threshold * ref_max) {
return 1; // 双讲状态
}
return 0; // 单讲状态
}
注意:threshold 一般取 0.5 ~ 0.7。太小了容易误判,太大了又检测不到。我曾经设了0.3,结果稍微有点环境噪声就被判成双讲,滤波器一直不更新,回声消不掉。
3.2 双讲时的处理策略
检测到双讲后,不是直接把滤波器冻住就完事了。我建议这样做:
- 冻结滤波器更新:停止NLMS的系数更新,防止语音被消掉
- 保持滤波输出:用冻结前的滤波器系数继续计算回声估计值
- 平滑切换:双讲结束时,不要立刻恢复更新,慢慢过渡回去
嗯,这里要注意,冻结不是瞬间的。我一般会做一个衰减窗口,让更新步长逐渐降到零,这样不会产生咔嗒声。
四、非线性处理(NLP)
线性AEC能消掉大部分回声,但总有一些残余。为啥?因为喇叭和功放有非线性失真,房间反射也有非线性成分。NLMS是线性滤波器,搞不定这些。
这时候就需要NLP上场了。NLP说白了就是“补刀”,把线性AEC没消干净的那点残余给干掉。
4.1 中心削波法
最简单的NLP就是中心削波。误差信号 e(n) 如果幅度小于某个阈值,就直接置零。
// 中心削波
float center_clip(float e, float threshold) {
if (fabs(e) < threshold) {
return 0;
}
return e;
}
但这个方法有个问题:阈值设大了,语音也削没了;设小了,残余回声还在。我一般不用纯削波,而是用软削波:
// 软削波
float soft_clip(float e, float threshold) {
float abs_e = fabs(e);
if (abs_e < threshold) {
return 0;
} else if (abs_e < 2 * threshold) {
return (abs_e - threshold) * sign(e);
}
return e;
}
4.2 频域后处理
更高级的做法是在频域做NLP。把误差信号变换到频域,对每个频点单独处理。回声能量主要集中在低频段,所以低频的阈值可以设高一点,高频的阈值设低一点。
我做过一个项目,用频域NLP把残余回声压到了-50dB以下,同时语音的MOS分只降了0.1。效果相当不错。
避坑指南:NLP做过头了,语音会变得不自然,有“音乐噪声”感。我曾经为了追求极致的回声抑制,把NLP阈值设得特别高,结果语音听起来像机器人。后来学乖了,NLP的强度要和双讲检测联动——单讲时NLP可以强一点,双讲时NLP要弱一点,保证语音质量。
五、整体流程串起来
最后,我把整个AEC的处理流程串一下:
- 参考信号 x(n) 和麦克风信号 d(n) 同步对齐
- DTD模块判断当前是单讲还是双讲
- 单讲时,NLMS正常更新滤波器系数,估计回声 ŷ(n)
- 双讲时,冻结NLMS更新,保持当前滤波器系数
- 计算误差信号 e(n) = d(n) - ŷ(n)
- NLP模块对 e(n) 做非线性处理,消除残余回声
- 输出处理后的信号
这个流程看着简单,但每个环节都有坑。我建议你从NLMS开始调,先把线性回声消到-20dB左右,再上DTD,最后加NLP。一步到位容易出问题,出了问题还不好定位。
好了,AEC的基础就聊到这儿。下一节咱们讲怎么把这些算法在嵌入式平台上跑起来,包括定点化、内存优化、实时性保障这些实战内容。到时候我会拿一个Cortex-M7的板子做例子,手把手带你调一遍。