时间戳与同步:音画不同步的罪魁祸首

专栏导读:你有没有遇到过视频中人物"对不上口型"的情况?这背后涉及音视频播放器最核心的技术——时间戳管理和同步。这一篇带你理解 PTS/DTS,掌握音视频同步的数学原理。


⏱️ 开场:音画不同步有多难受?

想象这样的场景:

视频画面: 演员张嘴说话 👄
音频声音: 0.5 秒后才传来声音 🔊
→ 像在看配音不准的烂片 😖

为什么会不同步?

视频流: 每秒 30 帧,每帧间隔 33.3 ms
音频流: 每秒 44100 个样本,每个样本间隔 0.023 ms

问题:
  1. 视频和音频的"时钟"不一样
  2. 解码速度不一致(视频慢,音频快)
  3. 网络传输有延迟(直播场景)

人眼的容忍度

音频超前视频 < 20 ms:  基本察觉不到 ✅
视频超前音频 < 40 ms:  可以接受 🆗
差距 > 100 ms:        明显不同步 ❌

核心问题:如何确保视频帧和音频样本在正确的时刻播放?

答案:时间戳(Timestamp)


📏 核心概念:PTS 和 DTS

PTS(Presentation Time Stamp)

定义:这一帧/样本应该呈现(显示/播放)的时间。

视频 PTS 示例:
  帧 0: PTS = 0 ms       → 立即显示
  帧 1: PTS = 33.3 ms    → 33.3 ms 时显示
  帧 2: PTS = 66.6 ms    → 66.6 ms 时显示
  帧 3: PTS = 100 ms     → 100 ms 时显示
音频 PTS 示例:
  样本 0-1023:   PTS = 0 ms
  样本 1024-2047: PTS = 23.2 ms  (1024/44100 ≈ 23.2 ms)
  样本 2048-3071: PTS = 46.4 ms

请添加图片描述


DTS(Decoding Time Stamp)

定义:这一帧应该解码的时间。

为什么需要 DTS?

因为视频编码中有 B 帧(双向预测帧),它依赖未来的帧

解码顺序 (DTS):    I₀  P₃  B₁  B₂  P₆  B₄  B₅
显示顺序 (PTS):    I₀  B₁  B₂  P₃  B₄  B₅  P₆

解释:
  B₁ 需要参考 I₀ 和 P₃,所以必须先解码 P₃
  → DTS(B₁) > DTS(P₃),但 PTS(B₁) < PTS(P₃)

请添加图片描述

关键规律

对于 I 帧和 P 帧:
  DTS = PTS  (解码即显示)

对于 B 帧:
  DTS > PTS  (解码后要等待,按 PTS 显示)

🔢 时间基(Time Base):统一的时钟

问题:不同流的时间单位不同

视频流: 1 秒 = 90000 ticks  (常见的 90 kHz 时间基)
音频流: 1 秒 = 44100 ticks  (采样率)
系统时钟: 1 秒 = 1000 ms

如何统一?

FFmpeg 使用**时间基(Time Base)**的概念:

Time Base = 1 / frequency

示例:
  视频 Time Base = 1/90000  (每 tick = 1/90000 秒)
  音频 Time Base = 1/44100  (每 tick = 1/44100 秒)

时间戳转换公式

核心公式

实际时间 (秒) = PTS × Time Base

示例 1 (视频):
  PTS = 3000 (ticks)
  Time Base = 1/90000
  实际时间 = 3000 / 90000 = 0.0333 秒 = 33.3 ms

示例 2 (音频):
  PTS = 1024 (样本数)
  Time Base = 1/44100
  实际时间 = 1024 / 44100 = 0.0232 秒 = 23.2 ms

转换到毫秒

// ZenPlay 项目中的实际代码(简化版)
double pts_seconds = packet->pts * av_q2d(stream->time_base);
int64_t pts_ms = static_cast<int64_t>(pts_seconds * 1000);

在这里插入图片描述


FFmpeg 中的时间基

常见时间基

流类型 常见时间基 含义
视频 1/90000 MPEG 标准时钟频率
音频 1/采样率 例如 1/44100, 1/48000
字幕 1/1000 毫秒级精度
容器 1/1000 或 1/90000 取决于容器格式

注意:同一视频文件中,容器的时间基流的时间基

示例:
  MP4 容器: Time Base = 1/1000 (毫秒)
  H.264 流: Time Base = 1/90000 (90 kHz)
  
转换: 需要用 av_rescale_q()

🎬 实战案例:B 帧的时间戳顺序

编码顺序 vs 显示顺序

假设视频帧序列:

I₀  B₁  B₂  P₃  B₄  B₅  P₆  ...

依赖关系

  • B₁ 依赖 I₀ 和 P₃
  • B₂ 依赖 I₀ 和 P₃
  • B₄ 依赖 P₃ 和 P₆
  • B₅ 依赖 P₃ 和 P₆

实际编码和传输顺序(DTS)

I₀ → P₃ → B₁ → B₂ → P₆ → B₄ → B₅

播放器的处理流程

解封装器 解码器 重排序缓冲区 渲染器 I₀ (DTS=0, PTS=0) 解码完成 PTS=0 立即显示 P₃ (DTS=100, PTS=100) 解码完成,等待 B₁ (DTS=33, PTS=33) 解码完成 PTS=33 显示 B₁ B₂ (DTS=67, PTS=67) 解码完成 PTS=67 显示 B₂ PTS=100 显示 P₃ 解封装器 解码器 重排序缓冲区 渲染器

关键点

  1. 解码器必须提前解码 P₃
  2. 解码后的帧放入缓冲区
  3. 渲染器按 PTS 排序后显示

📊 实战:FFmpeg 时间戳分析

实验 1:查看数据包的时间戳

# 显示视频流的前 20 个数据包的 PTS/DTS
ffprobe -show_packets -select_streams v:0 -read_intervals "%+#20" input.mp4

# 只显示关键字段(PTS, DTS, 帧类型)
ffprobe -show_packets -show_entries packet=pts,dts,flags -select_streams v:0 \
        -read_intervals "%+#20" input.mp4

输出示例(H.264 视频):

[PACKET]
pts=0
dts=0
flags=K_      # K = 关键帧 (I 帧)
[/PACKET]

[PACKET]
pts=3003
dts=1001      # DTS < PTS,这是 B 帧
flags=__
[/PACKET]

[PACKET]
pts=1001
dts=2002      # DTS > PTS,B 帧解码后需要重排序
flags=__
[/PACKET]

[PACKET]
pts=2002
dts=3003
flags=__
[/PACKET]

[PACKET]
pts=6006
dts=4004      # P 帧
flags=__
[/PACKET]

观察

  • I 帧pts == dts,标记 K_
  • B 帧pts < dts,标记 __
  • P 帧pts == dts,标记 __

🧠 思考题

Q1:为什么 I 帧一定是 DTS == PTS,而 B 帧一定是 DTS > PTS

点击查看答案

I 帧(独立帧)

I 帧不依赖任何其他帧
→ 收到即可解码
→ 解码完成即可显示
→ DTS == PTS

B 帧(双向预测帧)

B₁ 依赖 I₀ 和 P₃
→ 必须等 P₃ 先解码完成
→ 传输顺序: I₀, P₃, B₁
→ 解码时间 DTS(B₁) 晚于 DTS(P₃)
→ 但显示时间 PTS(B₁) 早于 PTS(P₃)
→ 因此 DTS(B₁) > PTS(B₁)

数值示例(时间单位:毫秒):

帧序列: I₀  B₁  B₂  P₃

显示顺序 (PTS):
  I₀: PTS=0
  B₁: PTS=33
  B₂: PTS=67
  P₃: PTS=100

传输顺序 (DTS):
  I₀: DTS=0   (第一个解码)
  P₃: DTS=100 (第二个解码)
  B₁: DTS=133 (第三个解码)
  B₂: DTS=167 (第四个解码)

对比:
  I₀: DTS=0,   PTS=0    → DTS == PTS ✅
  P₃: DTS=100, PTS=100  → DTS == PTS ✅
  B₁: DTS=133, PTS=33   → DTS > PTS ✅
  B₂: DTS=167, PTS=67   → DTS > PTS ✅

Q2:为什么播放器通常选择"音频为主"的同步策略,而不是"视频为主"?

点击查看答案

原因 1:人耳对延迟更敏感

音频断续阈值: 50 ms  (人耳能明显察觉)
视频丢帧阈值: 100 ms (人眼不易察觉)

原因 2:音频必须连续输出

音频硬件 (声卡): 每秒需要 44100 个样本
→ 如果数据供应不及时 → 产生"咔咔"爆音
→ 无法容忍中断

视频硬件 (显卡): 可以偶尔重复帧/跳帧
→ 人眼不易察觉(视觉暂留效应)

原因 3:音频时钟更稳定

音频时钟: 由硬件晶振提供,精度 ±10 ppm
视频解码: 受 CPU 负载影响,时间不固定
  - I 帧解码慢 (10-20 ms)
  - P/B 帧解码快 (2-5 ms)

实际策略

正常播放: 音频为主,视频追音频 ✅
静音模式: 视频为主(无音频参考)
直播场景: 外部时钟为主(服务器时间)

Q3:如果视频文件没有 B 帧(例如 -bf 0 编码),是否就不需要 DTS 了?

点击查看答案

理论上:是的!

如果只有 I 帧和 P 帧:

I₀  P₁  P₂  P₃  P₄ ...

依赖关系:
  P₁ 依赖 I₀  → 必须先解码 I₀
  P₂ 依赖 P₁  → 必须先解码 P₁
  ...

解码顺序 = 显示顺序
→ DTS == PTS(始终相等)
→ 不需要单独的 DTS 字段

实际情况

某些容器格式 (如 raw H.264 bitstream) 确实不存储 DTS
→ 解码器根据帧依赖关系推算

但大多数容器 (MP4/MKV) 仍然会写入 DTS
→ 便于 seek 和流处理
→ 即使 DTS == PTS,也会冗余存储

ZenPlay 的处理

// 即使没有 B 帧,也优先使用 PTS
if (frame->pts != AV_NOPTS_VALUE) {
  use_timestamp = frame->pts;
} else if (frame->pkt_dts != AV_NOPTS_VALUE) {
  use_timestamp = frame->pkt_dts;  // 回退到 DTS
} else {
  use_timestamp = frame->best_effort_timestamp;
}

📚 下一篇预告

下一篇《解封装:打开视频文件的第一步》,我们将深入探讨:

  • 容器格式(MP4/MKV/FLV)的结构
  • Demuxer 的工作原理(如何分离音视频流)
  • Seek 操作的实现(快进/快退的秘密)
  • ZenPlay 项目中的 Demuxer 类实现

敬请期待!📦


🔗 相关资源

  • FFmpeg 时间基文档:https://ffmpeg.org/ffmpeg-utils.html#time-duration-syntax
  • MPEG 时间戳标准:ISO/IEC 13818-1 (PTS/DTS 定义)
  • 推荐阅读:《FFmpeg 从入门到精通》
  • ZenPlay 项目地址https://github.com/Sunshine334419520/zenplay
Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐