泛音是一个 Flutter 界面的全平台播放器,C++ 引擎 avox 开源。这篇讲倍速播放——一个看着只需要”把时钟乘个系数”、实际要拆成两条渲染链的功能。

先把分水岭画出来
用户视角的倍速是 0.25x 到 64x 的一条档位滑杆。实现视角不是:4 倍是一条线,线两边是两套不同的跑法。
- 4 倍以内:音频照常解码、照常出声,只是时间轴被压缩(变速但不改变音调);视频照常解码,每帧的显示时长按倍速缩放,跟不上就丢帧追赶。声音在。
- 4 倍以上:视频只保留关键帧(I 帧,包含完整画面、可独立解码的帧),其余帧在进包处就丢掉;音频整条退出——不是静音,是包根本不进管线。声音不在。
线为什么画在 4 倍?很直接:4 倍大体就是解码器能实时跑到的上限。解码是这条链上最重的一段,满负荷也就实时四倍上下——所以 4 倍以内还得把每一帧都解出来,靠渲染端自动丢帧来控节奏(这一段解码器是全速跑,连让出时间片都省了:能解就立刻解下一包);再往上,把全部帧解出来已经追不上播放速度,只能不解非关键帧。于是超过 4 倍改成只解关键帧,解码器的压力反而降下来——代码注释里的原话是「超过 4 倍后只解码 I 帧,相反可以慢下来了」。屏幕刷新率、音频变速的可听性、用户在高倍速下的意图,是把这条线说圆的次要理由,不是它的出处。
档位本身是产品配置:白名单里是 0.25 / 0.5 / 0.75 / 1 / 1.25 / 1.5 / 1.75 / 2 / 3 / 4 / 8 / 16 / 32 / 64,设置里勾选哪几档、播放页的倍速菜单就出哪几档,默认勾的是 0.5 / 1 / 1.5 / 2 / 4。
四倍以内:变速不变调的音频,丢帧追赶的视频
音频这一半靠一个插件:引擎里有一层音频变速接口,配套实现是 SoundTouch(用 WSOLA 算法——加窗、重叠、再按目标速率拼接,所以变速不变调)。档位变化在渲染线程里平滑生效,不需要冲刷已排队的音频帧;从倍速回到 1x 时,把变速器旁路掉并丢掉它内部残留的窗,免得切回瞬间带出一小段旧速率的尾巴。插件缺席时降级成重采样,也就是会变调的老行为。
视频这一半是丢帧追赶。每帧的显示时长按倍速缩放(帧时长 ÷ 倍速),渲染基准落后主时钟到一定程度,就丢掉队首帧。这里有两个看着简单、实际很容易写错的细节。
- 落后判据里必须先要求”本该等待”。当画面已经落后主时钟,等待时长是零,这时”再丢一帧”等于判每帧都过期,判据几乎恒成立——实测会有约四分之三的帧被丢掉,画面发滞。
- 丢帧时必须同步推进渲染基准。否则基准既不前进、又过不了上面的纠正条件,时间越走越远,于是每一帧都被判过期,画面彻底静止,只有 seek 重置时钟后才能短暂恢复。
还有一处保护:起播那一下的确定性初始化(比如 4K HDR 硬解的首帧簇)会拖出一串”落后”的帧,渲染侧留了一个保护窗,不把这笔初始化成本变成用户可见的跳帧。
四倍以上:I 帧模式怎么渲染
超过 4 倍,播放器换一套跑法。触发条件是”开关打开 且 倍速大于 4”合成的一个原子真值(开关默认打开),这个真值被三处共用:进包处、渲染节奏、音频解码线程——共用是纪律,理由在下面。
进包处:丢。 视频的非关键帧(P/B 帧)直接丢,音频包也直接丢,配置帧(解码器参数)照常放行——放行配置帧是为了降回 4 倍时能立刻恢复。
音频为什么是整条丢、而不是只静音?如果只是静音,音频管线仍按原速消费,包队列(百来个包)会被堆满,入队阻塞之后 demux(拆包器)就再也喂不进视频包——视频的 I 帧管线被饿死,画面反而更慢;反过来,如果消费过快,demux 又会狂读,进度条乱闪。把音频包在进包处丢掉,demux 就只被视频 I 帧管线按倍速自节流,音频侧不需要任何节流调参。代价是音视频同步也一起关掉了:4 倍以上音频不渲染,同步没有意义。
渲染节奏:每帧按缩放后的时长显示,不追时钟。 普通模式下画面跟着主时钟走(默认主时钟是音频),逐帧纠正;I 帧模式下,每一帧只显示”自己的时长 ÷ 倍速”这么久——一个 2 秒一个关键帧的片源,8 倍速就是每帧 250 毫秒,像幻灯片一样往前走。对应的三处特殊处理:不把长间隔当时间戳跳变(关键帧之间本来就隔得远);不做丢帧(帧本来就少,丢掉就是真卡);不追时钟(没有音频时钟可追)。
时钟和进度:进度条取”渲染到哪”,不是”读到哪”。 这条是专门为 I 帧模式改的:4 倍以上磁盘或网络会超前读,视频队列里能缓存上百个关键帧,相当于几十秒到几分钟的内容,拿读取位置当进度,进度条会说谎。
另外两处:录制不受影响(录制拿的是完整原始流,丢帧只是播放策略);缓冲恢复的判据也换了——4 倍以内要等队列攒够设定时长,4 倍以上有视频帧就恢复。
源侧的 I 帧模式。 上面说的都是播放侧。还有一种是源侧:某些直播源(服务端能真正按倍速切流的推流源)在倍速下由服务端只发关键帧,引擎这一侧也进入 I 帧模式,渲染走同一套节奏。它有个前提——只有真有”倍速语义”的源才这么预判;对包流不随倍速变化的本地/点播源,预判只会白静音并打乱时钟。另一条路是自动识别:连续收到时间戳不同的关键帧,就判定这条流本身就是纯关键帧流(一些切片、监看类流就是这样),直接按 I 帧模式渲染,等有 P/B 帧再自动退出。
一条容易被忽略的纪律:进包处的丢弃判据和音频解码线程的超时豁免,必须用同一个真值。如果音频那侧图省事写成”倍速大于 4”,那么当开关关掉、音频包正常流动时,超时豁免仍然生效,真实的解码故障会被永久吞掉。
另外两种速度
用户选的倍速之外,引擎里还有两处会自己动速度的地方。
一是直播追赶。低延迟模式下,如果音视频队列都堆到缓冲目标的两倍以上(说明已经落后直播边缘),引擎自动把播放速度提到约 1.2 倍,把积压烧掉;消费追平后恢复 1.0。它只在用户速度为 1 时生效,不会覆盖用户手里的档位——用户选了倍速,就不再自动追赶。
二是低速端的对称处理。低于 0.2 倍音频同样不渲染,和 4 倍以上是一套逻辑:两端都不再逐样本变速,一端是太慢没有意义,一端是太快没有信息。
分水岭两边:切换才是坑
现在说切换。这是这条链上最容易出问题的地方,而且两个坑对称地分布在线的两侧。
升速(从 4 倍以内进到 4 倍以上):丢弃只拦新包。 全量模式下,包队列里已经积压着几十秒的 P/B 帧;进包处的丢弃只对之后到的包生效,已经排进队列的旧包必须被解码、排干,才能真进入 I 帧模式——这个排干是秒级到几十秒。用户看到的现象是:点了 8 倍速,画面还在慢慢地放一会儿,然后突然开始跳。修法是把它当成一次 seek:对可以重新定位的源,直接重定位到当前渲染位置——冲刷掉旧包,重启后的包全部经过已经打开的丢弃,立刻以纯关键帧跑。另外,在 4 倍以上内部调速(比如 8 倍调到 6 倍)本来就没有积压,不会触发这条路径。
降速(从 4 倍以上回到 4 倍以内):两个轴错位了。 4 倍以上音频包被丢掉、demux 又超前读,于是音频”恢复播放”的位置(在超前的地方)和视频的渲染位置对不上,跟随音频时钟的进度条会前后跳。修法同样是重定位到渲染位置重新对齐,也只对可重新定位的源成立;对不可重定位的源(纯直播),误差只能靠缓冲和后续同步慢慢收敛。
还有一个不起眼的滞后:变速插件的”播放态”一旦标定,并不会在回到 1x 时立刻复位。原因是 0.5 倍、2 倍期间攒下的音视频解码轴发散要等队列排空才收敛,在那之前,对齐看门狗会把这段发散误报成异常,所以这个闩锁一直留到换片或关闭播放器才复位。这不是疏漏,是一个被实测逼出来的取舍。
最后一句体验层面的:4 倍以上声音本来就不渲染,所以穿越这道线的感受是”有声音 ↔ 没声音”,而不是音量变小——变调只发生在 4 倍以内、且变速插件缺席的降级路径上。
给做播放器的人
倍速播放不是”把时钟乘个系数”,而是”在哪一档用哪条渲染链”的策略问题。4 倍这道线的位置,由音频变速的可听上限、屏幕刷新率和用户的意图共同决定;穿过这条线要付出一次重定位的代价,所以两边的切换最好按一次 seek 来设计,而不是当成一次参数更新。I 帧模式省下的算力和带宽很可观——只解关键帧,一条只发关键帧的流甚至能把解码开销降一个量级——代价是画面变成幻灯片,以及一堆只在切换瞬间才暴露出来的时序问题。
引擎 avox 在 GitHub 开源。系列其他篇:产品介绍从开源引擎 avox 到成品:全平台播放器「泛音」。