本文主要描述音視頻同步原理,及常見的音視頻同步方案,並以代碼示例,展示如何以音頻的播放時長為基准,將視頻同步到音頻上以實現視音頻的同步播放。內容如下:
1.音視頻同步簡單介紹
對於一個播放器,一般來說,其基本構成均可划分為以下幾部分:
數據接收(網絡/本地)->解復用->音視頻解碼->音視頻同步->音視頻輸出。
基本框架如下圖所示:
為什么需要音視頻同步?
媒體數據經過解復用流程后,音頻/視頻解碼便是獨立的,也是獨立播放的。而在音頻流和視頻流中,其播放速度都是有相關信息指定的:
- 視頻:幀率,表示視頻一秒顯示的幀數。
- 音頻:采樣率,表示音頻一秒播放的樣本的個數。
從幀率及采樣率,即可知道視頻/音頻播放速度。聲卡和顯卡均是以一幀數據來作為播放單位,如果單純依賴幀率及采樣率來進行播放,在理想條件下,應該是同步的,不會出現偏差。
以一個44.1KHz的AAC音頻流和24FPS的視頻流為例:
一個AAC音頻frame每個聲道包含1024個采樣點,則一個frame的播放時長(duration)為:(1024/44100)×1000ms = 23.22ms;一個視頻frame播放時長(duration)為:1000ms/24 = 41.67ms。理想情況下,音視頻完全同步,音視頻播放過程如下圖所示:
但實際情況下,如果用上面那種簡單的方式,慢慢的就會出現音視頻不同步的情況,要不是視頻播放快了,要么是音頻播放快了。可能的原因如下:
-
一幀的播放時間,難以精准控制。音視頻解碼及渲染的耗時不同,可能造成每一幀輸出有一點細微差距,長久累計,不同步便越來越明顯。(例如受限於性能,42ms才能輸出一幀)
-
音頻輸出是線性的,而視頻輸出可能是非線性,從而導致有偏差。
-
媒體流本身音視頻有差距。(特別是TS實時流,音視頻能播放的第一個幀起點不同)
所以,解決音視頻同步問題,引入了時間戳:
首先選擇一個參考時鍾(要求參考時鍾上的時間是線性遞增的);
編碼時依據參考時鍾上的給每個音視頻數據塊都打上時間戳;
播放時,根據音視頻時間戳及參考時鍾,來調整播放。
所以,視頻和音頻的同步實際上是一個動態的過程,同步是暫時的,不同步則是常態。以參考時鍾為標准,放快了就減慢播放速度;播放快了就加快播放的速度。
接下來,我們介紹媒體流中時間戳的概念。
2.DTS和PTS簡介
2.1I/P/B幀
在介紹DTS/PTS之前,我們先了解I/P/B幀的概念。I/P/B幀本身和音視頻同步關系不大,但理解其概念有助於我們了解DTS/PTS存在的意義。
視頻本質上是由一幀幀畫面組成,但實際應用過程中,每一幀畫面會進行壓縮(編碼)處理,已達到減少空間占用的目的。
編碼方式可以分為幀內編碼和幀間編碼。
內編碼方式:
即只利用了單幀圖像內的空間相關性,對冗余數據進行編碼,達到壓縮效果,而沒有利用時間相關性,不使用運動補償。所以單靠自己,便能完整解碼出一幀畫面。
幀間編碼:
由於視頻的特性,相鄰的幀之間其實是很相似的,通常是運動矢量的變化。利用其時間相關性,可以通過參考幀運動矢量的變化來預測圖像,並結合預測圖像與原始圖像的差分,便能解碼出原始圖像。所以,幀間編碼需要依賴其他幀才能解碼出一幀畫面。
由於編碼方式的不同,視頻中的畫面幀就分為了不同的類別,其中包括:I 幀、P 幀、B 幀。I 幀、P 幀、B 幀的區別在於:
-
I 幀(Intra coded frames):
I 幀圖像采用幀I 幀使用幀內壓縮,不使用運動補償,由於 I 幀不依賴其它幀,可以獨立解碼。I 幀圖像的壓縮倍數相對較低,周期性出現在圖像序列中的,出現頻率可由編碼器選擇。
-
P 幀(Predicted frames):
P 幀采用幀間編碼方式,即同時利用了空間和時間上的相關性。P 幀圖像只采用前向時間預測,可以提高壓縮效率和圖像質量。P 幀圖像中可以包含幀內編碼的部分,即 P 幀中的每一個宏塊可以是前向預測,也可以是幀內編碼。
-
B 幀(Bi-directional predicted frames):
B 幀圖像采用幀間編碼方式,且采用雙向時間預測,可以大大提高壓縮倍數。也就是其在時間相關性上,還依賴后面的視頻幀,也正是由於 B 幀圖像采用了后面的幀作為參考,因此造成視頻幀的傳輸順序和顯示順序是不同的。
也就是說,一個 I 幀可以不依賴其他幀就解碼出一幅完整的圖像,而 P 幀、B 幀不行。P 幀需要依賴視頻流中排在它前面的幀才能解碼出圖像。B 幀則需要依賴視頻流中排在它前面或后面的I/P幀才能解碼出圖像。
對於I幀和P幀,其解碼順序和顯示順序是相同的,但B幀不是,如果視頻流中存在B幀,那么就會打算解碼和顯示順序。
正因為解碼和顯示的這種非線性關系,所以需要DTS、PTS來標識幀的解碼及顯示時間。
2.2時間戳DTS、PTS
- DTS(Decoding Time Stamp):即解碼時間戳,這個時間戳的意義在於告訴播放器該在什么時候解碼這一幀的數據。
- PTS(Presentation Time Stamp):即顯示時間戳,這個時間戳用來告訴播放器該在什么時候顯示這一幀的數據。
當視頻流中沒有 B 幀時,通常 DTS 和 PTS 的順序是一致的。但如果有 B 幀時,就回到了我們前面說的問題:解碼順序和播放順序不一致了,即視頻輸出是非線性的。
比如一個視頻中,幀的顯示順序是:I B B P,因為B幀解碼需要依賴P幀,因此這幾幀在視頻流中的順序可能是:I P B B,這時候就體現出每幀都有 DTS 和 PTS 的作用了。DTS 告訴我們該按什么順序解碼這幾幀圖像,PTS 告訴我們該按什么順序顯示這幾幀圖像。順序大概如下:
從流分析工具看,流中P幀在B幀之前,但顯示確實在B幀之后。
需要注意的是:雖然 DTS、PTS 是用於指導播放端的行為,但它們是在編碼的時候由編碼器生成的。
以我們最常見的TS為例:
TS流中,PTS/DTS信息在打流階段生成在PES層,主要是在PES頭信息里。
標志第一位是PTS標識,第二位是DTS標識。
標志:
00,表示無PTS無DTS;
01,錯誤,不能只有DTS沒有PTS;
10,有PTS;
11,有PTS和DTS。
PTS有33位,但是它不是直接的33位數據,而是占了5個字節,PTS分別在這5字節中取。
TS的I/P幀攜帶PTS/DTS信息,B幀PTS/DTS相等,進保留PTS;由於聲音沒有用到雙向預測,它的解碼次序就是它的顯示次序,故它只有PTS。
TS的編碼器中有一個系統時鍾STC(其頻率是27MHz),此時鍾用來產生指示音視頻的正確顯示和解碼時間戳。
PTS域在PES中為33bits,是對系統時鍾的300分頻的時鍾的計數值。它被編碼成為3個獨立的字段:
PTS[32…30][29…15][14…0]。
DTS域在PES中為33bits,是對系統時鍾的300分頻的時鍾的計數值。它被編碼成為3個獨立的字段:
DTS[32…30][29…15][14…0]。
因此,對於TS流,PTS/DTS時間基均為1/90000秒(27MHz經過300分頻)。
PTS對於TS流的意義不僅在於音視頻同步,TS流本身不攜帶duration(可播放時長)信息,所以計算duration也是根據PTS得到。
附上TS流解析PTS示例:
#define MAKE_WORD(h, l) (((h) << 8) | (l))
//packet為PES
int64_t get_pts(const uint8_t *packet)
{
const uint8_t *p = packet;
if(packet == NULL) {
return -1;
}
if(!(p[0] == 0x00 && p[1] == 0x00 && p[2] == 0x01)) { //pes sync word
return -1;
}
p += 3; //jump pes sync word
p += 4; //jump stream id(1) pes length(2) pes flag(1)
int pts_pts_flag = *p >> 6;
p += 2; //jump pes flag(1) pes header length(1)
if (pts_pts_flag & 0x02) {
int64_t pts32_30, pts29_15, pts14_0, pts;
pts32_30 = (*p) >> 1 & 0x07;
p += 1;
pts29_15 = (MAKE_WORD(p[0],p[1])) >> 1;
p += 2;
pts14_0 = (MAKE_WORD(p[0],p[1])) >> 1;
p += 2;
pts = (pts32_30 << 30) | (pts29_15 << 15) | pts14_0;
return pts;
}
return -1;
}
3.常用同步策略
前面已經說了,實現音視頻同步,在播放時,需要選定一個參考時鍾,讀取幀上的時間戳,同時根據的參考時鍾來動態調節播放。現在已經知道時間戳就是PTS,那么參考時鍾的選擇一般來說有以下三種:
- 將視頻同步到音頻上:就是以音頻的播放速度為基准來同步視頻。
- 將音頻同步到視頻上:就是以視頻的播放速度為基准來同步音頻。
- 將視頻和音頻同步外部的時鍾上:選擇一個外部時鍾為基准,視頻和音頻的播放速度都以該時鍾為標准。
當播放源比參考時鍾慢,則加快其播放速度,或者丟棄;快了,則延遲播放。
這三種是最基本的策略,考慮到人對聲音的敏感度要強於視頻,頻繁調節音頻會帶來較差的觀感體驗,且音頻的播放時鍾為線性增長,所以一般會以音頻時鍾為參考時鍾,視頻同步到音頻上。
在實際使用基於這三種策略做一些優化調整,例如:
- 調整策略可以盡量采用漸進的方式,因為音視頻同步是一個動態調節的過程,一次調整讓音視頻PTS完全一致,沒有必要,且可能導致播放異常較為明顯。
- 調整策略僅僅對早到的或晚到的數據塊進行延遲或加快處理,有時候是不夠的。如果想要更加主動並且有效地調節播放性能,需要引入一個反饋機制,也就是要將當前數據流速度太快或太慢的狀態反饋給“源”,讓源去放慢或加快數據流的速度。
- 對於起播階段,特別是TS實時流,由於視頻解碼需要依賴第一個I幀,而音頻是可以實時輸出,可能出現的情況是視頻PTS超前音頻PTS較多,這種情況下進行同步,勢必造成較為明顯的慢同步。處理這種情況的較好方法是將較為多余的音頻數據丟棄,盡量減少起播階段的音視頻差距。
4.音視頻同步簡單示例代碼
代碼參考ffplay實現方式,同時加入自己的修改。以audio為參考時鍾,video同步到音頻的示例代碼:
- 獲取當前要顯示的video PTS,減去上一幀視頻PTS,則得出上一幀視頻應該顯示的時長delay;
- 當前video PTS與參考時鍾當前audio PTS比較,得出音視頻差距diff;
- 獲取同步閾值sync_threshold,為一幀視頻差距,范圍為10ms-100ms;
- diff小於sync_threshold,則認為不需要同步;否則delay+diff值,則是正確糾正delay;
- 如果超過sync_threshold,且視頻落后於音頻,那么需要減小delay(FFMAX(0, delay + diff)),讓當前幀盡快顯示。
如果視頻落后超過1秒,且之前10次都快速輸出視頻幀,那么需要反饋給音頻源減慢,同時反饋視頻源進行丟幀處理,讓視頻盡快追上音頻。因為這很可能是視頻解碼跟不上了,再怎么調整delay也沒用。 - 如果超過sync_threshold,且視頻快於音頻,那么需要加大delay,讓當前幀延遲顯示。
將delay*2慢慢調整差距,這是為了平緩調整差距,因為直接delay+diff,會讓畫面畫面遲滯。
如果視頻前一幀本身顯示時間很長,那么直接delay+diff一步調整到位,因為這種情況再慢慢調整也沒太大意義。 - 考慮到渲染的耗時,還需進行調整。frame_timer為一幀顯示的系統時間,frame_timer+delay- curr_time,則得出正在需要延遲顯示當前幀的時間。
{
video->frameq.deQueue(&video->frame);
//獲取上一幀需要顯示的時長delay
double current_pts = *(double *)video->frame->opaque;
double delay = current_pts - video->frame_last_pts;
if (delay <= 0 || delay >= 1.0)
{
delay = video->frame_last_delay;
}
// 根據視頻PTS和參考時鍾調整delay
double ref_clock = audio->get_audio_clock();
double diff = current_pts - ref_clock;// diff < 0 :video slow,diff > 0 :video fast
//一幀視頻時間或10ms,10ms音視頻差距無法察覺
double sync_threshold = FFMAX(MIN_SYNC_THRESHOLD, FFMIN(MAX_SYNC_THRESHOLD, delay)) ;
audio->audio_wait_video(current_pts,false);
video->video_drop_frame(ref_clock,false);
if (!isnan(diff) && fabs(diff) < NOSYNC_THRESHOLD) // 不同步
{
if (diff <= -sync_threshold)//視頻比音頻慢,加快
{
delay = FFMAX(0, delay + diff);
static int last_delay_zero_counts = 0;
if(video->frame_last_delay <= 0)
{
last_delay_zero_counts++;
}
else
{
last_delay_zero_counts = 0;
}
if(diff < -1.0 && last_delay_zero_counts >= 10)
{
printf("maybe video codec too slow, adjust video&audio\n");
#ifndef DORP_PACK
audio->audio_wait_video(current_pts,true);//差距較大,需要反饋音頻等待視頻
#endif
video->video_drop_frame(ref_clock,true);//差距較大,需要視頻丟幀追上
}
}
//視頻比音頻快,減慢
else if (diff >= sync_threshold && delay > SYNC_FRAMEDUP_THRESHOLD)
delay = delay + diff;//音視頻差距較大,且一幀的超過幀最常時間,一步到位
else if (diff >= sync_threshold)
delay = 2 * delay;//音視頻差距較小,加倍延遲,逐漸縮小
}
video->frame_last_delay = delay;
video->frame_last_pts = current_pts;
double curr_time = static_cast<double>(av_gettime()) / 1000000.0;
if(video->frame_timer == 0)
{
video->frame_timer = curr_time;//show first frame ,set frame timer
}
double actual_delay = video->frame_timer + delay - curr_time;
if (actual_delay <= MIN_REFRSH_S)
{
actual_delay = MIN_REFRSH_S;
}
usleep(static_cast<int>(actual_delay * 1000 * 1000));
//printf("actual_delay[%lf] delay[%lf] diff[%lf]\n",actual_delay,delay,diff);
// Display
SDL_UpdateTexture(video->texture, &(video->rect), video->frame->data[0], video->frame->linesize[0]);
SDL_RenderClear(video->renderer);
SDL_RenderCopy(video->renderer, video->texture, &video->rect, &video->rect);
SDL_RenderPresent(video->renderer);
video->frame_timer = static_cast<double>(av_gettime()) / 1000000.0 ;
av_frame_unref(video->frame);
//update next frame
schedule_refresh(media, 1);
}