Android 音视频同步机制

时间:2022-04-01 16:17:14

一、概述

音视频同步(avsync),是影响多媒体应用体验质量的一个重要因素。而我们在看到音视频同步的时候,最先想到的就是对齐两者的pts,但是实际使用中的各类播放器,其音视频同步机制都比这些复杂的多。

这里我们先介绍一些音视频同步相关的知识:

1. 如何测试音视频同步情况

最简单的就是播放一个演唱会视频,通过目测看看声音和嘴形是否能对上。

这里我们也可以使用一个更科学的设备:Sync-One。Sync-One是从纯物理的角度来测试音视频同步情况的,通过播放特定的测试片源,并检测声音和屏幕亮度的变化,评判声音是落后于视频,还是领先于视频,如果达到了完美的音视频同步结果,会在电子屏上显示数字0,当然这很难==,一般我们会设定一个标准区间,只要结果能落在这个区间内,即可认为视音频基本是同步的。

2. 如何制定音视频同步的标准

音视频同步的标准其实是一个非常主观的东西,仁者见仁智者见智。我们既可以通过主观评价实验来统计出一个合理的区间范围,也可以直接参考杜比等权威机构给出的区间范围。同时,不同的输出设备可能也需要给不同的区间范围。比如,默认设备的音视频同步区间是[-60, +30]ms, 蓝牙音箱输出时的音视频同步区间是[-160, +60]ms, 功放设备输出时的音视频同步区间是[-140, +40]ms。负值代表音频落后于视频,正值代表音频领先于视频。

3. 在梳理音视频同步逻辑我们应该关注什么

毫无疑问,音视频同步逻辑的梳理要分别从视频和音频两个角度来看。
视频方面,我们关注的是同步逻辑对视频码流的pts做了哪些调整。
音频方面,我们关注的是同步逻辑中是如何获取“Audio当前播放的时间”的。

二、ExoPlayer 的 avsync 逻辑梳理

下面的时序图简单的展示了一下ExoPlayer在音视频同步这块的基本流程:

Android 音视频同步机制

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

三、NuPlayer 的 avsync 逻辑梳理

下面的时序图简单的展示了一下NuPlayer在音视频同步这块的基本流程:

Android 音视频同步机制

NuPlayerDecoder 拿到解码后的音视频数据后 queueBuffer给NuPlayerRenderer,在NuPlayerRenderer中通过postDrainAudioQueue_l方法调用AudioSink进行写入,并且获取“Audio当前播放的时间”,可以看到这里也调用了AudioTrack的getTimeStamp和getPosition方法,和ExoPlayer中类似,同时会利用MediaClock类记录一些锚点时间戳变量。NuPlayerRenderer中调用postDrainVideoQueue方法对video数据进行处理,包括计算实际送显时间,利用vsync信号调整送显时间等,这里的调整是利用VideoFrameScheduler类完成的。需要注意的是,实际上NuPlayerRenderer方法中只进行了avsync的调整,真正的播放还要通过onRendereBuffer调用到NuPlayerDecoder中,进而调用MediaCodec的release方法进行播放。

Video部分

1、利用pts和系统时间计算realTimeUs

NuPlayer::Renderer::postDrainVideoQueue
int64_t nowUs = ALooper::GetNowUs();
BufferItem *bufferItem = &*mBufferItems.begin();
int64_t itemMediaUs = bufferItem->mTimestamp / ;
//这里就是调用MediaClock的getRealTimeFor方法,得到“视频帧应该显示的时间”
int64_t itemRealUs = getRealTime(itemMediaUs, nowUs);
realTimeUs = PTS - nowMediaUs + nowUs = PTS - (mAnchorTimeMediaUs + (nowUs - mAnchorTimeRealUs)) + nowUs 

mAnchorTimeMediaUs代表锚点媒体时间戳,可以理解为最开始播放的时候记录下来的第一个媒体时间戳。
mAnchorTimeRealUs代表锚点real系统时间戳。

nowUs - mAnchorTimeRealUs即为从开始播放到现在,系统时间经过了多久。
再加上mAnchorTimeMediaUs,即为“在当前系统时间下,对应的媒体时间戳”
用PTS减去这个时间,表示“还有多久该播放这一帧”
最后再加上一个系统时间,即为这一帧应该显示的时间。

2、利用vsync信号调整realTimeUs

NuPlayer::Renderer::postDrainVideoQueue realTimeUs = mVideoScheduler->schedule(realTimeUs * ) / ;

schedule方法非常复杂,我们难以完全理解,但也看到了计算ns精度的视频帧间隔的代码,这也与exoplayer的做法相同。

3、提前2倍vsync duration进行render

NuPlayer::Renderer::postDrainVideoQueue
//2倍vsync duration
int64_t twoVsyncsUs = * (mVideoScheduler->getVsyncPeriod() / );
//利用调整后的realTimeUs再计算一次“还有多久该播放这一帧”
delayUs = realTimeUs - nowUs;
// post 2 display refreshes before rendering is due
//如果delayUs大于两倍vsync duration,则延迟到“距离显示时间两倍vsync duration之前的时间点”再发消息进入后面的流程,否则立即走后面的流程
msg->post(delayUs > twoVsyncsUs ? delayUs - twoVsyncsUs : );

4、丢帧与送显

NuPlayer::Renderer::onDrainVideoQueue

//取出pts值
CHECK(entry->mBuffer->meta()->findInt64("timeUs", &mediaTimeUs));
nowUs = ALooper::GetNowUs();
//考虑到中间发消息等等会有耗时,所以这里重新利用pts计算一次realTimeUs
realTimeUs = getRealTimeUs(mediaTimeUs, nowUs);
//如果nowUs>realTimeUs,即代表视频帧来晚了
setVideoLateByUs(nowUs - realTimeUs);
//如果晚了40ms,即认为超过了门限值
tooLate = (mVideoLateByUs > );
//把realTimeUs赋值给timestampNs,通过消息发出去
entry->mNotifyConsumed->setInt64("timestampNs", realTimeUs * 1000ll);

5、视频部分总结

a). 计算video送显时间的核心公式如下

realTimeUs = PTS - nowMediaUs + nowUs = PTS - (mAnchorTimeMediaUs + (nowUs - mAnchorTimeRealUs)) + nowUs

b). 比较exoplayer和nuplayer

相同点包括
a.都是比较系统时间与视频帧的送显时间来判断要不要丢帧,丢帧门限值固定为40ms;
b.都会在计算送显时间时考虑函数调用与消息传递的耗时;
c.计算送显在计算送显时间时,都利用到了vsync信号来对送显时间进行校准

差异:
a.nuplayer会在最开始的时候就先确保音视频保持基本范围的同步
b.nuplayer中会有一个提前两倍vsync时间开始执行releaseOutputbuffer的逻辑,这一点与API注释中的描述一致

Audio部分

1、初始pts的纠正

NuPlayer::Renderer::onQueueBuffer
int64_t diff = firstVideoTimeUs - firstAudioTimeUs;
...
if (diff > 100000ll) {
// Audio data starts More than 0.1 secs before video.
// Drop some audio.
// 这里是对音视频的第一个pts做一下纠正,保证一开始两者是同步的,但是这里只是考虑了audio提前的情况,而没有考虑video提前的情况
(*mAudioQueue.begin()).mNotifyConsumed->post();
mAudioQueue.erase(mAudioQueue.begin());
return;
}

2、利用pts更新几个锚点变量

NuPlayer::Renderer::onDrainAudioQueue

//pts减去“还没播放的时间”,就是当前已经播放的时间,即playedDuration,将其设置为nowMediaUs
int64_t nowMediaUs = mediaTimeUs - getPendingAudioPlayoutDurationUs(nowUs);
//计算“还没播放的时间”
//计算writtenFrames对应的duration
//writtenDuration = writtenFrames/sampleRate
int64_t writtenAudioDurationUs =
getDurationUsIfPlayedAtSampleRate(mNumFramesWritten);
//用wriitenDuration - playedDuration,即为“还没播出的时长pendingPlayDuration”
return writtenAudioDurationUs - getPlayedOutAudioDurationUs(nowUs);=
//计算playedDuration – 使用getTimeStamp方法
status_t res = mAudioSink->getTimestamp(ts);
//当前播放的framePosition
numFramesPlayed = ts.mPosition;
//framePosition对应的系统时间
numFramesPlayedAt =
ts.mTime.tv_sec * 1000000LL + ts.mTime.tv_nsec / ;
int64_t durationUs = getDurationUsIfPlayedAtSampleRate(numFramesPlayed)
+ nowUs - numFramesPlayedAt;

这里可以说是avsync的核心逻辑了
来简单说说这几个变量,numFramesPlayed代表“从底层获取到的已播放帧数”,需要注意的是,这个并不一定是当前系统时间下已经播放的实时帧数,而numFramesPlayedAt代表“numFramesPlayed对应的系统时间”,所以
durationUs = numFramesPlayed/sampleRate +nowUs - numFramesPlayedAt才是当前系统时间下已经播放的音频时长

//计算playedDuration – 使用getPosition方法
//与exoplayer中的逻辑一样,如果getTimestamp用不了,再走getposition流程
res = mAudioSink->getPosition(&numFramesPlayed);
numFramesPlayedAt = nowUs;
//当前系统时间加上latency才是真正playedOut的时间,这里取了latency/2,可以看做是一种平均,因为latency方法返回值可能并不准
numFramesPlayedAt += 1000LL * mAudioSink->latency() / ;
int64_t durationUs = getDurationUsIfPlayedAtSampleRate(numFramesPlayed)
+ nowUs - numFramesPlayedAt;
//利用当前系统时间,当前播放的媒体时间戳,pts,更新锚点
mMediaClock->updateAnchor(nowMediaUs, nowUs, mediaTimeUs);
void MediaClock::updateAnchor(
int64_t anchorTimeMediaUs,
int64_t anchorTimeRealUs,
int64_t maxTimeMediaUs) {

int64_t nowUs = ALooper::GetNowUs();
int64_t nowMediaUs =
anchorTimeMediaUs + (nowUs - anchorTimeRealUs) * (double)mPlaybackRate;
...
mAnchorTimeRealUs = nowUs;
mAnchorTimeMediaUs = nowMediaUs;
}

3、音频部分总结

整个逻辑核心的公式就是如何计算已经播出的audio时长:

durationUs = numFramesPlayed/sampleRate +nowUs - numFramesPlayedAt

与exoplayer一样,可以通过getTimeStamp或者getPosition方法来获取

不同的地方有几点:首先是调用getTimeStamp的间隔不同,exoplayer中是500ms间隔,
而nuplayer中的间隔是pendingPlayedOutDuration/2,没有取定值;
其次是调用getPosition方法时,加上的是latency/2。

至于那些锚点变量的计算,看似复杂,其中心思想也大同小异。

四、MediaSync的使用与原理

MedaiSync是android M新加入的API,可以帮助应用视音频的同步播放。

1、MediaSync的基本用法

第一步:初始化MediaSync, 初始化mediaCodec和AudioTrack, 将AudioTrack和surface传给MeidaSync

MediaSync sync = new MediaSync();
sync.setSurface(surface);
Surface inputSurface = sync.createInputSurface();
...
// MediaCodec videoDecoder = ...;
videoDecoder.configure(format, inputSurface, ...);
...
sync.setAudioTrack(audioTrack);

第二步: MediaSync只会对audiobuffer做操作,一个是代表写入的queueAudio方法,一个是代表写完了的回调方法,也就是下面的

onAudioBufferConsumed
sync.setCallback(new MediaSync.Callback() {
@Override
public void onAudioBufferConsumed(MediaSync sync, ByteBuffer audioBuffer, int bufferId) {
...
}
}, null);

第三步:设置播放速度

// This needs to be done since sync is paused on creation.
sync.setPlaybackParams(new PlaybackParams().setSpeed(1.f));

第四步:开始流转音视频buffer,这里就和MediaCodec的基本调用流程一样了,当拿到audioBuffer后,通过queueAudio将buffer给MediaSync,在对应的回调方法中release播放出去,至于video部分,直接releaseOutputBuffer即可

for (;;) {
...
// send video frames to surface for rendering, e.g., call
videoDecoder.releaseOutputBuffer(videoOutputBufferIx,videoPresentationTimeNs);
...
sync.queueAudio(audioByteBuffer, bufferId, audioPresentationTimeUs); // non-blocking.
// The audioByteBuffer and bufferId will be returned via callback.
}

第五步:播放完毕

sync.setPlaybackParams(new PlaybackParams().setSpeed(0.f));
sync.release();
sync = null;

如果用的是MediaCodec的异步流程,如下,通过下面的代码可以更好的理解video buffer和audio buffer分别是怎么处理的

onOutputBufferAvailable(MediaCodec codec, int bufferId, BufferInfo info) {
// ...
if (codec == videoDecoder) {
codec.releaseOutputBuffer(bufferId, 1000 * info.presentationTime);
} else {
ByteBuffer audioByteBuffer = codec.getOutputBuffer(bufferId);
sync.queueAudio(audioByteBuffer, bufferId, info.presentationTime);
}
// ...
}
onAudioBufferConsumed(MediaSync sync, ByteBuffer buffer, int bufferId) {
// ...
audioDecoder.releaseBuffer(bufferId, false);
// ...
}

2、MediaSync的关键变量与方法

SyncParams:Android M新加入的API,用于控制AV同步的方法,具体包括:

1)、倍速播放时如何处理audio

int AUDIO_ADJUST_MODE_DEFAULT
System will determine best handling of audio for playback rate adjustments.
Used by default. This will make audio play faster or slower as required by the sync source without changing its pitch; however, system may fall back to some other method (e.g. change the pitch, or mute the audio) if time stretching is no longer supported for the playback rate. int AUDIO_ADJUST_MODE_RESAMPLE
Resample audio when playback rate must be adjusted.
This will make audio play faster or slower as required by the sync source by changing its pitch (making it lower to play slower, and higher to play faster.) int AUDIO_ADJUST_MODE_STRETCH
Time stretch audio when playback rate must be adjusted.
This will make audio play faster or slower as required by the sync source without changing its pitch, as long as it is supported for the playback rate.

2)、选择avsync的基准

int SYNC_SOURCE_AUDIO
Use audio track for sync source. This requires audio data and an audio track. int SYNC_SOURCE_DEFAULT
Use the default sync source (default). If media has video, the sync renders to a surface that directly renders to a display, and tolerance is non zero (e.g. not less than 0.001) vsync source is used for clock source. Otherwise, if media has audio, audio track is used. Finally, if media has no audio, system clock is used. int SYNC_SOURCE_SYSTEM_CLOCK
Use system monotonic clock for sync source. int SYNC_SOURCE_VSYNC
Use vsync as the sync source. This requires video data and an output surface that directly renders to the display, e.g. SurfaceView

PlaybackParams Android M 新加入的API,主要用于控制倍速播放

get & setPlaybackParams (PlaybackParams params)
Gets and Sets the playback rate using PlaybackParams.

MediaTimestamp Android M新加入的API

MediaTimestamp getTimestamp ()
Get current playback position.

五、提高&升华

尝试开发一个音视频同步的播放器

参考资料:张晖的专栏