一、概述
音視頻同步(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
三、NuPlayer 的 avsync 邏輯梳理
下面的時序圖簡單的展示了一下NuPlayer在音視頻同步這塊的基本流程:
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 / 1000; //這里就是調用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 * 1000) / 1000;
schedule方法非常復雜,我們難以完全理解,但也看到了計算ns精度的視頻幀間隔的代碼,這也與exoplayer的做法相同。
3、提前2倍vsync duration進行render
NuPlayer::Renderer::postDrainVideoQueue //2倍vsync duration int64_t twoVsyncsUs = 2 * (mVideoScheduler->getVsyncPeriod() / 1000); //利用調整后的realTimeUs再計算一次“還有多久該播放這一幀” delayUs = realTimeUs - nowUs; // post 2 display refreshes before rendering is due //如果delayUs大於兩倍vsync duration,則延遲到“距離顯示時間兩倍vsync duration之前的時間點”再發消息進入后面的流程,否則立即走后面的流程 msg->post(delayUs > twoVsyncsUs ? delayUs - twoVsyncsUs : 0);
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 > 40000); //把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 / 1000; 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() / 2; 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.
五、提高&升華
嘗試開發一個音視頻同步的播放器
參考資料:張暉的專欄