夜雨聆风学习资料网

ARTICLE · 1234422

WebRTC 源码地图:api、pc、call、audio、video、modules 分别负责什么?

WebRTC 源码地图:api、pc、call、audio、video、modules 分别负责什么?

做过音视频开发,再打开 WebRTC 源码,最先遇到的困难往往不是某个算法看不懂,而是不知道应该从哪里读起。

准备接入芯片的 H.264 编码器,搜索 encoder,会同时遇到 VideoEncoder、VideoStreamEncoder、VideoEncoderFactory。准备分析视频卡顿,video/、call/、modules/rtp_rtcp/ 和 modules/pacing/ 又都能找到相关代码。几个类都带着 Send,到底谁接收原始帧,谁处理编码结果,谁真正负责发包?

目录名能提供线索,但只有把目录、对象和数据流联系起来,才能找到问题的入口。

本文沿用嵌入式可视对讲这个场景:设备采集摄像头和麦克风数据,与远端交换音视频;视频使用平台编码能力,音频需要回声消除;网络会出现丢包和带宽波动。我们借这个场景说明源码各部分的职责,再把视频发送、音频收发和网络反馈三条路径连起来。

1. 先区分三件事:目录职责、对象关系、数据路径

先看一张职责图。

图 1:这张图用于回答“某类工作主要由谁负责”。它不是严格的 C++ 依赖图,也不表示每一帧都会依次经过图中的全部目录。

如果只记住六个目录,可以先建立下面这层认识。表中的入口是阅读起点,不是每个目录的完整文件清单。

目录
主要解决的问题
第一批值得看的入口
api/
应用和可替换组件通过什么接口接入;参数、媒体对象和基础类型怎样表达
peer_connection_interface.h
、media_stream_interface.h、video_codecs/video_encoder.h
pc/
怎样把媒体能力、SDP 协商、收发方向和传输对象组织成 PeerConnection
peer_connection.cc
、sdp_offer_answer.cc、rtp_transceiver.cc
call/
怎样创建媒体收发流,并协调会话内共享的传输控制、带宽分配等状态
call.h
、call.cc、rtp_transport_controller_send.cc
audio/
怎样把音频采集处理、编码发送、接收解码和播放组织成可运行的收发链
audio_transport_impl.cc
、audio_send_stream.cc、channel_receive.cc
video/
怎样组织视频发送与接收,包括编码调度、适配、帧缓冲和解码调度
video_stream_encoder.cc
、video_send_stream_impl.cc、video_receive_stream2.cc
modules/
提供音频处理、编解码、RTP/RTCP、发送调度、拥塞控制等具体能力
audio_processing/
、audio_coding/neteq/、rtp_rtcp/、pacing/、congestion_controller/

这些职责可以从接口、装配代码和具体流实现交叉确认。它们存在协作和交叉,不是互不相干的六个子系统。

还必须补上三个目录。media/ 负责把上层媒体配置连接到音视频引擎,理解它才能把 pc/ 与 call/ 接起来;p2p/ 包含 ICE、候选连接及相关底层传输实现;rtc_base/ 提供线程、网络、日志等基础设施。视频帧、音频信号处理等公共能力也可能位于 api/、common_video/、common_audio/,不能用“所有算法都在 modules”作为搜索规则。

读源码时,建议给每一条箭头都问一句:它传递的究竟是什么?

  • 创建或配置关系:工厂创建对象,协商结果设置参数。
  • 媒体数据:PCM、原始视频帧、编码后的帧。
  • 网络数据:RTP、RTCP 以及其他协议报文。
  • 控制信息:目标码率、关键帧请求、网络状态、统计回调。

这四类关系在代码里可能都表现为一次函数调用,但含义不同。CreateVideoSendStream() 创建了发送流,并不意味着每张摄像头图像都要重新走一遍创建流程。api/ 中声明了 VideoEncoder,也不意味着一个视频帧先“流过 api 目录”再进入实现。

2. api:先看接入契约,再找实现

2.1 api 里有接口,也有参数和媒体类型

对于应用接入,api/ 是首要入口。PeerConnectionInterface 描述会话能力,MediaStreamTrackInterface 描述媒体轨,VideoFrame 描述视频帧,VideoEncoder 描述编码器需要实现的行为。它们解决的是“双方如何合作”,并不把所有执行细节放在同一个地方。

这里的“接口”也不只指 C++ 纯虚类。api/ 还包含配置结构、值类型、工厂入口及部分实现文件,例如 api/enable_media.cc。读目录时,不能把“头文件”和“公共接口”、“有 .cc 文件”和“内部实现”简单画等号。

官方 native-api.md 将 api/ 列为主要 API 目录,同时列出了一批历史 API 目录。文档也明确说明,名称包含 impl、internal 的内部对象不属于公共 API。对接产品时优先选择明确的接口;为了分析问题可以继续进入内部类,但应意识到这些实现更容易随版本变化。WebRTC 的原生接口也不代表永久不变的 C++ ABI。

2.2 以硬件视频编码器为例

平台有一个 H.264 编码 SDK,WebRTC 怎么使用它?先读两个文件就能找到接入方向:

  • api/video_codecs/video_encoder_factory.h:描述支持哪些编码格式,以及怎样创建编码器。
  • api/video_codecs/video_encoder.h:描述编码器初始化、提交帧、返回结果和更新速率等行为。

本基线的工厂创建入口是 VideoEncoderFactory::Create(const Environment&, const SdpVideoFormat&)。具体编码器实现需要落实 InitEncode()、RegisterEncodeCompleteCallback()、Encode()、SetRates()、Release() 等接口约定。平台代码可以在这些入口中连接厂商 SDK,而 WebRTC 上层通过统一接口使用它。

这样分工以后,VideoStreamEncoder 可以决定什么时候编码、以什么目标码率编码,具体 VideoEncoder 则负责把原始帧变成指定格式的编码数据。硬件编码器不会因为被放进一个工厂,就自动具备弱网适配能力;它还必须正确执行速率变化、关键帧要求,并把真实的编码结果反馈回来。这一点后面还会沿发送路径继续说明。

这里也要区分两种接入方式:把原始帧交给 WebRTC 管理的编码器,与把外部已经编码好的帧注入发送端,是不同的路径。本基线存在 EncodedVideoFrameInjectorInterface,但它不是把任意 H.264 字节交给普通 VideoTrack 的同义写法。外部编码方案还需要处理协商格式、时间戳、码率联动和关键帧响应,不能靠跳过编码器接口就省掉这些工作。

2.3 配置与依赖,不是一回事

配置表达“想怎样运行”,例如服务器地址、策略和媒体参数;依赖提供“由谁来执行”,例如音频设备、编码器工厂、音频处理构造器。嵌入式移植最值得先找的是后者,而不是一开始就修改整个 video/ 或 audio/。

当前官方示例 examples/peerconnection/client/conductor.cc 的创建路线值得先读:准备 PeerConnectionFactoryDependencies,提供所需编解码工厂,调用 EnableMedia(deps),然后调用 CreateModularPeerConnectionFactory()。模块化依赖默认没有启用媒体,EnableMedia() 负责装配媒体能力;当前 APM 构造依赖使用的是 audio_processing_builder 字段。

模块化构造也给体积裁剪留下了空间,但“没有传某个对象”不等于已经完成二进制裁剪。真正减少固件体积,还要检查构建目标、链接依赖及实际产物。官方桌面示例适合学习对象怎样装配,它本身不代表某块嵌入式板卡的驱动和硬件编解码已经适配完成。

3. pc:把媒体能力、协商和传输组织成一次会话

3.1 PeerConnection 是会话协调入口

pc/ 中的 PeerConnection 实现了应用熟悉的会话操作。添加 Track、创建 Offer/Answer、设置本地和远端描述、接收远端 ICE 候选,最终都需要在这里协调媒体对象与传输状态。较大的工作又拆给 SdpOfferAnswerHandler、JsepTransportController 等对象完成。

因此,不宜把 pc/ 简写成“信令目录”。它处理协商,也连接实际传输;pc/rtp_transport.cc、pc/srtp_transport.cc 就涉及 RTP 路由与 SRTP 保护。而业务使用 WebSocket、MQTT 还是其他通道交换 SDP 和候选,并不是 PeerConnection 替应用规定的协议。

对可视对讲设备来说,按门铃、呼叫哪台手机、响铃多久、是否允许接听,是应用业务。PeerConnection 负责把已经确定要建立的实时连接组织起来。业务状态和媒体状态可以互相影响,但不能混成同一个状态机。

3.2 先分清几个相似的对象

对象
在源码中应怎样理解
容易混淆的地方
PeerConnectionFactory
装配底层能力,创建 PeerConnection、Track 等对象
一个工厂可以服务多个 PeerConnection,它本身不是一次通话
MediaSource
对媒体来源的抽象
音频、视频默认采集接入方式并不完全对称
MediaStreamTrack
一条带有类型、标识和状态的逻辑音轨或视频轨
不等于一个 RTP 包、一个固定 SSRC 或一个编码器实例
MediaStream
Track 的逻辑容器及归组信息
不把音视频编码复用成一个字节流
RtpSender
 / RtpReceiver
应用可见的发送、接收对象,关联 Track 和 RTP 参数
不是底层 RTP 包发送器,也不是 UDP socket
RtpTransceiver
Unified Plan 下,同一媒体类型的 sender 和 receiver 组成的协商单元,通过 MID 关联 SDP 媒体描述
不是一条独立物理网络连接,也不意味着两个方向都在发媒体

这些对象的语义主要由 api/media_stream_interface.h 和 RTP sender、receiver、transceiver 接口定义,具体行为再进入 pc/ 实现。

例如,一条视频 Track 在多编码层或多路编码的配置下,可以关联多个编码和 RTP 流标识,所以不要从“只有一个 Track”直接推断“只有一个 SSRC”。同样,Transceiver 的 direction 表达期望方向,current_direction 表达已协商方向;刚设置了期望,不代表网络上已经立即改变了收发行为。

MediaStream 也不是音画同步器。把远端音轨和视频轨放进同一个容器,只是提供归组信息;真正播放到同一时间轴上,还涉及 RTP 时间戳映射、接收端时序和播放调度。后面分析同步问题时,仍要进入具体收流和渲染链路。

3.3 哪些问题先读 pc

如果出现“设置远端 SDP 失败”“协商完成却没有创建期望的媒体流”“切换方向后行为不符合预期”,先从 pc/ 看对象状态和参数落地,比直接进入编解码器更有效。

如果是“持续收到完整视频帧,但硬件解码器输出异常”,则应转向视频接收和平台解码实现。PeerConnection 仍然参与会话,但它已经不是最短的排查入口。这种按现象选入口的能力,比记住一串目录名更有用。

4. media 与 call:协商结果怎样变成真正的收发流

4.1 media 是不能漏掉的一层

从 pc/ 直接跳到 video/,经常会丢失一个关键问题:SDP 里协商出来的 codec、方向、RTP 扩展和流标识,是怎样变成音视频引擎参数的?这条连接需要看 media/。

在本基线中,RtpTransceiver::CreateChannel() 创建媒体收发 Channel,并构造 BaseChannel。BaseChannel 持有分开的 MediaSendChannelInterface 与 MediaReceiveChannelInterface,承担媒体参数应用、传输关联以及接收包分发等衔接工作。

继续进入 media/engine/webrtc_video_engine.cc 和 webrtc_voice_engine.cc,会看到 WebRtcVideoSendChannel、WebRtcVideoReceiveChannel 及对应的 Voice 收发 Channel。这些对象再通过 Call::CreateVideoSendStream()、CreateAudioSendStream() 等接口创建具体流,把配置落到运行实体上。

旧版本教程可能画的是另一套 Channel 类名。学习时可以保留“协商对象—媒体桥接—具体流”这层认识,但对照源码时必须以当前类型和实际调用为准。

图 2:箭头表示装配、关联或配置方向,不是每帧执行顺序;省略了部分代理、辅助对象和跨序列切换。DataChannel 有自己的 SCTP/DTLS 路径,不能硬套进音视频 Stream。

4.2 Call 管媒体会话,不负责业务呼叫

第一次看到 call/,很容易想到拨号、接听和挂断。源码里的 Call 更接近媒体会话引擎:创建和销毁音视频收发 Stream,接收媒体网络包,协调会话内共享的传输状态、带宽估计与码率分配。

例如,同一个会话同时发送麦克风音频和摄像头视频,不能让两条流各自认为“整条网络带宽都归自己”。共享估计、分配和发送调度正是理解 call/ 的价值所在。RtpTransportControllerSend 是继续追踪发送传输控制的重要入口。

这里的 AudioSendStream、VideoSendStream 与上一节的 MediaStream 不是同一个层次:前者是内部媒体处理链的流对象,后者是 API 层的 Track 容器。遇到名字中的 Stream,先看命名空间、所属接口和处理的数据,再判断含义。

通常,启用音视频的 PeerConnection 会对应一个 Call;但不要写成“任何 PeerConnection 都无条件包含 Call”。当前工厂的 CreateCall_s() 在没有媒体引擎或媒体工厂时可以返回空,纯 DataChannel 等没有启用媒体的组合需要单独理解。

读到这里,已经可以把创建路线连起来:工厂装配能力,PeerConnection 组织协商,媒体 Channel 应用配置,Call 创建并组织具体收发流。 接下来再看这些流运行起来以后,数据到底经过哪里。

5. video:沿一帧视频,把编码与发送连起来

5.1 原始帧、编码帧和 RTP 包是三种数据

摄像头交上来的原始图像,编码器输出的 H.264 等压缩数据,以及网络上发送的 RTP 包,分别处在不同阶段。一次原始帧输入可能产生一张编码帧,一张编码帧又可能拆成多个 RTP 包。丢包恢复和帧依赖判断因此也不能简单等同于“丢了一帧就重发一帧”。

在原始帧入口附近,先看 VideoTrackSourceInterface、AdaptedVideoTrackSource、VideoFrame 与 VideoTrack 的关系。平台采集适配代码负责把摄像头输出接到这些约定中,传递图像缓冲、尺寸、旋转和时间信息。适配层可以连接厂商采集接口,不要求所有设备都先走 WebRTC 自带的视频采集实现。

这里有一个很实际的区分:类名中有 Source,不代表它一定直接操作摄像头驱动。Source 可以包装设备、转发应用提供的帧,也可以是测试源。判断它做了什么,要继续看真正提交帧的代码。

5.2 VideoStreamEncoder 是编码调度者

video/video_stream_encoder.cc 是视频发送侧最值得反复阅读的文件之一。它连接源端输出与具体编码器,处理编码配置、帧处理节奏、编码队列、码率更新及资源适配协作。

实际路径中还有 frame_cadence_adapter 等辅助对象,所以不能把源端到 VideoStreamEncoder::OnFrame() 画成一个没有中间调度的同步调用。本文图中保留的是主要处理阶段;阅读实现时还要跟踪进入编码序列的任务。

当进入具体编码阶段,EncodeVideoFrame() 会调用 encoder_->Encode()。这里的 encoder_ 才是实现 VideoEncoder 契约的对象,它可以是内置软件实现,也可以是接入的平台实现。modules/video_coding/codecs/h264/h264_encoder_impl.cc 是内置 H.264 路线的阅读入口之一,不应把它当作所有硬编都必须修改的文件。

图 3:实线表示主要媒体数据方向,虚线表示控制反馈。图中省略部分封装、适配和任务队列切换;反馈抵达并不意味着硬件编码器已经立即执行完新设置。

5.3 编码完成以后,仍然有很长一段路径

编码结果通过回调返回到 VideoStreamEncoder::OnEncodedImage(),再交给它的编码结果接收者。沿当前实现继续读,可以找到 VideoSendStreamImpl::OnEncodedImage()、RtpVideoSender::OnEncodedImage(),以及 RTPSenderVideo::SendEncodedImage()。这条路线把“编码完成”连接到 RTP 封装。

RTP 封装之后还涉及发送调度、传输保护和网络发送。正常 WebRTC 媒体路径使用 SRTP 保护 RTP;DTLS 参与建立安全上下文,但媒体 RTP 不是被当成普通 DTLS 应用数据逐包包裹后发送。路径选择和底层收发再进入 ICE 相关传输。这部分协议关系已在第 2 篇展开,这里重点记住源码入口在 modules/pacing/、pc/ 和 p2p/ 分散协作。

对硬件编码来说,回调的执行位置尤其重要。VideoSendStreamImpl::OnEncodedImage() 的代码注释明确考虑了由编码器内部线程回调的情况。接入时需要按接口约定处理缓冲区和对象生命周期,不能默认“编码调用返回了,硬件也已经不再使用输入内存”。具体内存释放时机还取决于所接芯片 SDK 的约定。

5.4 视频接收不会经过 NetEq

接收端要先把 RTP 包重新组织成可解码的帧,再处理帧依赖、等待时机和解码。当前 RtpVideoStreamReceiver2 涉及包缓冲、H.26x 相关组帧处理和参考帧关系;VideoStreamBufferController 连接帧缓冲与解码时机,VideoReceiveStream2 组织接收、解码和后续输出。

这里容易被旧源码路径误导:本基线的通用 FrameBuffer 实现在 api/video/frame_buffer.{h,cc},不能只到旧版本的 modules/video_coding/frame_buffer2.h 中找。视频接收还需要同时考虑“包有没有收齐”“依赖帧是否可用”“何时值得解码”,这些工作分散在多个对象中。

因此,“WebRTC 有一个 jitter buffer,音视频都往里放”会造成误解。抗网络抖动是共同目标,音频与视频使用的缓冲单位、补偿手段和调度要求不同。视频帧缓冲与音频 NetEq 应分别建立模型。

6. audio:把设备、3A、编解码和播放接起来

6.1 audio 目录负责组织收发链

音频不能只盯着 3A 算法。对于双向对讲,设备是否按时交付录音,播放端是否连续拉取数据,采样率和声道数是否一致,都会影响最终结果。audio/ 的价值在于把这些步骤和媒体流组织起来。

几个常用对象可以先这样记:AudioTransportImpl 连接设备音频回调与内部处理;AudioSendStream 组织发送流;发送方向的 ChannelSend 衔接编码与 RTP;接收方向的 ChannelReceive 连接接收包、NetEq 和解码后的音频输出。

这里的 ChannelSend、ChannelReceive 位于 audio/,与前面 pc/ 的 BaseChannel、media/engine/ 的媒体 Channel 处于不同位置。看到相似名字时,不要省略所属文件和上下文。

6.2 先按 Native 默认音频路径理解

以下讨论的是所列源码中的 Native 音频接入路径。浏览器或其他集成方式可以另行组织音频设备、处理器和回调;例如源码对 Chromium 的 PullRenderData() 路径有专门说明,不能把所有集成方式都画成完全相同的音频链路。

录音侧,音频设备通过 RecordedDataIsAvailable() 提交 PCM。AudioTransportImpl 按需要进行声道、采样率等处理,并在配置了 APM 时完成采集侧处理,再把数据送往注册的音频发送者。后续由发送流和 ChannelSend 接入音频编码与 RTP 发送。

Native 这条回调路径以 10 ms 音频块为处理单位,但10 ms 处理块不等于一个 RTP 包固定包含 10 ms 音频。编码器可以累积输入,按其配置的帧长和打包行为产生输出。ChannelSend 的代码中,提交 10 ms 数据与最终编码回调、发包是分开的步骤。

图 4:箭头表示 PCM 或编码音频的主要数据方向。播放端由设备通过 NeedMorePlayData() 拉取,回调内部驱动混音;图中不能按箭头顺序理解成所有函数被主动依次调用。

6.3 AEC 为什么需要播放侧参考

3A 通常指回声消除 AEC、噪声抑制 NS 和自动增益控制 AGC。这些能力的主要实现在 modules/audio_processing/,其中 AEC3 的入口之一是 aec3/echo_canceller3.cc;audio/ 负责把音频链路连接到这些能力。

AEC 处理的是扬声器声音经过声学路径进入麦克风形成的回声。仅有麦克风 PCM 不足以描述这条路径,还需要知道播放侧送出了什么。沿默认 Native 播放路径看,NeedMorePlayData() 先从混音器取得 PCM,在配置 APM 时将混音结果送入反向处理路径,再按设备需求处理输出采样率。

所以,AEC 参考不是远端的 Opus 包,也不是随便取一条接收流的压缩数据。它应与实际送往播放链路的音频内容及其时序相匹配。如果平台又做了混音、增益或额外缓存,接入设计必须把这些变化和延迟纳入考虑;仅仅把 AEC 开关设成开启,不能证明参考路径已经接对。

这也是嵌入式对讲常见的排查顺序:先确认录音、播放和参考数据的来源及时间关系,再深入 AEC 算法。否则,很容易花很多时间调整算法参数,却一直没有修正错误的输入。

6.4 NetEq 不只是一个等包队列

接收侧,ChannelReceive 把压缩音频及相关包信息交给 NetEq。播放需要音频时,再向 NetEq 获取可输出的音频块。NetEq 包含包与音频缓冲、解码驱动、播放延迟管理及丢包补偿、时间尺度调整等协作逻辑,不能把它简化成“一个先入先出的 RTP 缓冲区”。

解码后的音频再参与混音和设备播放。这里的先后顺序必须讲清楚:常规路径不是先在 NetEq 外面把全部音频解码成 PCM,再把 PCM 放进一个叫 NetEq 的 jitter buffer;解码本身就是 NetEq 组织的工作之一。

同样,不要把所有接收端恢复机制都归到 NetEq。音频的重传请求跟踪还涉及 audio/nack_tracker.h 和 ChannelReceive;网络重传与播放补偿协作,但不属于同一个算法。当前音频发送也可以接入共用发送调度基础设施,是否按视频同样的节奏和预算处理音频,则有独立策略,不能写成“音频总绕过 pacer”或“音频与视频完全同样排队”。

6.5 VAD 要按具体用途找

VAD 是语音活动检测,回答当前音频是否包含语音或语音概率多大。AEC、NS、AGC 与 VAD 有关联,但各自解决的问题不同,不能把它们当作一个总开关。

源码中既有 common_audio/vad/ 的传统检测实现,也有音频处理中的检测器封装及 agc2/rnn_vad/ 等实现。某个内部模块用于增益判断的语音概率,并不自动成为应用层通用的“开始说话/结束说话”事件接口。分析 VAD 时,要先确定使用场景和当前调用者,再看输入要求、状态和输出语义。

本篇先把这些位置标出来。后续研究 3A、语音检测、双讲和静音策略时,就可以分别从处理链、输入输出和具体算法展开,不必把它们混在一篇里。

7. modules:把算法位置和使用者一起记

modules/ 很大,比较实用的读法是同时记录两件事:这里提供什么能力,上层谁在使用它。只记目录名,等到实际排查问题时仍然容易断链。

位置
主要能力
与上层的联系及阅读提醒
modules/audio_processing/
APM、AEC、降噪、增益控制及内部分析
沿 AudioTransportImpl 或具体集成方的处理调用进入;先区分采集侧和播放参考侧
modules/audio_coding/neteq/
音频包管理、解码与播放补偿
从 ChannelReceive 的插包、取音频路径进入;不是视频帧缓冲
modules/audio_device/
音频设备模块及平台接入实现
API 声明在当前 api/audio/audio_device.h 等文件;设备 SDK 不会自动被适配
modules/video_coding/
视频编码相关实现及部分辅助能力
具体编码器、流级调度、帧缓冲未必在同一子目录;同时看 video/ 和 api/video/
modules/rtp_rtcp/
RTP 封装、RTCP、包历史与重传等
与 RtpVideoSender、音频 Channel 和传输控制器协作
modules/pacing/
按发送策略安排包的发送时机
从 RtpTransportControllerSend 和具体发送路径看接线;区分音频策略、探测和正常发送
modules/congestion_controller/goog_cc/
GoogCC 的估计与网络控制逻辑
通过发送传输控制器接收反馈、输出更新;不能只看某一个带宽计算函数
modules/video_capture/
库内视频采集实现和相关接入
自定义视频源也可由应用或平台实现,不是所有摄像头帧的固定入口

这些入口分别可在 APM、NetEq、设备、视频编码、RTP/RTCP、pacing 和 GoogCC 源码中核对。

这里还应分清三个概念。模块是代码组织和能力划分;接口是组件合作时的约定;算法是组件内部的处理方法。一个模块可能包含多种算法,一种功能也可能跨多个模块完成。

例如,丢包恢复涉及接收端发现缺包、反馈重传请求、发送端保存并重发历史包,以及接收端决定是否继续等待。这不可能只靠一个 Nack 类完成。带宽估计也不能单独决定画质,还要经过分配、编码目标、实际编码输出和发送队列。

模块化同样不等于可以随意复制一个目录单独编译。真实依赖由构建文件和接口使用关系决定,APM、NetEq、RTP 组件是否能以所需方式独立使用,需要按目标版本和产品边界验证。

8. 网络反馈怎样回到编码器:不要把 NACK、GCC 和关键帧请求混成一条链

前面一直沿着发送方向读。对弱网优化来说,另一半路线更重要:接收端反馈到达后,发送端究竟改变了什么?

8.1 同一类 RTCP 入口,后面有不同分支

在 modules/rtp_rtcp/source/rtcp_receiver.cc 中,TriggerCallbacksFromRtcpPacket() 对不同反馈触发不同观察者。对源码地图而言,至少要分清下面三路。

反馈
主要诉求
优先追踪的后续路径
NACK
请求重传指定的缺失 RTP 包
RTCP 回调、发送端包历史与重传逻辑
PLI / FIR
请求恢复可解码画面,通常需要编码器提供关键帧
EncoderRtcpFeedback
、关键帧请求节流、VideoStreamEncoder::SendKeyFrame()
传输反馈,如 TWCC / CCFB
提供包接收与时间等信息,帮助发送端判断传输状况
RtpTransportControllerSend
、反馈适配与网络控制器

NACK 不等于让编码器重新编码那一帧,PLI/FIR 也不等于列出一批待重传 RTP 包。它们可以在同一次弱网事件中先后发生,但表达的是不同需求。关键帧请求还可能被节流,不是远端每请求一次就无条件立即编码一次。

8.2 GCC 给出网络控制结果,流和编码器再落实

沿传输反馈往下读,会进入 RtpTransportControllerSend 的反馈处理,再进入网络控制器。在常见 GoogCC 路线中,算法实现主要位于 modules/congestion_controller/goog_cc/。源码还包含实验配置和其他选择分支,因此定位实际控制器时,要同时检查创建工厂、依赖注入和 field trial,不能只凭某个目录存在就判断它在运行。

控制器输出还要经过会话和流级处理。Call::OnTargetTransferRate() 将网络估计交给码率分配器的 OnNetworkEstimateChanged(),再由分配逻辑通知各流。当前 VideoSendStreamImpl::OnBitrateUpdated() 会结合 RTP 发送侧计算和保护开销处理,再把相关结果交给 VideoStreamEncoder::OnBitrateUpdated();后者在编码序列上更新速率,最终通过 SetEncoderRates() 调用具体编码器的 SetRates()。

因此,网络估计值、分配给视频的目标、编码器的实际产出、网卡最终发出的速率,应该分别记录。 它们在统计口径、协议开销和时间窗口上不必相等。只看一个瞬时数字,很难判断问题究竟发生在估计、分配、编码还是排队阶段。

举一个排查例子:如果网络目标已经下降,但平台编码器连续输出大帧,队列仍可能增长。此时应核对 SetRates() 的实参、厂商 SDK 是否接受更新、什么时候生效,以及后续实际帧大小。如果这些环节没有证据,直接认定“GCC 算错了”往往为时过早。

8.3 BBR、FEC 和 QoS 在这张地图上怎样放

BBR 是拥塞控制方向的研究主题,但它不是本篇标准 GoogCC 路线里的一个必经模块。要讨论替换控制器,必须验证实际工厂、接口和实验开关;不能把“学习过 BBR”写成“WebRTC 默认用 BBR”。

FEC 与重传主要关注媒体恢复,但冗余与重传都消耗传输预算;pacing 安排发包时间,带宽估计和拥塞控制判断网络目标,编码适配再影响画质和输出量。这些机制需要联合分析,不能把某一个开关等同于完整的弱网优化方案。

工程里常说的 QoS,在本系列中可以理解为围绕清晰度、流畅度、实时性和资源开销进行的综合质量控制。它不是源码中一个叫 QoS 的总算法。后续研究每种机制时,都应在这张地图上说明:输入来自哪里,改变了哪个决策,效果由什么指标验证。

9. 目录找对以后,还要看线程和任务序列

9.1 不能再直接照搬“固定三个线程”的旧图

很多旧资料用 signaling、worker、network 三个线程介绍 WebRTC。作为历史职责划分,它有帮助;如果直接拿来解释当前每一个实例,就可能出错。

在本文固定提交中,便捷函数 CreatePeerConnectionFactory() 的线程参数已经只有 network_thread 和 signaling_thread;模块化依赖结构仍保留 worker_thread 字段,但已标记弃用。ConnectionContext 默认让 worker 与 network 共用,旧字段还有兼容处理;提供不同的 worker 时,当前实现会输出弃用错误日志,并非在这一处无条件拒绝创建。

这也不等于整个 WebRTC 只运行两个操作系统线程。编码、解码、音频设备以及平台 SDK 都可能有自己的任务序列和线程。职责名称、任务队列和物理线程数量,是三个需要分别确认的问题。

9.2 TaskQueue 约束执行顺序,不等于固定一个 CPU 核

TaskQueueBase 提供串行任务执行的约定,常规任务不会彼此重叠,普通 PostTask() 有相应的顺序保证;但接口不保证底层永远由同一个操作系统线程执行。延迟任务也不因此成为硬实时定时器。

所以,读到 PostTask() 之后,要继续读 lambda 或回调体,不能把“提交成功”当成“处理已经完成”。读到 BlockingCall(),要把它理解为同步调用:跨线程时,调用方等待目标线程执行完成;如果目标就是当前线程,则可以直接执行。再结合 RTC_DCHECK_RUN_ON、SequenceChecker 和代理类,确认哪些调用允许直接执行,哪些依赖特定序列。

对嵌入式平台,尤其应记录三件事:厂商回调在哪个线程执行;帧缓冲由谁持有、何时释放;关闭设备或挂断时,尚未完成的任务怎样退出。源码路径追对了,生命周期却理解错了,仍可能出现偶发花屏或崩溃。

9.3 Environment 是共用服务的传递方式

现在的接口中经常出现 Environment。它组织时钟、任务队列工厂、事件日志和 field trials 等共用服务,并让使用者携带这些依赖。它不是操作系统环境变量,也不是“把所有组件变成全局单例”。

看到 Environment,可以顺手记录这段代码使用的是哪个时钟、哪种任务队列、什么实验配置。分析延迟和拥塞控制时,这些信息往往比一个函数名更能解释实际行为。

10. 嵌入式开发中,怎样用这张地图定位问题

10.1 先把平台接入边界画清楚

对于摄像头和麦克风已经能正常工作的设备,可以先把平台能力与 WebRTC 的合作边界列出来。

平台能力
优先检查的接入位置
接入时要验证的行为
摄像头、ISP、图像缓冲
视频 Source、VideoFrame、平台采集适配代码
尺寸、像素格式、旋转、时间戳,以及缓冲持有关系
硬件视频编码器
VideoEncoderFactory
、VideoEncoder
协商能力是否真实;动态码率、关键帧要求和结果回调是否落实
硬件视频解码与显示
视频解码器工厂/接口、接收流输出及平台渲染适配
编码参数、解码结果、显示时机、显示队列积压
麦克风、扬声器、音频驱动
AudioDeviceModule
、音频传输回调及平台实现
采样率、声道、回调节奏、录放延迟和设备生命周期
AEC、降噪、增益控制
APM 配置和构造、采集处理与播放参考接线
输入是否正确,平台已有处理是否与软件处理重复或冲突

这些是接口和默认链路给出的接入方向,不是一份对所有芯片都相同的移植补丁。具体需要实现哪些能力,仍应以平台、编解码格式和目标构建为准。

10.2 五类现象,对应五个优先入口

图 5:每一行都是“现象—源码入口—需要取得的证据”。它给出优先检查方向,不把某种现象直接等同于唯一根因。

连接建立不起来。 先记录信令、ICE 和 DTLS 等状态变化,检查候选和所选路径,再进入 pc/ 与 p2p/。连接层尚未完成时,先调视频编码参数通常解释不了问题。

持续有 RTP 到达,却没有画面。 先确认包是否分发给正确的接收流,再看帧是否完整、依赖是否满足、解码器是否被调用、是否产生输出。依次经过 pc/ 的传输分发、视频 RTP 接收、帧缓冲和解码入口,能够避免把所有黑屏都归因于网络。

画面越来越迟,最后延迟很大。 需要分别测量采集等待、编码排队、编码耗时、发送队列、接收等待、解码与显示排队。VideoStreamEncoder、pacer 和接收帧缓冲只是其中几处;芯片 SDK 与显示系统的队列也必须观察。只把接收缓冲调小,不能消除发送端已经积累的延迟。

对讲有回声,调 AEC 参数没有明显变化。 先沿 AudioTransportImpl 和平台播放回调验证参考 PCM 是否正确,核对录放延迟、声道和平台额外处理,再进入 APM/AEC3。处理对象拿到什么数据,是判断算法效果的前提。

网络差了,画质或延迟却没有按预期变化。 同时记录网络控制目标、视频分配、SetRates() 参数、实际编码帧大小和发送队列。先找哪一环不再跟随,再决定分析 GCC、分配逻辑还是硬件编码器。

这里的日志和测量项是排查方法,不是本篇已经在某块板卡上测得的结果。做性能结论时,需要补上设备型号、编码配置、网络条件和观测时间窗口。

11. 一条可以照着走的源码阅读路线

11.1 第一遍:只回答“对象怎样装起来”

先读 native-api.md,确认公共接口的边界。然后读 api/peer_connection_interface.h,建立 Factory、PeerConnection 和依赖结构的基本认识。接着跟随当前 conductor.cc 的创建代码,进入 EnableMedia()、PeerConnectionFactory 和 ConnectionContext。

这一遍不要求读完每个函数。需要得到的是一张自己的创建记录:哪些依赖由应用提供,哪些对象内部创建,媒体能力在哪里启用,设备和编解码工厂怎样交进去。

11.2 第二遍:追一次媒体配置落地

从一次视频协商过程入手,跟踪视频 Transceiver 的 RtpTransceiver::CreateChannel()、BaseChannel 的内容设置、WebRtcVideoSendChannel 中的流创建,最后到 Call::CreateVideoSendStream()。每一层只记录输入参数、持有的对象及它接下来配置了谁。

下面的命令用于源码检索,假设当前目录是所选提交的 src 根目录。它们不会构建工程,也不意味着搜索结果中所有位置都处于同一条运行路径。

git rev-parse HEAD

rg -n 'EnableMedia|CreateModularPeerConnectionFactory' \
  examples/peerconnection/client/conductor.cc api/enable_media.cc

rg -n 'CreateChannel|CreateMediaContentChannels' pc/rtp_transceiver.cc

rg -n 'SetLocalContent_w|SetRemoteContent_w' pc/channel.cc

rg -n 'CreateVideoSendStream|CreateAudioSendStream' media/engine call

如果一个调用查到的是接口,继续找实现;如果查到代理或 PostTask(),继续跟到实际执行体。不要因为 IDE 已经跳到一个同名方法,就假定找到了最终处理点。

11.3 第三遍:只追一帧,再补反馈

先追视频发送,找到 EncodeVideoFrame()、具体编码器的 Encode()、编码结果回调、VideoSendStreamImpl::OnEncodedImage() 和 RtpVideoSender::OnEncodedImage()。给每个观察点标清楚是原始帧、编码帧还是 RTP 包。

rg -n 'EncodeVideoFrame|encoder_->Encode|OnEncodedImage|SetEncoderRates' \
  video/video_stream_encoder.cc

rg -n 'OnEncodedImage|OnBitrateUpdated' video/video_send_stream_impl.cc

rg -n 'OnEncodedImage|SendEncodedImage' \
  call/rtp_video_sender.cc modules/rtp_rtcp/source/rtp_sender_video.cc

rg -n 'TriggerCallbacksFromRtcpPacket|OnReceivedNack' \
  modules/rtp_rtcp/source/rtcp_receiver.cc

然后分别补两条反馈线:一条从网络控制结果到 VideoEncoder::SetRates(),另一条从 PLI/FIR 到 SendKeyFrame()。NACK 则单独追踪包重传,不要强行接到编码器重编码上。

音频再用相同方法追一次:录音回调在哪里进来,APM 在哪里处理,编码结果在哪里发送,接收包怎样进入 NetEq,播放拉取怎样驱动混音。看到 RecordedDataIsAvailable() 与 NeedMorePlayData() 的实际调用和数据以后,3A 与 NetEq 就不再只是两个目录名。

相关学习资料