音画同步(A/V sync):用音频做主时钟,视频围着它“等一等 / 丢一丢 / 多显示一会儿(补帧)”

132 阅读5分钟

1) 基本时间概念

  • PTS(presentation timestamp) :每个音/视频帧自带的“应该被播放”的媒体时间,单位常见是微秒或 timebase(如 1/90000)。

  • 时钟(clock) :把“现在硬件已经播到的媒体时间”算出来。

    常用三种:

    1. 音频时钟(Audio clock,常用作主时钟)

      • Android 上来自 AudioTrack.getTimestamp()/playbackHeadPosition:拿到已播出的帧数 playedFrames,转成时间:

        audioClockUs = anchorMediaTimeUs + playedFrames * 1_000_000 / sampleRate - latencyUs

    2. 视频时钟(Video clock) :用“最后渲染的那帧的 PTS + 估计的显示进度”,通常不用作主时钟。

    3. 外部/系统时钟(System clock) :实时时间与 firstPts 的映射,作为兜底。

为什么选音频:音频最不容忍抖动;硬件音频输出粒度小(每毫秒几十个采样),持续匀速,是天然稳定时钟。


2) 总体策略(音频做主,视频跟随)

设:

  • A = audioClock():此刻音频已经播到的媒体时间(us)

  • 取一个同步阈值 Tsync(比如 40–100ms)

  • 当前待显示视频帧的 PTS 为 V

计算偏差:diff = V - A(视频相对音频“早/晚”)

  • |diff| <= Tsync:认为同步 → 按计划时间对齐 VSYNC 渲染即可。

  • diff > Tsync(视频走快,音频还没到):

    • 补帧:保持上一帧继续显示(自然“重复帧”),或把本帧延后到 V 对应的时间再投递。
  • diff < -Tsync(视频落后,音频已走过):

    • 丢帧:丢弃这帧以及所有 PTS < A - Tsync 的过期帧,直到拿到“追上音频”的下一帧。

ffplay 的典型阈值(仅作参考):

AV_SYNC_THRESHOLD_MIN=0.04s,AV_SYNC_THRESHOLD_MAX=0.1s,AV_NOSYNC_THRESHOLD=10s,AV_SYNC_FRAMEDUP_THRESHOLD≈0.1s。


3) “补帧”与“丢帧”到底怎么做?

补帧(video ahead)

最常见的“补帧”并不是“算法生成新帧”,而是重复显示上一帧(或这帧)更久,直到该帧的 PTS 到点。

  • 屏幕 60Hz,而视频 24fps,自然就会重复:24 的每帧会跨越多个 VSYNC(2–3 个),这就是“补帧”。不需要你生成像素,只需按正确的释放时间把同一帧送到 Surface。

  • 进阶:**插帧(Motion Interpolation)**是另外一类,需做光流/块匹配生成中间帧;多数播放器不做,TV/后处理才做。

实现要点

  • 计算目标释放时间:releaseTime = mapMediaTimeToSystemTime(V)(ExoPlayer 有 FrameReleaseTimeHelper)。

  • 若还没到时刻,就不投递下一帧(或者继续把同一帧在多个 VSYNC 重复显示),等到时刻再渲染。

  • Android 端可配合 Choreographer/Surface.setFrameRate() 与 VSYNC 对齐。

丢帧(video late)

当视频太晚(音频已经过去很多),这帧就算渲染出来也“历史了”,应该丢掉

做法

  • 在解码器输出队列取到帧时,计算 diff = V - A。

  • 如果 diff < -Tsync,直接 drop,继续取下一帧,直到遇到 V >= A - Tsync 再渲染。

  • 为防连丢造成卡顿:

    • 限制“连续丢帧数”或“最大追赶时长”;
    • 必要时触发“快进到关键帧”(seekToNextKeyframe)或请求解码器跳 B 帧(有些平台支持)。
  • ExoPlayer/MediaCodecRenderer 就有“late > some threshold”自动 dropBuffer() 的逻辑。


4) 如何算“音频时钟”(Android 视角)

以 AudioTrack 为例(ExoPlayer AudioSink 也是类似):

  1. 音频渲染时记录一个锚点:anchorMediaTimeUs(这一批样本的媒体时间零点)。

  2. 用 getTimestamp() 或 playbackHeadPosition 拿到已经播放出去的帧数 playedFrames(注意回绕/暂停的处理)。

  3. 减去设备输出的硬件延迟 latencyUs(getLatency() 或估计值)。

  4. 计算:

    audioClockUs = anchorMediaTimeUs + playedFrames * 1_000_000 / sampleRate - latencyUs

  5. 若变速播放(1.2x/0.8x):

    • 音频通过重采样/变速不变调(Sonic/AudioTrack playback params)输出;
    • 视频按相同速度缩放 PTS 或渲染节奏。

5) 简化伪代码(核心同步循环)

val Tsync = 80.ms    // 40–100ms 之间择一
while (running) {
    val frame = video.dequeue() ?: continue
    val A = audioClockUs()       // 主时钟
    val V = frame.ptsUs
    val diff = V - A

    when {
        abs(diff) <= Tsync -> {
            // 正常:按 releaseTime 对齐 VSYNC
            scheduleRenderAt(mapMediaToSystemTime(V), frame)
        }
        diff > Tsync -> {
            // 视频早:补帧(hold)
            // 方法1:延迟投递到 V; 方法2:重复上一帧直到到点
            scheduleRenderAt(mapMediaToSystemTime(V), frame)
        }
        else -> { // diff < -Tsync
            // 视频晚:丢帧追赶
            drop(frame)
            // 连续丢到最近一帧 >= A - Tsync
            continue
        }
    }
}

mapMediaToSystemTime(V) 把媒体时间映射到系统时刻,可用:

  • systemTime = anchorSystemTime + (V - anchorMediaTime),再对齐 VSYNC;
  • ExoPlayer 里 VideoFrameReleaseHelper 已封装了刷新率、display偏差估计等。

6) 一些细节坑

  • 时间基转换:不同流有不同 timebase(如 1/90000)。统一换成微秒再比较。
  • 音频时间抖动:getTimestamp() 在部分设备会偶有抖动,要做滑动平均/外推。
  • 加入阈值:没有阈值就会“抖动补/丢”,画面颤动。阈值应随帧时长自适应(min(max(Tmin, frameDuration/2), Tmax))。
  • 首帧/缓冲:起播先累积一定帧,或允许一段 joiningTimeMs,减少一开始的丢帧。
  • 刷新率匹配:24/25/30fps 建议调用 Surface.setFrameRate() 或切 120Hz/60Hz/48Hz 匹配,减少重复/抖动。
  • HLS/变码率:切流时重置锚点,避免时钟跳变。
  • 真“补帧插值” :若要 24→60 丝滑,需要 GPU/NN 插帧,工程复杂且耗电高;普通播放器不做。

7) 在 ExoPlayer 里这些东西在哪里?

  • 主时钟:MediaClock → 实际由 AudioSink 驱动;DefaultAudioSink 基于 AudioTrack 计算 positionUs。
  • 视频对齐:MediaCodecVideoRenderer 使用 VideoFrameReleaseHelper(旧名 FrameReleaseTimeHelper)按显示刷新率/VSYNC 调整 releaseTimeNs。
  • 丢帧:当估计释放时间已显著晚于现在(超阈值)→ dropBuffer(),否则 renderOutputBufferV21(...)。
  • 阈值:ExoPlayer 内有“最大允许迟到/过早”阈值与 allowedJoiningTimeMs 等常量,可配置或间接影响。

小结

  • 音频做主时钟是业界常规,因为它最稳定;

  • 视频以 V- A 的偏差为依据:早→补(延后 / 重复显示)晚→丢

  • “补帧”多数情况下就是重复上一帧到正确时刻,并非生成新帧;

  • 真要“插帧”,那是另一个课题(运动估计/光流/ML);

  • 在 Android/ExoPlayer 上,上述逻辑已有成熟实现,主要需要你理解何时丢/何时等以及时钟的来源