播放器的音画同步与时钟

从零做一个全平台播放器·约 2260 字

视频侧按帧率刷,音频侧的节拍归设备,两条链各自带着自己的时间戳往前走,把它们放到同一条时间线上是这一篇要解决的事。做法是给每条轨配一个时钟,把「现在播到哪」算成一个随时能读的值;再挑一条当主时钟,另一条照着它调。调的办法两不一样:视频改的是等待时间和丢帧,音频改的是这一次送出去多少数据。这篇把这一套讲完:时钟里存了什么、主时钟怎么定、偏差分几档处理、哪几种情况干脆关掉同步,以及积压太多时怎么自动提速。

两条链的节奏:视频按帧率,音频由设备

两条链的节奏,与挂在系统时间上的两条时钟;主时钟默认取音频那条

视频侧是按帧率刷的。渲染循环算出一帧该显示多久,到点换下一张,节奏由播放器自己安排。音频侧的节奏不在播放器手里:数据递进设备之后,什么时候从扬声器出来由设备说了算,播放器能问到的是它手上还积着多少没放完。

于是两条链都会回答「现在播到哪」,但答案的性质不一样。视频那侧是一个由帧序列推出来的位置,音频那侧是从设备缓冲水位倒推出来的位置,两者各有各的误差,而且都会漂。要让画面和声音对上,得先有一个共同的参照物,让两条链都能换算到它上面。

参照物取的是系统时间。这个选择没什么玄机:它两边都在走,随时可读,谁都能拿它给自己的进度打一个刻度。

时钟里存的三样:PTS、系统时间与差值

每条轨对象带一个时钟。时钟里存的是最后拿到的那个 PTS、当时读到的系统时间,以及两者的差。

「现在播到哪」不走定时器,每次被问的时候现算:把那个差加上当前系统时间就是答案。这样做的结果,是轨上不来数据的时候时钟照样在走,数据一到、把新的 PTS 写进去,时钟的刻度就重新对一次。

时钟自己带一个速度。倍速播放时,流逝的系统时间要乘上倍速再算进度,于是「现在播到哪」也跟着倍速走。改倍速在时钟这一层就是改一个变量,渲染间隔和音频侧的数据量各自再按它折算。

暂停恢复的时候要把基准重新锚一次。不重锚,暂停的那段时间会被算进播放进度,恢复播放时进度直接往前跳一截。

默认以音频为主时钟

主时钟有四档可选:音频、视频、外部、不同步。默认取音频。

理由来自感官的差别。音频丢一帧或者快慢变一点,耳朵立刻能听出来;视频丢几帧、间隔抖一点,眼睛多半察觉不到。让视频去迁就音频,代价更小。

音频轨在自己的时钟更新时把值交给播放器;视频轨在音频为主时反过来读主时钟,拿它当参照。字幕视图取的是同一个时钟,不另造一条时间线——字幕和画面用的是同一个刻度,对时这件事就只剩两条链之间的账。

视频侧:容差随帧间隔走

视频侧:差值落后就把等待时间钳成 0,超前则加差值或翻倍;底下是丢帧与保护窗

视频轨拿自己的时钟和主时钟相减。差为正说明视频走在前面,为负说明视频落后。判断「算不算差」的容差不是一个定值,取的是这一帧自己的间隔,再钳在一个上下限之间:下限是一帧不到的时间,上限是百毫秒那一档。间隔短的帧容差跟着收紧,间隔长的帧容差放宽。

按差值分三种处理:

  • 视频落后,把等待时间直接减掉差值;减到小于零就钳成零,意思是不再等,立刻刷下一帧。
  • 视频超前,而且上一帧的间隔本来就大,把差值加到等待时间上,这一帧多显示一会儿。
  • 视频超前,但上一帧间隔小,等待时间翻倍。

等待时间用完之后,渲染基准要往前推一格,推的是按倍速折算过的间隔。这里有个防线:要是当前系统时间已经把基准甩开超过上限,基准直接拉回当前时间。不拉回来的话误差会一格格累积,越走越偏。

真正跟不上时靠丢帧解决:当前时间比基准超出一帧半,就把队列最前面那一帧弹掉。丢帧这件事在硬解帧上要显式做——帧背后是 GPU 资源,弹掉的时候得通知帧对象释放,不然后面拿这个索引就出事。

丢帧还带着一个坑。落后到等待时间被钳成零之后,基准本身是不推进的,而「把基准拉回当前时间」那条防线只在等待时间大于零时才生效——两件事叠在一起,当前时间会越走越远,于是每一帧都被判成该丢,画面彻底静止,只有 seek 重置时钟之后才短暂恢复。所以丢帧的同时必须把基准同步推到现在,同一条路径上还返回一个「慢」的信号,让窗口跳过这一帧的等待直接刷:不返回这个信号,窗口的唤醒节拍被帧率标签卡住,落后就永远追不回来。

起播和 seek 之后还有一小段保护窗,窗内不丢帧。渲染链的头几拍是惰性初始化的——视频设备的建立、着色器的编译、图形设备的建图分摊在前几拍上,实测能拖到一百毫秒以上,最差的一拍过半秒。不设这个窗,这几拍会被当成严重落后,帧先被丢光,第一帧迟迟出不来。

音频侧:改的是送出去的数据量

音频侧:差值先做指数平滑、攒够一批样本,平均误差超过几十毫秒这个量级才动手;把误差换成采样点数加在这一帧上,再重采样送进设备

音频当主时钟时不需要纠偏,它自己就是基准。反过来主时钟不是音频时,音频要迁就,办法跟视频侧完全不同。

音频轨比较自己的时钟和主时钟,但不看单次的差值。单次差值抖得厉害,它把差值做指数平滑,攒够一批样本之后再看平均误差;平均误差超过几十毫秒这个量级,才决定动手。

动手的方式是改这一次送出去的数据量:把误差换算成采样点数,加到这个固定长度的帧上,再交给重采样器按调整后的采样率过一遍,然后才进设备。变动幅度还要限一下,最多到帧长的十分之一,不让它一次跳太多——调得太猛,听感上就是声音忽快忽慢。

这一路长期挂着,说明两个时钟一直在离心;日志里隔一段记一条,给排查留痕。

对不齐的源干脆关掉同步

有些源从起播就对不齐,硬对没有意义,播放器会整个关掉同步。判定看两件事:

一是两条轴的时间差。音频与视频的解码输出时间差超过两倍缓存目标,而且渲染时间差也超过一个缓存目标(缓存目标那个量级),算不对齐。二是音频的 PTS 跨度与数据量对不上——比如 PTS 过了一秒,手里只有半秒的数据,这种源音频时钟本身就不可信,一并判不对齐。

关掉同步不等于关掉时钟。播放器自己的时钟换由视频轨来喂,进度条跟着视频走,画面继续按自己的节奏刷。关掉前的档位会被记住,源对齐了再恢复回来。

倍速超过四倍时同步也会被关掉:这个档位下音频包已经在丢、视频只解 I 帧,对齐没有比较的对象。从超四倍降回四倍以内时,能 seek 的源会按当前渲染时间重新定位一次——超四倍期间音频包是丢掉的、读的位置又往前跑了,不重新定位,音频恢复时的位置和画面就对不上,进度条会跟着前后跳。

积压太多时自动提速

队列的积压会被量化成时长,分两段算:包队列里读进来的位置领先解码到哪儿,帧队列里解码出来的位置领先渲染到哪儿,两段加起来就是攒了多少。超过缓存目标就进缓冲状态、暂停渲染与时钟;恢复的门槛也是这个量,恢复时时钟重新锚一次。

有一种情况这套不够用:源为了秒开,一连上就吐一大段数据过来,正常速度播下去延迟会一直很高。所以低延迟那一档还带一条自适应:播放中、音频和视频两侧的积压都超过缓存目标的两倍时,自动把速度提到一个略大于一倍的档(可配)去烧积压,任一侧降到缓存目标以下就回到常速。

这条自适应只在当前速度是常速、而且用户没自己选过倍速时跑。用户手里的档位优先,它不会去盖。

引擎 avox 在 GitHub 开源:github.com/xxxzhou/avox,时钟、同步判定与两侧的纠偏都在里面。

音视频开发 播放器 C++ 音画同步 时钟