14、音视频同步基础:时间戳管理、同步策略
音视频同步,说白了就是让画面和声音对上嘴。这个问题我当年刚入行时觉得很简单,结果第一次做播放器,画面比声音快了半秒,用户直接反馈「这什么鬼」。嗯,从那以后我再也不敢小看同步了。
今天咱们就聊聊时间戳管理和三种同步策略。我会把我在项目里踩过的坑、总结的经验都抖出来。
14.1 时间戳:同步的基石
没有时间戳,同步就是空谈。每个音频帧和视频帧都带着一个时间标签,告诉播放器「我应该在什么时候被展示」。
14.1.1 PTS 和 DTS
- PTS(Presentation Time Stamp):显示时间戳。告诉播放器这帧画面或这段音频该在什么时候呈现给用户。
- DTS(Decode Time Stamp):解码时间戳。告诉解码器什么时候开始解码这帧数据。
对于大部分音频来说,PTS 和 DTS 是一样的。但视频就不一定了——特别是存在 B 帧(双向预测帧)的时候。B 帧需要依赖前后的帧才能解码,所以解码顺序和显示顺序是错开的。
核心要点:同步时我们只关心 PTS。DTS 是解码器的事,别搞混了。
14.1.2 时间基(Time Base)
时间戳的单位不是「秒」,而是一个分数。比如 time_base = 1/90000,那 PTS = 3600 就代表 3600/90000 = 0.04 秒。
FFmpeg 里用 AVRational 表示时间基。我习惯把所有时间戳统一转成微秒(microseconds)来处理,这样比较直观,不容易出错。
// 将 AVFrame 的 PTS 转为微秒
int64_t pts_us = (int64_t)(frame->pts * av_q2d(time_base) * 1000000);
我的习惯:内部统一用微秒。显示给用户时再转成毫秒或秒。这样加减比较时不会丢精度。
14.2 同步策略:三种主流方案
同步策略说白了就是「谁跟着谁走」。主流方案有三种:音频为主、视频为主、自定义时钟。我个人最常用的是音频为主,因为人对声音的延迟更敏感。
14.2.1 音频为主(Audio Master)
音频时钟作为参考时钟。视频跟着音频走。这是最常用的方案,也是 Android 系统 MediaPlayer 的默认行为。
核心逻辑:
- 音频持续播放,维护一个音频时钟(audio clock)。
- 视频渲染时,拿当前视频帧的 PTS 和音频时钟对比。
- 如果视频帧 PTS 比音频时钟小(视频慢了),就尽快渲染甚至丢帧。
- 如果视频帧 PTS 比音频时钟大(视频快了),就等待。
// 伪代码:音频为主同步
int64_t audio_clock = get_audio_clock(); // 当前音频播放位置(微秒)
int64_t video_pts = frame->pts_us; // 当前视频帧 PTS(微秒)
int64_t diff = video_pts - audio_clock;
if (diff < -100000) {
// 视频落后太多(超过100ms),丢帧
skip_frame();
} else if (diff > 50000) {
// 视频超前(超过50ms),等待
usleep(diff);
} else {
// 在误差范围内,直接渲染
render_frame();
}
注意:阈值不能设得太死。我曾经设了 10ms 的阈值,结果画面频繁等待和丢帧,反而更卡。后来调到 50-100ms 才稳定。
14.2.2 视频为主(Video Master)
视频时钟作为参考。音频跟着视频走。这种方案用得少,但在某些场景下很合适——比如视频会议,画面流畅比声音同步更重要。
核心逻辑:
- 视频渲染时记录当前视频时钟。
- 音频播放时,对比音频 PTS 和视频时钟。
- 如果音频慢了,就加快播放速度(通过重采样或变速)。
- 如果音频快了,就减慢播放速度或插入静音帧。
嗯,这里有个坑。音频变速处理不好会有「小黄人」效果。我建议用 SoundTouch 或 FFmpeg 的 swr_resample 来做,保持音调不变。
14.2.3 自定义时钟(External Clock)
不依赖音频也不依赖视频,自己维护一个独立时钟。音频和视频都跟着这个时钟走。
适用场景:
- 纯视频播放(无音频轨道)。
- 需要精确控制播放速度(比如慢放、快放)。
- 音视频来自不同源(比如直播中的画中画)。
// 自定义时钟实现思路
typedef struct {
int64_t base_time; // 起始时间(微秒)
double speed; // 播放速度(1.0 为正常)
int64_t pause_time; // 暂停时的累计时间
int paused;
} CustomClock;
int64_t get_custom_clock(CustomClock *c) {
if (c->paused) return c->pause_time;
int64_t now = get_current_time_us();
return c->base_time + (int64_t)((now - c->base_time) * c->speed);
}
避坑指南:自定义时钟需要定期校准。我曾经直接用系统时间累加,跑了半小时后偏差了 2 秒。后来每 10 秒用音频时钟校准一次,问题就解决了。
14.3 同步策略对比
| 策略 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|
| 音频为主 | 听觉体验好,实现简单 | 视频可能丢帧或卡顿 | 电影、音乐播放 |
| 视频为主 | 画面流畅,适合实时场景 | 音频处理复杂,可能变调 | 视频会议、直播 |
| 自定义时钟 | 灵活可控,支持变速 | 需要额外校准,实现复杂 | 特殊播放需求、多源同步 |
14.4 同步流程总览
下面这张图展示了音视频同步的整体流程。我把它画成了 SVG,方便你理解数据是怎么流转的。
14.5 实战中的坑与经验
做同步最怕什么?我总结了几条血泪教训:
- 时间戳溢出:PTS 是 int64 的,但有些封装格式用 int32。我曾经遇到一个视频,播放到 2 小时左右画面突然跳回开头——就是因为 PTS 溢出回绕了。解决方案:检测到 PTS 突然变小,就加上一个偏移量。
- B 帧导致的 PTS 乱序:解码器输出的帧顺序可能不是 PTS 顺序。你需要自己维护一个排序队列。我习惯用
std::priority_queue按 PTS 排序。 - 音频驱动延迟:Android 的 AudioTrack 写入后不会立即播放,有几十毫秒的缓冲区延迟。计算音频时钟时要把这个延迟减掉。
// 计算音频时钟时考虑驱动延迟
int64_t audio_clock = audio_track_get_timestamp() - audio_driver_latency_us;
我曾经踩过的坑:在低端手机上,音频时钟的 getTimestamp() 可能不准。后来我改用「已写入字节数 / 采样率」来计算理论时钟,再结合驱动延迟修正,效果稳定多了。
14.6 小结
音视频同步说白了就是三件事:
- 管好时间戳(PTS),统一单位。
- 选好主时钟(音频/视频/自定义)。
- 定好阈值,别太敏感也别太迟钝。
我个人建议新手先从「音频为主」入手,把基础流程跑通。等遇到具体问题(比如直播延迟、变速播放)时,再考虑自定义时钟。同步没有银弹,只有不断调试和优化。