3. 音画不同步的常见原因

做音视频开发这么多年,我见过不少新手在时间戳上栽跟头。说白了,PTS 和 DTS 就是视频帧的两张「身份证」——一张告诉你什么时候解码,一张告诉你什么时候显示。但问题在于,这两张身份证上的时间,有时候并不一致。

为什么需要两套时间戳?

先想一个简单场景:一段视频,所有帧都是按顺序编码、按顺序解码、按顺序显示。这种情况下,PTS 和 DTS 完全相等,用一套就够了。

但现实世界没这么理想。视频编码里有个东西叫 B 帧(双向预测帧),它需要参考前后的帧才能解码。这就导致了一个问题:解码顺序和显示顺序是错位的。

举个例子。假设我有三帧:I 帧(第1帧)、B 帧(第2帧)、P 帧(第3帧)。显示顺序应该是 1→2→3。但解码时,B 帧需要依赖 P 帧的信息,所以解码顺序变成了 1→3→2。

你看,解码器必须先拿到 P 帧,才能处理中间的 B 帧。这就是 DTS 存在的意义——它告诉解码器:「按这个顺序来解」。而 PTS 告诉渲染器:「按那个顺序来显示」。

核心区别一句话总结:

  • DTS(Decoding Time Stamp):解码器的工作排期,决定帧的解码顺序
  • PTS(Presentation Time Stamp):渲染器的播放排期,决定帧的显示顺序

B 帧带来的 PTS 乱序问题

我在项目中遇到过最典型的场景,就是处理 H.264 的 B 帧流。当时从摄像头拉流,直接按接收顺序丢给解码器,结果画面一顿一顿的。查了半天才发现——我拿到的顺序是 DTS 顺序,不是 PTS 顺序。

为什么会乱序?我们来看一个实际的 GOP(Group of Pictures)结构:

帧类型 显示顺序(PTS) 解码顺序(DTS) 说明
I 帧 1 1 关键帧,独立解码
P 帧 4 2 前向预测,参考 I 帧
B 帧 2 3 双向预测,参考 I 和 P
B 帧 3 4 双向预测,参考 I 和 P

看到没?显示顺序是 I→B→B→P,但解码顺序是 I→P→B→B。如果你按接收顺序直接渲染,第2帧和第3帧就会变成 P 帧和 B 帧的混搭,画面直接崩掉。

避坑指南:我曾经在做一个播放器项目时,没对 B 帧做 PTS 重排序,结果在低延迟模式下画面撕裂。后来加了一个基于 PTS 的帧缓存队列,才彻底解决。记住:解码器输出的是 DTS 顺序,渲染器需要的是 PTS 顺序。

FFmpeg 中的 pts/dts 字段解析

在 FFmpeg 里,时间戳藏在 AVPacket 和 AVFrame 这两个结构体中。我习惯把它们叫做「数据包」和「解码帧」。

AVPacket 层:原始码流的时间戳

从容器里读出来的数据,还没解码,时间戳存在 AVPacket 里:

typedef struct AVPacket {
    int64_t pts;          // 显示时间戳
    int64_t dts;          // 解码时间戳
    int64_t duration;     // 该帧的持续时间
    int   stream_index;   // 所属流索引
    // ... 其他字段
} AVPacket;

这里有个坑:不是所有格式都同时有 pts 和 dts。比如 MP4 容器里,B 帧的 pts 和 dts 可能不同;但某些直播流(如 RTMP),dts 可能直接等于 pts。我个人习惯是:永远不要假设它们相等。

AVFrame 层:解码后的时间戳

解码完成后,时间戳会传递到 AVFrame:

typedef struct AVFrame {
    int64_t pts;          // 显示时间戳(解码后赋值)
    int64_t pkt_dts;      // 对应的原始包 dts
    int64_t duration;     // 帧持续时间
    // ... 其他字段
} AVFrame;

注意 pkt_dts 这个字段——它记录的是原始 AVPacket 的 dts。如果你需要做帧级别的同步,这个字段很有用。

实用技巧:在 FFmpeg 里,时间戳的单位是 time_base(时间基)。比如 time_base = 1/90000,那 pts = 3600 就代表 3600/90000 = 0.04 秒。我建议你始终用 av_rescale_q() 做时间基转换,别手算——我吃过这个亏,算错过一次,音画不同步了半小时。

PTS/DTS 的处理流程

在实际工程里,时间戳的处理分三步走。我画了一张流程图,帮你理清逻辑:

PTS/DTS 处理流程 读取 AVPacket 判断 pts != dts(有B帧) 是 按 DTS 解码 按 PTS 重排序 否 直接解码 pts = dts 获取 AVFrame.pts 按 PTS 渲染显示 关键点 解码器输出 DTS 顺序 渲染器需要 PTS 顺序 中间需要重排序队列

实际工程中的处理要点

讲完了理论,说说我在实际代码里怎么处理。以下是我总结的几个关键点:

  1. 读取时保留双时间戳:从 av_read_frame() 拿到 AVPacket 后,同时保存 pts 和 dts。别偷懒只存一个。
  2. 解码后取 AVFrame.pts:解码完成后,AVFrame 的 pts 字段会被赋值。如果解码器内部做了重排序,这个 pts 就是正确的显示时间。
  3. 处理 B 帧乱序:如果发现 pts != dts,说明有 B 帧。这时候需要维护一个缓存队列,按 pts 排序后再送渲染。
  4. 时间基统一:不同流(视频、音频)的 time_base 可能不同。用 av_rescale_q() 统一到同一个时间基上再做比较。

一个简单的重排序伪代码:

// 伪代码:B 帧重排序
queue<AVFrame> frame_buffer;

void on_frame_decoded(AVFrame *frame) {
    frame_buffer.push(frame);
    // 按 pts 排序
    sort(frame_buffer.begin(), frame_buffer.end(), 
         [](AVFrame &a, AVFrame &b) { 
             return a.pts < b.pts; 
         });
    // 检查队首帧是否该显示
    while (frame_buffer.front().pts <= current_time) {
        render(frame_buffer.front());
        frame_buffer.pop();
    }
}

嗯,这里要注意:缓存队列的大小不能无限增长。我一般设置最大 30 帧的缓存,超过就强制丢帧。否则内存会爆。

常见问题排查

现象 可能原因 排查方法
画面卡顿、跳跃 PTS 乱序未重排 打印 pts/dts 对比,看是否递增
音画不同步 时间基未统一 检查音频和视频的 time_base 是否一致
解码器报错 DTS 顺序错误 确认送入解码器的顺序是 DTS 顺序
内存持续增长 重排序队列未及时释放 检查队列大小限制和丢帧逻辑

最后说一句:PTS 和 DTS 是音视频同步的基石。搞懂它们,你就能解决 80% 的同步问题。剩下的 20%,我们后面章节慢慢聊。