简介:面向实时音视频开发者的WebRTC音频3A可编译代码参考包,聚焦自动增益控制(AGC)、自动噪声抑制(ANS)与回声消除(AEC)三大核心算法。这三项技术是保证实时通话音质的关键:AGC动态调整麦克风增益以保持音量平稳,ANS过滤背景噪声以提升语音清晰度,AEC则消除扬声器回授,避免通话回声。资源共249个文件,压缩包整体约7.49MB,包含ANS、AGC等模块的C/C++源文件、头文件、CMake构建脚本,以及用于验证效果的WAV测试音频;同时保留编译中间文件与产物,便于对照检查构建流程。已有342人学习浏览。直接提供可编译的AGC、ANS、AEC模块实现,配合标准构建配置与测试音频,读者可在本地编译运行,直观观察算法对不同音频输入的实时处理效果。WebRTC上游代码库更新频繁,整理好的可编译版本能够省去自行拉取、适配的繁琐步骤,适合学习研究核心算法或在此基础上做二次优化。
1. 为什么要让 webrtc audio 3a 代码真正能编译,先得把它当工程对待
很多人拿到 webrtc audio 3a 代码的第一反应,是把modules/audio_processing目录直接拖进自己的工程,然后祈祷编译一次通过。结果通常是几百个编译错误,从缺少 absl 头文件到rtc_base/logging.h找不到,最后只能放弃。标题里真正关键的不是“3A”这三个字母,而是“可编译”三个字。较新的音频处理模块已经不是一个孤立算法,它带着日志、线程、SIMD 指令选择、10ms 帧对齐和基础库依赖,直接 Copy-Paste 必然翻车。这篇文章会从模块边界讲起,给你一条能编译、能运行的落地路径,再给出三套参数模板和五个现场级排查经验。适合想把 AEC、AGC、ANS 集成进自己采集链路的工程师,也适合拿这块代码当算法参考的开发者。
2. WebRTC 音频 3A 的调用关系:AEC、AGC、ANS 各自管哪一段
2.1 三个字母背后的信号链路
先别急着敲编译命令,把信号链路摆清楚更重要。AEC 解决的是声学回声:扬声器播放出来的声音被麦克风收回,变成一个延迟且被房间反射扭曲的副本。AGC 解决的是响度漂移:说话人离麦克风忽远忽近,音量也跟着忽大忽小。ANS 解决的是稳态噪声:风扇、空调、交通噪音这些背景声,在语音间隙里显得尤其刺耳。这三件事在 WebRTC 里被打包成一个叫 AudioProcessing 的模块,顺序固定地串在采集端。
这条链路的常见排序是 AEC 在最前,AGC 和 NS 在后。如果先做 AGC,回声和近端信号一起被放大,AEC 的误差信号里会混入更多非线性成分,自适应滤波器收敛更难。如果先做 NS,降噪会把参考信号里的细节也抹掉一部分,AEC 对回声路径的估计一样会受影响。所以你在参考代码里会看到,ProcessStream入口先处理 AEC 相关逻辑,再把输出交给增益控制和降噪。这个顺序是多年工程调试的结果,不是拍脑袋定的。
WebRTC 里现在主推的 AEC3,和教科书里的 NLMS 差别很大。它内部有延迟估计、双讲检测、多级自适应滤波器和一套抑制增益计算。换句话说,它不是简单地把回声信号“减掉”,而是在检测到双讲时会主动收住滤波器更新,避免把对方说话的声音一起消掉。这也是为什么有人尝试用一段简化的 LMS 代码替换 WebRTC 的 3A 之后,效果反而明显变差。AEC3 的复杂度主要就集中在这里,而不是在某个单独的滤波公式上。
整个处理过程以 10ms 为一帧对齐。48kHz 采样率下,一帧是 480 个采样点;16kHz 下是 160 个。AudioFrame里要同时设置sample_rate_hz()、num_channels()、samples_per_channel(),这三个字段只要和 APM 配置不一致,运行时就会直接 CHECK 失败。这个约束后面会反复遇到,在线调试时几乎一半的崩溃都跟它有关。
2.2 为什么独立项目也敢选 WebRTC 的 3A
做自研音频链路的人,大多数最后还是选择把 WebRTC 的 3A 抠出来,而不是自己从零实现。第一个原因是工作量:单说 AEC3 的双讲检测和延迟估计,没有半年以上音频信号处理功底很难做到生产可用。第二个原因是维护性:上游持续在修边界问题,比如蓝牙设备的延迟抖动、外接声卡的时钟漂移,自己实现这些经验积累太慢。第三个原因是许可:WebRTC 代码对商用集成友好,很多会议终端、直播软件、耳机产品的底层都有它的影子。
很多人以为“最新参考代码”是去网上找一个别人打包好的 3A 编译包,我建议反过来:直接面对上游源码主线。打包好的旧代码往往停留在一两年前的版本,那时候 AEC3 还没有后来加进去的延迟鲁棒性补丁,AGC 也还是老式的模拟增益风格。旧代码或许能顺利编过,但遇到现在常见的蓝牙卡顿、USB 音频延迟抖动,很多问题是旧代码里根本没有修复过的。所以后面所有的编译步骤,都按较新的主线源码来写。
2.3 模块最小依赖:先认清要拿哪几块
WebRTC 的音频 3A 不是一个孤立目录。modules/audio_processing只是核心,它的头文件会引用到common_audio、api/audio、rtc_base和system_wrappers。真要动手,先对下面这张表心里有数。
| 目录 / 模块 | 作用 | 能不能砍 |
|---|---|---|
| modules/audio_processing | APM 核心实现,AEC3、AGC、NS 都在这里 | 不能 |
| common_audio | 重采样、音频 buffer、滤波工具 | 不能,但可以裁剪到具体 .cc |
| api/audio | AudioFrame、ProcessingConfig 等公共接口 | 不能 |
| rtc_base | 日志、线程、智能指针工具 | 大部分要留,但不用全量 |
| system_wrappers | CPU 特性检测、时间戳 | 通常需要 |
| 音频设备层、视频模块 | 采集播放设备、视频编解码 | 能砍,编译时直接关掉 |
看到这张表,你应该能理解为什么网上总有人说 WebRTC 的 3A 没有“轻量版”。就算只处理 10ms 帧,AudioProcessing 内部也依赖 rtc_base 的日志、absl 的参数解析、system_wrappers 的 CPU 指令集检测。想做到单文件集成,只有两种可靠办法:一是用上游的 GN 构建,让构建系统把依赖关系算清楚;二是先编出静态库,再给自己的应用封装一层 C 接口。第二种在集成阶段更省事,但前提是你能先把目标编出来。我一般会先用第一条路线把代码跑通,确认 API 行为和参数效果,再考虑第二条路线做产品集成。
提示:GN 生成工程时,如果网络环境不好,第三方依赖下载可能失败。先确认依赖缓存完整再开始编译,避免卡在中间步骤。
3. 搭一个能编译的最小工程:从上游源码到 10ms 音频帧跑通
3.1 上游构建路线:GN + Ninja 是最稳的起点
GN 是 WebRTC 源码自带的构建系统生成器,Ninja 负责实际编译。不要因为没听过 GN 就绕开,它在大型 C++ 工程里很成熟。拿到源码后,先别急着敲命令,打开源码根目录下的 BUILD.gn,找到音频处理模块对应的构建目标。不同版本里这个目标可能叫audio_processing或webrtc_audio_processing,以文件里实际写的为准。
# 在 WebRTC 源码根目录执行 gn gen out/audio \ --args='target_os="linux" is_component_build=false \ rtc_include_internal_audio_device=false \ rtc_enable_protobuf=false' # 编译音频处理模块 ninja -C out/audio audio_processing这里几个参数按实际需要调。target_os决定目标系统,做交叉编译时改成android或ios;is_component_build=false表示编译成静态库,产物方便带走;rtc_include_internal_audio_device=false会砍掉桌面端的音频设备采集实现,因为我们要的是信号处理模块,不是录音放音的底层驱动;rtc_enable_protobuf=false能去掉调试转储用的 protobuf 依赖,省掉一大块第三方代码。如果在移动端交叉编译,通常还要加rtc_build_with_neon=true,让 AEC3 的 FFT 走 NEON 加速。
第一次编这个目标,即使只编译音频模块,也会连带把 common_audio、rtc_base、absl 这些编一遍。耗时从几分钟到十几分钟不等,取决于机器性能。这段时间不要以为命令卡住了,只要 ninja 还在打印进度,就让它跑完。成功后你会得到一个静态库,这就是后续集成到业务工程的基础。
3.2 CMake 手写路线的边界:骨架能看,别指望一次编过
第二种路线的风险在于源文件列表不可能一次写全。AEC3 的实现分散在modules/audio_processing/aec3/下的几十个文件里,而且不少文件会 include 到 common_audio 和 rtc_base 的内部头文件。手写一个 CMakeLists 把依赖列清楚是能做到的,但前提是你已经知道完整依赖是什么。下面这段代码只展示结构,不是拿来就能编的完整版本。
cmake_minimum_required(VERSION 3.16) project(audio_3a_standalone CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 假设 WebRTC 源码放在 third_party/webrtc 下 set(WSRC "${CMAKE_CURRENT_SOURCE_DIR}/third_party/webrtc") include_directories( ${WSRC} ${WSRC}/api ${WSRC}/modules/audio_processing/include ${WSRC}/common_audio ${WSRC}/rtc_base ) # 只供演示:源文件列表必须按 BUILD.gn 的 deps 补全 set(APM_SOURCES ${WSRC}/api/audio/audio_frame.cc ${WSRC}/modules/audio_processing/audio_processing.cc # ... 这里至少有几十个 .cc ) add_executable(audio_3a_demo demo/main.cc ${APM_SOURCES}) target_link_libraries(audio_3a_demo PRIVATE absl::strings)注意代码里把源码根目录也加入了 include 路径,这是为了让#include "modules/audio_processing/include/audio_processing.h"能按 WebRTC 自己的头文件习惯找到路径。target_link_libraries 里的 absl,还需要你的 CMake 环境额外提供;如果环境里没有,就得把 absl 源码也引入工程,依赖会进一步膨胀。
所以我更建议第一次编的时候别自己写 CMake。等 GN 编过之后,用 ninja 的-t commands导出每个目标文件的实际编译命令,或者生成 compile_commands.json,再反过来填 CMake 的源文件列表。一个反常识的排查经验是:源文件列表漏了,报错往往不是漏文件本身,而是在链接阶段冒出一个奇怪的 undefined reference,比如重采样函数找不到。你会误以为是算法库的问题,其实只是 common_audio 没有编进来。
3.3 最小编译后的调用代码:AudioProcessingBuilder 与 10ms 帧
拿到能链接的库之后,上面这段代码是最小的调用骨架,不是完整的生产代码。它把 APM 生命周期讲清楚了:Create 返回一个带默认配置的实例;ApplyConfig 要在开始处理之前调用;ProcessStream 处理采集端近端数据;ProcessReverseStream 喂播放端参考数据。AEC 只有在两边数据都持续喂入时才会收敛。
#include <memory> #include "modules/audio_processing/include/audio_processing.h" #include "api/audio/audio_frame.h" std::unique_ptr<webrtc::AudioProcessing> CreateApm( bool enable_aec, bool enable_agc, bool enable_ns) { webrtc::AudioProcessingBuilder builder; auto apm = builder.Create(); webrtc::AudioProcessing::Config config; config.echo_canceller.enabled = enable_aec; config.gain_controller1.enabled = enable_agc; config.noise_control.enabled = enable_ns; apm->ApplyConfig(config); return apm; } void ProcessOneFrame(webrtc::AudioFrame* capture_frame, webrtc::AudioFrame* render_frame, webrtc::AudioProcessing* apm) { // 播放端参考信号要先喂 if (render_frame != nullptr) { apm->ProcessReverseStream(render_frame); } // 采集端近端信号从麦克风来 apm->ProcessStream(capture_frame); }代码里的config.gain_controller1是较新代码里的常见字段名,不同版本可能被改写成gain_controller2或别的结构。拿到源码后,先打开audio_processing.h看一眼Config里实际有哪些字段,不要照抄老博客。字段名变化是版本升级里最折腾人的地方,但核心链路是稳定的。
AudioFrame的构造和使用是编译后最容易崩的地方。在 48kHz 双声道下,一条 10ms 帧的samples_per_channel必须是 480,num_channels必须是 2。如果采集设备给的是 16kHz 单声道,就要先做重采样,或者把 APM 的格式配置改成 16kHz。APM 会按格式参数去校验内部 buffer,任何一边不匹配,Debug 版本直接 CHECK 失败,Release 版本可能产生不可预测的噪声。另外,真实工程里AudioFrame的数据指针最好做到 16 字节对齐,尤其是在开了 SIMD 的情况下,某些崩溃就来自未对齐访问。
3.4 编译参数与宏:SIMD、C++ 标准和调试开关
编译参数直接影响 3A 的实时性和可调试性。AEC3 在纯 C++ 模式下也能跑,但 48kHz 双声道下,一个不带 SIMD 的 ARM 核心可能会被吃满;开启 NEON 后,帧均耗时可能降到原来的三分之一。所以编译阶段就把指令集选对,比后面在代码层面做优化要省力得多。
| 开关 / 宏 | 作用 | 建议 |
|---|---|---|
| WEBRTC_APM_DEBUG_DUMP | 打开 APM 调试转储,记录每帧中间结果 | 调试时开,发布前关 |
| -march=native 或 -msse4.2 | 让 AEC3 的 FFT 走桌面端 SIMD | 目标机支持就开 |
| rtc_build_with_neon | 移动端 NEON 加速 | 交叉编译移动端时开 |
| -DNDEBUG | 关闭 CHECK 断言 | 发布时开,但保留关键日志 |
第一次跑测试时,建议把WEBRTC_APM_DEBUG_DUMP打开。它能把每帧的回声估计、滤波器系数、AGC 增益曲线写进一个转储文件,后面用脚本解析就能看到参数调整对内部状态的影响。没有这个开关,3A 的调试基本全靠听感,定位问题会慢很多。
4. 三套可抄的参数模板:通话、直播采集、离线 WAV 批处理
4.1 全双工通话场景:AEC 优先,参考信号必须从播放器拿
全双工通话是 3A 最经典的场景。我的常见模板是:AEC 开,NS 低档,AGC 用自适应数字增益。参考信号必须从播放器缓冲里取原始 PCM,如果播放之后再用麦克风在空气中录一遍,等于把整个声学路径又绕回输入端,AEC 永远收敛不了。
webrtc::AudioProcessing::Config config; config.echo_canceller.enabled = true; config.noise_control.enabled = true; config.noise_control.level = webrtc::AudioProcessing::Config::NoiseControl::Level::kLow; config.gain_controller1.enabled = true; apm->ApplyConfig(config);这里 echo_canceller 用默认模式,适合大多数桌面和嵌入式通话。移动端有时要把 mobile_mode 置为 true,它会限制滤波器阶数,用一点回声抑制性能换取 CPU 占用下降。如果遇到蓝牙设备的声音延迟抖动,重点查 AEC3 的延迟估计相关配置;较新代码里这块默认行为有调整,但效果和具体设备强相关,需要实测。
4.2 直播和演唱采集:没有参考信号就别开 AEC
直播场景和通话最大的区别是:很多主播只开麦克风,不放伴奏也不开监听,播放端根本没有参考信号。这种情况下 AEC 开着反而是负优化,双讲检测会来回误判,所以我习惯直接把echo_canceller.enabled置为 false,只保留 AGC 和 NS。NS 档位可以提到中高,但要注意高灵敏度麦克风会把呼吸声也当噪声,降噪太强时人声高频泛音会受损。
如果直播里有伴奏和监听回放,播放器就要把伴奏 PCM 同步喂给 ProcessReverseStream,AEC 才能消掉伴奏窜入麦克风的部分。这种场景下 AGC 别用太激进的压缩,compression_gain_db从默认 9dB 往下调到 6dB 左右,保留人声动态;同时打开 limiter,防止主播突然贴近麦克风导致爆音。
4.3 离线 WAV 批处理:帧连续是关键,声道不能搞反
离线批处理常用于回归测试和参数调优。我一般录一个双通道 WAV:左声道是麦克风原始近端信号,右声道是参考信号,然后逐 10ms 帧送给 APM,输出写回单声道 WAV。最典型的错误是声道搞反:AEC 的参考必须是播放端的数据,近端必须是麦克风的数据,一旦交换,滤波器会试图把整个近端信号都抵消掉。
离线处理同样要保持状态连续,不能每帧重建 APM。有人写测试脚本时图省事,对每个 10ms 块都创建一个新实例,结果 AEC 永远不收敛,ERLE 是负数。正确做法是一个 APM 实例从头处理到尾,中途不要改配置。
4.4 三套模板对照与关键参数表
| 场景 | AEC | AGC | NS | 最容易踩的坑 |
|---|---|---|---|---|
| 全双工通话 | 开 | 自适应数字增益 | 低档 | 参考信号没从播放器取 |
| 直播 / 演唱 | 有伴奏才开 | compression_gain_db 6dB 左右 | 中高档 | 无参考时还强行开 AEC |
| 离线 WAV 批处理 | 按场景 | 按场景 | 按场景 | 每帧重建 APM、声道接反 |
这张表可以当抄作业的起点,但不能当终点。同一个产品在不同麦克风、不同设备上的表现差异很大。关键不在于抄到哪个参数,而在于你有没有把 debug dump 开起来。WebRTC 的 APM 会把每帧的回声估计、增益曲线和降噪后频谱写进文件,解析出来就能看到参数变化对内部状态的影响。否则参数调整基本就是盲调,只能靠耳朵来回试。
5. 避坑:编译与效果现场的五个案例,从 ASSERT 崩溃到 AGC 忽大忽小
5.1 现象:编译错误刷了几百行,头文件缺失
刚拿到较新代码那天,最容易看到的就是编译错误刷屏。从缺 absl 头文件到rtc_base/logging.h找不到,有人甚至怀疑源码包是不是损坏了。
原因:新版 APM 的头文件里大量使用 absl 类型和 rtc_base 工具,直接把手写的工程和源码目录拼在一起,依赖链必然会断。
解决:先用 GN 生成 ninja 工程,让构建系统把 absl 和 rtc_base 的路径自动导出。如果坚持手写 CMake,就先给 absl 建一个能用的 CMake 导入,再从头文件里逐个确认用了哪些子库。不要靠猜,每猜一次就会多花半小时。
5.2 现象:依赖列表像雪球一样越滚越大
编完 audio_processing,发现还要编 common_audio;编完 common_audio,又发现 rtc_base 里引了线程模块;线程模块又依赖更多系统 API。看起来像死循环。
原因:WebRTC 的模块边界是按 BUILD.gn 的 target 划分的,不是按源码目录划分。你用了核心模块,它列在 deps 里的所有依赖都得编。
解决:打开 BUILD.gn,看 audio_processing 这个 target 的 deps 列表,照着这个列表去组织自己的构建工程。同时关掉rtc_include_internal_audio_device和rtc_enable_protobuf这类与信号处理无关的开关,能少掉一大半第三方依赖。
5.3 现象:编译过了,一运行就 CHECK failed
编译顺利通过,但程序一启动就报Check failed: samples_per_channel == 480之类的错误。
原因:APM 使用 48kHz 处理,而测试代码给了一个 16kHz 的 AudioFrame,一帧只有 160 个采样。采集设备确实是 16kHz,但这里缺了重采样环节。
解决:把 APM 的 ProcessingConfig 设成和输入一致,或者在送进 APM 之前用 common_audio 里的重采样器把 16k 升到 48k。还要检查声道数:单声道帧不能喂给双声道配置,这个错误通常在 iOS 和 Android 的采集回调里特别常见。
5.4 现象:回声消掉了,伴奏和人声一起被挖掉一块
在某音乐播放场景下,AEC 开了之后,回声被压住了,但歌曲里的歌声和伴奏也被削弱,听感像被人挖掉一块。
原因:参考信号和实际播放信号没有对齐,或者 AEC 在双讲段仍然更新滤波器,导致它把近端伴奏也当成回声来抵消。抑制增益开得过猛也会加重这个问题。
解决:先看延迟估计日志,确认参考与近端的延迟在合理范围;把双讲检测的灵敏度调高;仍然误消的话,就降低抑制增益或者把 NS 降一档。音乐场景下,3A 不是越强越好,过度抑制比不消回声更难听。
5.5 现象:AGC 让人声忽大忽小,打击乐一段声音就被压下去
AGC 开启后,人声前后音量不稳,鼓点一来整段音量被瞬时压低。
原因:AGC 在波形能量高的地方会快速调小增益,音乐里打击乐节奏强、峰值突出,会反复触发增益调整。另外,如果 AGC 放在 AEC 前面,回声能量也会参与计算,增益变化会更夸张。
解决:调低 compression_gain_db,限制最大增益变化步长,开启 limiter 防止过冲。把增益变化时间常数调慢一些,让人耳的感知更自然。如果产品里还有另一层自动响度控制,建议关掉一重,避免双 AGC 互相打架。
6. 把双通道 WAV 变成 3A 效果的量化证明:用 ERLE 判断回声压得干不干净
验证 3A 效果不能只靠耳朵,我常用的第一道量化指标是 ERLE,回声返回损耗增强。它衡量 AEC 把线性回声压掉了多少 dB,定义是近端输入功率和误差输出功率之比。注意 ERLE 只对 AEC 有效,AGC 和 NS 的效果要另外看响度曲线和残留噪声。
import wave import numpy as np def read_wav(path): with wave.open(path, 'rb') as w: sr = w.getframerate() data = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16) ch = w.getnchannels() return sr, data.reshape(-1, ch) # mic_raw.wav 是原始近端,apm_out.wav 是 3A 处理后输出 sr, rec = read_wav('mic_raw.wav') sr, out = read_wav('apm_out.wav') n = min(len(rec), len(out), sr // 10) # 只看前 1 秒 frame_len = sr // 100 # 10ms 一帧 erle_list = [] for i in range(0, n - frame_len, frame_len): ref_p = np.mean(rec[i:i + frame_len].astype(np.float64) ** 2) out_p = np.mean(out[i:i + frame_len].astype(np.float64) ** 2) if ref_p > 1e-6 and out_p > 1e-6: erle_list.append(10 * np.log10(ref_p / out_p)) print('median ERLE: %.2f dB' % np.median(erle_list))脚本里我只看前 1 秒,避免长时间统计把单讲段和双讲段糊在一起。一般的经验线是:单讲时 ERLE 中位数能到 20dB 以上,双讲段掉到 5 到 10dB 也算正常。如果看到中位数不到 10dB,先检查参考信号有没有对齐;查了一圈还是低,就把 debug dump 打开,看 AEC 滤波器是否真正收敛。我现在每调一组参数,都会把左右声道 WAV 和这个脚本放在同一个目录里,先出数字再去听,避免又回到盲调的状态。希望帮到你。
本文还有配套的精品资源,点击获取