24、音频处理实战:音频降噪、回声消除、自动增益控制,实现一个音频预处理工具。

说实话,做音视频开发这么多年,我踩过最多的坑反而不是编解码,而是音频预处理。你想想看,一个视频通话产品,画面再清晰,如果对方那边传来呼呼的风扇声、刺耳的回声,或者声音忽大忽小,用户立马就挂断了。音频预处理,说白了就是给原始音频信号“洗个澡”,让它干干净净地送出去。

今天我们就来手撸一个音频预处理工具,把降噪、回声消除、自动增益控制这三个核心模块串起来。我个人习惯把它们叫做“音频三件套”,缺一个都不行。

24.1 音频预处理的核心流程

先看整体架构。我画了一张流程图,帮你理清数据是怎么流转的。

原始音频 降噪模块 NS 回声消除 AEC 自动增益 AGC 远端参考信号 音频预处理三件套:NS → AEC → AGC

数据从左到右依次经过三个模块。注意看,回声消除模块多了一个“远端参考信号”输入。这是关键——没有参考信号,回声消除就是瞎搞。我在项目中遇到过有人把AEC当降噪用,结果声音全乱了。

24.2 降噪模块:把背景噪声按下去

降噪的本质是什么?说白了就是区分“人声”和“噪声”。人声有谐波结构,噪声通常是平稳的。我常用的方法是谱减法,简单粗暴但有效。

核心思路:在频域里,把噪声的幅度谱从信号中减掉。噪声谱怎么估计?用静音段来建模。

来看一段实现代码。我用的是WebRTC的降噪算法,但为了让你理解原理,我写了一个简化版:

// 简化版谱减法降噪
void NoiseSuppression::Process(float* input, int samples) {
    // 1. 分帧 + 加窗
    // 2. FFT到频域
    // 3. 估计噪声谱(前几帧作为噪声基底)
    // 4. 计算增益:G = max(1 - alpha * noise_spectrum / signal_spectrum, floor)
    // 5. 应用增益
    // 6. IFFT回时域
    for (int i = 0; i < samples; ++i) {
        // 实际项目中用重叠保留法
        float gain = ComputeGain(input[i]);
        output[i] = input[i] * gain;
    }
}

我的经验:alpha值一般取2~3,floor取0.01~0.1。alpha太大会有音乐噪声,太小降噪不干净。我曾经调了一整天,最后发现是窗函数选错了——用汉明窗比矩形窗好很多。

24.3 回声消除:别让喇叭的声音再回来

回声消除是音频处理里最头疼的模块。为什么?因为它要处理的是“非线性”问题。喇叭出来的声音经过空气传播、麦克风采集,中间有各种失真。

AEC的基本原理是自适应滤波。你想想看,我们已知远端播放的信号,也采集到了近端麦克风的信号。如果能估计出回声路径的冲激响应,就能从麦克风信号中减去回声。

参数 典型值 说明
滤波器长度 512 ~ 2048 取决于房间混响时间,我一般用1024
步长因子 0.1 ~ 0.5 太大发散,太小收敛慢
双讲检测阈值 0.3 ~ 0.6 双方同时说话时停止更新滤波器

注意:双讲检测(Double Talk Detection)是AEC的命门。如果双方同时说话时还继续更新滤波器,滤波器会发散,回声反而变大。我曾经在一个项目中没做双讲检测,结果测试时客户说“你们这个产品会自己说话”……

代码实现上,我推荐用NLMS(归一化最小均方)算法:

void AcousticEchoCanceller::Process(
    float* mic_signal,   // 麦克风采集信号
    float* ref_signal,   // 远端参考信号
    int samples) 
{
    // 1. 双讲检测
    if (IsDoubleTalk(mic_signal, ref_signal)) {
        // 不更新滤波器系数
    } else {
        // 2. 自适应滤波
        float error = mic_signal[i] - FilterOutput(ref_signal);
        // 3. 更新滤波器系数
        for (int j = 0; j < filter_len; ++j) {
            h[j] += mu * error * ref_signal[i - j] / (power + epsilon);
        }
    }
    // 4. 输出 = 误差信号
    output[i] = error;
}

24.4 自动增益控制:让音量稳如老狗

AGC其实是最容易被忽视的模块。很多人觉得“音量调大点不就行了”?但实际场景中,人离麦克风远一点声音就小,近一点就爆音。AGC要做的就是动态调整增益,让输出音量保持在一个目标水平。

我常用的AGC策略是:

  • 目标电平: -26 dBFS(语音通信标准)
  • 增益范围: -20 dB ~ +20 dB
  • 攻击时间: 10 ms(音量突然变大时快速压下来)
  • 释放时间: 200 ms(音量变小时慢慢提上去)

关键点:攻击要快,释放要慢。为什么?因为人耳对音量突然变大很敏感,但对慢慢变小不太敏感。这是心理声学的基本原理。

void AutomaticGainControl::Process(float* input, int samples) {
    float target_level = 0.05f;  // -26 dBFS
    float current_level = ComputeRMS(input, samples);
    float gain = target_level / (current_level + 1e-6f);
    
    // 限制增益变化速度
    gain = Clamp(gain, prev_gain * 0.9f, prev_gain * 1.1f);
    prev_gain = gain;
    
    // 应用增益
    for (int i = 0; i < samples; ++i) {
        output[i] = input[i] * gain;
        // 防止削波
        if (output[i] > 1.0f) output[i] = 1.0f;
        if (output[i] < -1.0f) output[i] = -1.0f;
    }
}

避坑指南:我曾经把AGC放在降噪前面,结果噪声被放大了,降噪模块压力巨大。正确的顺序是:降噪 → 回声消除 → AGC。AGC放在最后,确保输出音量稳定。

24.5 整合成一个预处理工具

好了,三个模块都讲完了。现在把它们串起来。我习惯用链式调用的方式:

class AudioPreprocessor {
public:
    void Process(float* mic_in, float* ref_in, float* out, int samples) {
        // 1. 降噪
        ns_.Process(mic_in, samples);
        // 2. 回声消除
        aec_.Process(mic_in, ref_in, samples);
        // 3. 自动增益
        agc_.Process(mic_in, samples);
        // 4. 拷贝输出
        memcpy(out, mic_in, samples * sizeof(float));
    }
private:
    NoiseSuppression ns_;
    AcousticEchoCanceller aec_;
    AutomaticGainControl agc_;
};

这个工具我已经在多个项目中用过。说实话,调参才是最花时间的。每个房间的声学环境不一样,麦克风硬件也不一样。我建议你准备一套默认参数,然后根据实际场景微调。

嗯,最后提醒一句:音频预处理没有银弹。降噪太狠会损伤语音,AGC太激进会引入呼吸声。你需要根据产品定位做取舍。做语音通话,保语音清晰度优先;做录音,保自然度优先。


公众号:蓝海资料掘金营,微信deep3321