18、音视频同步:DTS/PTS概念、同步策略、音视频队列管理、时间戳校准

音视频同步,圈里人常说的「唇同步」,是直播推流里最磨人的环节之一。我早年做播放器时,遇到过画面都播到第三幕了,声音还在念片头字幕——那种割裂感,用户直接骂娘。后来做推流,才真正体会到:录制端不同步,后面解码端再怎么折腾也救不回来。

说白了,音视频同步的核心就一句话:让音频和视频按照各自的时间戳,在同一个时间轴上对齐播放。但实现起来,坑多得很。今天我就把这块掰开揉碎讲清楚。

18.1 DTS 与 PTS:时间戳到底在戳什么?

先搞清楚两个基本概念:DTS(Decoding Time Stamp)PTS(Presentation Time Stamp)

  • PTS:显示时间戳。告诉播放器「这帧画面/这段音频应该在什么时候呈现给用户」。
  • DTS:解码时间戳。告诉解码器「这帧数据应该在什么时候开始解码」。

你可能会问:为什么要有两个?直接一个 PTS 不就行了?

嗯,这里有个关键点——视频编码存在 B 帧。B 帧是双向预测帧,它需要参考前后的 I 帧和 P 帧才能解码。所以解码顺序和显示顺序往往不一致。举个例子:

显示顺序: I1  P2  B3  P4  B5
解码顺序: I1  P2  P4  B3  B5

你看,B3 虽然显示在 P2 之后,但解码时它必须等 P4 先解码完。所以 DTS 和 PTS 就分开了。我在项目中遇到过新手直接把 DTS 当 PTS 用,结果画面一顿一顿的,排查了半天才发现是时间戳搞混了。

重要结论:对于音频,由于没有 B 帧,DTS 和 PTS 通常是相等的。对于视频,如果编码器开启了 B 帧,两者必然不同。

18.2 同步策略:三种主流玩法

音视频同步的策略,业界主要有三种。我按推荐程度排个序:

  1. 音频为主同步(Audio Master):以音频时钟为基准,视频去对齐音频。这是最常用的方案,因为人耳对音频抖动的敏感度远高于视频。你想想看,画面卡一下还能忍,声音一卡一卡的,直接没法听。
  2. 视频为主同步(Video Master):以视频时钟为基准,音频去对齐视频。用得少,除非你的场景对视频帧率要求极其严格。
  3. 外部时钟同步(External Clock):用一个独立的时钟源(比如 NTP 时间戳)作为基准,音视频各自去对齐。适合多设备协同录制或直播场景。

我个人习惯用音频为主同步。为什么?因为音频的采样率是固定的(比如 44100Hz),时钟精度天然高。而视频帧率受编码器负载、网络波动影响大,用它做主时钟容易飘。

我的经验:在 Android 上做音频为主同步时,建议用 AudioTrack 的 getTimestamp() 方法获取当前播放进度,而不是自己维护一个计数器。后者容易累积误差。

18.3 音视频队列管理:生产者与消费者的博弈

同步策略定好了,接下来就是队列管理。说白了,就是两个生产者(音频编码线程、视频编码线程)往队列里塞数据,一个消费者(推流线程)往外取数据。怎么管?

我一般用两个独立的阻塞队列,一个放音频帧,一个放视频帧。推流线程轮询两个队列,根据时间戳决定下一帧取谁的数据。

// 伪代码示意
BlockingQueue<AudioFrame> audioQueue = new LinkedBlockingQueue<>();
BlockingQueue<VideoFrame> videoQueue = new LinkedBlockingQueue<>();

// 推流线程循环
while (isPushing) {
    AudioFrame audio = audioQueue.peek();
    VideoFrame video = videoQueue.peek();
    
    if (audio == null || video == null) {
        // 某个队列为空,等一会儿再试
        Thread.sleep(1);
        continue;
    }
    
    long audioPts = audio.pts;
    long videoPts = video.pts;
    
    if (videoPts < audioPts) {
        // 视频落后了,优先推视频
        videoQueue.poll();
        pushVideoFrame(video);
    } else {
        // 音频落后或同步,推音频
        audioQueue.poll();
        pushAudioFrame(audio);
    }
}

这里有个坑:队列长度控制。我曾经在项目里没限制队列大小,结果视频编码器突然卡了一下,队列瞬间堆了几百帧,内存直接爆了。后来我加了最大容量限制,满了就丢旧帧——丢帧总比 OOM 强。

避坑指南:我曾经遇到过音频队列一直为空,视频队列却堆满了。排查发现是音频采集线程被系统回收了。所以一定要加超时监控,队列长时间无数据要主动告警或重启采集线程。

18.4 时间戳校准:别让误差累积

时间戳校准是同步的最后一道防线。即使你队列管理做得再好,时间戳本身如果不准,一切都是白搭。

校准的核心思路:用系统单调时钟(System.nanoTime())作为基准,把音视频的 PTS 统一映射到这个时间轴上

具体做法:

  1. 在采集第一帧音频时,记录当前系统时间 startTime
  2. 音频每帧的 PTS = 当前帧的采样位置 / 采样率 × 1000000(微秒)。
  3. 视频每帧的 PTS = 当前帧的采集时间 - startTime。
  4. 推流时,所有 PTS 都减去 startTime,保证从 0 开始。
// 时间戳校准示例
private long startTime = -1;

public void onAudioFrame(byte[] data, long presentationTimeUs) {
    if (startTime == -1) {
        startTime = System.nanoTime() / 1000; // 微秒
    }
    long adjustedPts = presentationTimeUs - startTime;
    // 推流
}

public void onVideoFrame(byte[] data, long timestampNs) {
    if (startTime == -1) {
        startTime = System.nanoTime() / 1000;
    }
    long adjustedPts = (timestampNs / 1000) - startTime;
    // 推流
}

你可能会问:为什么不用 MediaCodec 输出的 PTS?因为不同设备、不同编码器输出的 PTS 基准可能不一致。我遇到过某款手机视频 PTS 从 0 开始,音频 PTS 从系统启动时间开始——直接对齐肯定错位。

核心原则:永远不要相信编码器输出的原始 PTS,一定要用系统单调时钟做一次映射校准。

18.5 知识体系总览

下面这张图把本章的核心逻辑串起来了,你可以对照着理解:

音视频同步核心逻辑 时间戳概念 DTS:解码时间戳 PTS:显示时间戳 视频有B帧时两者不同 同步策略 音频为主同步(推荐) 视频为主同步 外部时钟同步 队列管理 独立阻塞队列 PTS比较决定优先级 注意队列长度控制 时间戳校准(核心防线) 用 System.nanoTime() 作为统一基准 所有 PTS 减去起始时间,从 0 开始 最终输出:同步的音视频流

从图上你能看到,整个同步链路是环环相扣的:先理解时间戳含义,再选同步策略,然后用队列管理调度,最后用时间戳校准兜底。任何一个环节出问题,最终输出的流都会「音画不同步」。

调试小技巧:在推流端每隔几秒打印一次音视频 PTS 的差值。如果差值稳定在 ±50ms 以内,说明同步做得不错。超过 200ms 就要警惕了。

好了,这一章的内容就到这里。音视频同步没有银弹,核心就是理解原理、选对策略、做好校准。你在项目中遇到同步问题,不妨从这三个方向逐一排查。


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