天天看點

談一談:音視訊同步原理及實作

作者:初壹十五a

本文主要描述音視訊同步原理,及常見的音視訊同步方案,并以代碼示例,展示如何以音頻的播放時長為基準,将視訊同步到音頻上以實作視音頻的同步播放。内容如下:

  • 1.音視訊同步簡單介紹
  • 2.DTS和PTS簡介2.1.I/P/B幀2.2時間戳DTS、PTS
  • 3.常用同步政策
  • 4.音視訊同步簡單示例代碼

參考内容:

(視訊+文字)騰訊最全面Android進階學習筆記;快來學一波

2022Android十一位大廠面試題;134道真題;再也不怕面試了

音視訊真的是太吃香了?60道音視訊經典面試題

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.1.I/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);
}
           

參考内容:

(視訊+文字)騰訊最全面Android進階學習筆記;快來學一波

2022Android十一位大廠面試題;134道真題;再也不怕面試了

音視訊真的是太吃香了?60道音視訊經典面試題

繼續閱讀