做音视频开发的人,迟早会去翻 WebRTC 源码。尤其音频这一块,网上讲 API 怎么用的多,讲内部类怎么协作的少。这篇是 WebRTC 源码阅读系列的第三篇,专门聊音频类。我先把手头的代码版本过一遍,把 audio 相关类从“设备采集”到“网络收发”这条链路上所有关键类拆出来,每个类到底负责什么、挂在哪个文件里、和上下游怎么配合,一次性讲清楚。适合正在啃 WebRTC 源码不知道怎么下手的同学,也适合那些想搞清楚实时音频内部机制的开发者。
1. WebRTC 音频源码的整体地图:先搞清楚代码放在哪
读源码最怕一头扎进某一行实现里,却不知道这个文件在整个工程里处于什么位置。WebRTC 的音频模块不是集中在某一个目录下的,它被刻意拆成了好几层。如果你在 src 根目录下看,会发现和音频直接相关的目录就有四五个,刚接触的人很容易懵。
1.1 音频代码分布在哪几个模块
先记住几个核心目录,后面所有类名都会落在这些目录里。
- audio/:音频引擎的组装层,也可以叫“上层装配层”。里面主要是 AudioState、AudioSendStream、AudioReceiveStream、Channel 这些类。它们负责把下面的各个模块拼装起来,对外暴露统一的流接口。
- modules/audio_device/:设备抽象层。麦克风采集、扬声器播放的硬件操作都归它管。它把 Windows、Linux、Android、iOS 的各种底层 API 封装成统一接口。
- modules/audio_processing/:音频处理模块,缩写 APM。回声消除、降噪、自动增益控制都在这里实现。
- modules/audio_coding/:编解码相关。包括 AudioCodingModule(ACM)、NetEq 等等。发送侧的编码、接收侧的解码和抖动缓冲都在这层。
- modules/rtp_rtcp/:RTP 与 RTCP 协议实现。音频数据编码完后要打成 RTP 包,这个模块负责协议细节。
这四个目录之间的关系可以这样理解:音频设备在 modules/audio_device 里采集原始 PCM 数据,交给 audio/ 层的 Channel 去做回声消除和编码,编码后的数据通过 modules/rtp_rtcp 发送出去;接收侧则是倒过来,RTP 包进来,Channel 交给 NetEq 做抖动处理和丢包补偿,再解码回 PCM,最后通过 AudioDeviceModule 播放出去。
我第一天整理这些源码的时候,第一步是先打开头文件把每个模块的关键类摘出来。这一步不急着看实现,只看头文件里的类名、主要成员变量、核心方法签名。因为 WebRTC 的习惯是把“接口”和“实现”拆得很开,头文件往往就能说明问题。
1.2 先记住一条主线:声音从麦克风到扬声器
在开始逐个看音频类之前,建议先建立一条完整的声音流转链路。实时音频的路径是固定的,你只需要记住这条主线,再去看类名,基本就能对号入座。
采集侧链路:麦克风通过设备抽象层采到原始 PCM 数据,送进 AudioDeviceBuffer,然后通过 AudioTransport 回调给上层——也就是 Channel 或音频引擎——交给 APM 做回声消除、降噪、增益,之后进入编码器,编码完成后打成 RTP 包交给传输模块。
接收侧链路:网络收到 RTP 包,先经过 NetEq 处理抖动、丢包补偿,再解码成 PCM,经过反向处理(接收侧 APM),最后 AudioTransport 把数据发给 AudioDeviceBuffer,由扬声器播放出来。
这整条链路里,AudioTransport 是一个很关键的接口。设备层和引擎层之间就是通过两个方法交互的:一个是 RecordedDataIsAvailable,设备把采集到的数据“推”给引擎;另一个是 PullRenderData,播放时设备线程来“拉”要播放的数据。一个推一个拉,这个设计后面细说。
建立好这条主线之后,你再看每个音频类的头文件,会发现所有类的职责其实都很清晰。接下来我按发送、接收、设备三层逐个拆解。
2. 发送侧:AudioSendStream 与 Channel 如何协作
发送侧是整个音频链路里最直观的部分,因为它的数据流方向是单向的:麦克风来数据,往网络走。WebRTC 在这里设计了两个重要的类,一个是对外暴露的 AudioSendStream,一个是对内干活的 Channel。
2.1 AudioSendStream 只是个门面,真正的逻辑在 Channel
很多第一次读 WebRTC 音频源码的人会去翻 AudioSendStream 的实现,想在里面找到编码、降噪的代码,结果翻半天只看到一堆转发调用。这是正常的,因为 AudioSendStream 本身就是个门面类。
AudioSendStream 定义在 audio/audio_send_stream.h,它实现了 RtpSenderInterface 之类的接口,外部模块(比如 Call 类)拿到它之后,就能调 SendAudio、SetMuted、SetEncoder 这类方法。但在它内部,真正干活的是 ChannelProxy。
举个例子,你调 SendAudio 的时候,实际代码是往 ChannelProxy 转了一下。ChannelProxy 是 Channel 的一个线程安全代理,它内部锁了 mutex,然后把调用转发给真正的 Channel 对象。为什么要多这一层?因为 Channel 不完全是线程安全的,而 AudioSendStream 可能会被好几个线程同时调用,比如编码线程、统计上报线程、用户手动设置线程。ChannelProxy 就是用来统一处理线程同步的。
如果你在调试器里沿着 SendAudio 的调用栈往下走,你会看到差不多这样的路径:
AudioSendStream::SendAudio → ChannelProxy::SendAudio → Channel::SendAudio → 真正的音频处理与编码
这也解释了为什么网上很多文章说“WebRTC 音频发送的核心在 Channel”。AudioSendStream 只是对外的一个壳子,它的生命周期和配置对象交给 Call 层管理,具体的状态机、统计、RTP 打包全在 Channel 里面。
2.2 Channel 里的处理链:PCM 进来、RTP 出去
Channel 定义在 audio/channel.h。注意,这个 Channel 不是只管音频,视频那边也有类似的 Channel 结构,它们的思想是相通的。音频 Channel 内部维护了一堆关键对象:AudioCodingModule、AudioProcessing、RtpSender、RtpReceiver、Transport、以及一系列统计数据。
Channel::SendAudio 的核心处理链大致如下:
PCM 数据进来后,如果启用了 APM,先送进 AudioProcessing::ProcessStream 做近端处理,比如降噪、回声消除的前半段。处理完的 PCM 再送到 AudioCodingModule 里。AudioCodingModule 内部有编码器,它按照 10ms 一帧的节奏把 PCM 编码成压缩后的音频比特流,然后通过一个回调把编码后的数据交还给 Channel。Channel 拿到编码数据之后,把它打包成 RTP 包,通过配置好的 Transport 发送到网络上。
这里有个值得注意的习惯:WebRTC 把“编码器”也抽象成了一个类,叫 AudioEncoder。你不需要关心具体用 Opus 还是 G.711,你只需要实现 AudioEncoder 接口,然后把实例 Set 进 AudioCodingModule。源码本身自带的编解码器实现都在 modules/audio_coding/ 下面,比如 Opus 的实现就藏在 modules/audio_coding/codecs/opus/ 里。
我建议在阅读 Channel 的时候,不要一次把整个头文件啃完。先抓三个点:构造函数里创建了哪些成员对象、SendAudio 的核心调用链、以及接收 RTP 包的入口函数。把这三个点理顺了,Channel 对你就没有秘密了。
2.3 编码器接入与 AudioCodingModule 的角色
AudioCodingModule 也是理解音频发送侧的关键,缩写是 ACM。很多老教程里会有 ACM 的身影,现在编码路径依然绕不开它。AudioCodingModule 的头文件在 modules/audio_coding/include/audio_coding_module.h,它的核心接口是 Add10MsData。
每次你给 ACM 喂 10ms 的音频数据,ACM 内部会调用当前持有的 AudioEncoder 去编码。编码完成后,ACM 通过一个 AudioPacketizationCallback 回调通知外部:编码数据准备好了。在 Channel 的实现里,这个回调最终会把数据拿到手,然后调用 RtpSender 的接口去发送。
这里还有一个容易误解的点:WebRTC 现在的发送侧,实际上会把 APM 和 ACM 的交错关系搞得比较复杂。你会在 Channel 里看到 audio_processing_ 和 acm_ 两个成员,有时候还要处理模拟输入、前向纠错(FEC)、RED 打包这些选项。第一次读的时候不用追这些连线,先把主干流程记住:PCM → APM 处理 → ACM 编码 → RTP 发送。
如果你在源码里搜索 Channel::SendAudio 的调用点,会发现它只被 AudioSendStream::SendAudio 调了一次。这说明发送数据的入口非常集中。调试的时候在这里打断点,基本上一次就能把发送链路走通。
3. 接收侧:从 RTP 还原成可播放的音频
接收侧比发送侧复杂,因为你要面对乱序、丢包、抖动、时钟偏差这些网络问题。WebRTC 接收侧的核心是 NetEq,而它前面同样有一个门面类 AudioReceiveStream。
3.1 AudioReceiveStream 和它的“另一半”Channel
AudioReceiveStream 定义在 audio/audio_receive_stream.h,它和 AudioSendStream 是对称的。外部通过它来接收网络包、获取解码后的音频数据、查询统计信息。
它的内部同样持有一个 ChannelProxy。网络侧收 RTP 包的时候,调用链是:Call 层把收到的包交给 AudioReceiveStream,AudioReceiveStream 转发给 ChannelProxy,ChannelProxy 再转给真正的 Channel。Channel 内部有一个 RtpReceiver 来处理 RTP 解包,然后把负载数据塞给 NetEq。
我第一次看接收侧的时候,一直在找“解码后的 PCM 到底存在哪里”,后来才明白:WebRTC 接收侧不是预先解码存在某个队列里,而是等着播放设备线程来“拉”。这个和发送侧的“推”正好相反。播放设备线程会周期性调用 AudioTransport::PullRenderData,这时接收链路才开始真正往下挤数据:NetEq 出 10ms PCM,APM 做接收侧处理,然后送给 AudioDeviceBuffer,最终硬件播放。
所以在接收侧理解关键之一,就是这个“拉”模式。你看见 PullRenderData 这个名字,就要意识到它是由设备线程驱动的,不是网络线程驱动的。网络线程只负责把包丢给 NetEq,NetEq 内部维护自己的缓冲和时间逻辑,不让接收网络包的速度影响播放节奏。
3.2 NetEq 为什么是接收侧的核心
NetEq 可能是整个 WebRTC 音频接收侧最值得读的模块,但也是劝退最多人的模块。它的源码在 modules/audio_coding/neteq/ 下,类名就叫 NetEq。它做的事情可以简单概括为:抖动缓冲、丢包补偿、解码、重采样、播放节奏控制。
网络上的包到达时间不是恒定的,可能一下子来好几个,也可能一会儿没有。NetEq 用一个缓冲机制把不稳定的包到达时间平滑成稳定的播放节奏。如果包丢了,它还能根据前后包的语音特征做丢包隐藏(PLC),也就是“脑补”一小段声音。这些都是实时通信里保证“声音不断流、不卡顿”的关键。
NetEq 的接口不复杂,核心就几个方法:InsertPacket 负责把新到的 RTP 包插进去;GetAudio 负责按照播放节奏取出 10ms 的音频数据。难的是 NetEq 内部的算法:怎么估计最优播放延迟、什么时候加速播放、什么时候减速播放、丢包率多高才触发 PLC。你如果只是想把音频整体流程搞懂,不需要把 NetEq 内部的算法全部看透,先弄明白这两个方法干什么就够了。
我自己的体会是,NetEq 属于“先会用、再细看”的类型。调试的时候,你可以把 InsertPacket 和 GetAudio 这两个断点都打上,观察被丢弃的包、重复的包、重排序的包分别会怎么影响 NetEq 的行为。这个过程中你对实时音频“抖动”这个词的理解,会比读十篇文章都要深。
3.3 解码后的数据怎么送到扬声器
解码完成后,接收侧的 Channel 会把 PCM 数据交给 APM 做反向处理,比如回声消除的渲染分支。处理完的数据会有两条路,一条是送到监听接口(如果你开了本地回放),另一条是关键路径:通过 AudioDeviceBuffer 输出给实际声卡。
播放设备和采集设备一样,都抽象在 AudioDeviceModule 里。AudioDeviceBuffer 内部维护了播放队列,设备线程来取数据的时候,如果队列里数据不够,就会出现所谓的“卡顿”或者“爆音”。这个调度是实时的,任何一步延迟都会直接体现在耳朵里。正因为这样,WebRTC 对音频线程的优先级和时序非常敏感,你在源码里经常会看到与线程相关的 log 和注释。
接收链路总结下来就是一句话:网络线程把包放进 NetEq,播放线程从 NetEq 拉 PCM,中间的节奏控制交给 NetEq,音质处理交给 APM。记住这个模型,接收侧就不乱了。
4. 设备抽象层:AudioDeviceModule 与 AudioDeviceBuffer 的配合
前面几节都在讲引擎内部,现在回到源头:设备。WebRTC 能跑在那么多平台上,靠的就是这一层把平台差异全部屏蔽掉。你要是在 Android、Windows、Linux 之间切过项目,就知道设备层抽象得好,能省掉多少麻烦。
4.1 AudioDeviceModule 定义了“能采集、能播放”
AudioDeviceModule 简称 ADM,头文件在 modules/audio_device/include/audio_device.h。它是一组纯虚接口,定义了音频设备的最基本能力:初始化、启动采集、启动播放、停止采集、停止播放、设置音量、查询延迟等。
不同平台有不同实现。你在 modules/audio_device/ 下能看到各种以平台命名的子目录,比如 Linux 下的 ALSA、PulseAudio,Windows 下的 Core Audio 实现,Android 下的 OpenSL ES、AAudio 实现等。每个平台实现都继承同一个接口,所以上层代码完全不需要关心当前跑在什么系统上。
ADM 最重要的特点是它管理的不是“流”,而是“设备”。你要采集,就 StartRecording;要播放,就 StartPlayout。它内部会启动独立的设备线程,采集线程反复从麦克风拿数据,播放线程反复向扬声器送数据。这两个线程是整个音频实时性的根基,如果你在调试的时候发现音频卡顿,先看设备线程有没有被优先级调度问题拖累。
4.2 AudioDeviceBuffer 与 AudioTransport 的回调契约
ADM 拿到硬件数据之后,并不是直接丢给上层的。中间有个 AudioDeviceBuffer,它是设备和引擎之间的数据中转站。AudioDeviceBuffer 在 modules/audio_device/audio_device_buffer.h 里定义,它的作用有两个:一个是缓冲数据,另一个是调用回调函数把数据交出去。
这里要理解 AudioTransport 这个接口。AudioTransport 定义在 modules/audio_device/include/audio_device_defines.h。它有两个纯虚方法,一个是 RecordedDataIsAvailable,另一个是 PullRenderData。设备采集线程采集到数据后,ADM 会调用 RecordedDataIsAvailable,把 PCM 数据、采样率、声道数、采样帧数、估计延迟等参数一次性传给引擎;播放线程需要数据时,调用 PullRenderData,引擎侧把解码好的 PCM 填进去。
这种“一推一拉”的设计,本质上是把控制权完全交给了设备线程。引擎不需要自己维护线程去轮询设备,设备线程自己会按照采样率主动来取数据。比如常见的 48kHz 采样率,每 10ms 就是 480 个采样,设备线程就以这个节奏不停调用接口。你理解了这两个方法,就等于理解了 WebRTC 音频设备层和引擎层的全部交互方式。
4.3 10ms 帧长与采样率的隐含约定
如果你读代码足够仔细,会发现 WebRTC 音频内部到处是 10ms 的概念。APM 处理按 10ms 一帧,ACM 编码按 10ms 一次,NetEq 输出按 10ms 一段。为什么偏偏是 10ms?
一方面,10ms 在实时通信里比较合适。音频本来对延迟敏感,如果帧太长,比如 100ms,虽然压缩效率高了,但端到端延迟大了很多,说话就会感觉“慢半拍”。另一方面,10ms 既足够短,让算法能快速响应语音变化,又不会短到编码开销过大。采样率 48000 时,10ms 就是 480 个采样,无论内部用什么格式,这个数字都是固定的。
这个隐含约定也解释了为什么很多代码里会出现 AudioBuffer 这种类。AudioBuffer 在 modules/audio_processing/audio_buffer.h 里,它专门用来处理采样率不一致的情况。比如设备采集的是 44100Hz,编码器要 48000Hz,中间就要有重采样。APM 处理的时候也经常在缓冲区内做分频带处理。把这个“10ms + 多采样率转换”的框架记住,你就不会被那些复杂的缓冲逻辑绕晕。
5. 读这套音频源码最容易踩的坑与建议顺序
最后这部分,我把这些年读 WebRTC 音频源码时踩过的坑梳理了一下,也算是一份“避坑指南”。如果你刚准备开始,希望下面的内容能帮你少走弯路。
5.1 最大的坑:版本差异和旧文档
网上关于 WebRTC 音频的教程很多,但 WebRTC 的代码改动非常快,两三年前的截图上那些类名可能已经变了。比如老的音频架构里有一个叫 VoiceEngine 的东西,在很多老文章里还能看到,但新版本里这个类早就被拆掉了。你要是按照旧文章的路径去找代码,大概率迷失方向。
我的建议是,一切以你自己拉取的代码为准。看到任何文章里提到的类,都先在当前代码库里搜一下,确认路径存在再继续。尤其是 audio/ 目录下的结构,不同分支差异极大。不要在旧代码描述上浪费太多时间。
5.2 我建议的阅读路线
如果让我从头再读一遍音频部分,会按下面这个顺序走:
第一步,读 AudioDeviceModule 的接口头文件。先知道设备层有哪些能力,不需要看具体平台实现。
第二步,读 AudioDeviceBuffer 和 AudioTransport。搞明白“推”和“拉”两条交互路径。
第三步,读 AudioSendStream 和 AudioReceiveStream 这两个门面类。知道对外暴露了哪些接口。
第四步,读 Channel 的头文件。这个类把你前面看到的大部分对象都串起来了,它是最关键的一环。
第五步,读 AudioProcessing 的接口,了解 APM 能做什么,暂时不用深入算法实现。
第六步,读 NetEq 的接口。到这里,你已经具备把整体链路讲清楚的能力了。
每一步都只读头文件,先建立“类与类的连接关系”,不要急着看实现。等整体结构清楚了,再回头挑你感兴趣的实现细节去啃。
5.3 断点、日志与一把好用的“类对照表”
动手调试的时候,我有几个小技巧可以分享。
第一,在 Channel 的 SendAudio 和接收 RTP 的入口函数各打一个断点,跑一对一的通话Demo,你可以最直观地看到发送和接收是两条完全独立的逻辑链。
第二,把 AudioDeviceBuffer 的采集回调处打个条件断点,采样率高于某个值才停一下,你可以观察到设备线程的频率和数据大小。实测下来很稳,比看日志直观得多。
第三,自己维护一张“类对照表”,把类名、头文件路径、一句话职责、上下级依赖写在表里。读源码最怕的就是“读了后面忘了前面”,这张表就是你的记忆索引。遇到新类就登记一下,后面再读相关实现的时候,能快速调出上下文。
第四,打开 WebRTC 的 verbose 日志。音频模块里有很多辅助日志,比如统计信息、丢包率、延迟数据。调试的时候开着日志,很多意外的音频问题都能在日志里找到线索,而不是瞎猜。
我还有一个小感受:WebRTC 的音频架构其实就围绕几个固定的角色在转——设备层负责采集和播放,Channel 负责串联,APM 负责音质,ACM 负责编码,NetEq 负责网络缓冲。你把每个类对应到一个角色上,再去看它的具体代码,就不会觉得是一堆散件堆在一起。读源码本身没有太多窍门,无非是找对入口、理清主干、再逐个击破。这套音频类看完之后,后面再追音频处理的具体算法,或者去看视频侧的 Channel,都会从容很多。