10、音视频同步基础:PTS/DTS时间戳理解、同步策略
做播放器开发,最头疼的问题是什么?
我个人觉得,不是解码慢,也不是渲染卡,而是——音画不同步。
你想想看,画面里人物嘴巴都闭上了,声音还在那儿叭叭地说。或者声音都结束了,画面还在慢悠悠地动。这种体验,用户直接就把播放器关了。
我在项目中遇到过好几次这样的问题。有一次做直播回放功能,本地文件播放好好的,一上网络流就音画错位。排查了三天,最后发现是时间戳处理逻辑有bug。嗯,从那以后,我对PTS/DTS就格外上心。
10.1 时间戳:PTS 和 DTS 到底是什么?
先说结论:PTS是给播放器看的,DTS是给解码器看的。
咱们用大白话解释一下。
- PTS(Presentation Time Stamp):显示时间戳。告诉播放器,这一帧画面应该在什么时刻显示出来。
- DTS(Decoding Time Stamp):解码时间戳。告诉解码器,这一帧数据应该在什么时刻开始解码。
为什么需要两个时间戳?因为视频编码里有B帧(双向预测帧)。
B帧的显示顺序和解码顺序不一样。举个例子:
- 显示顺序:I帧 → B帧 → P帧
- 解码顺序:I帧 → P帧 → B帧
你看,解码器必须先解码P帧,才能拿到B帧需要的参考数据。所以DTS和PTS就分开了。
关键点:对于音频来说,通常没有B帧的概念,所以音频的PTS和DTS是相等的。视频则不一定。
我刚开始做播放器时,直接把视频帧的DTS当PTS用,结果画面时序全乱了。后来才明白,渲染时必须用PTS,解码时才关心DTS。
10.2 时间基:时间戳的单位换算
时间戳本身是一个整数,比如 3600、7200。但它代表多少秒?这取决于时间基(time_base)。
FFmpeg里,时间基通常是一个分数。比如:
time_base = 1/90000:每个单位代表 1/90000 秒time_base = 1/1000:每个单位代表 1 毫秒
换算成秒的公式很简单:
秒数 = PTS × time_base
举个例子:
PTS = 3600, time_base = 1/90000
秒数 = 3600 × (1/90000) = 0.04 秒
嗯,这里要注意:不同流的时间基可能不一样。音频流和视频流的时间基通常是独立的。做同步时,必须先把它们统一换算成同一个时间基准,比如毫秒。
个人习惯:我一般把所有时间戳都转成微秒(microseconds)来比较。这样精度够,计算也方便。
10.3 三种同步策略:你选哪一种?
音视频同步的核心思路很简单:找一个主时钟,其他流跟着它走。
常见的策略有三种。我分别说说它们的优缺点。
| 同步策略 | 主时钟 | 适用场景 | 缺点 |
|---|---|---|---|
| 音频为主 | 音频时钟 | 大多数本地播放 | 音频设备异常时画面会卡 |
| 视频为主 | 视频时钟 | 视频会议、直播推流 | 音频可能断续 |
| 外部时钟 | 系统时间 | 网络播放、多设备同步 | 实现复杂 |
10.3.1 音频为主同步
这是最常用的策略。说白了,就是让视频去适应音频。
为什么?因为人耳对声音的抖动比眼睛对画面的抖动敏感得多。音频一旦卡顿或断续,用户立刻就能察觉。画面稍微快一点慢一点,反而不太明显。
具体做法:
- 音频正常播放,用音频设备的时钟作为主时钟
- 视频渲染时,计算当前视频帧的PTS与音频时钟的差值
- 如果视频快了,就延迟渲染;如果视频慢了,就丢帧追赶
我曾经踩过的坑:音频设备打开后,它的时钟可能不是从0开始的。比如你打开音频设备,它内部已经跑了100ms。这时候直接拿音频时钟和视频PTS比较,会发现视频永远落后。正确的做法是:记录音频设备启动时的系统时间,然后计算相对偏移。
10.3.2 视频为主同步
这种策略反过来,让音频去适应视频。
什么时候用?比如视频会议场景。画面必须实时,不能延迟。声音稍微滞后一点,用户还能接受。
做法:
- 视频按PTS正常渲染
- 音频播放时,根据视频时钟调整播放速度
- 如果音频快了,就拉长音频缓冲区;如果慢了,就加快播放
嗯,这里要注意:调整音频速度不能改变音调,否则听起来像变声了。需要用音调不变的时间缩放算法,比如WSOLA算法。
10.3.3 外部时钟同步
这种策略不依赖音频或视频设备,而是用系统时间或者NTP时间作为主时钟。
适用场景:
- 网络直播,多个客户端需要同步
- 多屏互动,比如电视和手机同时播放
做法:
- 所有流都按照外部时钟的绝对时间进行渲染
- 如果某个流落后了,就加速追赶;如果超前了,就等待
这种策略实现起来最复杂,但也是最可控的。
10.4 实现一个简单的播放器
说了这么多理论,咱们动手写个简单的播放器核心逻辑。这里我用伪代码加注释的方式,方便你理解整体流程。
// 播放器主循环
void play(const char* url) {
// 1. 打开文件,获取音视频流
AVFormatContext* fmt_ctx = avformat_open_input(url);
avformat_find_stream_info(fmt_ctx);
// 2. 找到音频和视频流索引
int audio_idx = find_best_stream(fmt_ctx, AVMEDIA_TYPE_AUDIO);
int video_idx = find_best_stream(fmt_ctx, AVMEDIA_TYPE_VIDEO);
// 3. 打开解码器
AVCodecContext* audio_ctx = open_codec(fmt_ctx, audio_idx);
AVCodecContext* video_ctx = open_codec(fmt_ctx, video_idx);
// 4. 初始化音频设备(使用SDL或ALSA)
AudioDevice* audio_dev = open_audio_device(audio_ctx);
// 5. 初始化视频渲染窗口
VideoWindow* video_win = create_window();
// 6. 主循环:读取包、解码、同步、渲染
AVPacket pkt;
while (av_read_frame(fmt_ctx, &pkt) >= 0) {
if (pkt.stream_index == audio_idx) {
// 音频包:解码后直接送入音频设备
AVFrame* frame = decode_audio(audio_ctx, &pkt);
audio_dev->play(frame);
} else if (pkt.stream_index == video_idx) {
// 视频包:解码后需要同步
AVFrame* frame = decode_video(video_ctx, &pkt);
// 获取当前音频时钟(以音频为主同步)
double audio_clock = audio_dev->get_clock();
// 计算视频帧的PTS(转成秒)
double video_pts = frame->pts * av_q2d(video_ctx->time_base);
// 计算差值
double diff = video_pts - audio_clock;
if (diff > 0) {
// 视频快了,等待
usleep(diff * 1000000);
} else if (diff < -0.1) {
// 视频慢了超过100ms,丢帧
av_frame_free(&frame);
continue;
}
// 渲染视频帧
video_win->render(frame);
av_frame_free(&frame);
}
av_packet_unref(&pkt);
}
}
这段代码的核心逻辑就是:读包 → 解码 → 比较PTS和音频时钟 → 决定等待还是丢帧。
我建议:实际项目中,不要在主循环里直接sleep。应该用条件变量或者定时器来触发渲染。否则UI线程会被阻塞,用户体验很差。
10.5 同步策略的对比图
下面这张图展示了三种同步策略的核心流程。你可以看到,无论选哪种,最终都是围绕一个主时钟来协调各个流。
10.6 避坑指南
最后,分享几个我实际开发中遇到的坑,希望能帮你少走弯路。
- 时间戳溢出:PTS是int64类型,但有些封装格式用int32。播放长视频时,时间戳可能会回绕。需要做溢出处理。
- B帧导致的PTS乱序:解码器输出的帧顺序可能不是PTS顺序。记得在解码后按PTS重新排序。
- 音频设备延迟:音频设备写入数据后,不会立刻播放出来,会有几十毫秒的延迟。计算音频时钟时,要把这个延迟考虑进去。
- 丢帧策略要温和:不要一发现视频慢了就疯狂丢帧。我建议设置一个阈值,比如超过200ms才丢。否则画面会频繁跳跃。
我曾经犯过的错:在移动端做播放器时,没考虑音频设备挂起的情况。用户接了个电话,音频时钟停了,视频还在按PTS渲染。等电话挂断,画面已经超前了十几秒。后来加了音频时钟有效性检测,才解决这个问题。
好了,关于音视频同步的基础知识就讲到这里。PTS/DTS的理解、三种同步策略的选择、以及实际编码中的细节,都是你做播放器时必须掌握的。动手写一个简单的播放器,跑起来看看效果,比看十遍文章都管用。