1) 基本时间概念
-
PTS(presentation timestamp) :每个音/视频帧自带的“应该被播放”的媒体时间,单位常见是微秒或 timebase(如 1/90000)。
-
时钟(clock) :把“现在硬件已经播到的媒体时间”算出来。
常用三种:
-
音频时钟(Audio clock,常用作主时钟)
-
Android 上来自 AudioTrack.getTimestamp()/playbackHeadPosition:拿到已播出的帧数 playedFrames,转成时间:
audioClockUs = anchorMediaTimeUs + playedFrames * 1_000_000 / sampleRate - latencyUs
-
-
视频时钟(Video clock) :用“最后渲染的那帧的 PTS + 估计的显示进度”,通常不用作主时钟。
-
外部/系统时钟(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 也是类似):
-
音频渲染时记录一个锚点:anchorMediaTimeUs(这一批样本的媒体时间零点)。
-
用 getTimestamp() 或 playbackHeadPosition 拿到已经播放出去的帧数 playedFrames(注意回绕/暂停的处理)。
-
减去设备输出的硬件延迟 latencyUs(getLatency() 或估计值)。
-
计算:
audioClockUs = anchorMediaTimeUs + playedFrames * 1_000_000 / sampleRate - latencyUs
-
若变速播放(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 上,上述逻辑已有成熟实现,主要需要你理解何时丢/何时等以及时钟的来源。