2. FFmpeg时钟体系:AVClock结构体源码分析,主时钟与从时钟设计
音画同步的核心是什么?说白了,就是让音频和视频各自按自己的节奏跑,然后找一个共同的「裁判」来对齐。这个裁判,就是时钟。
FFmpeg里没有用一个全局大时钟搞定一切,而是设计了一套灵活的主从时钟体系。我当年第一次看这段代码时,也觉得绕。但搞懂了之后,你会发现这设计其实挺巧妙的。
2.1 AVClock结构体:时钟的最小单元
先看最基础的数据结构。AVClock定义在ffplay.c里,别看它小,整个同步逻辑都建立在它之上。
typedef struct AVClock {
double pts; // 当前时钟基准时间
double pts_drift; // 时钟漂移修正值
double last_updated; // 上次更新时间
double speed; // 时钟播放速度
int serial; // 播放序列号
int paused; // 暂停标志
int *queue_serial; // 指向队列的序列号
} AVClock;
每个字段都有它的使命。我一个个说。
| 字段 | 类型 | 作用 |
|---|---|---|
pts |
double | 时钟的基准时间戳,单位秒 |
pts_drift |
double | 记录pts与系统时间的差值,用于漂移修正 |
last_updated |
double | 上次调用set_clock_at时的系统时间 |
speed |
double | 播放速度,正常为1.0 |
serial |
int | 当前播放序列号,seek后递增 |
paused |
int | 暂停标志,1为暂停 |
queue_serial |
int* | 指向队列的序列号,用于判断时钟是否过期 |
核心理解:AVClock不是简单存一个时间值,它通过pts_drift和last_updated实现了「动态时间推算」。你每次读时钟时,它都会根据上次更新的时间和速度,计算出当前应该是什么时间。
2.2 时钟的核心操作:设置与读取
光有结构体不行,还得看它怎么用。两个函数是关键:set_clock_at和get_clock。
2.2.1 设置时钟:set_clock_at
static void set_clock_at(AVClock *c, double pts, int serial, double time)
{
c->pts = pts;
c->last_updated = time;
c->pts_drift = pts - time;
c->serial = serial;
}
这个函数做了什么事?它把当前的时间戳pts和系统时间time绑定在一起。然后计算一个差值pts_drift。
为什么要算这个差值?你想想看,如果我只存了pts,过了一秒后,时钟应该前进多少?我不知道。但有了pts_drift,我就能推算出来。
2.2.2 读取时钟:get_clock
static double get_clock(AVClock *c)
{
if (*c->queue_serial != c->serial)
return NAN;
if (c->paused) {
return c->pts;
} else {
double time = av_gettime_relative() / 1000000.0;
return c->pts_drift + time;
}
}
这里有个小细节。它先检查serial是否匹配。如果不匹配,说明这个时钟已经过期了——比如用户seek之后,旧时钟的数据就不能用了。
然后看是否暂停。暂停就直接返回pts。正常播放时,计算公式是:
当前时间 = pts_drift + 当前系统时间
因为pts_drift = pts - time,所以:
当前时间 = (pts - time) + 当前系统时间 = pts + (当前系统时间 - time)
说白了,就是在基准时间pts的基础上,加上经过的时间。这个设计我在项目中遇到过类似场景,用来做动画的时间轴推算,确实很实用。
我的习惯:调试音画同步问题时,我通常会先打印get_clock的返回值,看看时钟是不是在正常递增。如果发现时钟跳变或者卡住,问题往往出在set_clock_at没有被正确调用。
2.3 三种时钟:音频、视频、外部
FFmpeg里维护了三个AVClock实例:
- 音频时钟(audclk):以音频播放位置为基准
- 视频时钟(vidclk):以视频播放位置为基准
- 外部时钟(extclk):以系统时间为基准
这三个时钟,谁当主时钟?看配置。默认情况下,音频时钟是主时钟。为什么?因为人对音频的延迟更敏感,音频一旦不同步,耳朵立马能听出来。
但也不是绝对的。我记得有一次做视频会议项目,对方传过来的音频流不稳定,经常丢包。这时候如果还用音频做主时钟,视频画面就会一顿一顿的。后来我改成外部时钟,画面就流畅多了。
2.4 主从时钟设计:谁说了算?
主从时钟的设计其实就一句话:从时钟跟着主时钟跑。
具体来说:
- 主时钟:负责提供参考时间,其他时钟向它看齐
- 从时钟:根据主时钟的时间,调整自己的播放速度
那怎么判断当前用的是哪个主时钟?看sync_type变量:
enum {
AV_SYNC_AUDIO_MASTER, // 音频为主时钟,默认
AV_SYNC_VIDEO_MASTER, // 视频为主时钟
AV_SYNC_EXTERNAL_MASTER, // 外部时钟为主时钟
};
我建议你在实际项目中,默认用音频主时钟。除非你明确知道音频有问题,再考虑切换。
避坑指南:我曾经遇到过一个情况,视频流是本地文件,音频流是网络实时流。结果音频经常卡顿,导致视频也跟着卡。后来发现是音频时钟被网络抖动影响了。解决方案是把音频缓冲加大,或者干脆切换到外部时钟。
2.5 时钟体系整体架构
下面这张图,是我梳理的FFmpeg时钟体系结构。你看一眼,就能明白三个时钟之间的关系。
从图上你能看到:三个时钟源都连接到主时钟选择器。选中的那个就是主时钟,它给从时钟提供参考。从时钟拿到参考后,调整自己的节奏,最终输出同步的音画。
2.6 时钟同步的核心逻辑
那从时钟具体怎么跟?看ffplay.c里的synchronize_audio和video_refresh函数。核心思路是:
- 计算时间差:从时钟的时间减去主时钟的时间
- 判断偏差范围:如果偏差在阈值内,微调播放速度;如果偏差过大,直接跳帧或插帧
- 执行调整:音频通过调整采样率,视频通过调整帧间隔
举个例子。假设音频是主时钟,当前时间是10.0秒。视频时钟是9.8秒,慢了0.2秒。这时候视频播放器会加快速度,把落后的时间追回来。
关键参数:
AV_SYNC_THRESHOLD_MIN:最小同步阈值,默认0.04秒AV_SYNC_THRESHOLD_MAX:最大同步阈值,默认0.1秒- 偏差小于最小值:不做调整,避免频繁抖动
- 偏差在中间范围:微调播放速度
- 偏差大于最大值:直接跳帧或插帧
嗯,这里要注意。阈值设得太小,音画会频繁抖动;设得太大,不同步的感觉会很明显。我一般建议保持默认值,除非你有特殊场景。
2.7 小结
AVClock的设计,说白了就是三个字:基准、推算、对齐。
- 基准:通过
set_clock_at设定一个时间锚点 - 推算:通过
get_clock动态计算当前时间 - 对齐:通过主从时钟机制,让音频和视频向同一个基准看齐
这套设计的好处是灵活。你可以随时切换主时钟,也可以单独调整某个时钟的速度。我在做多路流同步时,就利用这个特性,让多个视频流都同步到同一个外部时钟上,效果还不错。