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 的默认行为。

核心逻辑

  1. 音频持续播放,维护一个音频时钟(audio clock)。
  2. 视频渲染时,拿当前视频帧的 PTS 和音频时钟对比。
  3. 如果视频帧 PTS 比音频时钟小(视频慢了),就尽快渲染甚至丢帧。
  4. 如果视频帧 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 和视频时钟。
  • 如果音频慢了,就加快播放速度(通过重采样或变速)。
  • 如果音频快了,就减慢播放速度或插入静音帧。

嗯,这里有个坑。音频变速处理不好会有「小黄人」效果。我建议用 SoundTouchFFmpeg 的 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,方便你理解数据是怎么流转的。

音视频同步流程 解复用(Demuxer) 音频解码 视频解码 音频时钟 同步模块(对比 PTS + 时钟) 渲染输出 音频流 视频流 同步控制

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),统一单位。
  • 选好主时钟(音频/视频/自定义)。
  • 定好阈值,别太敏感也别太迟钝。

我个人建议新手先从「音频为主」入手,把基础流程跑通。等遇到具体问题(比如直播延迟、变速播放)时,再考虑自定义时钟。同步没有银弹,只有不断调试和优化。

公众号:蓝海资料掘金营,微信deep3321