1) 为什么音频是最稳的主时钟
1.1 连续流 vs 离散投递
-
音频输出链路 (AudioTrack → HAL → DSP/Codec → DAC/扬声器)是恒速传送带:只要 sampleRate=48 kHz,它就稳定地每秒吃 48 000 个采样。硬件里有 PLL/时钟恢复,速率非常稳定。
-
视频显示链路 (解码 → 合成 → VSYNC → 面板)是离散的"按帧点投递" :你必须把一帧在"正确的时刻"投给 Surface/合成器,错过 VSYNC 就要等下一拍;帧率低(24/25/30/60),并受解码耗时、UI阻塞、刷新率变化等影响,抖动不可避免。
1.2 音频时钟可观测、线性
-
AudioTrack 提供已播放帧数 ("真正出喇叭的数量"),在 Android 上可通过 getTimestamp()(含 framePosition 与对应的系统时间)或 playbackHeadPosition 系列指标拿到。它几乎线性增长,短期抖动极小。
-
有了"已播放帧数",就能把媒体时间算出来(见 §2.1 公式),而且误差不会随着时间累积飘走。
1.3 同步策略简单、听感最好
-
以音频时钟 A 作为"事实真相:已经播放到哪里了"。视频拿当前帧 PTS=V 与 A 比:
- V > A ⇒ 视频"领先",等一等 (延后到 VSYNC)或重复上一帧更久(补帧=延长显示时间)。
- V < A ⇒ 视频"落后",丢帧追赶,直到赶上。
-
如果反过来以视频 做主时钟,就得让音频不断重采样/变速去贴合离散帧时刻,极易产生抖动与听感损伤(颤音、忽快忽慢)。
结论 :音频=连续、可测、硬件驱动;视频=离散、调度敏感。让视频围着音频转,系统最平滑。
2) 音画同步怎么落地
2.1 音频时钟 A 的计算
记:
-
sr = sampleRate(例如 48000)
-
playedFrames = 已播放样本数(单声道按采样计,立体声依旧以"每声道采样"为单位)
-
anchorMediaTimeUs = 把一段 PCM 写入 AudioTrack 起点对应的媒体时间(us)
-
latencyUs = 输出延迟估计(硬件/管线延迟,可近似为 AudioTrack 缓冲深度 / sr)
公式:
ini
A = anchorMediaTimeUs
+ playedFrames * 1_000_000 / sr
- latencyUs
实现要点:
-
获取 playedFrames:优先用 getTimestamp()(含系统时间锚点,漂移更小),退化再用 playbackHeadPosition(注意回绕/暂停补偿)。
-
估计 latency :可以保守估计为"写入环形缓冲后仍未播出的样本量 / sr",或者用平台提供的延迟值;难以精确,就用常量+滑动平均,保证 A 单调增长即可。
2.2 视频调度:补/丢策略
设当前视频帧的 PTS=V,阈值 Tsync=40~100ms(自适应见 §2.4)。计算 diff = V - A:
-
abs(diff) <= Tsync:按计划时间渲染。
计算目标释放系统时刻 releaseTime = mapMediaToSystemTime(V)(把媒体时间映射为墙钟时间,并对齐 VSYNC)。
-
diff > Tsync(视频快):补帧****
- 本质是"延长显示时间"。要么等待到 releaseTime 再投递,要么让上一帧自然在多个 VSYNC 上重复。
- 不需要"算法插帧"(那是光流/ML 范畴)。
-
diff < -Tsync(视频慢):丢帧****
-
丢掉所有 PTS < A - Tsync 的帧,直到遇到"追上来的"下一帧再渲染。
-
可设"最大连续丢帧上限""最大追赶时长",避免卡死。
-
伪代码
scss
val Tsync = 80.ms
loop {
val frame = video.dequeue() ?: continue
val A = audioClockUs()
val V = frame.ptsUs
val diff = V - A
when {
abs(diff) <= Tsync -> renderAt(mapMediaToSystemTime(V), frame)
diff > Tsync -> renderAt(mapMediaToSystemTime(V), frame) // 等到点(=补帧)
else -> drop(frame) // diff < -Tsync
}
}
2.3 把媒体时间映射到系统时刻
维护一对锚点 (mediaAnchorUs, systemAnchorNs);系统时刻预测:
ini
systemTimeNs = systemAnchorNs + (V - mediaAnchorUs) * 1000
然后用 VSYNC 对齐(Android 可用 Choreographer 或 ExoPlayer 的 VideoFrameReleaseHelper),微调到最接近的显示时刻,避免"投得太早/太晚"。
2.4 自适应阈值(防抖)
阈值过小会频繁补/丢导致颤动。常见策略:
ini
Tsync = clamp( frameDuration / 2 , Tmin=40ms , Tmax=100ms )
帧率低(24fps)时阈值可大些;高帧率(60/120)可小些。
2.5 速率漂移矫正(drift)
设备间实际输出采样率可能与名义值有微小偏差(ppm 级)。长时间后,音频时钟 A 和"外部墙钟"会出现缓慢漂移,导致视频总在"慢慢早/慢慢晚"。
修正办法:
-
微调视频释放时刻:每隔一段时间根据平均 diff 做一丢丢的相位校正(ns 级),用户无感。
-
或在极端情况下轻微变速视频(0.999x~1.001x),不改音频速率;人的视觉对这样的小变速不敏感。
2.6 倍速播放
- 音频经变速不变调 (Sonic/PhaseVocoder/平台 playback params)输出,playedFrames/sr 仍然是真实播出时间,A 依旧按 §2.1 公式算(注意 sampleRate 等效变化/时间拉伸)。
- 视频按同一倍率缩放 PTS 或调度节奏即可。
3) 在 ExoPlayer 里这些逻辑在哪里
-
音频时钟:MediaClock 实现由 DefaultAudioSink 驱动,基于 AudioTrack 的播放进度得到 positionUs。
-
视频释放时刻:MediaCodecVideoRenderer 使用 VideoFrameReleaseHelper(旧名 FrameReleaseTimeHelper)根据刷新率/VSYNC 估计 releaseTimeNs 并微调。
-
丢帧:当估计的释放时间明显晚于"现在"(超过阈值),dropBuffer();否则 renderOutputBuffer...()。
-
自适应阈值/Joining:内部有最大允许早/晚阈值与 allowedJoiningTimeMs 等控制参数(避免起播阶段误判)。
------如果你做自定义 Renderer,照这个思路把音频时钟读数 和视频释放策略接进去即可。
4) 调参与指标(工程实战)
建议记录以下指标,便于线上排查:
-
audioClockUs() 每 100 ms 采样一次(滑动平均)。
-
每帧的 V-A(diff 分布),统计 50/90/99 分位。
-
droppedFrames 总数与峰值、连续丢帧最大次数。
-
firstVideoFrameMs(首帧出图耗时)、切流/seek 后到首帧时间。
-
刷新率/帧率匹配情况(Surface.setFrameRate() 是否生效)。
常见经验值:
- Tsync 初始 80 ms,自适应范围 40--100 ms。
- 24/25fps 在 60Hz 面板上会自然重复帧;能用 48/120Hz 更好,或启用平台的 seamless refresh rate。
- 关键帧间隔 ≤ 2 s,减少 seek/切流等待黑屏。
5) "黑屏有声"的补充定位(简要复盘)
出现"黑屏但有声音",通常是视频渲染链路没出图 ,而音频链路正常。高频根因:
- Surface/TextureView 未就绪或被销毁/尺寸为 0(最常见)。→ 先 setSurface 再 prepare;监听可用性;onRenderedFirstFrame 超时即告警。
- 等待关键帧(切流/seek 后没有 IDR)。→ 提高关键帧密度,或 seek-to-keyframe。
- DRM 安全要求(L1 轨用的 Surface 不安全)。→ 使用 SurfaceView / secure path。
- 解码兼容/色域问题(Profile/Level/HDR 不兼容)。→ 降级、切软解或禁用 HDR。
- 持续严重迟到而被丢帧(CPU/GPU 压力过大)。→ 低清晰度/降帧率/优化 UI。
6) 一段可复用的"音频主时钟 + 视频调度"骨架(Kotlin 伪实现)
kotlin
class AvSyncController(
private val vsyncScheduler: (targetNs: Long, frame: VideoFrame) -> Unit,
private val drop: (VideoFrame) -> Unit,
private val nowNs: () -> Long
) {
var sr = 48000
var TsyncUs = 80_000L
// 锚点:把写入 AudioTrack 的媒体时间与系统时间对齐
var anchorMediaTimeUs = 0L
var anchorSystemTimeNs = 0L
fun audioClockUs(playedFrames: Long, latencyUs: Long): Long {
return anchorMediaTimeUs + playedFrames * 1_000_000L / sr - latencyUs
}
fun schedule(frame: VideoFrame, audioClockUs: Long) {
val V = frame.ptsUs
val A = audioClockUs
val diff = V - A
when {
kotlin.math.abs(diff) <= TsyncUs -> {
val targetNs = anchorSystemTimeNs + (V - anchorMediaTimeUs) * 1000L
vsyncScheduler(targetNs.coerceAtLeast(nowNs() + 1_000_000L/120), frame)
}
diff > TsyncUs -> {
val targetNs = anchorSystemTimeNs + (V - anchorMediaTimeUs) * 1000L
vsyncScheduler(targetNs, frame) // 等到点(补帧=延长显示)
}
else -> drop(frame) // 视频落后,丢
}
}
}
实战里把 TsyncUs 做成 clamp(frameDurationUs/2, 40_000, 100_000),并加入刷新率估计与 VSYNC 相位微调。
总结一句话
- 音频=恒速、可测、稳 ,最适合作为主时钟;
- 视频=离散、易抖 ,围着音频的时钟做延时(补帧=延长显示)/丢帧即可;
- 做到位的关键是:音频时钟计算正确 、视频释放时刻对齐 VSYNC 、阈值自适应 + 漂移微调,再辅以指标监控与设备兼容策略。