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做解码调度,解码器直接崩掉。

关键点:音频一般没有B帧概念,所以音频的PTS和DTS通常是相等的。视频则不一定。

三种同步策略:选哪个?

业界主流的同步策略有三种。我分别说说它们的适用场景和坑。

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();
}
我的经验:阈值设置很关键。我个人习惯设50ms为容忍范围,超过100ms才丢帧。太激进会导致画面频繁跳跃,太保守则音画不同步明显。

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; // 毫秒
注意:AudioTrack的getPlaybackHeadPosition()在暂停或未开始播放时可能返回0。我建议在音频开始播放后再启用同步逻辑。

步骤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不是严格递增的)。
  • 长时间不同步(比如网络卡顿导致音频缓冲耗尽)。

我的做法是加一个“同步状态机”:正常态、追赶态、等待态。当差值超过一定范围时,进入追赶态,主动丢帧或插帧;当差值恢复正常,回到正常态。

个人经验:不要试图让音视频完全精确对齐到每一毫秒。人眼和人耳都有一定的容忍范围。我一般设±40ms为“完美同步”,±80ms为“可接受”,超过±120ms才触发主动调整。

知识体系图

下面这张图总结了音视频同步的核心逻辑,我画成了流程图,方便你理解整体脉络。

音视频同步核心逻辑 音视频数据流 分离音频流与视频流 音频解码 → 获取PTS 视频解码 → 获取PTS 音频时钟(主时钟) PTS差值计算 同步决策:渲染 / 等待 / 丢帧

嗯,以上就是音视频同步的核心内容。从PTS/DTS的概念,到三种同步策略的选型,再到SurfaceView上的具体实现,每一步都有坑,但也都有解法。我个人觉得,做同步最重要的是“容忍”——不要追求绝对精确,而是追求人眼人耳感知不到的误差范围。

一句话总结:音频为主是王道,PTS是命根,阈值设置看场景,丢帧等待要果断。

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