声学回声消除(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 双讲时的处理策略

检测到双讲后,不是直接把滤波器冻住就完事了。我建议这样做:

  1. 冻结滤波器更新:停止NLMS的系数更新,防止语音被消掉
  2. 保持滤波输出:用冻结前的滤波器系数继续计算回声估计值
  3. 平滑切换:双讲结束时,不要立刻恢复更新,慢慢过渡回去

嗯,这里要注意,冻结不是瞬间的。我一般会做一个衰减窗口,让更新步长逐渐降到零,这样不会产生咔嗒声。

四、非线性处理(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的处理流程串一下:

  1. 参考信号 x(n) 和麦克风信号 d(n) 同步对齐
  2. DTD模块判断当前是单讲还是双讲
  3. 单讲时,NLMS正常更新滤波器系数,估计回声 ŷ(n)
  4. 双讲时,冻结NLMS更新,保持当前滤波器系数
  5. 计算误差信号 e(n) = d(n) - ŷ(n)
  6. NLP模块对 e(n) 做非线性处理,消除残余回声
  7. 输出处理后的信号

这个流程看着简单,但每个环节都有坑。我建议你从NLMS开始调,先把线性回声消到-20dB左右,再上DTD,最后加NLP。一步到位容易出问题,出了问题还不好定位。

好了,AEC的基础就聊到这儿。下一节咱们讲怎么把这些算法在嵌入式平台上跑起来,包括定点化、内存优化、实时性保障这些实战内容。到时候我会拿一个Cortex-M7的板子做例子,手把手带你调一遍。