24、音视频同步:音视频同步原理、PTS/DTS概念、同步策略(音频为主/视频为主/外部时钟)、A/V同步实现
音视频同步,圈内人常说的“A/V sync”,是播放器开发里最磨人的环节之一。我早年做第一个播放器项目时,觉得解码渲染都跑通了,结果画面比声音快了半秒,用户反馈说“像在看配音版译制片”。嗯,从那以后我再也不敢小看同步这件事。
说白了,音视频同步的核心就一句话:让音频和视频按照各自的时间戳,在同一个时间轴上对齐播放。但实现起来,坑多得很。
PTS与DTS:时间戳的“身份证”
先搞清楚两个基本概念:PTS(Presentation Time Stamp)和DTS(Decode Time Stamp)。
- PTS:显示时间戳。告诉播放器“这帧画面/这段音频应该在什么时候呈现”。
- DTS:解码时间戳。告诉解码器“这个数据包应该在什么时候解码”。
为什么要有两个?因为视频编码里存在B帧(双向预测帧)。B帧的显示顺序和解码顺序不一样。举个例子:
解码顺序:I帧(0) → P帧(2) → B帧(1) → P帧(4) → B帧(3)
显示顺序:I帧(0) → B帧(1) → P帧(2) → B帧(3) → P帧(4)
你看,B帧(1)虽然显示在P帧(2)之前,但解码时它必须等P帧(2)先解码完。所以DTS和PTS就分开了。我在项目中遇到过一些低码率视频,B帧特别多,如果只拿PTS做解码调度,解码器直接崩掉。
三种同步策略:选哪个?
业界主流的同步策略有三种。我分别说说它们的适用场景和坑。
1. 音频为主(Audio Master)
这是最常用的策略。以音频时钟为基准,视频去追音频。为什么?因为人耳对声音的抖动比眼睛对画面的抖动敏感得多。你想想看,声音卡顿一下,观众立刻觉得“坏了”;画面掉几帧,很多人根本察觉不到。
实现逻辑:
- 音频按PTS正常播放,驱动一个“主时钟”。
- 视频渲染时,拿当前视频帧的PTS和主时钟比较。
- 如果视频PTS落后主时钟太多 → 丢帧(跳过不渲染)。
- 如果视频PTS超前主时钟太多 → 等待(sleep一下)。
// 伪代码示意
long audioClock = getAudioPlaybackPosition(); // 音频当前播放位置
long videoPTS = currentFrame.getPTS();
long diff = videoPTS - audioClock;
if (diff > THRESHOLD_SKIP) {
// 视频太快,跳过当前帧
skipFrame();
} else if (diff < -THRESHOLD_WAIT) {
// 视频太慢,等待
Thread.sleep(Math.abs(diff));
} else {
// 在容忍范围内,正常渲染
renderFrame();
}
2. 视频为主(Video Master)
这种策略很少用,除非你的场景对画面流畅度要求极高,而对声音可以容忍轻微调整。比如某些游戏录制回放工具。
实现时,视频驱动主时钟,音频去追视频。但音频调整手段有限——要么变速播放(变调),要么静音插值。说实话,效果都不太好。我曾经试过在直播场景用视频为主,结果声音忽快忽慢,用户直接投诉。
3. 外部时钟(External Clock)
用一个独立的时钟源(比如系统单调时钟)作为主时钟,音频和视频都去追它。这种策略常用于需要多路媒体流同步的场景,比如画中画、多视角直播。
好处是音频和视频的地位平等,不会出现“谁迁就谁”的问题。坏处是实现复杂——你需要同时控制音频和视频的播放速度,而且外部时钟本身需要高精度。
// 外部时钟示例
long externalClock = System.nanoTime() / 1_000_000; // 毫秒级
long expectedAudioPTS = externalClock + audioOffset;
long expectedVideoPTS = externalClock + videoOffset;
// 分别调整音频和视频
adjustAudioToTarget(expectedAudioPTS);
adjustVideoToTarget(expectedVideoPTS);
A/V同步实现:实战步骤
下面我结合SurfaceView,讲一个完整的同步实现流程。假设我们采用“音频为主”策略。
步骤1:获取PTS
从MediaCodec解码后的Buffer中提取PTS。注意,MediaCodec输出的PTS单位是微秒(μs),而AudioTrack的播放位置单位是毫秒(ms),记得做单位换算。
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
int outputIndex = videoDecoder.dequeueOutputBuffer(info, TIMEOUT_US);
if (outputIndex >= 0) {
long ptsUs = info.presentationTimeUs; // 微秒
// 后续同步用
}
步骤2:维护音频时钟
音频时钟可以通过AudioTrack的getPlaybackHeadPosition()获取。但要注意,这个方法返回的是已播放的采样帧数,需要换算成时间。
// 获取音频当前播放位置(毫秒)
AudioTrack audioTrack = ...;
long playedFrames = audioTrack.getPlaybackHeadPosition();
long audioClockMs = playedFrames * 1000 / sampleRate; // 毫秒
步骤3:视频帧调度
在SurfaceView的渲染循环中,每拿到一帧视频,就计算它的PTS与音频时钟的差值,然后决定是渲染、等待还是丢帧。
long videoPtsMs = info.presentationTimeUs / 1000;
long diff = videoPtsMs - audioClockMs;
if (Math.abs(diff) <= SYNC_THRESHOLD_MS) {
// 同步范围内,直接渲染
videoDecoder.releaseOutputBuffer(outputIndex, true);
} else if (diff > SYNC_THRESHOLD_MS) {
// 视频超前,等待
Thread.sleep(diff - SYNC_THRESHOLD_MS);
videoDecoder.releaseOutputBuffer(outputIndex, true);
} else {
// 视频落后,丢帧
videoDecoder.releaseOutputBuffer(outputIndex, false);
}
步骤4:处理异常情况
实际项目中,你可能会遇到:
- 音频时钟突然跳变(比如AudioTrack被重置)。
- 视频PTS出现乱序(某些编码器输出的PTS不是严格递增的)。
- 长时间不同步(比如网络卡顿导致音频缓冲耗尽)。
我的做法是加一个“同步状态机”:正常态、追赶态、等待态。当差值超过一定范围时,进入追赶态,主动丢帧或插帧;当差值恢复正常,回到正常态。
知识体系图
下面这张图总结了音视频同步的核心逻辑,我画成了流程图,方便你理解整体脉络。
嗯,以上就是音视频同步的核心内容。从PTS/DTS的概念,到三种同步策略的选型,再到SurfaceView上的具体实现,每一步都有坑,但也都有解法。我个人觉得,做同步最重要的是“容忍”——不要追求绝对精确,而是追求人眼人耳感知不到的误差范围。
公众号:蓝海资料掘金营,微信deep3321