4. 时钟与时间基准
说实话,做音频同步这么多年,我见过太多翻车案例。最后查下来,十有八九是时间戳没搞对。时间戳这东西,看着简单,但坑特别多。今天我就把这块掰开了讲清楚。
4.1 时间戳到底是个啥?
时间戳,说白了就是给每一帧音频数据贴个标签。告诉播放器:这帧数据应该在什么时候播放。没有这个标签,播放器就是个瞎子,不知道什么时候该出声。
我习惯把时间戳理解成「火车时刻表」。每一帧数据就像一节车厢,时间戳就是它到站的时间。没有时刻表,火车乱跑,乘客全懵。
核心概念:时间戳是音频同步的基准。所有音视频流的对齐,最终都要落到时间戳上。
在音视频领域,时间戳通常用 pts 和 dts 两个值来表示。嗯,这里要注意,这两个东西不一样,很多人搞混。
4.2 PTS 与 DTS:一对双胞胎,性格不同
PTS(Presentation Time Stamp)——显示时间戳。它告诉播放器:这帧数据什么时候该展示给用户。
DTS(Decoding Time Stamp)——解码时间戳。它告诉解码器:这帧数据什么时候该解码。
你可能会问:为什么要有两个?直接用一个不就行了?
我刚开始做的时候也这么想。直到遇到 B 帧(双向预测帧),才明白为什么需要两个时间戳。
举个例子,一个典型的 GOP(Group of Pictures)结构:
帧顺序: I B B P B B P
显示顺序:0 1 2 3 4 5 6
解码顺序:0 2 3 1 5 6 4
看到了吗?B 帧需要参考后面的 P 帧才能解码。所以解码顺序和显示顺序不一样。DTS 管解码顺序,PTS 管显示顺序。
| 属性 | PTS | DTS |
|---|---|---|
| 全称 | Presentation Time Stamp | Decoding Time Stamp |
| 作用 | 控制显示/播放时机 | 控制解码时机 |
| 适用场景 | 所有帧类型 | 存在 B 帧时必须 |
| 关系 | PTS ≥ DTS | DTS ≤ PTS |
| 常见错误 | PTS 乱序导致画面跳跃 | DTS 缺失导致解码失败 |
我的习惯:只要编码器支持 B 帧,我一定同时维护 PTS 和 DTS。哪怕有些播放器只认 PTS,我也把 DTS 带上。为什么?因为后面做转码、剪辑的时候,DTS 能救命。
4.3 时间戳对齐:音频和视频的握手
音频和视频是两条独立的流。它们各自有自己的时间戳。对齐,就是让这两条流的时间戳能对应上。
我记得有一次做直播项目,音频和视频总是对不上。查了两天,最后发现是音频采样率和视频帧率的时间基准不一样。音频用毫秒,视频用微秒,差了 1000 倍。
对齐的核心原则:统一时间基准。
常见的做法:
- 统一使用微秒(μs):精度高,适合高帧率场景
- 统一使用毫秒(ms):计算简单,适合低延迟场景
- 使用时间基(time_base):FFmpeg 的做法,用分数表示
举个例子,FFmpeg 里怎么对齐:
// 音频时间基:1/48000(每 tick 代表一个采样点)
// 视频时间基:1/90000(每 tick 代表 1/90000 秒)
// 音频 PTS 转换到视频时间基
audio_pts_in_video_tb = audio_pts * 90000 / 48000;
// 视频 PTS 转换到音频时间基
video_pts_in_audio_tb = video_pts * 48000 / 90000;
这里有个坑:整数溢出。音频采样率 48000,视频时间基 90000,乘起来很容易超过 32 位整数范围。我建议用 64 位整数,或者用浮点数计算。
我曾经踩过的坑:在嵌入式设备上,CPU 不支持 64 位整数除法。结果时间戳转换全错了,音视频差了 2 秒多。后来改用查表法,提前算好转换因子,才解决。
4.4 时间戳抖动处理:让播放器不抽风
时间戳抖动,就是时间戳忽大忽小,不连续。这会导致播放器卡顿、音画不同步、甚至崩溃。
抖动的常见原因:
- 网络抖动:RTP 包乱序、丢包重传
- 编码器不稳定:帧率波动、编码延迟
- 容器封装问题:时间戳写入错误
- 系统时钟漂移:不同设备时钟不同步
怎么处理?我一般用三个方法:
- 抖动缓冲区(Jitter Buffer):缓存几帧数据,平滑输出
- 时间戳修正(Timestamp Correction):检测异常值,用插值替换
- 时钟同步(Clock Synchronization):用 NTP 或 PTP 同步设备时钟
下面是我常用的抖动检测算法:
// 检测时间戳是否抖动
bool is_jitter(int64_t current_pts, int64_t last_pts, int64_t expected_interval) {
int64_t diff = current_pts - last_pts;
// 正常范围:0.5x ~ 2x 期望间隔
if (diff < expected_interval * 0.5 || diff > expected_interval * 2.0) {
return true; // 抖动
}
// 负值:时间戳回退
if (diff < 0) {
return true; // 严重抖动
}
return false;
}
我的经验:抖动阈值不要设太死。网络环境好的时候,1.5 倍阈值就够了。弱网环境,我放宽到 3 倍。太严格反而容易误判,导致播放器频繁重置。
修正抖动时,我习惯用线性插值:
// 修正抖动的时间戳
int64_t correct_pts(int64_t last_pts, int64_t expected_interval, int frame_count) {
return last_pts + expected_interval * frame_count;
}
嗯,这里要注意:修正后的时间戳不能超过原始时间戳太多。否则会导致音视频整体偏移。我一般限制修正幅度在 10% 以内。
4.5 时间戳处理流程总览
说了这么多,画张图总结一下整个流程:
这张图把整个流程串起来了。从原始流进来,到解析 PTS/DTS,再到对齐、检测抖动、修正,最后输出同步后的流。每一步都有坑,每一步都要小心。
4.6 避坑指南
最后,把我这些年踩过的坑总结一下:
我曾经犯过的错:
- 忽略 DTS:只维护 PTS,结果遇到 B 帧多的视频,解码器直接崩了。后来老老实实把 DTS 加上。
- 时间基准不统一:音频用采样率做基准,视频用帧率做基准,结果对齐的时候算错了。现在统一用微秒。
- 抖动阈值太死:弱网环境下频繁触发抖动修正,导致播放器一直在重置。后来改成自适应阈值。
- 整数溢出:32 位整数做时间戳转换,乘完直接溢出。现在全部用 64 位。
时间戳这东西,看着简单,但细节特别多。你想想看,一个直播系统里,音频、视频、字幕、弹幕,全都要靠时间戳对齐。任何一个环节出问题,用户体验就是灾难。
我个人习惯是:宁可多算一步,不要少算一步。时间戳相关的代码,我都会加详细的日志。出问题的时候,看日志就能定位到是哪一步算错了。
嗯,时间戳这块就讲这么多。记住一句话:时间戳是音频同步的命根子。命根子保住了,同步就成功了一半。