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 同步策略:三种主流玩法
音视频同步的策略,业界主要有三种。我按推荐程度排个序:
- 音频为主同步(Audio Master):以音频时钟为基准,视频去对齐音频。这是最常用的方案,因为人耳对音频抖动的敏感度远高于视频。你想想看,画面卡一下还能忍,声音一卡一卡的,直接没法听。
- 视频为主同步(Video Master):以视频时钟为基准,音频去对齐视频。用得少,除非你的场景对视频帧率要求极其严格。
- 外部时钟同步(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 统一映射到这个时间轴上。
具体做法:
- 在采集第一帧音频时,记录当前系统时间
startTime。 - 音频每帧的 PTS = 当前帧的采样位置 / 采样率 × 1000000(微秒)。
- 视频每帧的 PTS = 当前帧的采集时间 - startTime。
- 推流时,所有 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 知识体系总览
下面这张图把本章的核心逻辑串起来了,你可以对照着理解:
从图上你能看到,整个同步链路是环环相扣的:先理解时间戳含义,再选同步策略,然后用队列管理调度,最后用时间戳校准兜底。任何一个环节出问题,最终输出的流都会「音画不同步」。
调试小技巧:在推流端每隔几秒打印一次音视频 PTS 的差值。如果差值稳定在 ±50ms 以内,说明同步做得不错。超过 200ms 就要警惕了。
好了,这一章的内容就到这里。音视频同步没有银弹,核心就是理解原理、选对策略、做好校准。你在项目中遇到同步问题,不妨从这三个方向逐一排查。