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 同步策略的对比图

下面这张图展示了三种同步策略的核心流程。你可以看到,无论选哪种,最终都是围绕一个主时钟来协调各个流。

音视频同步策略对比 音频为主同步 主时钟:音频设备时钟 视频根据音频调整 视频快了 → 等待 视频慢了 → 丢帧 优点:音质稳定 缺点:画面可能卡顿 适用:本地播放 视频为主同步 主时钟:视频帧率 音频根据视频调整 音频快了 → 拉长缓冲 音频慢了 → 加速播放 优点:画面实时 缺点:音频可能变调 适用:视频会议 外部时钟同步 主时钟:系统/NTP时间 所有流跟随外部时钟 落后 → 加速追赶 超前 → 等待 优点:多设备统一 缺点:实现复杂 适用:网络直播 核心原则:选择一个主时钟,其他流通过等待或丢帧来对齐

10.6 避坑指南

最后,分享几个我实际开发中遇到的坑,希望能帮你少走弯路。

  1. 时间戳溢出:PTS是int64类型,但有些封装格式用int32。播放长视频时,时间戳可能会回绕。需要做溢出处理。
  2. B帧导致的PTS乱序:解码器输出的帧顺序可能不是PTS顺序。记得在解码后按PTS重新排序。
  3. 音频设备延迟:音频设备写入数据后,不会立刻播放出来,会有几十毫秒的延迟。计算音频时钟时,要把这个延迟考虑进去。
  4. 丢帧策略要温和:不要一发现视频慢了就疯狂丢帧。我建议设置一个阈值,比如超过200ms才丢。否则画面会频繁跳跃。

我曾经犯过的错:在移动端做播放器时,没考虑音频设备挂起的情况。用户接了个电话,音频时钟停了,视频还在按PTS渲染。等电话挂断,画面已经超前了十几秒。后来加了音频时钟有效性检测,才解决这个问题。

好了,关于音视频同步的基础知识就讲到这里。PTS/DTS的理解、三种同步策略的选择、以及实际编码中的细节,都是你做播放器时必须掌握的。动手写一个简单的播放器,跑起来看看效果,比看十遍文章都管用。


公众号:蓝海资料掘金营,微信deep3321