封装格式与时间基:MP4/MKV/TS 的时间基解析
各位好,我是老周。今天咱们聊聊封装格式里的时间基。这玩意儿看着基础,但我在项目里见过太多人在这上面栽跟头。说白了,时间基就是音视频同步的「尺子」——尺子刻度不对,后面所有同步逻辑都是空中楼阁。
2.1 为什么需要时间基?
你想想看,一个视频文件里,视频帧可能是 30fps,音频采样率是 48000Hz。这两个东西的时间刻度完全不一样。封装格式必须有个统一的时间坐标系,才能把视频帧和音频样本对齐到同一个时间轴上。
这个统一坐标系,就是时间基(Time Base)。
核心概念:时间基定义了时间戳的最小刻度单位。比如时间基是 1/1000 秒,那时间戳 500 就代表 0.5 秒。所有音视频帧的时间戳,都基于这个刻度来计算。
2.2 三大封装格式的时间基设计
2.4.1 MP4:基于毫秒的优雅设计
MP4 的时间基设计得很干净。它用 timescale 来表示每秒的刻度数。比如 timescale = 1000,那每个刻度就是 1 毫秒。
我记得第一次解析 MP4 时,看到 moov box 里的 mdhd 字段,里面有个 timescale 值。当时我直接拿它当帧率用,结果音频对不上。后来才明白,timescale 是时间基,不是帧率。
// MP4 时间戳计算示例
// mdhd box 中 timescale = 1000
// 视频帧的 duration = 33(表示 33ms,约 30fps)
// 音频帧的 duration = 23(表示 23ms,约 43fps)
// 时间戳转换
uint64_t pts_ms = (uint64_t)pts * 1000 / timescale;
// 如果 timescale = 1000,pts 直接就是毫秒值
| MP4 关键字段 | 含义 | 典型值 |
|---|---|---|
| timescale | 每秒的刻度数 | 1000, 90000, 48000 |
| duration | 当前帧占用的刻度数 | 33(30fps 视频) |
| composition_time_offset | 解码时间到显示时间的偏移 | 0(B 帧场景下非零) |
2.4.2 MKV:灵活的分数时间基
MKV 的时间基设计更灵活。它用 TimecodeScale 来表示每个刻度的纳秒数。默认是 1000000(1 毫秒),但你可以改成任意值。
我曾经处理过一个 MKV 文件,它的 TimecodeScale 设成了 10000000(10 毫秒)。结果所有时间戳都变成了 10 的倍数,精度直接丢了。嗯,这就是灵活性的代价——你得自己处理精度问题。
// MKV 时间戳转换
// TimecodeScale = 1000000(1ms)
// 视频帧的 Timecode = 33(表示 33ms)
// 音频帧的 Timecode = 23(表示 23ms)
// 转换为毫秒
uint64_t pts_ms = timecode * timecode_scale / 1000000;
// 转换为 90kHz 时间基(用于音视频同步)
uint64_t pts_90k = pts_ms * 90;
注意:MKV 的 TimecodeScale 可以是非整数吗?理论上可以,但实际编码器很少这么干。我建议你解析时直接取整,避免浮点精度问题。
2.4.3 TS:MPEG-2 的 90kHz 标准
TS 流的时间基最「硬核」。它基于 MPEG-2 系统层标准,固定使用 90kHz 作为时间基。为什么是 90kHz?因为 90kHz 能被常见的帧率(24、25、30、50、60)整除,计算起来方便。
TS 流里有两种时间戳:
- PCR(Program Clock Reference):系统时钟参考,用于解码器恢复时钟
- PTS/DTS(Presentation/Decoding Time Stamp):显示/解码时间戳
// TS 时间戳解析
// PCR 由两部分组成:PCR_base(33位) + PCR_ext(9位)
// PCR_base 单位是 1/90000 秒
// PCR_ext 单位是 1/27000000 秒
// 计算 PCR 值(单位:秒)
double pcr_seconds = pcr_base / 90000.0 + pcr_ext / 27000000.0;
// PTS 是 33 位整数,单位也是 1/90000 秒
uint64_t pts_seconds = pts / 90000;
2.3 tbn / tbr / tbc 概念
这三个缩写,我在面试时经常问。很多人答不上来,或者混为一谈。咱们一个一个说清楚。
| 缩写 | 全称 | 含义 | 典型值 |
|---|---|---|---|
| tbn | Time Base Numerator | 时间基的分子(即时间基的倒数) | 1000, 90000, 48000 |
| tbr | Time Base Rate | 帧率(视频)或采样率(音频) | 30, 25, 48000 |
| tbc | Time Base Codec | 编解码器内部的时间基 | 90000(H.264), 48000(AAC) |
tbn 是封装格式的时间基。比如 MP4 的 timescale = 1000,那 tbn = 1000。所有时间戳在封装层都用这个刻度。
tbr 是帧率或采样率。视频 30fps,音频 48000Hz。这个值决定了每秒钟有多少帧或样本。
tbc 是编解码器内部的时间基。H.264 编码器内部用 90kHz,AAC 用 48kHz。解码器输出的时间戳,通常基于 tbc。
我的经验:做音视频同步时,我习惯把所有时间戳统一到毫秒(tbn = 1000)。这样计算简单,调试也方便。但要注意精度——如果你处理的是 24fps 的视频,毫秒精度可能不够,建议用微秒或 90kHz。
2.4 时间基转换公式
实际开发中,你经常需要在不同时间基之间转换。公式其实很简单:
// 从时间基 A 转换到时间基 B
// pts_a:在时间基 A 下的时间戳
// tbn_a:时间基 A 的刻度数
// tbn_b:时间基 B 的刻度数
uint64_t pts_b = (uint64_t)pts_a * tbn_b / tbn_a;
// 示例:从 90kHz 转换到毫秒
// pts_90k = 45000(表示 0.5 秒)
// tbn_90k = 90000
// tbn_ms = 1000
uint64_t pts_ms = (uint64_t)45000 * 1000 / 90000; // = 500(毫秒)
这里有个坑:乘法可能溢出。pts_a 是 33 位或 64 位整数,乘以 tbn_b 后可能超过 64 位。我建议先除后乘,或者用 128 位中间变量。
// 安全的转换方式(先除后乘)
// 前提:pts_a 能被 tbn_a 整除
uint64_t pts_b = (pts_a / tbn_a) * tbn_b;
// 如果不能整除,用 128 位
// 或者用浮点(不推荐,精度问题)
double pts_b_double = (double)pts_a * tbn_b / tbn_a;
2.5 时间基转换的实战场景
我做过一个项目,需要把 TS 流里的音频同步到 MP4 容器里。TS 的时间基是 90kHz,MP4 是 1000。转换过程是这样的:
- 从 TS 解析出 PTS(90kHz 单位)
- 转换为毫秒:
pts_ms = pts_90k * 1000 / 90000 - 写入 MP4 的
mdhd.timescale = 1000 - 计算 MP4 的 duration:
duration = pts_ms
看起来简单?但实际遇到的问题是:TS 的 PTS 是 33 位整数,最大值约 9 小时。超过这个时间,PTS 会回绕。你得处理回绕逻辑,否则时间戳会突然变小。
避坑指南:我曾经在处理 24 小时直播流时,没处理 PTS 回绕,结果音频在 9 小时后突然跳到开头。从那以后,我所有时间戳处理都加了回绕检测。
2.6 知识体系图
下面这张图总结了本章的核心逻辑:
这张图展示了从封装格式到时间基,再到 tbn/tbr/tbc 概念,最后到转换公式和实战的完整链路。你写代码时,按这个顺序思考,基本不会乱。
2.7 小结
时间基是音视频同步的基石。MP4 用 timescale,MKV 用 TimecodeScale,TS 用 90kHz。tbn 是封装时间基,tbr 是帧率/采样率,tbc 是编解码器时间基。转换公式就是简单的比例计算,但要注意溢出和回绕。
嗯,这一章就到这儿。下回咱们聊聊时间戳的生成和校正——那才是真正考验细节的地方。