WebRTC 音视频同步源码分析:从 RTP 时间戳到扬声器与画面

本文介绍 WebRTC 接收侧的音视频同步机制,从媒体时间映射讲到播放延时控制。
类名和参数对应本文分析的默认实现,不同 WebRTC 版本的细节可能有所不同。
公式中的数值用于推演计算过程,不代表实测的播放延时。

文章目录

音频能连续播放,视频也不卡,并不意味着口型一定对得上。

比如音频经过接收缓存和设备缓冲,已经播放到了“说完一个字”的位置;
视频还在解码队列里,画面刚显示到嘴唇张开。两路各自都在正常工作,组合起来却是声音抢先。

反过来,把两路缓存都设成 100 ms,也不能保证同步。音视频可能在发送端经历了不同的
编码耗时,网络到达节奏不同,接收端后面还有解码、设备与渲染等待。

接收端要做的事情,可以拆成三步:

  1. 确定哪一路音频和哪一路视频属于同一组。
  2. 把两路各自的 RTP 时间戳放到共同的媒体时间轴上。
  3. 估计哪一路整体更慢,再调整较快一路的等待,使它们逐渐靠拢。

前两步如果没有成立,第三步再怎么调缓存,也只是在猜。

一、先看同步模块站在哪里

在NetEq 分析中,音频侧已经有一套延时控制:
网络抖动大时多留余量,积压偏多时通过时间伸缩消化。视频也有自己的 Jitter Buffer
和 VCMTiming,决定何时解码、何时渲染。

音视频同步位于这两套机制之上。它读取两路状态,再向两路提交最小播放延时要求。

媒体时间、接收时间、延时

媒体时间、接收时间、延时

最小播放延时

最小播放延时

相同且非空的 sync_group

RtpStreamsSynchronizer
约每 1 s 评估一次

音频 RTP / RTCP SR

AudioReceiveStream
ChannelReceive / NetEq

视频 RTP / RTCP SR

VideoReceiveStream2
Jitter Buffer / VCMTiming

音频设备

解码与渲染

主类名是 RtpStreamsSynchronizer,文件位于 video/rtp_streams_synchronizer2.cc。
具体的相对延时计算和调节策略在 video/stream_synchronization.cc。

同步控制器不会在每一个音频回调里找一张视频帧配对,也不会通过阻塞播放线程来
硬等另一路。它以较慢的周期调整两路约束,让各自的播放机制完成后续调度。

二、同一个 PeerConnection,不代表已经建立同步关系

在当前实现中,音频和视频接收配置都有一个 sync_group。
Call::ConfigureSync() 按这个字段寻找可配对的接收流。

media/engine/webrtc_voice_engine.cc 和 webrtc_video_engine.cc 的接收配置构造中,
都会在存在 stream id 时取第一个:

config.sync_group = stream_ids[0];

这里写的是提炼后的关键赋值,两个文件实际取得 stream_ids 的表达式略有不同。
往上追溯,它来自信令建立的媒体流关联,典型情况下对应 SDP msid 中的 stream id。

例如下面两条属性出现在各自的媒体段中:

a=msid:live-stream audio-track
a=msid:live-stream video-track

两个 track id 可以不同,作为分组依据的 stream id 都是 live-stream。
是否最终形成这组配置,仍应以接收流中的实际 sync_group 为准。

几种常见的关联信息不能互相替代:

信息主要解决什么问题
PeerConnection管理连接、收发器和媒体协商
BUNDLE多个媒体段共用传输
RTCP CNAMERTP 会话中的源身份关联
sync_group当前 Call 实现中接收音视频配对的直接依据

因此,只确认“音视频走同一条连接”或“CNAME 相同”还不够。
这条接收实现并不是在 ConfigureSync() 里靠 CNAME 临时寻找配对。

还有一个容易忽略的限制:当前 Call::ConfigureSync() 只同步同一组中的第一对
音频和视频。源码明确保留了支持更多 A/V pair 的 TODO,其余视频会取消这种配对。
多路拉流场景不能把所有流都塞进同一个组,再期待每一路自动找到正确搭档。

三、RTP 时间戳为什么不能直接比较

假设收到一个音频包,时间戳为 480000;同时收到一个视频包,时间戳为 9000000。
这两个整数谁大,和谁应该先播放没有直接关系。

一方面,时间单位不同。48 kHz 音频每秒增加 48000,常见视频 RTP 时钟每秒增加 90000。
另一方面,RTP 初始时间戳可以有任意偏移,音视频不要求从同一个数值起步。

所以,把音频 RTP 除以 48000、视频 RTP 除以 90000,只是分别换了单位,
并没有消掉各自的起点。

3.1 RTCP SR 提供的是一对时钟坐标

RTCP Sender Report 中包含一对关键值:

NTP timestamp
RTP timestamp

它们描述发送侧同一时刻的时钟对应关系。可以把它读成:
“当发送侧公共时钟走到这个 NTP 时刻时,这路媒体的 RTP 时钟走到了这个位置。”

这个 RTP 值不要求恰好等于某个已经发送媒体包的时间戳。
NTP 值也不是 SR 抵达接收机时的本地时间。

音频 RTP 时间轴
48 kHz,独立起点

音频 RTCP SR
若干组 RTP ↔ NTP

视频 RTP 时间轴
90 kHz,独立起点

视频 RTCP SR
若干组 RTP ↔ NTP

音频映射
NTP = ka × RTP + ba

视频映射
NTP = kv × RTP + bv

共同的发送侧媒体时间轴

接收侧为两路分别维护映射:

M a ( r a ) = k a r a + b a M_a(r_a)=k_a r_a+b_a Ma​(ra​)=ka​ra​+ba​

M v ( r v ) = k v r v + b v M_v(r_v)=k_v r_v+b_v Mv​(rv​)=kv​rv​+bv​

有了它们,即使 RTP 的数值和频率完全不同,也可以比较两路媒体对应的时刻。

3.2 当前 RtpToNtpEstimator 用线性回归

rtc_base/rtp_to_ntp_estimator.cc 的 UpdateParameters() 使用普通最小二乘法,
不是卡尔曼滤波。设输入点为 ( r i , n i ) (r_i,n_i) (ri​,ni​),则:

k = ∑ i ( r i − r ˉ ) ( n i − n ˉ ) ∑ i ( r i − r ˉ ) 2 , b = n ˉ − k r ˉ k=\frac{\sum_i(r_i-\bar r)(n_i-\bar n)} {\sum_i(r_i-\bar r)^2},\qquad b=\bar n-k\bar r k=∑i​(ri​−rˉ)2∑i​(ri​−rˉ)(ni​−nˉ)​,b=nˉ−krˉ

当前最多保留 20 组 SR 样本。RTP 先展开以处理 32 位回绕,NTP 在内部按 64 位
32.32 定点表示参与拟合。本文为了好读,后面的 NTP 数值都换算成毫秒。

理论上,如果采样频率完全准确,48 kHz 音频的斜率就是 1 / 48 1/48 1/48 ms/sample,
90 kHz 视频就是 1 / 90 1/90 1/90 ms/tick。实际用多个点拟合,可以跟踪真实的时钟推进关系,
而不是只假定标称频率永远精确。

实现至少需要两个不同且有效的样本才能建立映射。NTP 或 RTP 重复的报告不会增加
拟合点;时间倒退、跨度异常等会被拒绝,连续异常达到门限后会清理旧模型并重新建立。
因此,“收到了 SR”与“估计器已经能返回有效时间”是两回事。

3.3 用一组数把映射算出来

下面把公共 NTP 起点减掉,只保留相对毫秒值。这个处理只是为了方便展示,
不表示线上 SR 使用从零开始的 NTP。

流第一次 SR:RTP / NTP第二次 SR:RTP / NTP
音频480000 / 10000 ms528000 / 11000 ms
视频9000000 / 10000 ms9090000 / 11000 ms

于是:

音频 RTP 484800
→ 10000 + (484800 - 480000) / 48
→ 10100 ms

视频 RTP 9009000
→ 10000 + (9009000 - 9000000) / 90
→ 10100 ms

这两个 RTP 数值相差很大,却对应同一个媒体时刻。
如果只把原始数值除以时钟频率,视频还会带着一个巨大的起点差,无法得到这种关系。

3.4 接收机必须和发送机校准到同一个 UTC 吗

这条相对同步算法不要求接收机的绝对时钟和发送机严格对齐。
它用发送侧 NTP 比较音视频媒体时刻,用接收侧本地时钟比较两路接收时刻,
再取两者之差。固定的跨机器时钟偏移会被消掉。

但音频与视频的发送侧时间基准必须彼此一致。如果两路来自不同设备、不同转码链路,
上游要先建立公共时间关系,不能只在两个互不相关的时间值外面都写上“NTP”。

对于转码服务,还要正确处理音视频各自的时间戳语义及编解码延时。
如果上游已经把同一事件的声音和画面标成不同媒体时刻,
接收侧再忠实地对齐这些时间戳,也不会自动修正内容层面的错位。

四、映射建好后,先算两路到达过程差了多少

同步控制器取音视频最近收到的媒体时间戳,并分别映射到公共 NTP 时间轴。
这两个包不一定对应同一个媒体时刻。

设:

  • A a A_a Aa​、 A v A_v Av​:音频、视频最近收到的包在本地的接收时间。
  • M a M_a Ma​、 M v M_v Mv​:这两个包经 SR 映射后对应的媒体时间。

StreamSynchronization::ComputeRelativeDelay() 计算:

R = ( A v − A a ) − ( M v − M a ) R=(A_v-A_a)-(M_v-M_a) R=(Av​−Aa​)−(Mv​−Ma​)

R > 0 R>0 R>0 表示相对于媒体时间,视频到达得更慢; R < 0 R<0 R<0 表示音频到达得更慢。

为什么要减去第二项?因为最新音频包和最新视频包本来就可能不是同一时刻的内容。
不扣掉这部分天然的媒体时间差,就会把帧率和打包节奏误判为传输延时差。

4.1 视频接收时间只晚 40 ms,为什么相对延时是 60 ms

看下面这组值:

观察项音频视频
对应媒体时间 M M M10000 ms9980 ms
本地接收时间 A A A50000 ms50040 ms

视频比音频晚收到 40 ms,但视频内容实际上比音频还早 20 ms。
因此:

R = ( 50040 − 50000 ) − ( 9980 − 10000 ) = 60   m s R=(50040-50000)-(9980-10000)=60\ \mathrm{ms} R=(50040−50000)−(9980−10000)=60 ms

把视频内容在媒体时间轴上对齐以后,可以看到它多落后了 60 ms。
这比单纯比较到包时间更接近同步真正需要的量。

如果假设:

本地接收时间 = 媒体时间 + 公共时钟偏移 + 本路到达前延时

代入上式后,公共偏移和媒体时刻都会消掉,留下两路到达前延时之差。
这里可能包含发送侧编码、排队和传输等差异,不应把 R R R 狭义地命名成
“两条物理网络的单向时延差”。

4.2 当前实现的观察点有多精确

音频 ChannelReceive 与视频 RtpVideoStreamReceiver2 记录最新接收状态时,
使用的是接收处理路径中读取的本地 now。它不是扬声器播放时间,也不是网卡硬件时间。
接收任务调度和批量交付会影响这一观察值。

视频侧这里使用最新收到的 RTP 状态,不要求已经组出完整可解码帧。
这和视频 Jitter Buffer 计算帧接收完成时间的用途不同。

当前相对延时超出正负 10000 ms 时,计算会被视为不可信而放弃。
这个边界用于挡住明显异常,不代表相差几秒仍属于正常可接受的口型状态。

五、到达差异还不够,接收端里面也有等待

假设视频和音频恰好同时到达,但视频还要缓存、解码和等待渲染;
音频只剩很短的设备缓冲,那么画面仍然会慢。

所以 ComputeDelays() 还接收两路内部延时:

E = L v − L a + R E=L_v-L_a+R E=Lv​−La​+R

其中 E E E 是本轮用于控制的音视频差异,正值表示视频整体落后。
这三个输入在当前源码中的口径如下:

量当前读取方式需要注意的边界
R R RComputeRelativeDelay()两路媒体时间校正后的到达差异
L a L_a La​NetEq FilteredCurrentDelayMs() + ADM PlayoutDelay()平滑缓存估计加设备报告的播放延时
L v L_v Lv​VCMTiming::TargetVideoDelay()视频目标延时,不是逐帧实测的最终上屏耗时

最容易误读的是视频侧。虽然结构体字段叫 current_delay,
VideoReceiveStream2::GetInfo() 填进去的实际是 timing_->TargetVideoDelay()。
阅读控制公式时,不能把字段名直接当成精确物理测量。

因此, E E E 适合驱动一个逐步反馈的控制器,不能直接宣称它等于用户此刻看到的精确口型差。
渲染端额外排队、设备延时估计误差,都可能让最终体验和这个估算存在差别。

六、约一秒一次,怎样决定加在哪一路

RtpStreamsSynchronizer::ConfigureSync() 绑定音频后,以 1000 ms 的延迟启动重复任务,
后续每 1000 ms 调一次 UpdateDelay()。

这个周期和 NetEq 的 10 ms 输出周期不同,也和视频帧率无关。
它处理的是两路延时关系的持续变化,不是逐帧调度。

6.1 每次定时器触发,并不一定真的调整

否

是

否

是

否

是

否

是

约每 1 s:UpdateDelay

已绑定音频

本轮结束

读取双路 GetInfo
最近 SR、RTP、接收时间、延时

双路数据有效
接收时间均有更新

双路 SR 映射已建立
相对延时在合法范围

计算并平滑 E = Lv - La + R

平滑差异绝对值至少 30 ms

计算有界步长
提交两路最小播放延时

GetInfo() 需要已经有 SR 和媒体接收信息;映射本身还需要积累至少两个不同有效 SR。
控制器也会检查两路记录的接收时间是否较上次更新。
某一路停止送媒体时,不会继续拿同一组旧状态无限计算。

这里并不要求“每一个同步周期都收到一份新的 SR”。
已有 SR 可能被再次交给估计器,重复样本不增加拟合点,但已有模型仍可以用来映射新 RTP。
需要区分媒体接收状态更新与 SR 模型更新。

6.2 先平滑,再走半步

StreamSynchronization::ComputeDelays() 的核心可以写成:

E = video_delay - audio_delay + relative_delay
avg = (3 * avg + E) / 4

如果 abs(avg) < 30 ms:
    本轮不调整

step = clamp(avg / 2, -80 ms, +80 ms)
动作发生后:
    avg = 0

源码使用整数毫秒,实际还有整数除法的截断。
30 ms 是平滑量的动作门限,80 ms 是单次差异步长的限制。
不能据此宣称最终硬件播放误差一定小于 30 ms,也不能理解成一次会把所有偏差清零。

动作后清掉平均值,是为了减少刚刚调整以后旧误差继续推动下一次动作。
还没跨过门限时则继续积累平滑结果,所以稳定的小偏差与一次短脉冲的表现会不同。

6.3 为什么优先撤掉以前加上的等待

如果 E > 0 E>0 E>0,视频整体落后。控制器先看视频侧有没有先前人为加上的额外等待,
如果有,优先减少它;没有可减的部分,再提高音频侧的最小延时要求。

如果 E < 0 E<0 E<0,逻辑反过来:先减已经加在音频侧的额外等待,再考虑让视频等。

这个顺序避免了每次方向变化都只给另一边继续加延时,最后两路虽然同步,
却一起积累出很大的播放延时。

实际代码不止一个 step 变量。音视频各自维护 extra_ms、last_ms,
还受 base_target_delay_ms_、范围限制和“本轮调整哪一路”的状态影响。
传出的数值是下一次最小播放延时请求,不能把它粗略翻译为:

这一路当前总延时 += step

如果 SetMinimumPlayoutDelay() 返回失败,同步器还会调用相应的
ReduceAudioDelay() 或 ReduceVideoDelay() 回退内部额外延时状态。
设置成功也只表示接口接受了约束,不表示物理播放在这一瞬间已经完成调整。

七、算一次完整的控制过程

沿用第四节的例子,设两路到达前的相对差异为:

R = 60 ms,视频更慢

再假设:

音频 NetEq 平滑延时 = 50 ms
音频设备延时       = 10 ms
La                 = 60 ms

视频目标延时 Lv    = 120 ms

那么控制差异为:

E = 120 − 60 + 60 = 120   m s E=120-60+60=120\ \mathrm{ms} E=120−60+60=120 ms

若平均值初始为零:

avg = (3 × 0 + 120) / 4 = 30 ms
step = 30 / 2 = 15 ms

30 ms 恰好达到动作门限。在没有既有额外视频等待、基础同步目标为零的假设下,
这一轮会把音频侧内部的额外延时要求提高到 15 ms。

7.1 为什么音频可能没有立刻多出 15 ms

因为 15 ms 是提交给 NetEq 的最小延时,不是在现有 60 ms 总延时上叠加 15 ms。

如果 NetEq 当前单靠网络条件就已经需要 50 ms,新的最小值 15 ms 低于它,
最终目标仍然可以是 50 ms。这次设置在接口上成功,却暂时没有抬高音频目标。

控制器后续还会观察相同方向的误差,内部请求逐步提高;
等最小值超过 NetEq 的自身目标,才会真正开始约束它。
NetEq 随后还要通过时间伸缩等方式建立新的实际余量,不会瞬间把等待填满。

因此,把这次计算写成“视频晚 120 ms,于是音频立刻加 15 ms”,会漏掉最关键的一层。

7.2 理想情况下,目标会朝哪里走

暂时忽略门限、动态变化和设置上限,若希望 E E E 接近零,应有:

L a ≈ L v + R = 180   m s L_a\approx L_v+R=180\ \mathrm{ms} La​≈Lv​+R=180 ms

设备侧占 10 ms,则 NetEq 部分可能需要接近 170 ms。
这只是固定输入下的平衡关系,不是说控制器第一轮就会请求 170 ms,
也不是说真实网络下一秒仍然维持同样的 R R R。

这个例子也说明,同步和总延时并不是同一个目标。
如果视频路径本身很慢,只让音频等待可以改善口型,却不能降低整体交互延时。
想同时改善这两项,需要回头缩短视频编码、传输、解码或渲染中的实际瓶颈。

八、同步要求怎样落到音频和视频的播放上

平滑缓存量与设备延时

视频目标延时

观测:SR 映射、接收时间、两路延时

StreamSynchronization
估算差异并逐步调整

音频最小播放延时

视频最小播放延时

NetEq DelayConstraints
形成目标,时间伸缩调整媒体进度

UpdatePlayoutDelays / VCMTiming
形成每帧渲染与解码计划

10 ms PCM → 音频设备

视频帧 → 渲染器

8.1 音频侧:同步只改变目标的一部分来源

音频调用最终进入 ChannelReceive::SetMinimumPlayoutDelay(),
经过范围处理后调用 NetEq::SetMinimumDelay()。

它会和网络估计、业务基础最小值、最大延时以及缓存容量限制共同决定 NetEq 目标。
所以,音频延时变高时不能直接归因于网络恶化,也可能是为了等视频。

反过来,如果音频自己已经需要很深的抗抖动缓存,同步要求再设一个更低的最小值,
并不能强迫 NetEq 把自己的目标降到这个值。

NetEq 仍然每次输出 10 ms。延时调整改变的是媒体播放进度与缓冲余量,
不会把同步控制器的一秒周期传递成“一秒取一次音频”。

8.2 视频侧:三个最小值先竞争

视频 SetMinimumPlayoutDelay() 先保存 syncable_minimum_playout_delay_,
再调用 UpdatePlayoutDelays()。

当前实现考虑三类最小要求:

frame_minimum_playout_delay     帧携带的播放延时要求
base_minimum_playout_delay      业务设置的基础要求
syncable_minimum_playout_delay  音视频同步要求

存在的最小值取最大。如果帧携带的最大延时比这个结果更小,
当前代码会优先采用该最大限制,并打印对应告警。

所以,“同步要求视频至少等 150 ms”不是绝对命令。
如果帧明确要求最多 100 ms,最终约束可能只允许 100 ms。
这种情况下即使 setter 返回成功,也不代表 150 ms 已经完整落实。

8.3 视频真正的渲染时刻还依赖自己的时间模型

在默认视频 timing 链路中,目标延时大致是:

L v , t a r g e t = max ⁡ ( L min ⁡ , J v i d e o + D d e c o d e + D r e n d e r ) L_{v,\mathrm{target}} =\max\left(L_{\min}, J_{\mathrm{video}}+D_{\mathrm{decode}}+D_{\mathrm{render}}\right) Lv,target​=max(Lmin​,Jvideo​+Ddecode​+Drender​)

其中 J v i d e o J_{\mathrm{video}} Jvideo​ 是视频抗抖动估计,
D d e c o d e D_{\mathrm{decode}} Ddecode​ 是解码耗时预算, D r e n d e r D_{\mathrm{render}} Drender​ 是渲染余量。
当前解码预算使用近期耗时的 95% 分位估计,窗口约 10 秒,并跳过最初若干次样本;
默认渲染余量通常为 10 ms。这些是调度预算,不能直接当作每帧真实耗时。

普通调度路径下:

T r e n d e r ( r ) = T l o c a l ( r ) + clamp ⁡ ( L c u r r e n t , L min ⁡ , L max ⁡ ) T_{\mathrm{render}}(r) =T_{\mathrm{local}}(r) +\operatorname{clamp}(L_{\mathrm{current}},L_{\min},L_{\max}) Trender​(r)=Tlocal​(r)+clamp(Lcurrent​,Lmin​,Lmax​)

再为解码及渲染处理预留时间,得到最晚开始解码的时机。
current_delay 会逐步变化,并不保证时刻等于 target_delay。
特殊低延时配置还有单独路径,不能只凭这一条公式概括所有分支。

这里的 T l o c a l ( r ) T_{\mathrm{local}}(r) Tlocal​(r) 来自视频自己的本地时间映射。
当前 TimestampExtrapolator 被封装在 video/timing/default_video_jitter_timing.cc 中,
根据 RTP 与本地接收观察建立时间关系。

音视频同步没有拿 SR 的 NTP 直接替换这个本地渲染时间轴。
SR 用来建立两路媒体可比较的关系,视频本地 timing 负责把本路帧安排到接收机时钟上,
同步要求通过最小延时把两者联系起来。

九、源码里几种“时间估计器”,不要混着看

读到这里,至少出现了三种容易混淆的时间转换:

对象输入关系输出或用途
RtpToNtpEstimatorSR 中的 RTP / NTP 对RTP 对应的远端公共媒体时间
TimestampExtrapolatorRTP / 本地接收时间观察预测本地时间轴上的基准时刻,服务视频调度
RemoteNtpTimeEstimatorSR 映射,加远近端时钟差估计把媒体时间进一步估算到接收端 NTP 域

RemoteNtpTimeEstimator 的远近端偏移估计会用到 RTT 等信息,
其中有类似单程延时取 RTT 一半的近似。不能把这一层的输出和
RtpStreamsSynchronizer 内部直接使用的远端 RTP→NTP 映射混为一谈。

Absolute Capture Time RTP 扩展也不应自动代入本文算法。
它可以给其他媒体时间估计提供信息,但当前 UpdateDelay() 的这条链路仍然从
GetInfo() 取 SR 关系;缺少 SR 时,不会在这里自动改用 ACT 完成同样的控制。

区分这些对象以后,很多“时间戳看起来对了,为什么画面还是不对”的问题会更容易定位:
有时错的是两路公共媒体时间,有时错的是本地调度,还有时是最后的设备与显示环节。

十、日志里的 sync offset,又是另一条计算

除了约每秒一次的控制,当前实现还会在视频帧交付附近调用
GetStreamSyncOffsetInMs(),估计播放端的音视频偏差并更新统计。

它没有直接把前面的 E = L v − L a + R E=L_v-L_a+R E=Lv​−La​+R 原样填进统计字段。
这次计算更接近“此刻两路分别播到哪个媒体位置”。

10.1 音频先扣掉设备中还没有播出的部分

ChannelReceive::GetPlayoutRtpTimestamp() 读取 NetEq 播放时间戳,
再根据音频设备报告的延时回退:

adjusted_audio_rtp
  = neteq_playout_rtp - device_delay_ms * rtp_ticks_per_ms

同时返回最近一次从 NetEq 取数的本地时刻。
这样估计的媒体位置比单看“已经从 NetEq 取出了哪里”更接近设备实际播放位置。

随后同步器把它映射到 NTP,再补上从那次取数到当前经过的时间:

M a , n o w = M a ( r a , a d j u s t e d ) + ( t n o w − t a , p u l l ) M_{a,\mathrm{now}} =M_a(r_{a,\mathrm{adjusted}})+(t_{\mathrm{now}}-t_{a,\mathrm{pull}}) Ma,now​=Ma​(ra,adjusted​)+(tnow​−ta,pull​)

10.2 视频扣掉距离预定渲染还剩下的时间

视频帧的 RTP 时间戳也映射到 NTP。如果距离预定渲染时刻还早,
就减去这部分未来等待:

M v , n o w = M v ( r v ) − max ⁡ ( T r e n d e r − t n o w , 0 ) M_{v,\mathrm{now}} =M_v(r_v)-\max(T_{\mathrm{render}}-t_{\mathrm{now}},0) Mv,now​=Mv​(rv​)−max(Trender​−tnow​,0)

最后:

s y n c _ o f f s e t = M a , n o w − M v , n o w \mathrm{sync\_offset}=M_{a,\mathrm{now}}-M_{v,\mathrm{now}} sync_offset=Ma,now​−Mv,now​

按照当前代码的符号,正值表示音频媒体进度领先视频,即声音抢先;负值表示音频落后。
例如音频估计播到 10120 ms,视频估计播到 10080 ms,offset 为 +40 ms。

10.3 这个统计仍然不是扬声器与屏幕的硬件实测

VideoReceiveStream2::OnFrame() 调用 renderer_->OnFrame() 后,
把相关元数据投递到 worker 上计算统计。源码也明确说明,没有真正的“已经上屏”回调,
帧此时可能显示了,也可能还没显示。

如果渲染器内部又排了几帧,或者音频设备上报的延时不准确,统计就会和用户感受有差别。
所以它很适合观察趋势、符号和明显异常,不能代替拍屏录音等端到端测量。

另外,控制循环与逐帧统计的输入不同,看到 E E E 与 sync_offset 数值不一致,
不应立刻判断代码自相矛盾。

十一、为什么起播、短时波动和长期漂移要分开看

从前面的计算可以看到,同步依赖两类状态:一类是已经建立的时钟映射,
另一类是不断变化的播放延时。观察到口型不一致时,应先判断是哪类状态出现了问题。

11.1 起播时,媒体可能先于同步信息就绪

收到媒体包以后,两路可以分别开始缓存、解码和播放,但跨流同步还要等公共时间关系建立。
在本文分析的实现中,一份 SR 只提供一个时钟对应点,至少两个不同的有效点才能拟合出映射。

映射形成以后,还要等到同步任务读取到两路的新接收状态,才能评估是否需要调节。
如果确实需要增加某一路等待,缓存与播放进度还要经历收敛过程。

两路媒体开始到达
各自缓存与播放

每路收到首个有效 SR
保存时钟对应点

每路积累至少两个不同有效 SR
建立 RTP → NTP 映射

同步周期读取新状态
计算两路延时差异

按误差决定是否调整
提交最小播放延时

两路继续播放
需要调整时逐渐收敛

图中按“媒体先到、SR 后到”展示一种起播过程,实际二者也可能交错到达。
它们没有固定的总耗时:SR 发送节奏、样本有效性、定时任务以及两路已有缓存都会产生影响。

因此,起播时短暂不同步、随后逐渐靠拢,与整个会话始终固定错开一段时间,需要分开分析。
前者应先观察模型建立与控制收敛;后者更应该检查分组、时间戳起点和发送侧媒体时间关系。

11.2 越播越偏,要检查时钟推进速度

固定的时间戳偏移和推进速率错误,表现不同。前者倾向于带来相对固定的错位,
后者则会随播放时间积累误差。

以 48 kHz 音频为例,连续的 20 ms 内容对应每声道 960 个采样。
若 RTP 时钟同样为 48 kHz,一包携带这段内容时,下一包的时间戳就应前进 960。
声道数量或播放回调次数都不应改变这段内容在媒体时间轴上占用的长度。

即使时间戳生成逻辑正确,实际采样时钟也可能偏离标称频率。
一个未被校正的 100 ppm 速率差,在 60 秒内就会积累约 6 ms 的时间差。
这只是频偏的数量级示例;SR 拟合与播放侧调节正是为了持续跟踪这类变化,
不能把它理解成 WebRTC 必然出现的漂移。

排查长期漂移时,可以把下面三件事放在一起核对:

每段音频解码后的每声道采样数与时长
相邻媒体包的 RTP 时间戳差与 RTP clock rate
相邻 SR 的 RTP / NTP 时间差

三者应描述一致的媒体推进关系。SR 拟合出来的斜率是否合理,媒体包是否沿这条时间轴推进,
比单独确认几个字段“都在递增”更有意义。

11.3 短时网络波动,两路的恢复方式并不相同

一次突发晚到或丢包,可能同时影响音频和视频,但两路不一定以相同方式恢复。
音频先消耗已有缓存,必要时用 PLC 维持输出;视频缺少参考依赖时,可能必须等待后续恢复。

在恢复阶段,音频的时间伸缩、视频的帧调度及各自的延时估计,又可能以不同速度调整。
所以短时出现声音继续、画面停顿,不能只从同步控制器里寻找原因。

同步器以约一秒的周期观察两路关系,持续偏差才会逐渐影响最小播放延时。
排查时应把突发前、突发中和恢复后的状态连起来看,区分媒体本身不可用与
已经有可播放媒体、但两路等待不一致这两种情况。

十二、遇到口型问题,按哪条线排查

可以先按现象分流,再决定该看哪个模块。

现象优先核实
从开始就固定错开一段时间stream 配对、SR 起点、上游媒体时间戳语义
刚起播不同步,过一会儿靠拢双路 SR 映射建立时间、同步控制启动与延时收敛
播放越久偏差越大RTP 推进频率、音频帧长、SR 斜率、采样时钟漂移
网络波动后短期错位两路恢复方式、NetEq 补偿、视频等帧、控制周期
同步请求一直增加却没效果最小值是否低于自然目标、最大延时是否压住请求
sync offset 接近零,实际仍明显错位渲染器内部排队、设备延时估计、上游内容与时间戳关系

真正动手时,第一步通常不是调门限,而是确认同步链路有没有跑起来。
建议先串起以下证据:

  1. 音视频接收流的 SSRC、sync_group 是否确实配成了所需的一对。
  2. 两路是否分别收到有效 SR,且至少有两个不同样本进入各自映射。
  3. 新媒体是否持续到达,UpdateDelay() 有没有因为信息缺失或时间未更新而返回。
  4. relative_delay_ms、音频延时、视频目标延时分别是多少,符号是否符合现象。
  5. 两路 setter 收到什么最小延时,最终 NetEq / VCMTiming 又采用了什么目标。

rtp_streams_synchronizer2.cc 中已有 Sync info stats 和 Sync delay stats 日志,
以及 SyncCurrentVideoDelay、SyncCurrentAudioDelay、SyncRelativeDelay trace。
常规日志有约 10 秒的节流条件,不能把“日志没每秒出现”当成定时任务没执行。

一次记录中尽量同时带上本地时间、两路 SSRC、SR 的 RTP/NTP 对、最新媒体 RTP、
接收时间和播放目标。单独截取一个 delay=120,往往连它属于哪一层都说不清。

最后再对照用户真正看到、听到的事件做测量。闪光与短音、拍手与画面动作都可以作为
内容上的共同事件。源码统计负责解释内部机制,端到端观察负责确认最终效果;
只有二者能够互相对应,才适合判断一次调整是否有效。

十三、源码阅读入口

路径主要入口或职责
media/engine/webrtc_voice_engine.cc音频接收配置中的 stream id 与 sync_group
media/engine/webrtc_video_engine.cc视频接收配置中的 sync_group
call/call.ccFindAudioStreamForSyncGroup()、ConfigureSync(),配对与数量限制
call/syncable.h两路向同步器提供的 Info、PlayoutInfo 和控制接口
video/rtp_streams_synchronizer2.cc周期更新、设置播放延时、播放偏差统计
video/stream_synchronization.cc相对延时公式、平滑、门限和额外延时状态
rtc_base/rtp_to_ntp_estimator.ccSR 样本校验、时间戳展开、线性回归
audio/audio_receive_stream.cc音频 Syncable 接口转发
audio/channel_receive.ccSR 与接收状态、NetEq/设备延时、音频播放 RTP 时间戳
video/rtp_video_stream_receiver2.cc视频 SR 与最新 RTP 接收状态
video/video_receive_stream2.ccGetInfo()、UpdatePlayoutDelays()、OnFrame()
modules/video_coding/timing/timing.ccVCMTiming 目标延时、当前延时和渲染时间
modules/video_coding/timing/decode_time_percentile_filter.cc视频解码耗时预算
video/timing/default_video_jitter_timing.cc默认视频抖动与本地 RTP 时间映射
modules/rtp_rtcp/source/remote_ntp_time_estimator.cc远端媒体时间到本地 NTP 域的估计
modules/audio_coding/neteq/delay_constraints.cc同步最小延时怎样参与音频目标约束

延伸阅读:CSDN 视频 Jitter Buffer 文章、
视频 Jitter Buffer 分析稿、
NetEq 分析。

视频 timing 决定本路帧怎样按时显示,NetEq 维持音频的连续输出,
音视频同步则通过共同媒体时间和延时反馈,协调两路的播放位置。

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐