音频输出与 AVSync:让声音和画面真正对齐
视频能显示,声音能播放,并不等于播放器完成了。真正难的是:声音和画面要在同一个时间线上前进。
你看到演员张嘴的那一刻,声音也应该出现。如果声音早了,用户会觉得“画面慢”;如果声音晚了,用户会觉得“声音拖”。这就是 AVSync,Audio Video Synchronization。
先理解三个时间
播放器里至少有三种时间。
文件时间:帧在媒体文件中的时间,也就是 PTS。
系统时间:设备当前时间,例如 steady_clock。
播放时间:播放器真正播到哪里了。
初学者最容易犯的错误,是把“现在读到了哪一帧”当成“现在应该显示哪一帧”。这两件事完全不同。
读到第 100 帧:说明数据准备到了这里
应该显示第 100 帧:说明播放时钟走到了这里
为什么通常用音频当主时钟
音频设备是很严格的。声音如果忽快忽慢,人耳很敏感。画面偶尔丢一帧,人眼通常还能接受。
所以多数播放器会让视频追随音频。
音频时钟:现在声音播到 10.000s
视频帧 PTS:这一帧应该在 10.033s 显示
判断:视频帧早了 33ms,稍微等一下
如果视频帧 PTS 是 9.900s,而音频已经到 10.000s,这帧已经晚了 100ms。继续显示它只会让画面更卡,通常应该丢掉。
最小音频路径
为了降低学习难度,第一版可以这样分工。
native 层:解复用、视频解码、视频渲染调度
Kotlin 层:AudioTrack 输出音频,回传音频播放位置
后续再把音频也迁移到 NDK 的 AAudio 或 Oboe。先把时间线跑通,比一开始追求全 native 更稳。
Kotlin 层可以通过 AudioTrack 写 PCM,并维护已写入帧数。
class AudioClock(private val sampleRate: Int) {
private var writtenFrames: Long = 0
fun onWritePcm(bytes: Int, channelCount: Int, bytesPerSample: Int) {
val frames = bytes / channelCount / bytesPerSample
writtenFrames += frames
}
fun positionUs(): Long {
return writtenFrames * 1_000_000L / sampleRate
}
}
这只是教学版。生产实现还要扣除设备缓冲延迟,或者从 AudioTrack.getTimestamp 获取更接近硬件播放头的时间。
Native 层时钟对象
native 层不要到处调用系统时间。集中放进 ClockSync。
class ClockSync {
public:
void updateAudioClockUs(int64_t audioUs) {
std::lock_guard<std::mutex> lock(mutex_);
audioClockUs_ = audioUs;
}
int64_t audioClockUs() const {
std::lock_guard<std::mutex> lock(mutex_);
return audioClockUs_;
}
int64_t videoDelayUs(int64_t videoPtsUs) const {
return videoPtsUs - audioClockUs();
}
private:
mutable std::mutex mutex_;
int64_t audioClockUs_ = 0;
};
videoDelayUs 大于 0,说明视频帧来早了。
videoDelayUs 小于 0,说明视频帧来晚了。
视频帧调度策略
最小策略可以分成三档。
早到太多:sleep 等一下
基本准时:渲染
迟到太多:丢帧
enum class FrameDecision {
Wait,
Render,
Drop,
};
FrameDecision decideFrame(int64_t delayUs) {
constexpr int64_t kEarlyThresholdUs = 20 * 1000;
constexpr int64_t kLateThresholdUs = -50 * 1000;
if (delayUs > kEarlyThresholdUs) {
return FrameDecision::Wait;
}
if (delayUs < kLateThresholdUs) {
return FrameDecision::Drop;
}
return FrameDecision::Render;
}
这个阈值不是绝对标准。不同设备、不同帧率、不同渲染路径都要调参。重点是:策略集中在一个函数里,后续才好观察和调整。
渲染循环
void renderFrame(AMediaCodec* codec, size_t outputIndex, int64_t ptsUs, ClockSync* clock) {
while (true) {
int64_t delayUs = clock->videoDelayUs(ptsUs);
FrameDecision decision = decideFrame(delayUs);
if (decision == FrameDecision::Wait) {
std::this_thread::sleep_for(std::chrono::microseconds(delayUs / 2));
continue;
}
if (decision == FrameDecision::Drop) {
AMediaCodec_releaseOutputBuffer(codec, outputIndex, false);
return;
}
AMediaCodec_releaseOutputBuffer(codec, outputIndex, true);
return;
}
}
这里用 delayUs / 2,是为了避免一次睡过头。真实项目可以使用更精细的调度器。
为什么不能只用 sleep
sleep 不是精确计时器。Android 系统有调度延迟,线程醒来的时间可能比你希望的晚。
所以 AVSync 不能只靠 sleep。它要同时具备三种动作。
等:帧早到了
放:帧基本准时
丢:帧已经迟到,继续显示只会恶化
无音频视频怎么办
有些文件只有视频,没有音频。此时可以使用系统单调时钟。
class VideoOnlyClock {
public:
void startAt(int64_t firstPtsUs) {
firstPtsUs_ = firstPtsUs;
startTime_ = std::chrono::steady_clock::now();
}
int64_t nowUs() const {
auto elapsed = std::chrono::steady_clock::now() - startTime_;
return firstPtsUs_ + std::chrono::duration_cast<std::chrono::microseconds>(elapsed).count();
}
private:
int64_t firstPtsUs_ = 0;
std::chrono::steady_clock::time_point startTime_;
};
指标比感觉更可靠
每秒输出一次同步统计。
audioUs=10333000 videoPtsUs=10366000 driftUs=33000 decision=wait
audioUs=10400000 videoPtsUs=10366000 driftUs=-34000 decision=render
audioUs=10500000 videoPtsUs=10400000 driftUs=-100000 decision=drop
这些日志能帮你区分问题来源。
一直 wait:视频生产太快或音频时钟慢
一直 drop:解码太慢或渲染阻塞
drift 忽大忽小:时钟来源不稳定或线程调度抖动
本章实验
准备一个 30fps、有音频的 MP4。
先不做丢帧,只 sleep,观察 drift。
再加入丢帧策略,观察卡顿和 drift 是否收敛。
最后切后台再回来,确认音频时钟没有从 0 重新开始。
建议记录这些指标。
driftUs 平均值
driftUs P95
dropCount
firstAudioUs
firstVideoPtsUs
工程风险与观测
AVSync 的风险不一定会崩溃,但会直接伤害体验。它需要持续观测。
av_drift_us
drop_frame_count
wait_frame_count
audio_clock_source
video_only_clock_enabled
如果 drift 长期为正,视频一直早到,可能音频时钟慢或视频调度太激进。
如果 drift 长期为负,视频一直迟到,可能解码慢、渲染慢或 CPU 被其他线程抢占。
如果 drop count 突然升高,要结合 simpleperf 看热点。
AVSync 不建议做硬回滚,但需要有降级策略。
音频时钟不可用时切到系统时钟
设备延迟异常时放宽丢帧阈值
后台恢复后重置首帧时钟基准
本章结论
AVSync 不是一个 API,而是一套时钟纪律。音频负责给出主时间线,视频根据差值选择等待、渲染或丢弃。只要时间线统一,播放器才会从“能播”变成“像播放器”。