音视频同步
今天我们来讲解一下音视频同步吧。讲完这篇,结合之前几篇博客,我们对音视频开发也算有一个入门级别的理解了。
首先思考几个问题:
1、为什么需要音视频同步?
因为视频和音频是两个独立的任务在运行,视频和音频的解码速度也不一样,解码出来的数据也不一定马上就可以显示出来。
- 视频:帧率,表示视频一秒显示的帧数。
- 音频:采样率,表示音频一秒播放的样本的个数。
从帧率及采样率,即可知道视频/音频播放速度。声卡和显卡均是以一帧数据来作为播放单位,如果单纯依赖帧率及采样率来进行播放,在理想条件下,应该是同步的,不会出现偏差。以一个44.1KHz的AAC音频流和24FPS的视频流为例:一个AAC音频frame每个声道包含1024个采样点,则一个frame的播放时长(duration)为:(1024/44100)×1000ms = 23.22ms;一个视频frame播放时长(duration)为:1000ms/24 = 41.67ms。理想情况下,音视频完全同步,音视频播放过程如下图所示:
但实际情况下,如果用上面那种简单的方式,慢慢的就会出现音视频不同步的情况,要不是视频播放快了,要么是音频播放快了。可能的原因如下:
- 一帧的播放时间,难以精准控制。音视频解码及渲染的耗时不同,可能造成每一帧输出有一点细微差距,长久累计,不同步便越来越明显。(例如受限于性能,42ms才能输出一帧)
- 音频输出是线性的,而视频输出可能是非线性,从而导致有偏差。
- 媒体流本身音视频有差距。(特别是TS实时流,音视频能播放的第一个帧起点不同)
2、那要怎么做音视频同步?
为了解决音视频同步问题,引入了时间戳:首先选择一个参考时钟(要求参考时钟上的时间是线性递增的);编码时依据参考时钟上的给每个音视频数据块都打上时间戳;播放时,根据音视频时间戳及参考时钟,来调整播放。所以,视频和音频的同步实际上是一个动态的过程,同步是暂时的,不同步则是常态。以参考时钟为标准,放快了就减慢播放速度;播放快了就加快播放的速度。
3、那么参考时钟都有哪些?
前面已经说了,实现音视频同步,在播放时,需要选定一个参考时钟,读取帧上的时间戳,同时根据的参考时钟来动态调节播放。现在已经知道时间戳就是PTS,那么参考时钟的选择一般来说有以下三种:
- 将视频同步到音频上:就是以音频的播放速度为基准来同步视频。
- 将音频同步到视频上:就是以视频的播放速度为基准来同步音频。
- 将视频和音频同步外部的时钟上:选择一个外部时钟为基准,视频和音频的播放速度都以该时钟为标准。
当播放源比参考时钟慢,则加快其播放速度,或者丢弃;快了,则延迟播放。
这三种是最基本的策略,**考虑到人对声音的敏感度要强于视频,频繁调节音频会带来较差的观感体验,且音频的播放时钟为线性增长,所以一般会以音频时钟为参考时钟,视频同步到音频上。**在实际使用基于这三种策略做一些优化调整,例如:
- 调整策略可以尽量采用渐进的方式,因为音视频同步是一个动态调节的过程,一次调整让音视频PTS完全一致,没有必要,且可能导致播放异常较为明显。
- 调整策略仅仅对早到的或晚到的数据块进行延迟或加快处理,有时候是不够的。如果想要更加主动并且有效地调节播放性能,需要引入一个反馈机制,也就是要将当前数据流速度太快或太慢的状态反馈给“源”,让源去放慢或加快数据流的速度。
- 对于起播阶段,特别是TS实时流,由于视频解码需要依赖第一个I帧,而音频是可以实时输出,可能出现的情况是视频PTS超前音频PTS较多,这种情况下进行同步,势必造成较为明显的慢同步。处理这种情况的较好方法是将较为多余的音频数据丢弃,尽量减少起播阶段的音视频差距。
以上就是关于音视频同步的理论知识了。有了理论知识之后,我们就可以去实践了,但是我们实践的是以外部时钟为方法的。为什么呢?因为这种比较简单,也容易理解。这里我会参考网上的一个例子去讲解,重点也只讲解音视频同步的那一部分,其他的解码部分,可以自己去阅读一下源码。github传送门
final override fun run() {
if (mState == DecodeState.STOP) {
mState = DecodeState.START
}
mStateListener?.decoderPrepare(this)
//【解码步骤:1. 初始化,并启动解码器】
if (!init()) return
Log.i(TAG, "开始解码")
try {
while (mIsRunning) {
if (mState != DecodeState.START &&
mState != DecodeState.DECODING &&
mState != DecodeState.SEEKING) {
Log.i(TAG, "进入等待:$mState")
waitDecode()
// ---------【同步时间矫正】-------------
//恢复同步的起始时间,即去除等待流失的时间
mStartTimeForSync = System.currentTimeMillis() - getCurTimeStamp()
}
if (!mIsRunning ||
mState == DecodeState.STOP) {
mIsRunning = false
break
}
//记录开始解码的时间
if (mStartTimeForSync == -1L) {
mStartTimeForSync = System.currentTimeMillis()
}
//如果数据没有解码完毕,将数据推入解码器解码
if (!mIsEOS) {
//【解码步骤:2. 见数据压入解码器输入缓冲】
mIsEOS = pushBufferToDecoder()
}
//【解码步骤:3. 将解码好的数据从缓冲区拉取出来】
val index = pullBufferFromDecoder()
if (index >= 0) {
// ---------【音视频同步】-------------
if (mSyncRender && mState == DecodeState.DECODING) {
sleepRender()
}
//【解码步骤:4. 渲染】
if (mSyncRender) {// 如果只是用于编码合成新视频,无需渲染
render(mOutputBuffers!![index], mBufferInfo)
}
//将解码数据传递出去
val frame = Frame()
frame.buffer = mOutputBuffers!![index]
frame.setBufferInfo(mBufferInfo)
mStateListener?.decodeOneFrame(this, frame)
//【解码步骤:5. 释放输出缓冲】
mCodec!!.releaseOutputBuffer(index, true)
if (mState == DecodeState.START) {
mState = DecodeState.PAUSE
}
}
//【解码步骤:6. 判断解码是否完成】
if (mBufferInfo.flags == MediaCodec.BUFFER_FLAG_END_OF_STREAM) {
Log.i(TAG, "解码结束")
mState = DecodeState.FINISH
mStateListener?.decoderFinish(this)
}
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
doneDecode()
release()
}
}
//通过线程sleep去做同步
private fun sleepRender() {
//获取从开始解码到这一次解码,一共流失的时间
val passTime = System.currentTimeMillis() - mStartTimeForSync
//如果当前的帧的时间戳比这个流失的时间要大的话,那么就需要休眠等待,否则就直接渲染。这里的同步方案确实有点问题,并没有考虑到很多的场景,但是不影响对音视频同步的这个理解,如果后面有机会的话,我还会和大家讲讲ffpaly的音视频同步的代码。那里的场景是以音频时钟作为同步时钟的
val curTime = getCurTimeStamp()
if (curTime > passTime) {
Thread.sleep(curTime - passTime)
}
}
override fun getCurTimeStamp(): Long {
return mBufferInfo.presentationTimeUs / 1000
}