同步只有一句话:拿一个时钟当基准,让另一条流去追它。 ffplay 拿音频当时钟,用两个阈值(10 毫秒、100 毫秒)分三档处理——小差不管、中差改延迟、大差丢帧。
这一节是全书的汇合点。 前面所有零件都在这里出场: 队列(决定能等多久)、时钟(决定等到几时)、格式转换与重采样(决定一帧有多长)、播放控制(决定要不要丢)。 所以这一节读起来会像「复习」——这正是它的价值:把散落的知识点串成一个能运转的整体。
一、一句话,三种基准
同步的本质是「比较两个时间,然后决定要不要动手」。问题是「跟谁比」:
| 基准 | 谁去追 | 代价 |
|---|---|---|
| 音频时钟 | 视频追音频 | 声音绝不卡,视频可能丢帧 |
| 视频时钟 | 音频追视频 | 画面顺,但声音会断或变调 |
| 外部时钟 | 两条都追 | 适合录制回放,直播少用 |
ffplay 默认第一行,理由不是「音频更重要」,而是「人耳更挑剔」: 声音断一下立刻听得出来,画面掉一帧常常看不出来。
第二行的代价为什么是「变调或断」: 因为让音频追上视频,只有两种办法——要么丢样本(听感是「卡一下」),要么拉伸样本(听感是「变调」)。 两种都不好听,所以这条路少有人走。
二、音频时钟是怎么来的
这一点常被误解:音频时钟不是「解码出来的 pts」,而是「声卡播到哪儿了」。
| 来源 | 含义 | 为什么不用 |
|---|---|---|
| 解码的 pts | 「这一帧本该在何时播」 | 它不反映实际播放进度 |
| 已交给声卡的样本数 | 「已经送进去多少」 | 比实际播得早一点 |
| 已播样本数 | 「真正响到哪儿」 | — |
第三行才是正的,它的算法是「已送给声卡的样本数,减去声卡缓冲里还剩的」 再除以采样率。 所以音频时钟是「硬件反馈」驱动的,不是「解码进度」驱动的。
这也是为什么音频能当基准:它有一个物理的、真实的节拍器——声卡。 视频没有,屏幕的刷新是固定的,但「画哪一帧」是你自己决定的。
顺带解释一个常见现象:
没有声卡的时候(比如 -an 或者纯视频),ffplay 会退化成「以视频为准」或者用外部时钟。
因为它没有那个物理节拍器可用了。
三、两个阈值,三档处理
ffplay 里有两个常数,单位是秒:AV_SYNC_THRESHOLD 是 0.01,
AV_SYNC_FRAMEDUP_THRESHOLD 是 0.1。
| 差值 | 怎么做 | 为什么 |
|---|---|---|
| 10 毫秒以内 | 当作同步,正常节奏 | 看不出来,不必动 |
| 10~100 毫秒 | 不丢帧,只改这一帧显示多久 | 丢帧比慢半拍更难看 |
| 100 毫秒以上 | 丢帧,或者重复上一帧 | 再等就明显不同步了 |
为什么分两档而不是一个阈值:小差值要「平滑」,大差值要「干脆」。 在 10 到 100 毫秒之间就动手丢帧,画面会一顿一顿地跳,比「稍微慢半拍」难看得多。
中间那档的做法值得细说:它改的是「这一帧该显示多久」,不是「这一帧的内容」。 视频单帧的显示时长本来就是个浮点数,多显示几毫秒、少显示几毫秒,观众看不出来, 但累积起来就能把偏差吃掉。
这个思路叫「用时间换平滑」,它在播放器里到处出现: 与其让画面跳一下,不如让每一帧稍微长一点。
四、三种追赶手段:等、丢、重复
遇到偏差,物理上只有三种动作:
| 手段 | 什么时候用 | 副作用 |
|---|---|---|
| 等(加长本帧) | 视频超前 | 画面略微变慢 |
| 丢(跳过本帧) | 视频落后很多 | 动作会不连贯 |
| 重复(再显示一帧) | 视频落后一点点 | 轻微卡顿感 |
第一行是「首选」,因为它的副作用最小。第二行是「最后手段」,只在偏差太大时用。 第三行介于两者之间。
选哪个,取决于「差多少」和「差的方向」。 这就是那两个阈值存在的原因:阈值不是「精度指标」,是「策略切换点」。
顺带说清一个常被搞混的概念:「丢帧」和「跳帧」不是一回事。 丢帧是「这一帧不要了」,播放位置连续;跳帧是「快进一段」,播放位置不连续。 前者用于同步,后者用于「追直播延迟」。
五、为什么偏差本来就会存在
很多人以为同步是「修 bug」,其实偏差是「天然就有」的。 Demo 用一个「故意造歪」的片子把这件事演示了一遍:
视频 58 帧、每秒 30 帧,就是不到两秒;音频的样本数换算出来,比视频短了半秒多。 两条时间轴的「长度」本来就不一样。
为什么会这样:因为录制时,摄像头和麦克风是两个独立的时钟, 它们的「一秒」长度有极微小的差异。 短片子看不出来,录一小时就能差出几百毫秒。
所以同步不是「把错的东西改对」,而是「让两条本来就不齐的流,看起来齐」。 这个认知很重要:它解释了为什么同步参数永远调不到「完美」——因为源头就没有完美的对齐。
工业上的做法是「定期重锚」:每隔一段时间,把视频时钟强制拉回音频时钟一次, 而不是让它一直靠「微调」去追。 因为微调会累积误差,重锚不会。
六、收尾:全书零件在这里汇合
把这一章用到的东西列一遍,就是整本书的目录:
| 零件 | 在这一节里干什么 |
|---|---|
| 解封装与队列 | 决定「能等多久」 |
| 解码 | 决定「一帧什么时候能好」 |
| 格式转换与重采样 | 决定「一帧有多长」 |
| 时钟 | 决定「现在该到几时」 |
| 播放控制 | 决定「要不要丢、要不要等」 |
五行里任何一行出错,表现都是「音画不同步」——所以「不同步」这个症状,从来不是「同步模块的 bug」,它是一个跨模块的现象。
这也是为什么这一节放在最后:不把前面八章看完,你根本没法判断「问题出在哪一行」。
最后一句总结:播放器不难在任何一个零件,难在「让所有零件按同一个时间轴动」。 FFmpeg 给了你所有零件,ffplay 给了你一种装法,而你要做的,是把它装进自己的壳里、调成自己的脾气。
七、动手 Demo:音视频同步——三种策略,各有各的代价
这一段不模拟同步算法,因为「调得顺不顺」在沙箱里根本量不到。 它只量一件能确定的事:两条时间轴本身差多少,以及这个差是怎么来的。 量完这个,同步逻辑为什么必须存在,就不需要论证了。
# 三条流分开解,各自统计「时长」
for pk in demux(audio, video):
if pk.stream is audio_stream:
for fr in adec.decode(pk):
asamp += fr.samples; arate = fr.sample_rate
else:
nv += len(vdec.decode(pk))
vdur = nv / 30.0
adur = asamp / arate
print(vdur - adur)
实跑输出:
--------------------------------------------------------------------
音视频同步——三种策略,各有各的代价
--------------------------------------------------------------------
**这是全书的压轴,也是最容易被讲玄的一节。**
**先把它讲成一句话**:**「拿一个时钟当基准,让另一条流去追它。」**
**三种基准对应三种策略**:
基准 谁去追 代价
----------------------------------------------------------------
以音频为准 视频追音频 声音绝不卡顿,视频可能丢帧
以视频为准 音频追视频 画面顺,但声音会变调或断
以外部时钟为准 两条都追 适合录制回放,直播少用
**ffplay 默认是第一种**,
**因为人对声音的不连续极其敏感**——**声音卡一下立刻听得出来,画面掉一帧常常看不出来。**
**同步为什么是必需品,先看一个具体的数**:**两条流各自有多长。**
视频:**58 帧 × 1/30 秒 = 1.933 秒**
音频:**62464 个样本 ÷ 44100 赫兹 = 1.416 秒**
两条相差 **516.9 毫秒**
**这个不齐是我造片子时故意留下的**:**每帧视频只配了 1024 个音频样本**,
**而 30 帧每秒需要 1470 个**——
**每帧少 446 个样本,折合 10.1 毫秒**
**58 帧累积下来就是 517 毫秒的缺口。**
**这不是我故意刁难自己**:**真实的摄像头和麦克风也不是同一个时钟**,
**录一小时能差出几十到几百毫秒。**
**所以「同步」不是锦上添花,是必需品**——**没有它,片子越长,口型偏得越离谱。**
**拿这两条时间轴当基准,就是前面那三种策略的分界线**:
**音频跑快了,视频就得等或者丢;视频跑快了,音频就得补静音。**
**两条都不动、让它们各自跑,那不叫播放器,叫两个播放器。**
**ffplay 里其实有两个阈值,都是秒**:**`AV_SYNC_THRESHOLD` 是 0.01(10 毫秒)**,
**`AV_SYNC_FRAMEDUP_THRESHOLD` 是 0.1(100 毫秒)。**
差值 ffplay 怎么做 为什么
--------------------------------------------------------------------------
10 毫秒以内 当作同步,正常节奏走 看不出来,不必动
10~100 毫秒 **不丢帧**,只调这一帧显示多久 丢帧比慢一点更难看
100 毫秒以上 丢帧或重复上一帧 再等就明显不同步了
**为什么要分两档,而不是一个阈值**:**小差值要「平滑」,大差值要「干脆」。**
**在 10 到 100 毫秒之间就动手丢帧,画面会一顿一顿地跳**,**比「稍微慢半拍」难看得多。**
**所以同步逻辑的本体只有三行**:
```text
diff = video_pts - audio_clock
if (diff > AV_SYNC_THRESHOLD) delay += 某值 /* 视频快了,多等 */
if (diff < -AV_SYNC_THRESHOLD) drop_frame() /* 视频慢了,丢掉 */
这三行里的「某值」才是全部难点:调大,画面会一顿一顿地跳;调小,追不上。 ffplay 用的是「按差值比例微调」,也就是让每一帧的显示时长上下浮动一点点,让眼睛看不出来。
最后要说清这段 Demo 的边界:它量的是「两条时间轴的时长差」,也就是「问题有多大」, 不是「播放器修正得怎么样」。 真正的修正动作要接上声卡、接上屏幕才能看效果。 渲染延迟、显示器响应、人耳容忍度,这三样在沙箱里一个都量不到。 所以本节能给的结论只有「偏差怎么算」,「看着顺不顺」只能上真机调。 ```
第一眼会以为「差半秒」是代码写错了, 其实是片子本身就不齐——Demo 在建片子时,每帧视频只配了 1024 个音频样本,而 30 帧每秒需要 1470 个。
每帧少 446 个样本,折合 10 毫秒;58 帧累积下来就是半秒多的缺口。 这个算术很朴素,但它把「同步为什么必要」讲清楚了。
真实的设备也是这样:摄像头的时钟和麦克风的时钟不是同一个, 录一小时能差出几十到几百毫秒。 所以「不同步」不是 bug,是常态。
那为什么平时的视频看起来是齐的: 因为播放器在「每帧微调」——让视频稍微等一下、或者提前一点显示,把这点缺口一点点吃掉。 你看到的「齐」,是持续修正的结果,不是原始状态。
最后交代这一节测不到的边界:「修正得顺不顺」要接上声卡和屏幕才能看, 渲染延迟、显示器响应、人耳容忍度,沙箱一样都没有。 所以本节能给的只有「偏差怎么算」和「策略怎么切」,「调多少才好看」只能上真机试。
30 秒实验
- 把每帧音频样本数改成 1470,看两条时间轴的差变成多少。
- 算一下:录一小时,两个时钟差 0.02%,音画会差多少毫秒?
- 判断一下:没有声卡时(
-an),ffplay 还能「以音频为准」吗?
常见误区
- 以为同步是修 bug。 偏差是天然存在的:摄像头和麦克风是两个时钟。
- 以为音频时钟来自解码的 pts。 它来自声卡反馈的「已播样本数」。
- 以为一个阈值就够。 ffplay 用 10 毫秒和 100 毫秒两档,小差平滑、大差干脆。
- 把「丢帧」和「跳帧」混为一谈。 丢帧用于同步,位置连续;跳帧用于追直播延迟,位置不连续。
自测三问(点开对答案)
1. 为什么 ffplay 默认以音频为基准? —— 因为人耳对声音不连续极其敏感,而画面掉一帧常常看不出来。 以视频为准会让声音断或变调,代价更大。 2. 10 到 100 毫秒之间,ffplay 怎么处理? —— 不丢帧,只改这一帧的显示时长,用「时间换平滑」,让偏差在几帧之内被吃掉。 3. 为什么「不同步」这个症状不能只查同步模块? —— 因为解封装、解码、转换、时钟、控制任一环节出错,表现都是不同步。它是跨模块现象。第九章到此为止,全书也到此为止。八节走完一条线:架构、结构体、流程、解码、转换、重采样、控制、同步。回头再看第一章那句「播放器分五段流水」,现在每一段你都能说出它对应哪个函数、花多少时间、错在哪。这就是读完源码和读完教程的区别。
- ☐ (全书完)