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时钟体系结构。你看一眼,就能明白三个时钟之间的关系。

FFmpeg 时钟体系架构图 主时钟选择器 音频时钟 audclk 视频时钟 vidclk 外部时钟 extclk 从时钟(跟随) 音画同步输出 💡 默认主时钟:音频时钟(AV_SYNC_AUDIO_MASTER) 💡 从时钟根据主时钟的时间差,动态调整播放速度,实现音画同步

从图上你能看到:三个时钟源都连接到主时钟选择器。选中的那个就是主时钟,它给从时钟提供参考。从时钟拿到参考后,调整自己的节奏,最终输出同步的音画。

2.6 时钟同步的核心逻辑

那从时钟具体怎么跟?看ffplay.c里的synchronize_audio和video_refresh函数。核心思路是:

  1. 计算时间差:从时钟的时间减去主时钟的时间
  2. 判断偏差范围:如果偏差在阈值内,微调播放速度;如果偏差过大,直接跳帧或插帧
  3. 执行调整:音频通过调整采样率,视频通过调整帧间隔

举个例子。假设音频是主时钟,当前时间是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动态计算当前时间
  • 对齐:通过主从时钟机制,让音频和视频向同一个基准看齐

这套设计的好处是灵活。你可以随时切换主时钟,也可以单独调整某个时钟的速度。我在做多路流同步时,就利用这个特性,让多个视频流都同步到同一个外部时钟上,效果还不错。