一、概述
音视频同步(avsync),是影响多媒体应用体验质量的一个重要因素。而我们在看到音视频同步的时候,最先想到的就是对齐两者的pts,但是实际使用中的各类播放器,其音视频同步机制都比这些复杂的多。
这里我们先介绍一些音视频同步相关的知识:
1. 如何测试音视频同步情况
最简单的就是播放一个演唱会视频,通过目测看看声音和嘴形是否能对上。
这里我们也可以使用一个更科学的设备:Sync-One。Sync-One是从纯物理的角度来测试音视频同步情况的,通过播放特定的测试片源,并检测声音和屏幕亮度的变化,评判声音是落后于视频,还是领先于视频,如果达到了完美的音视频同步结果,会在电子屏上显示数字0,当然这很难==,一般我们会设定一个标准区间,只要结果能落在这个区间内,即可认为视音频基本是同步的。
2. 如何制定音视频同步的标准
音视频同步的标准其实是一个非常主观的东西,仁者见仁智者见智。我们既可以通过主观评价实验来统计出一个合理的区间范围,也可以直接参考杜比等权威机构给出的区间范围。同时,不同的输出设备可能也需要给不同的区间范围。比如,默认设备的音视频同步区间是[-60, +30]ms, 蓝牙音箱输出时的音视频同步区间是[-160, +60]ms, 功放设备输出时的音视频同步区间是[-140, +40]ms。负值代表音频落后于视频,正值代表音频领先于视频。
3. 在梳理音视频同步逻辑我们应该关注什么
毫无疑问,音视频同步逻辑的梳理要分别从视频和音频两个角度来看。
视频方面,我们关注的是同步逻辑对视频码流的pts做了哪些调整。
音频方面,我们关注的是同步逻辑中是如何获取“Audio当前播放的时间”的。
二、ExoPlayer 的 avsync 逻辑梳理
下面的时序图简单的展示了一下ExoPlayer在音视频同步这块的基本流程:
ExoPlayerImplInternal是Exoplayer的主loop所在处,这个大loop不停的循环运转,将下载、解封装的数据送给AudioTrack和MediaCodec去播放。
(注:ExoPlayerImplInternal位于:library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java)
MediaCodecAudioRenderer和MediaCodecVideoRenderer分别是处理音频和视频数据的类。
在MediaCodecAudioRenderer中会调用AudioTrack的write方法,写入音频数据,同时还会调用AudioTrack的getTimeStamp、getPlaybackHeadPosition、getLantency方法来获得“Audio当前播放的时间”。
在MediaCodecVideoRenderer中会调用MediaCodec的几个关键API,例如通过调用releaseOutputBuffer方法来将视频帧送显。在MediaCodecVideoRenderer类中,会依据avsync逻辑调整视频帧的pts,并且控制着丢帧的逻辑。
VideoFrameReleaseTimeHelper可以获取系统的vsync时间和间隔,并且利用vsync信号调整视频帧的送显时间。
Video 部分
1. 利用pts和系统时间计算预计送显时间(即视频帧应该在这个时间点显示)
MediaCodecVideoRenderer#processOutputBuffer //计算 “当前帧的pts(bufferPresentationTimeUs )” 与“Audio当前播放时间(positionUs )”之间的时间间隔, //最后还减去了一个elapsedSinceStartOfLoopUs的值,代表的是程序运行到此处的耗时, //减去这个值可以看做一种使计算值更精准的做法 long elapsedSinceStartOfLoopUs = (SystemClock.elapsedRealtime() * 1000) - elapsedRealtimeUs; earlyUs = bufferPresentationTimeUs - positionUs - elapsedSinceStartOfLoopUs; // Compute the buffer's desired release time in nanoseconds. // 用当前系统时间加上前面计算出来的时间间隔,即为“预计送显时间” long systemTimeNs = System.nanoTime(); long unadjustedFrameReleaseTimeNs = systemTimeNs + (earlyUs * 1000);
2. 利用vsync对预计送显时间进行调整
MediaCodecVideoRenderer#processOutputBuffer long adjustedReleaseTimeNs = frameReleaseTimeHelper.adjustReleaseTime(bufferPresentationTimeUs, unadjustedFrameReleaseTimeNs);
在 adjustReleaseTime 方法里面做了以下几件事:
a.计算ns级别的平均帧间隔时间,因为vsync的精度是ns
b.寻找距离当前送显时间点(unadjustedFrameReleaseTimeNs)最近的vsync时间点,我们的目标是在这个vsync时间点让视频帧显示出来
c.上面计算出的是我们的目标vsync显示时间,但是要提前送,给后面的显示流程以时间,所以再减去一个vsyncOffsetNs时间,这个时间是写死的,定义为.8*vsyncDuration,减完之后的这个值就是真正给MediaCodec.releaseOutputBuffer方法的时间戳
3. 丢帧和送显
MediaCodecVideoRenderer#processOutputBuffer
//计算实际送显时间与当前系统时间之间的时间差
earlyUs = (adjustedReleaseTimeNs - systemTimeNs) / 1000;
//将上面计算出来的时间差与预设的门限值进行对比
if (shouldDropOutputBuffer(earlyUs, elapsedRealtimeUs)) {
dropOutputBuffer(codec, bufferIndex);
return true;
}
…
if (earlyUs < 50000) {
//视频帧来的太晚会被丢掉, 来的太早则先不予显示,进入下次loop,再行判断
renderOutputBufferV21(codec, bufferIndex, adjustedReleaseTimeNs);
如果earlyUs 时间差为正值,代表视频帧应该在当前系统时间之后被显示,换言之,代表视频帧来早了,反之,如果时间差为负值,代表视频帧应该在当前系统时间之前被显示,换言之,代表视频帧来晚了。如果超过一定的门限值,即该视频帧来的太晚了,则将这一帧丢掉,不予显示。按照预设的门限值,视频帧比预定时间来的早了50ms以上,则进入下一个间隔为10ms的循环,再继续判断,否则,将视频帧送显
4. Video 部分总结
我们平时一般理解avsync就是比较audio pts和video pts,也就是比较码流层面的“播放”时间,来早了就等,来晚了就丢帧,但为了更精确地计算这个差值,exoplayer里面一方面统计了函数调用的一些耗时,一方面实际上是在比较系统时间和当前视频帧的送显时间来判断要不要丢帧,也就是脱离了码流层面。既然牵涉到实际送显时间的计算,就需要将播放时间映射到vsync时间上,也就有了cloestVsync的计算,也有了提前80% vsync信号间隔时间送显的做法,同时因为vsync信号时间的精度为ns,为了更好匹配这一精度,而没有直接用ms精度的码流pts值,而是另外计算了ns级别的视频帧间隔时间。
Audio部分
1. 使用AudioTrack.getTimeStamp方法获取到当前播放的时间戳
AudioTrack#getCurrentPositionUs(boolean sourceEnded) positionUs = framesToDurationUs(AudioTimestamp.framePosition) + systemClockUs – AudioTimestamp.nanoTime/1000
对getTimeStamp方法的调用是以500ms为间隔的,所以AudioTimestamp.nanoTime是上次调用时拿到的结果,systemClockUs – AudioTimestamp.nanoTime 得到的就是距离上次调用所经过的系统时间,framesToDurationUs(AudioTimestamp.framePosition)代表的是上次调用时获取到的“Audio当前播放的时间”,二者相加即为当前系统时间下的“Audio当前播放的时间”。
2. 使用AudioTrack.getPlaybackHeadPosition方法获取到当前播放的时间
AudioTrack#getCurrentPositionUs(boolean sourceEnded) //因为 getPlayheadPositionUs() 的粒度只有约20ms, 如果直接拿来用的话精度不够 //要进行采样和平滑演算得到playback position positionUs = systemClockUs + smoothedPlayheadOffsetUs = systemClockUs + avg[playbackPositionUs(i) – systemClock(i)] positionUs -= latencyUs ;
上式中i最大取10,因为getPlayheadPositionUs的精度不足以用来做音视频同步,所以这里通过计算每次getPlayheadPositionUs拿到的值与系统时钟的offset,并且取平均值,来解决精度不足的问题,平滑后的值即为smoothedPlayheadOffsetUs,再加上系统时钟即为“Audio当前播放的时间”。当然,最后要减去通过AudioTrack.getLatency方法获取到的底层delay值,才是最终的结果。
3. 音频部分总结
总体来说,音视频同步机制中的同步基准有两种选择:利用系统时间或audio playback position. 如果是video only的流,则利用系统时间,这方面比较简单,不再赘述。
a. 如果是用audio position的话, 推荐使用startMediaTimeUs + positionUs来计算,式中的startMediaTimeUs为码流中拿到的初始audio pts值, positionUs是一个以0为起点的时间值,代表audio 播放了多长时间的数据。
b.计算positionUs值则有两个方法, 根据设备支持情况来选择:
b.1.用AudioTimeStamp值来计算,需要注意的是,因为getTimeStamp方法不建议频繁调用,在ExoPlayer中是以500ms为间隔调用的,所以对应的逻辑可以化简为:
positionUs = framePosition/sampleRate + systemClock – nanoTime/1000
b.2. 用audioTrack.getPlaybackHeadPosition方法来计算, 但是因为这个值的粒度只有20ms, 可能存在一些抖动, 所以做了一些平滑处理, 对应的逻辑可以化简为:
positionUs = systemClockUs + smoothedPlayheadOffsetUs - latencyUs = systemClockUs + avg[playbackPositionUs(i) - systemClock(i)] - latencyUs = systemClockUs + avg[(audioTrack.getPlaybackHeadPosition/sampleRate)(i) -systemClock(i)] - latencyUs