FFmpeg簡易播放器的實現5-音視頻同步


本文為作者原創,轉載請注明出處:https://www.cnblogs.com/leisure_chn/p/10284653.html

基於 FFmpeg 和 SDL 實現的簡易視頻播放器,主要分為讀取視頻文件解碼和調用 SDL 顯示兩大部分。

FFmpeg 簡易播放器系列文章如下:
[1]. FFmpeg簡易播放器的實現1-最簡版
[2]. FFmpeg簡易播放器的實現2-視頻播放
[3]. FFmpeg簡易播放器的實現3-音頻播放
[4]. FFmpeg簡易播放器的實現4-音視頻播放
[5]. FFmpeg簡易播放器的實現5-音視頻同步

前面四次實驗,從最簡入手,循序漸進,研究播放器的實現過程。第四次實驗,雖然音頻和視頻都能播放出來,但是聲音和圖像無法同步,而沒有音視頻同步的播放器只是屬於概念性質的播放器,無法實際使用。本次實驗將實現音頻和視頻的同步,這樣,一個能夠實際使用的簡易播放器才算初具雛形,在這個基礎上,后續可再進行完善和優化。

音視頻同步是播放器中比較復雜的一部分內容。前幾次實驗中的代碼遠不能滿足要求,需要大幅修改。本次實驗不在前幾次代碼上修改,而是基於 ffplay 源碼進行修改。ffplay 是 FFmpeg 工程自帶的一個簡單播放器,盡管稱為簡單播放器,其代碼實現仍顯得過為復雜,本實驗對 ffplay.c 進行刪減,刪掉復雜的命令選項、濾鏡操作、SEEK 操作、逐幀插放等功能,僅保留最核心的音視頻同步部分。

盡管不使用之前的代碼,但播放器的基本原理和大致流程相同,前面幾次實驗仍具有有效參考價值。

1. 視頻播放器基本原理

下圖引用自 “雷霄驊,視音頻編解碼技術零基礎學習方法”,因原圖太小,看不太清楚,故重新制作了一張圖片。
播放器基本原理示意圖
如下內容引用自 “雷霄驊,視音頻編解碼技術零基礎學習方法”:

解協議
將流媒體協議的數據,解析為標准的相應的封裝格式數據。視音頻在網絡上傳播的時候,常常采用各種流媒體協議,例如 HTTP,RTMP,或是 MMS 等等。這些協議在傳輸視音頻數據的同時,也會傳輸一些信令數據。這些信令數據包括對播放的控制(播放,暫停,停止),或者對網絡狀態的描述等。解協議的過程中會去除掉信令數據而只保留視音頻數據。例如,采用 RTMP 協議傳輸的數據,經過解協議操作后,輸出 FLV 格式的數據。

解封裝
將輸入的封裝格式的數據,分離成為音頻流壓縮編碼數據和視頻流壓縮編碼數據。封裝格式種類很多,例如 MP4,MKV,RMVB,TS,FLV,AVI 等等,它的作用就是將已經壓縮編碼的視頻數據和音頻數據按照一定的格式放到一起。例如,FLV 格式的數據,經過解封裝操作后,輸出 H.264 編碼的視頻碼流和 AAC 編碼的音頻碼流。

解碼
將視頻/音頻壓縮編碼數據,解碼成為非壓縮的視頻/音頻原始數據。音頻的壓縮編碼標准包含 AAC,MP3,AC-3 等等,視頻的壓縮編碼標准則包含 H.264,MPEG2,VC-1 等等。解碼是整個系統中最重要也是最復雜的一個環節。通過解碼,壓縮編碼的視頻數據輸出成為非壓縮的顏色數據,例如 YUV420P,RGB 等等;壓縮編碼的音頻數據輸出成為非壓縮的音頻抽樣數據,例如 PCM 數據。

音視頻同步
根據解封裝模塊處理過程中獲取到的參數信息,同步解碼出來的視頻和音頻數據,並將視頻音頻數據送至系統的顯卡和聲卡播放出來。

2. 簡易播放器的實現-音視頻同步

2.1 實驗平台

實驗平台:      openSUSE Leap 42.3  
                Microsoft Visual Studio 2017 (WIN10)  
FFmpeg 版本:   4.1  
SDL 版本:      2.0.9  

本工程支持在 Linux 和 Windows 平台上運行。
Linux 下 FFmpeg 開發環境搭建可參考 “FFmpeg 開發環境構建”。
Windows 下使用 Microsoft Visual Studio 2017 打開工程目錄下 ffplayer.sln 文件即可運行。

2.2 源碼清單

使用如下命令下載源碼:

svn checkout https://github.com/leichn/ffplayer/trunk

ffplay 所有源碼集中在 ffplay.c 一個文件中,ffplay.c 代碼很長。本實驗將 ffplay.c 按功能點拆分為多個文件,源文件說明如下:

player.c    運行主線程,SDL 消息處理
demux.c     解復用線程
video.c     視頻解碼線程和視頻播放線程
audio.c     音頻解碼線程和音頻播放線程
packet.c    packet 隊列操作函數
frame.c     frame 隊列操作函數
main.c      程序入口,外部調用示例
Makefile    Linux 平台下編譯用 Makefile
lib_wins    Windows 平台下 FFmpeg 和 SDL 編譯時庫和運行時庫

本來想將 ffplay.c 中全局使用的大數據結構 VideoState 也拆分分散到各文件中去,但發現各文件對此數據結構的引用關系錯綜復雜,很難拆分,因此作罷。

2.3 源碼流程分析

源碼流程和 ffplay 基本相同,不同的一點是 ffplay 中視頻播放和 SDL 消息處理都是在同一個線程中(主線程),本工程中將視頻播放獨立為一個線程。本工程源碼流程如下圖所示:
FFmpeg簡易播放器流程圖

ffplay 的源碼流程可參考 “ffplay源碼分析3-代碼框架”。

2.4 音視頻同步

音視頻同步的詳細介紹可參考 “ffplay源碼分析4-音視頻同步”,為保證文章的完整性,本文保留此節內容。與 “ffplay源碼分析4-音視頻同步” 相比,本節源碼及文字均作了適當精簡。

音視頻同步的目的是為了使播放的聲音和顯示的畫面保持一致。視頻按幀播放,圖像顯示設備每次顯示一幀畫面,視頻播放速度由幀率確定,幀率指示每秒顯示多少幀;音頻按采樣點播放,聲音播放設備每次播放一個采樣點,聲音播放速度由采樣率確定,采樣率指示每秒播放多少個采樣點。如果僅僅是視頻按幀率播放,音頻按采樣率播放,二者沒有同步機制,即使最初音視頻是基本同步的,隨着時間的流逝,音視頻會逐漸失去同步,並且不同步的現象會越來越嚴重。這是因為:一、播放時間難以精確控制,二、異常及誤差會隨時間累積。所以,必須要采用一定的同步策略,不斷對音視頻的時間差作校正,使圖像顯示與聲音播放總體保持一致。

音視頻同步的方式基本是確定一個時鍾(音頻時鍾、視頻時鍾、外部時鍾)作為主時鍾,非主時鍾的音頻或視頻時鍾為從時鍾。在播放過程中,主時鍾作為同步基准,不斷判斷從時鍾與主時鍾的差異,調節從時鍾,使從時鍾追趕(落后時)或等待(超前時)主時鍾。按照主時鍾的不同種類,可以將音視頻同步模式分為如下三種:
音頻同步到視頻,視頻時鍾作為主時鍾。
視頻同步到音頻,音頻時鍾作為主時鍾。
音視頻同步到外部時鍾,外部時鍾作為主時鍾。

本實驗采用 ffplay 默認的同步方式:視頻同步到音頻。ffplay 中同步模式的定義如下:

enum {
    AV_SYNC_AUDIO_MASTER, /* default choice */
    AV_SYNC_VIDEO_MASTER,
    AV_SYNC_EXTERNAL_CLOCK, /* synchronize to an external clock */
};

2.4.1 time_base

time_base 是 PTS 和 DTS 的時間單位,也稱時間基。

不同的封裝格式time_base不一樣,轉碼過程中的不同階段time_base也不一樣。

以 mpegts 封裝格式為例,假設視頻幀率為 25 FPS。編碼數據包 packet(數據結構 AVPacket) 對應的 time_base 為 AVRational{1,90000}。原始數據幀 frame(數據結構 AVFrame) 對應的 time_base 為 AVRational{1,25}。在解碼或播放過程中,我們關注的是 frame 的 time_base,定義在 AVStream 結構體中,其表示形式 AVRational{1,25} 是一個分數,值為 1/25,單位是秒。在舊的 FFmpeg 版本中,AVStream 中的 time_base 成員有如下注釋:

For fixed-fps content, time base should be 1/framerate and timestamp increments should be 1.

當前新版本中已無此條注釋。

2.4.2 PTS/DTS/解碼過程

DTS(Decoding Time Stamp, 解碼時間戳),表示 packet 的解碼時間。
PTS(Presentation Time Stamp, 顯示時間戳),表示 packet 解碼后數據的顯示時間。

音頻中 DTS 和 PTS 是相同的。視頻中由於 B 幀需要雙向預測,B 幀依賴於其前和其后的幀,因此含 B 幀的視頻解碼順序與顯示順序不同,即 DTS 與 PTS 不同。當然,不含 B 幀的視頻,其 DTS 和 PTS 是相同的。

解碼順序和顯示順序相關的解釋可參考 “視頻編解碼基礎概念”,選用下圖說明視頻流解碼順序和顯示順序:
解碼和顯示順序

理解了含 B 幀視頻流解碼順序與顯示順序的不同,才容易理解視頻解碼函數 video_decode_frame() 的處理過程:
avcodec_send_packet() 按解碼順序發送 packet。
avcodec_receive_frame() 按顯示順序輸出 frame。
這個過程由解碼器處理,不需要用戶程序費心。

video_decode_frame() 是非常核心的一個函數,實現如下:

// 從packet_queue中取一個packet,解碼生成frame
static int video_decode_frame(AVCodecContext *p_codec_ctx, packet_queue_t *p_pkt_queue, AVFrame *frame)
{
    int ret;
    
    while (1)
    {
        AVPacket pkt;

        while (1)
        {
            // 3. 從解碼器接收frame
            // 3.1 一個視頻packet含一個視頻frame
            //     解碼器緩存一定數量的packet后,才有解碼后的frame輸出
            //     frame輸出順序是按pts的順序,如IBBPBBP
            //     frame->pkt_pos變量是此frame對應的packet在視頻文件中的偏移地址,值同pkt.pos
            ret = avcodec_receive_frame(p_codec_ctx, frame);
            if (ret < 0)
            {
                if (ret == AVERROR_EOF)
                {
                    av_log(NULL, AV_LOG_INFO, "video avcodec_receive_frame(): the decoder has been fully flushed\n");
                    avcodec_flush_buffers(p_codec_ctx);
                    return 0;
                }
                else if (ret == AVERROR(EAGAIN))
                {
                    av_log(NULL, AV_LOG_INFO, "video avcodec_receive_frame(): output is not available in this state - "
                            "user must try to send new input\n");
                    break;
                }
                else
                {
                    av_log(NULL, AV_LOG_ERROR, "video avcodec_receive_frame(): other errors\n");
                    continue;
                }
            }
            else
            {
                frame->pts = frame->best_effort_timestamp;
                //frame->pts = frame->pkt_dts;

                return 1;   // 成功解碼得到一個視頻幀或一個音頻幀,則返回
            }
        }

        // 1. 取出一個packet。使用pkt對應的serial賦值給d->pkt_serial
        if (packet_queue_get(p_pkt_queue, &pkt, true) < 0)
        {
            return -1;
        }

        if (pkt.data == NULL)
        {
            // 復位解碼器內部狀態/刷新內部緩沖區
            avcodec_flush_buffers(p_codec_ctx);
        }
        else
        {
            // 2. 將packet發送給解碼器
            //    發送packet的順序是按dts遞增的順序,如IPBBPBB
            //    pkt.pos變量可以標識當前packet在視頻文件中的地址偏移
            if (avcodec_send_packet(p_codec_ctx, &pkt) == AVERROR(EAGAIN))
            {
                av_log(NULL, AV_LOG_ERROR, "receive_frame and send_packet both returned EAGAIN, which is an API violation.\n");
            }

            av_packet_unref(&pkt);
        }
    }
}

本函數實現如下功能:
[1] 從視頻 packet 隊列中取一個 packet。
[2] 將取得的 packet 發送給解碼器。
[3] 從解碼器接收解碼后的 frame,此 frame 作為函數的輸出參數供上級函數處理。

注意如下幾點:
[1] 含 B 幀的視頻文件,其視頻幀存儲順序與顯示順序不同。
[2] 解碼器的輸入是 packet 隊列,視頻幀解碼順序與存儲順序相同,是按 dts 遞增的順序。dts 是解碼時間戳,因此存儲順序解碼順序都是 dts 遞增的順序。avcodec_send_packet() 就是將視頻文件中的 packet 序列依次發送給解碼器。發送 packet 的順序如 IPBBPBB。
[3]. 解碼器的輸出是 frame 隊列,frame 輸出順序是按 pts 遞增的順序。pts 是解碼時間戳。pts 與 dts 不一致的問題由解碼器進行了處理,用戶程序不必關心。從解碼器接收 frame 的順序如 IBBPBBP。
[4]. 解碼器中會緩存一定數量的幀,一個新的解碼動作啟動后,向解碼器送入好幾個 packet 后解碼器才會輸出第一個 packet,這比較容易理解,因為解碼時幀之間有信賴關系,例如 IPB 三個幀被送入解碼器后,B 幀解碼需要依賴 I 幀和 P 幀,所以在 B 幀輸出前,I 幀和 P 幀必須存在於解碼器中而不能刪除。理解了這一點,后面視頻 frame 隊列中對視頻幀的顯示和刪除機制才容易理解。
[5]. 解碼器中緩存的幀可以通過沖洗(flush)解碼器取出。沖洗(flush)解碼器的方法就是調用 avcodec_send_packet(..., NULL),然后多次調用 avcodec_receive_frame() 將緩存幀取盡。緩存幀取完后,avcodec_receive_frame() 返回 AVERROR_EOF。

如何確定解碼器的輸出 frame 與輸入 packet 的對應關系呢?可以對比 frame->pkt_pos 和 pkt.pos 的值,這兩個值表示 packet 在視頻文件中的偏移地址,如果這兩個變量值相等,表示此 frame 來自此 packet。調試跟蹤這兩個變量值,即能發現解碼器輸入幀與輸出幀的關系。為簡便,就不貼圖了。

2.4.3 視頻同步到音頻

視頻同步到音頻是 ffplay 的默認同步方式。在視頻播放線程中實現。

視頻播放線程中有一個很重要的函數 video_refresh(),實現了視頻播放(包含同步控制)核心步驟,理解起來有些難度。

相關函數關系如下:

main() -->
player_running() -->
open_video() -->
open_video_playing() -->
SDL_CreateThread(video_playing_thread, ...) 創建視頻播放線程

video_playing_thread() -->
video_refresh()

視頻播放線程源碼如下:

static int video_playing_thread(void *arg)
{
    player_stat_t *is = (player_stat_t *)arg;
    double remaining_time = 0.0;

    while (1)
    {
        if (remaining_time > 0.0)
        {
            av_usleep((unsigned)(remaining_time * 1000000.0));
        }
        remaining_time = REFRESH_RATE;
        // 立即顯示當前幀,或延時remaining_time后再顯示
        video_refresh(is, &remaining_time);
    }

    return 0;
}

video_refresh() 函數源碼如下:

/* called to display each frame */
static void video_refresh(void *opaque, double *remaining_time)
{
    player_stat_t *is = (player_stat_t *)opaque;
    double time;
    static bool first_frame = true;

retry:
    if (frame_queue_nb_remaining(&is->video_frm_queue) == 0)  // 所有幀已顯示
    {    
        // nothing to do, no picture to display in the queue
        return;
    }

    double last_duration, duration, delay;
    frame_t *vp, *lastvp;

    /* dequeue the picture */
    lastvp = frame_queue_peek_last(&is->video_frm_queue);     // 上一幀:上次已顯示的幀
    vp = frame_queue_peek(&is->video_frm_queue);              // 當前幀:當前待顯示的幀

    // lastvp和vp不是同一播放序列(一個seek會開始一個新播放序列),將frame_timer更新為當前時間
    if (first_frame)
    {
        is->frame_timer = av_gettime_relative() / 1000000.0;
        first_frame = false;
    }

    // 暫停處理:不停播放上一幀圖像
    if (is->paused)
        goto display;

    /* compute nominal last_duration */
    last_duration = vp_duration(is, lastvp, vp);        // 上一幀播放時長:vp->pts - lastvp->pts
    delay = compute_target_delay(last_duration, is);    // 根據視頻時鍾和同步時鍾的差值,計算delay值

    time= av_gettime_relative()/1000000.0;
    // 當前幀播放時刻(is->frame_timer+delay)大於當前時刻(time),表示播放時刻未到
    if (time < is->frame_timer + delay) {
        // 播放時刻未到,則更新刷新時間remaining_time為當前時刻到下一播放時刻的時間差
        *remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);
        // 播放時刻未到,則不播放,直接返回
        return;
    }

    // 更新frame_timer值
    is->frame_timer += delay;
    // 校正frame_timer值:若frame_timer落后於當前系統時間太久(超過最大同步域值),則更新為當前系統時間
    if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)
    {
        is->frame_timer = time;
    }

    SDL_LockMutex(is->video_frm_queue.mutex);
    if (!isnan(vp->pts))
    {
        update_video_pts(is, vp->pts, vp->pos, vp->serial); // 更新視頻時鍾:時間戳、時鍾時間
    }
    SDL_UnlockMutex(is->video_frm_queue.mutex);

    // 是否要丟棄未能及時播放的視頻幀
    if (frame_queue_nb_remaining(&is->video_frm_queue) > 1)  // 隊列中未顯示幀數>1(只有一幀則不考慮丟幀)
    {         
        frame_t *nextvp = frame_queue_peek_next(&is->video_frm_queue);  // 下一幀:下一待顯示的幀
        duration = vp_duration(is, vp, nextvp);             // 當前幀vp播放時長 = nextvp->pts - vp->pts
        // 當前幀vp未能及時播放,即下一幀播放時刻(is->frame_timer+duration)小於當前系統時刻(time)
        if (time > is->frame_timer + duration)
        {
            frame_queue_next(&is->video_frm_queue);         // 刪除上一幀已顯示幀,即刪除lastvp,讀指針加1(從lastvp更新到vp)
            goto retry;
        }
    }

    // 刪除當前讀指針元素,讀指針+1。若未丟幀,讀指針從lastvp更新到vp;若有丟幀,讀指針從vp更新到nextvp
    frame_queue_next(&is->video_frm_queue);

display:
    video_display(is);                      // 取出當前幀vp(若有丟幀是nextvp)進行播放
}

視頻同步到音頻的基本方法是:如果視頻超前音頻,則不進行播放,以等待音頻;如果視頻落后音頻,則丟棄當前幀直接播放下一幀,以追趕音頻。

此函數執行流程參考如下流程圖:

video_refresh()流程圖

步驟如下:
[1] 根據上一幀 lastvp 的播放時長 duration,校正等到 delay 值,duration 是上一幀理想播放時長,delay 是上一幀實際播放時長,根據delay 值可以計算得到當前幀的播放時刻
[2] 如果當前幀 vp 播放時刻未到,則繼續顯示上一幀 lastvp,並將延時值 remaining_time 作為輸出參數供上級調用函數處理
[3] 如果當前幀 vp 播放時刻已到,則立即顯示當前幀,並更新讀指針

在 video_refresh() 函數中,調用了 compute_target_delay() 來根據視頻時鍾與主時鍾的差異來調節 delay 值,從而調節視頻幀播放的時刻:

// 根據視頻時鍾與同步時鍾(如音頻時鍾)的差值,校正delay值,使視頻時鍾追趕或等待同步時鍾
// 輸入參數delay是上一幀播放時長,即上一幀播放后應延時多長時間后再播放當前幀,通過調節此值來調節當前幀播放快慢
// 返回值delay是將輸入參數delay經校正后得到的值
static double compute_target_delay(double delay, VideoState *is)
{
    double sync_threshold, diff = 0;

    /* update delay to follow master synchronisation source */
    if (get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER) {
        /* if video is slave, we try to correct big delays by
           duplicating or deleting a frame */
        // 視頻時鍾與同步時鍾(如音頻時鍾)的差異,時鍾值是上一幀pts值(實為:上一幀pts + 上一幀至今流逝的時間差)
        diff = get_clock(&is->vidclk) - get_master_clock(is);
        // delay是上一幀播放時長:當前幀(待播放的幀)播放時間與上一幀播放時間差理論值
        // diff是視頻時鍾與同步時鍾的差值

        /* skip or repeat frame. We take into account the
           delay to compute the threshold. I still don't know
           if it is the best guess */
        // 若delay < AV_SYNC_THRESHOLD_MIN,則同步域值為AV_SYNC_THRESHOLD_MIN
        // 若delay > AV_SYNC_THRESHOLD_MAX,則同步域值為AV_SYNC_THRESHOLD_MAX
        // 若AV_SYNC_THRESHOLD_MIN < delay < AV_SYNC_THRESHOLD_MAX,則同步域值為delay
        sync_threshold = FFMAX(AV_SYNC_THRESHOLD_MIN, FFMIN(AV_SYNC_THRESHOLD_MAX, delay));
        if (!isnan(diff) && fabs(diff) < is->max_frame_duration) {
            if (diff <= -sync_threshold)        // 視頻時鍾落后於同步時鍾,且超過同步域值
                delay = FFMAX(0, delay + diff); // 當前幀播放時刻落后於同步時鍾(delay+diff<0)則delay=0(視頻追趕,立即播放),否則delay=delay+diff
            else if (diff >= sync_threshold && delay > AV_SYNC_FRAMEDUP_THRESHOLD)  // 視頻時鍾超前於同步時鍾,且超過同步域值,但上一幀播放時長超長
                delay = delay + diff;           // 僅僅校正為delay=delay+diff,主要是AV_SYNC_FRAMEDUP_THRESHOLD參數的作用,不作同步補償
            else if (diff >= sync_threshold)    // 視頻時鍾超前於同步時鍾,且超過同步域值
                delay = 2 * delay;              // 視頻播放要放慢腳步,delay擴大至2倍
        }
    }

    av_log(NULL, AV_LOG_TRACE, "video: delay=%0.3f A-V=%f\n",
            delay, -diff);

    return delay;
}

compute_target_delay() 的輸入參數 delay 是上一幀理想播放時長 duration,返回值 delay 是經校正后的上一幀實際播放時長。為方便描述,下面我們將輸入參數記作 duration(對應函數的輸入參數 delay),返回值記作 delay(對應函數返回值 delay)。

本函數實現功能如下:
[1] 計算視頻時鍾與音頻時鍾(主時鍾)的偏差 diff,實際就是視頻上一幀 pts 減去音頻上一幀 pts。所謂上一幀,就是已經播放的最后一幀,上一幀的 pts 可以標識視頻流/音頻流的播放時刻(進度)。
[2] 計算同步域值 sync_threshold,同步域值的作用是:若視頻時鍾與音頻時鍾差異值小於同步域值,則認為音視頻是同步的,不校正 delay;若差異值大於同步域值,則認為音視頻不同步,需要校正 delay值。同步域值的計算方法如下:
若 duration < AV_SYNC_THRESHOLD_MIN,則同步域值為 AV_SYNC_THRESHOLD_MIN
若 duration > AV_SYNC_THRESHOLD_MAX,則同步域值為 AV_SYNC_THRESHOLD_MAX
若 AV_SYNC_THRESHOLD_MIN < duration < AV_SYNC_THRESHOLD_MAX,則同步域值為 duration
[3] delay 校正策略如下:
[3.1] 視頻時鍾落后於同步時鍾且落后值超過同步域值:
[3.1.1] 若當前幀播放時刻落后於同步時鍾(delay+diff<0),則 delay=0(視頻追趕,立即播放);
[3.1.2] 否則 delay=duration+diff
[3.2] 視頻時鍾超前於同步時鍾且超過同步域值:
[3.2.1] 上一幀播放時長過長(超過最大值),僅校正為 delay=duration+diff;
[3.2.2] 否則 delay=duration×2,視頻播放放慢腳步,等待音頻
[3.3] 視頻時鍾與音頻時鍾的差異在同步域值內,表明音視頻處於同步狀態,不校正 delay,則 delay=duration

對上述視頻同步到音頻的過程作一個總結,參考下圖:

ffplay音視頻同步示意圖

圖中,小黑圓圈是代表幀的實際播放時刻,小紅圓圈代表幀的理論播放時刻,小綠方塊表示當前系統時間(當前時刻),小紅方塊表示位於不同區間的時間點,則當前時刻處於不同區間時,視頻同步策略為:
[1] 當前時刻在 T0 位置,則重復播放上一幀,延時 remaining_time 后再播放當前幀
[2] 當前時刻在 T1 位置,則立即播放當前幀
[3] 當前時刻在 T2 位置,則忽略當前幀,立即顯示下一幀,加速視頻追趕

上述內容是為了方便理解進行的簡單而形象的描述。實際過程要計算相關值,根據 compute_target_delay() 和 video_refresh() 中的策略來控制播放過程。

2.4.4 音頻播放過程

音頻時鍾是同步主時鍾,音頻按照自己的節奏進行播放即可。視頻播放時則要參考音頻時鍾。音頻播放函數由 SDL 音頻播放線程回調,回調函數實現如下:

// 音頻處理回調函數。讀隊列獲取音頻包,解碼,播放
// 此函數被SDL按需調用,此函數不在用戶主線程中,因此數據需要保護
// \param[in]  opaque 用戶在注冊回調函數時指定的參數
// \param[out] stream 音頻數據緩沖區地址,將解碼后的音頻數據填入此緩沖區
// \param[out] len    音頻數據緩沖區大小,單位字節
// 回調函數返回后,stream指向的音頻緩沖區將變為無效
// 雙聲道采樣點的順序為LRLRLR
static void sdl_audio_callback(void *opaque, Uint8 *stream, int len)
{
    player_stat_t *is = (player_stat_t *)opaque;
    int audio_size, len1;

    int64_t audio_callback_time = av_gettime_relative();

    while (len > 0) // 輸入參數len等於is->audio_hw_buf_size,是audio_open()中申請到的SDL音頻緩沖區大小
    {
        if (is->audio_cp_index >= (int)is->audio_frm_size)
        {
           // 1. 從音頻frame隊列中取出一個frame,轉換為音頻設備支持的格式,返回值是重采樣音頻幀的大小
           audio_size = audio_resample(is, audio_callback_time);
           if (audio_size < 0)
           {
                /* if error, just output silence */
               is->p_audio_frm = NULL;
               is->audio_frm_size = SDL_AUDIO_MIN_BUFFER_SIZE / is->audio_param_tgt.frame_size * is->audio_param_tgt.frame_size;
           }
           else
           {
               is->audio_frm_size = audio_size;
           }
           is->audio_cp_index = 0;
        }
        // 引入is->audio_cp_index的作用:防止一幀音頻數據大小超過SDL音頻緩沖區大小,這樣一幀數據需要經過多次拷貝
        // 用is->audio_cp_index標識重采樣幀中已拷入SDL音頻緩沖區的數據位置索引,len1表示本次拷貝的數據量
        len1 = is->audio_frm_size - is->audio_cp_index;
        if (len1 > len)
        {
            len1 = len;
        }
        // 2. 將轉換后的音頻數據拷貝到音頻緩沖區stream中,之后的播放就是音頻設備驅動程序的工作了
        if (is->p_audio_frm != NULL)
        {
            memcpy(stream, (uint8_t *)is->p_audio_frm + is->audio_cp_index, len1);
        }
        else
        {
            memset(stream, 0, len1);
        }

        len -= len1;
        stream += len1;
        is->audio_cp_index += len1;
    }
    // is->audio_write_buf_size是本幀中尚未拷入SDL音頻緩沖區的數據量
    is->audio_write_buf_size = is->audio_frm_size - is->audio_cp_index;
    /* Let's assume the audio driver that is used by SDL has two periods. */
    // 3. 更新時鍾
    if (!isnan(is->audio_clock))
    {
        // 更新音頻時鍾,更新時刻:每次往聲卡緩沖區拷入數據后
        // 前面audio_decode_frame中更新的is->audio_clock是以音頻幀為單位,所以此處第二個參數要減去未拷貝數據量占用的時間
        set_clock_at(&is->audio_clk, 
                     is->audio_clock - (double)(2 * is->audio_hw_buf_size + is->audio_write_buf_size) / is->audio_param_tgt.bytes_per_sec, 
                     is->audio_clock_serial, 
                     audio_callback_time / 1000000.0);
    }
}

3. 編譯與驗證

3.1 編譯

gcc -o ffplayer ffplayer.c -lavutil -lavformat -lavcodec -lavutil -lswscale -lswresample -lSDL2

3.2 驗證

選用 clock.avi 測試文件,下載工程后,測試文件在 resources 子目錄下

查看視頻文件格式信息:

ffprobe clock.avi

打印視頻文件信息如下:

[avi @ 0x9286c0] non-interleaved AVI
Input #0, avi, from 'clock.avi':
  Duration: 00:00:12.00, start: 0.000000, bitrate: 42 kb/s
    Stream #0:0: Video: msrle ([1][0][0][0] / 0x0001), pal8, 320x320, 1 fps, 1 tbr, 1 tbn, 1 tbc
    Stream #0:1: Audio: truespeech ([34][0][0][0] / 0x0022), 8000 Hz, mono, s16, 8 kb/s

運行測試命令:

./ffplayer clock.avi  

可以聽到每隔 1 秒播放一次 “嘀” 聲,聲音播放 12 次。時針每隔 1 秒跳動一格,跳動 12 次。聲音播放正常,畫面播放也正常,聲音與畫面基本同步。

4. 問題記錄

[1] 在Windows平台上有些電腦無法播放出聲音
異常現象:
在一台電腦上聲音能正常播放,在另一台電腦上無法正常播放
原因分析:
原因不清楚
解決方法:
環境一個變量 SDL_AUDIODRIVER=directsound(或 winmm) 即可。
參考資料 “[12] FFplay: WASAPI can't initialize audio client

[2] 音頻播放過程中持續卡頓
異常現象:
音頻播放過程中持續卡頓,類似播一下停一下
原因分析:
SDL 音頻緩沖區設置過小。緩沖區小可緩存數據量少,實時性要求高,緩沖區數據被取完,又無新數據送入時,會出現播放停頓現象。
解決方法:
增大 SDL 音頻緩沖區

5. 遺留問題

[1]. 啟動播放瞬間,視頻畫面未及時播放
[2]. 點擊關閉按鈕關閉播放器會引起內存異常報錯

6. 參考資料

[1] 雷霄驊,視音頻編解碼技術零基礎學習方法
[2] 視頻編解碼基礎概念, https://www.cnblogs.com/leisure_chn/p/10285829.html
[3] FFmpeg基礎概念, https://www.cnblogs.com/leisure_chn/p/10297002.html
[4] 零基礎讀懂視頻播放器控制原理:ffplay播放器源代碼分析, https://cloud.tencent.com/developer/article/1004559
[5] An ffmpeg and SDL Tutorial, Tutorial 05: Synching Video
[6] 視頻同步音頻, https://zhuanlan.zhihu.com/p/44615401
[7] 音頻同步視頻, https://zhuanlan.zhihu.com/p/44680734
[8] 音視頻同步(播放)原理, https://blog.csdn.net/zhuweigangzwg/article/details/25815851
[9] 對ffmpeg的時間戳的理解筆記, https://blog.csdn.net/topsluo/article/details/76239136
[10] ffmpeg音視頻同步---視頻同步到音頻時鍾, https://my.oschina.net/u/735973/blog/806117
[11] FFmpeg音視頻同步原理與實現, https://www.jianshu.com/p/3578e794f6b5
[12] FFplay: WASAPI can't initialize audio client, https://stackoverflow.com/questions/46835811/ffplay-wasapi-cant-initialize-audio-client-ffmpeg-3-4-binaries
[13] WASAPI can't initialize audio client, https://blog.csdn.net/A694543965/article/details/78786230

7. 修改記錄

2019-01-17 V1.0 初稿


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM