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 包乱序、丢包重传
  • 编码器不稳定:帧率波动、编码延迟
  • 容器封装问题:时间戳写入错误
  • 系统时钟漂移:不同设备时钟不同步

怎么处理?我一般用三个方法:

  1. 抖动缓冲区(Jitter Buffer):缓存几帧数据,平滑输出
  2. 时间戳修正(Timestamp Correction):检测异常值,用插值替换
  3. 时钟同步(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 时间戳对齐 抖动检测 抖动修正 直接输出 同步后的音视频流 关键点 • 统一时间基准 • 检测抖动阈值 • 修正幅度限制

这张图把整个流程串起来了。从原始流进来,到解析 PTS/DTS,再到对齐、检测抖动、修正,最后输出同步后的流。每一步都有坑,每一步都要小心。

4.6 避坑指南

最后,把我这些年踩过的坑总结一下:

我曾经犯过的错:

  • 忽略 DTS:只维护 PTS,结果遇到 B 帧多的视频,解码器直接崩了。后来老老实实把 DTS 加上。
  • 时间基准不统一:音频用采样率做基准,视频用帧率做基准,结果对齐的时候算错了。现在统一用微秒。
  • 抖动阈值太死:弱网环境下频繁触发抖动修正,导致播放器一直在重置。后来改成自适应阈值。
  • 整数溢出:32 位整数做时间戳转换,乘完直接溢出。现在全部用 64 位。

时间戳这东西,看着简单,但细节特别多。你想想看,一个直播系统里,音频、视频、字幕、弹幕,全都要靠时间戳对齐。任何一个环节出问题,用户体验就是灾难。

我个人习惯是:宁可多算一步,不要少算一步。时间戳相关的代码,我都会加详细的日志。出问题的时候,看日志就能定位到是哪一步算错了。

嗯,时间戳这块就讲这么多。记住一句话:时间戳是音频同步的命根子。命根子保住了,同步就成功了一半。