FFmpeg+SDL,如何用少于1000行代码编写视频播放器
此文檔翻譯國外dranger教程:
An ffmpeg and SDL Tutorial or How to Write a Video Player in Less Than 1000 Lines
因為原版文檔許久未更新,翻譯過程中有刪除一部分已失效鏈接,并將FFmpeg結構體說明鏈接轉嫁到雷霄驊博客中
FFmpeg是一個很好的庫,可以用來創建視頻應用或者生成特定的工具。FFmpeg幾乎為你把所有的繁重工作都做了,比如解碼、編碼、復用和解復用。這使得多媒體應用程序變得容易編寫。它是一個簡單的,用C語言編寫的,快速的并且能夠解碼幾乎所有你能用到的格式,當然也包括編碼多種格式。
唯一的問題是它的文檔基本上是沒有的。這就是為什么當我決定研究FFmpeg來弄清楚音視頻應用程序是如何工作的過程中,我決定把這個過程用文檔的形式記錄并且發布出來作為初學指導的原因。
在FFmpeg工程中有一個示例的程序叫作ffplay。它是一個用C編寫的利用FFmpeg來實現完整視頻播放的簡單播放器。這個系列教程將從 Martin Bohme 寫的一個更新版本的原始教程開始(我借鑒了一些他的工作),基于Fabrice Bellard的ffplay,我將從那里開發一個可以使用的視頻播放器。在每一個Tutorial中,我將介紹一個或者兩個新的思想并且講解我們如何來實現它。每一個Tutorial都會有一個C源文件,你可以下載,編譯并沿著這條思路來自己做。源文件將向你展示一個真正的程序是如何運行,我們如何來調用所有的部件,也將告訴你在Tutorial中技術實現的細節并不重要。當我們結束這個系列教程的時候,我們將有一個少于1000行代碼的可以工作的視頻播放器。
在寫播放器的過程中,我們將使用 SDL 來輸出音頻和視頻。SDL是一個優秀的跨平臺的多媒體庫,被用在MPEG播放、模擬器和很多視頻游戲中。你將需要下載并安裝SDL開發庫到你的系統中,以便于編譯這個指導中的程序。
這篇指導適用于具有相當編程背景的人。至少應該懂得C并且知道隊列和互斥量等概念。你應當了解基本的多媒體中的像波形一類的概念,但是你不必知道的太多,因為我將在這篇指導中介紹很多這樣的概念。
本教程同樣是舊風格的ASCII文本教程,你也可以獲得壓縮的教程和示例源碼,或者僅僅只是示例源碼。
歡迎給我發郵件到dranger@gmail.com,討論關于程序問題、疑問、注釋、思路、特性等任何的問題。
文章目錄
- Tutorial 01: 制作屏幕錄像
- 概要
- 打開文件
- 保存數據
- 讀取數據
- Tutorial 02: 輸出到屏幕
- SDL和視頻
- 創建一個顯示
- 顯示圖像
- 繪制圖像
- Tutorial 03: 播放音頻
- 音頻
- 設置音頻
- 隊列
- 意外情況
- 將包加入隊列
- 讀取包
- 最后解碼音頻
- Tutorial 04:創建線程
- 概要
- 簡化代碼
- 我們的第一個線程
- 得到幀:video_thread
- 把幀隊列化
- 顯示視頻
- Tutorial: 05 音視頻同步
- 警告
- 音視頻如何同步
- PTS 和 DTS
- 同步
- 編碼:獲得幀的時間戳
- 編碼:使用PTS來同步
- 同步:音頻時鐘
- Tutorial 06: 音頻同步
- 概要
- 生成視頻時鐘
- 時鐘抽象化
- 同步音頻
- 修正樣本數
- Tutorial 07: Seeking
- 處理seek命令
- 刷新緩沖區
- 結語
- 參考
Tutorial 01: 制作屏幕錄像
源代碼:tutorial01.c
概要
電影文件有很多基本的組成部分。首先,文件本身被稱為容器(Container),容器的類型決定了信息被存放在文件中的位置。AVI 和 Quicktime 格式就是容器的例子。接著,你有一組數據流(Streams),例如,你經常有的是一個音頻流和一個視頻流。(一個流只是一種想像出來的詞語,用來表示一連串的通過時間來串連的數據元素)。在流中的數據元素被稱為幀(Frame)。每個流是由不同的編碼器(Codec) 來編碼生成的。編解碼器描述了實際的數據是如何被編碼Coded和解碼Decoded的,因此它的名字叫做CODEC。Divx和MP3就是編解碼器的例子。接著從流中被讀出來的叫做包(Packets)。包是一段數據,它包含了一段可以被解碼成方便我們最后在應用程序中操作的原始幀的數據。根據我們的目的,每個包包含了完整的幀或者對于音頻來說是許多格式的完整幀。
基本上來說,處理視頻和音頻流是很容易的:
10 從Video.avi文件中打開視頻流
20 從視頻流中讀取包,并轉碼為幀
30 如果這個幀還不完整,返回20
40 處理幀
50 返回20
在這個程序中使用FFmpeg來處理多種媒體是相當容易的,雖然很多程序可能在對幀進行處理的時候非常的復雜。因此在這篇指導中,我們將打開一個文件,讀取里面的視頻流,而且我們對幀的操作將是把這個幀寫到一個PPM文件中。
打開文件
首先,來看一下我們如何打開一個文件。使用FFmpeg,你必需先初始化這個庫。
#include <libavcodec/avcodec.h> #include <libavformat/avformat.h> #include <ffmpeg/swscale.h> ... int main(int argc, charg *argv[]) { av_register_all();這里注冊了所有的文件格式和編解碼器的庫,所以它們將自動檢測文件格式。注意你只需要調用av_register_all()一次,因此我們在主函數main()中來調用它。如果你喜歡,也可以只注冊特定的格式和編解碼器,但是通常你沒有必要這樣做。
現在我們可以真正的打開文件:
AVFormatContext *pFormatCtx = NULL;// Open video file if(avformat_open_input(&pFormatCtx, argv[1], NULL, 0, NULL)!=0)return -1; // Couldn't open file我們通過第一個參數來獲得文件名。這個函數讀取文件的頭部并且把信息保存到我們給的AVFormatContext結構體中。最后三個參數用來指定特殊的文件格式,緩沖大小和格式參數,但如果把它們設置為空NULL或者0,libavformat將自動檢測這些參數。
這個函數只是檢測了文件的頭部,所以接著我們需要檢查在文件中的流的信息:
// Retrieve stream information if(avformat_find_stream_info(pFormatCtx, NULL)<0)return -1; // Couldn't find stream information這個函數為pFormatCtx->streams填充上正確的信息。我們引進一個手工調試的函數來看一下里面有什么:
// Dump information about file onto standard error dump_format(pFormatCtx, 0, argv[1], 0);現在pFormatCtx->streams僅僅是一組大小為pFormatCtx->nb_streams的指針,所以讓我們先跳過它直到我們找到一個視頻流(AVStream)。
int i; AVCodecContext *pCodecCtxOrig = NULL; AVCodecContext *pCodecCtx = NULL;// Find the first video stream videoStream=-1; for(i=0; i<pFormatCtx->nb_streams; i++)if(pFormatCtx->streams[i]->codec->codec_type==AVMEDIA_TYPE_VIDEO) {videoStream=i;break;} if(videoStream==-1)return -1; // Didn't find a video stream// Get a pointer to the codec context for the video stream pCodecCtx=pFormatCtx->streams[videoStream]->codec;流中關于編解碼器的信息就是被我們叫做"codec context"(編解碼器上下文AVCodecContext)的東西。這里面包含了流中所使用的關于編解碼器的所有信息,現在我們有了一個指向它的指針。但是我們必需要找到真正的編解碼器(AVCodec)并且打開它:
AVCodec *pCodec = NULL;// Find the decoder for the video stream pCodec=avcodec_find_decoder(pCodecCtx->codec_id); if(pCodec==NULL) {fprintf(stderr, "Unsupported codec!\n");return -1; // Codec not found } // Copy context pCodecCtx = avcodec_alloc_context3(pCodec); if(avcodec_copy_context(pCodecCtx, pCodecCtxOrig) != 0) {fprintf(stderr, "Couldn't copy codec context");return -1; // Error copying codec context } // Open codec if(avcodec_open2(pCodecCtx, pCodec)<0)return -1; // Could not open codec注意:我們不能直接在視頻流中使用AVCodecContext!所以我們必須使用avcodec_copy_context()方法將context復制到新的內存位置(當然,在我們為它分配內存之后);
保存數據
現在我們需要找到一個地方來保存幀(AVFrame):
AVFrame *pFrame = NULL;// Allocate video frame pFrame=av_frame_alloc();因為我們準備輸出保存24位RGB色的PPM文件,我們必需把幀的格式從原來的轉換為RGB。FFmpeg將為我們做這些轉換。在大多數項目中(包括我們的這個)我們都想把原始的幀轉換成一個特定的格式。讓我們先為轉換來申請一幀的內存:
// Allocate an AVFrame structure pFrameRGB=av_frame_alloc(); if(pFrameRGB==NULL)return -1;即使我們申請了一幀的內存,當轉換的時候,我們仍然需要一個地方來放置原始的數據。我們使用avpicture_get_size來獲得我們需要的大小,然后手工申請內存空間:
uint8_t *buffer = NULL; int numBytes; // Determine required buffer size and allocate buffer numBytes=avpicture_get_size(PIX_FMT_RGB24, pCodecCtx->width,pCodecCtx->height); buffer=(uint8_t *)av_malloc(numBytes*sizeof(uint8_t));av_malloc是FFmpeg的malloc,用來實現一個簡單的malloc的包裝,這樣來保證內存地址是對齊的(4字節對齊或者2字節對齊)。它并不能保護你不被內存泄漏,重復釋放或者其它malloc的問題所困擾。
現在我們使用avpicture_fill來把幀和我們新申請的內存來結合。關于AVPicture的構成:AVPicture結構體是AVFrame結構體的子集――AVFrame結構體的開始部分與AVPicture結構體是一樣的。
// Assign appropriate parts of buffer to image planes in pFrameRGB // Note that pFrameRGB is an AVFrame, but AVFrame is a superset // of AVPicture avpicture_fill((AVPicture *)pFrameRGB, buffer, PIX_FMT_RGB24,pCodecCtx->width, pCodecCtx->height);最后,我們已經準備好來從流中讀取數據了。
讀取數據
我們將要做的是通過讀取包來讀取整個視頻流,然后把它解碼成幀,最后轉換格式并且保存。
struct SwsContext *sws_ctx = NULL; int frameFinished; AVPacket packet; // initialize SWS context for software scaling sws_ctx = sws_getContext(pCodecCtx->width,pCodecCtx->height,pCodecCtx->pix_fmt,pCodecCtx->width,pCodecCtx->height,PIX_FMT_RGB24,SWS_BILINEAR,NULL,NULL,NULL);i=0; while(av_read_frame(pFormatCtx, &packet)>=0) {// Is this a packet from the video stream?if(packet.stream_index==videoStream) {// Decode video frameavcodec_decode_video2(pCodecCtx, pFrame, &frameFinished, &packet);// Did we get a video frame?if(frameFinished) {// Convert the image from its native format to RGBsws_scale(sws_ctx, (uint8_t const * const *)pFrame->data,pFrame->linesize, 0, pCodecCtx->height,pFrameRGB->data, pFrameRGB->linesize);// Save the frame to diskif(++i<=5)SaveFrame(pFrameRGB, pCodecCtx->width, pCodecCtx->height, i);}}// Free the packet that was allocated by av_read_frameav_free_packet(&packet); }這個循環過程是比較簡單的:av_read_frame()讀取一個包并且把它保存到AVPacket結構體中。注意我們僅僅申請了一個包的結構體 – FFmpeg為我們申請了內部的數據的內存并通過packet.data指針來指向它。這些數據可以在后面通過av_free_packet()來釋放。函數avcodec_decode_video()把包轉換為幀。然而當解碼一個包的時候,我們可能沒有得到我們需要的關于幀的信息。因此,當我們得到下一幀的時候,avcodec_decode_video()為我們設置了幀結束標志frameFinished。最后,我們使用 img_convert()函數來把幀從原始格式(pCodecCtx->pix_fmt)轉換成為RGB格式。要記住,你可以把一個AVFrame結構體的指針轉換為AVPicture結構體的指針。最后,我們把幀和高度、寬度信息傳遞給我們的SaveFrame函數。
Packets注釋
從技術上講一個包可以包含部分或者其它的數據,但是FFmpeg的解釋器保證了我們得到的包Packets包含的要么是完整的要么是多種完整的幀
現在我們需要做的是讓SaveFrame函數能把RGB信息寫入到一個PPM格式的文件中。我們將生成一個簡單的PPM格式文件,請相信,它是可以工作的。
void SaveFrame(AVFrame *pFrame, int width, int height, int iFrame) {FILE *pFile;char szFilename[32];int y;// Open filesprintf(szFilename, "frame%d.ppm", iFrame);pFile=fopen(szFilename, "wb");if(pFile==NULL)return;// Write headerfprintf(pFile, "P6\n%d %d\n255\n", width, height);// Write pixel datafor(y=0; y<height; y++)fwrite(pFrame->data[0]+y*pFrame->linesize[0], 1, width*3, pFile);// Close filefclose(pFile); }我們做了一些標準的文件打開動作,然后寫入RGB數據。我們一次向文件寫入一行數據。PPM格式文件的是一種包含一長串的RGB數據的文件。如果你了解 HTML色彩表示的方式,那么它就類似于把每個像素的顏色頭對頭的展開,就像#ff0000#ff0000…一個充滿紅色的屏幕。(它被保存成二進制方式并且沒有分隔符,但是你自己是知道如何分隔的)。文件的頭部表示了圖像的寬度和高度以及最大的RGB值的大小。
現在,回顧我們的main()函數。一旦我們開始讀取完視頻流,我們必需清理一切:
你會注意到我們使用av_free來釋放我們使用avcode_alloc_fram和av_malloc來分配的內存。
上面的就是代碼!下面,我們將使用Linux或者其它類似的平臺,你將運行:
gcc -o tutorial01 tutorial01.c -lavutil -lavformat -lavcodec -lz -lavutil -lm
如果你使用的是老版本的FFmpeg,你可以去掉-lavutil參數:
gcc -o tutorial01 tutorial01.c -lavformat -lavcodec -lz -lm
大多數的圖像處理函數可以打開PPM文件。可以使用一些電影文件來進行測試。
Tutorial 02: 輸出到屏幕
源碼:tutorial02.c
SDL和視頻
我們將使用SDL將解碼數據輸出到屏幕。 SDL代表Simple Direct Layer,是一個優秀的多媒體庫,它是跨平臺的,可用于多個項目。 您可以在官方網站上獲取該庫,也可以下載支持您的操作系統的開發包。 您需要這些庫來編譯本教程的代碼(以及其他代碼)。
SDL庫中有許多種方式來在屏幕上繪制圖形,而且它有一個特殊的方式來在屏幕上顯示圖像――這種方式叫做YUV覆蓋。YUV(從技術上來講并不叫YUV而是叫做YCbCr)是一種類似于 RGB 方式的存儲原始圖像的格式。粗略的講,Y是亮度分量,U和V是色度分量。(這種格式比RGB復雜的多,因為很多的顏色信息被丟棄了,而且你可以每2個Y有1個U和1個V)。SDL的YUV覆蓋使用一組原始的YUV數據并且在屏幕上顯示出他們。它可以允許4種不同的 YUV格式,但是其中的YV12是最快的一種。還有一個叫做YUV420P的YUV格式,它和YV12是一樣的,除了U和V分量的位置被調換了以外。 420意味著它以4:2:0的比例進行了二次抽樣,基本上就意味著1個顏色分量對應著4個亮度分量。所以它的色度信息只有原來的1/4。這是一種節省帶寬的好方式,因為人眼感覺不到這種變化。在名稱中的P表示這種格式是平面的――簡單的說就是Y,U和V分量分別在不同的數組中。FFmpeg可以把圖像格式轉換為YUV420P,但是現在很多視頻流的格式已經是YUV420P的了或者可以被很容易的轉換成YUV420P格式。
于是,我們現在計劃把Tutorial 01中的SaveFrame()函數替換掉,讓它直接輸出我們的幀到屏幕上去。但一開始我們必需要先看一下如何使用SDL庫。首先我們必需先包含SDL庫的頭文件并且初始化它。
#include <SDL.h> #include <SDL_thread.h>if(SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_TIMER)) {fprintf(stderr, "Could not initialize SDL - %s\n", SDL_GetError());exit(1); }SDL_Init()函數告訴了SDL庫,哪些特性我們將要用到。當然SDL_GetError()是一個用來手工除錯的函數。
創建一個顯示
現在我們需要在屏幕上的一個地方放上一些東西。在SDL中顯示圖像的基本區域叫做面surface。
SDL_Surface *screen;screen = SDL_SetVideoMode(pCodecCtx->width, pCodecCtx->height, 0, 0); if(!screen) {fprintf(stderr, "SDL: could not set video mode - exiting\n");exit(1); }這就創建了一個給定高度和寬度的屏幕。下一個選項是屏幕的顏色深度――0表示使用和當前一樣的深度。(這個在OS X系統上不能正常工作,原因請看源代碼)
現在我們在屏幕上來創建一個YUV繪制區域以便于我們輸入視頻上去,并配置我們的SWSContext 將原始圖像數據轉換為YUV420:
正如前面我們所說的,我們使用YV12來顯示圖像。
顯示圖像
前面那些都是很簡單的。現在我們需要來顯示圖像。讓我們看一下是如何來處理完成后的幀的。我們將原來對RGB處理的方式,并且替換SaveFrame() 為顯示到屏幕上的代碼。為了顯示到屏幕上,我們將先建立一個AVPicture結構體并且設置其數據指針和行尺寸來為我們的YUV繪制服務:
if(frameFinished) {SDL_LockYUVOverlay(bmp);AVPicture pict;pict.data[0] = bmp->pixels[0];pict.data[1] = bmp->pixels[2];pict.data[2] = bmp->pixels[1];pict.linesize[0] = bmp->pitches[0];pict.linesize[1] = bmp->pitches[2];pict.linesize[2] = bmp->pitches[1];// Convert the image into YUV format that SDL usessws_scale(sws_ctx, (uint8_t const * const *)pFrame->data,pFrame->linesize, 0, pCodecCtx->height,pict.data, pict.linesize);SDL_UnlockYUVOverlay(bmp);}首先,我們鎖定這個繪制區域,因為我們將要去改寫它。這是一個避免以后發生問題的好習慣。正如前面所示的,這個AVPicture結構體有一個數據指針指向一個有4個元素的指針數據。由于我們處理的是YUV420P,所以我們只需要3個通道即只要三組數據。其它的格式可能需要第四個指針來表示alpha通道或者其它參數。行尺寸正如它的名字表示的意義一樣。在YUV覆蓋中相同功能的結構體是像素pixel和程度pitch。(程度pitch是在SDL里用來表示指定行數據寬度的值)。所以我們現在做的是讓我們的覆蓋中的pict.data中的三個指針有一個指向必要的空間的地址。類似的,我們可以直接從覆蓋中得到行尺寸信息。像前面一樣我們使用img_convert來把格式轉換成PIX_FMT_YUV420P。
繪制圖像
但我們仍然需要告訴SDL如何來實際顯示我們給的數據。我們也會傳遞一個表明電影位置、寬度、高度和縮放大小的矩形參數給SDL的函數。這樣,SDL為我們做縮放并且它可以通過顯卡的幫忙來進行快速縮放。
SDL_Rect rect;if(frameFinished) {/* ... code ... */// Convert the image into YUV format that SDL usessws_scale(sws_ctx, (uint8_t const * const *)pFrame->data,pFrame->linesize, 0, pCodecCtx->height,pict.data, pict.linesize);SDL_UnlockYUVOverlay(bmp);rect.x = 0;rect.y = 0;rect.w = pCodecCtx->width;rect.h = pCodecCtx->height;SDL_DisplayYUVOverlay(bmp, &rect);}現在我們的視頻顯示出來了!
讓我們再花一點時間來看一下SDL的特性:它的事件驅動系統。SDL被設置成當你在SDL中點擊或者移動鼠標或者向它發送一個信號它都將產生一個事件的驅動方式。如果你的程序想要處理用戶輸入的話,它就會檢測這些事件。你的程序也可以產生事件并且傳遞給SDL事件系統。當使用SDL進行多線程編程的時候,這相當有用,這方面代碼我們可以在Tutorial 04中看到。在這個程序中,我們將在處理完包以后就立即輪詢事件。現在而言,我們將處理SDL_QUIT事件以便于我們退出:
SDL_Event event;av_free_packet(&packet);SDL_PollEvent(&event);switch(event.type) {case SDL_QUIT:SDL_Quit();exit(0);break;default:break;}讓我們去掉舊的冗余代碼,開始編譯。如果你使用的是Linux或者其變體,使用SDL庫進行編譯的最好方式為:
gcc -o tutorial02 tutorial02.c -lavformat -lavcodec -lswscale -lz -lm \
`sdl-config --cflags --libs`
這里的sdl-config命令會打印出用于gcc編譯的包含正確SDL庫的適當參數。為了進行編譯,在你自己的平臺你可能需要做的有點不同:請查閱一下 SDL文檔中關于你的系統的那部分。一旦可以編譯,就馬上運行它。
當運行這個程序的時候會發生什么呢?電影簡直跑瘋了!實際上,我們只是以我們能從文件中解碼幀的最快速度顯示了所有的電影的幀。現在我們沒有任何代碼來計算出我們什么時候需要顯示電影的幀。最后(在Tutorial 05),我們將花足夠的時間來探討同步問題。但一開始我們會先忽略這個,因為我們有更加重要的事情要處理:音頻!
Tutorial 03: 播放音頻
源代碼:tutorial03.c
音頻
現在我們要來播放聲音。SDL也為我們準備了輸出聲音的方法。函數SDL_OpenAudio()本身就是用來打開聲音設備的。它使用一個叫做 SDL_AudioSpec結構體作為參數,這個結構體中包含了我們將要輸出的音頻的所有信息。
在我們展示如何建立之前,讓我們先解釋一下電腦是如何處理音頻的。數字音頻是由一長串的樣本流組成的。每個樣本表示聲音波形中的一個值。聲音按照一個特定的采樣率來進行錄制,采樣率表示以多快的速度來播放這段樣本流,它的表示方式為每秒多少次采樣。例如22050和44100的采樣率就是電臺和CD常用的采樣率。此外,大多音頻有不只一個通道來表示立體聲或者環繞。例如,如果采樣是立體聲,那么每次的采樣數就為2個。當我們從一個電影文件中等到數據的時候,我們不知道我們將得到多少個樣本,但是FFmpeg將不會給我們部分的樣本――這意味著它將不會把立體聲分割開來。
SDL播放聲音的流程是這樣的:你先設置聲音的選項——采樣率(在SDL的結構體中被叫做freq [頻率frequency] ),聲音通道數和其它的參數,然后我們設置一個回調函數和一些用戶數據userdata。當開始播放音頻的時候,SDL將不斷地調用這個回調函數并且要求它來向聲音緩沖填入一個特定的數量的字節。當我們把這些信息放到SDL_AudioSpec結構體中后,我們調用函數SDL_OpenAudio()就會打開聲音設備并且給我們返回另一個AudioSpec結構體。這個結構體是我們實際上用到的--因為我們不能保證得到我們所要求的。
設置音頻
目前先把講的記住,因為我們實際上還沒有任何關于聲音流的信息。讓我們回過頭去看一下前面Tutorial的代碼,是如何找到視頻流的,同樣我們也可以找到聲音流。
// Find the first video stream videoStream=-1; audioStream=-1; for(i=0; i < pFormatCtx->nb_streams; i++) {if(pFormatCtx->streams[i]->codec->codec_type==AVMEDIA_TYPE_VIDEO&&videoStream < 0) {videoStream=i;}if(pFormatCtx->streams[i]->codec->codec_type==AVMEDIA_TYPE_AUDIO &&audioStream < 0) {audioStream=i;} } if(videoStream==-1)return -1; // Didn't find a video stream if(audioStream==-1)return -1;從這里我們可以從描述流的AVCodecContext中得到我們想要的信息,就像我們得到視頻流的信息一樣。
AVCodecContext *aCodecCtxOrig; AVCodecContext *aCodecCtx;aCodecCtxOrig=pFormatCtx->streams[audioStream]->codec;如果您還記得前面的Tutorial,我們仍然需要打開音頻解碼器。這很簡單:
AVCodec *aCodec;aCodec = avcodec_find_decoder(aCodecCtx->codec_id); if(!aCodec) {fprintf(stderr, "Unsupported codec!\n");return -1; } // Copy context aCodecCtx = avcodec_alloc_context3(aCodec); if(avcodec_copy_context(aCodecCtx, aCodecCtxOrig) != 0) {fprintf(stderr, "Couldn't copy codec context");return -1; // Error copying codec context } /* set up SDL Audio here */avcodec_open2(aCodecCtx, aCodec, NULL);從編解碼上下文中我們能夠得到用來設置音頻的所有信息:
wanted_spec.freq = aCodecCtx->sample_rate; wanted_spec.format = AUDIO_S16SYS; wanted_spec.channels = aCodecCtx->channels; wanted_spec.silence = 0; wanted_spec.samples = SDL_AUDIO_BUFFER_SIZE; wanted_spec.callback = audio_callback; wanted_spec.userdata = aCodecCtx;if(SDL_OpenAudio(&wanted_spec, &spec) < 0) {fprintf(stderr, "SDL_OpenAudio: %s\n", SDL_GetError());return -1; }讓我們看看這些配置信息:
- freq 前面所講的采樣率
- format 告訴SDL我們將要給的格式。在“S16SYS”中的S表示有符號的signed,16表示每個樣本是16位長的,SYS表示大小頭的順序是與使用的系統相同的。這些格式是由avcodec_decode_audio2為我們給出來的輸入音頻的格式。
- channels 聲音的通道數
- silence 這是用來表示靜音的值。因為聲音采樣是有符號的,所以當然就是0這個值。
- samples 這是當我們想要更多聲音的時候,我們想讓SDL給出來的聲音緩沖區的大小。一個比較合適的值在512到8192之間;ffplay使用1024。
- callback 這個是我們的回調函數。我們后面將會詳細討論。
- userdata 這個是SDL供給回調函數運行的參數。我們將讓回調函數得到整個編解碼的上下文;你將在后面知道原因。
最后,我們使用SDL_OpenAudio函數來打開音頻。
隊列
嗯!現在我們已經準備好從流中取出聲音信息。但是我們如何來處理這些信息呢?我們將會不斷地從文件中得到這些包,但同時SDL也將調用回調函數。解決方法是創建一個全局的結構體變量以便于我們從文件中得到的聲音包有地方存放,同時也保證SDL中的聲音回調函數audio_callback能從這個地方得到聲音數據。所以我們要做的是創建一個包的隊列queue。在FFmpeg中有一個叫AVPacketList的結構體可以幫助我們,這個結構體實際是一串包的鏈表。下面就是我們的隊列結構體:
typedef struct PacketQueue {AVPacketList *first_pkt, *last_pkt;int nb_packets;int size;SDL_mutex *mutex;SDL_cond *cond; } PacketQueue;首先,我們應當指出nb_packets是與size不一樣的-size表示我們從packet->size中得到的字節數。你會注意到我們有一個互斥量mutex和一個條件變量cond在結構體里面。這是因為SDL是在一個獨立的線程中來進行音頻處理的。如果我們沒有正確的鎖定這個隊列,我們有可能把數據搞亂。我們將來看一個這個隊列是如何來運行的。每一個程序員應當知道如何來生成的一個隊列,然而我們還是把這部分加進來討論,從而可以學習到SDL的函數。
一開始我們先創建一個函數來初始化隊列:
接著我們再做一個函數來給隊列中填入東西:
int packet_queue_put(PacketQueue *q, AVPacket *pkt) {AVPacketList *pkt1;if(av_dup_packet(pkt) < 0) {return -1;}pkt1 = av_malloc(sizeof(AVPacketList));if (!pkt1)return -1;pkt1->pkt = *pkt;pkt1->next = NULL;SDL_LockMutex(q->mutex);if (!q->last_pkt)q->first_pkt = pkt1;elseq->last_pkt->next = pkt1;q->last_pkt = pkt1;q->nb_packets++;q->size += pkt1->pkt.size;SDL_CondSignal(q->cond);SDL_UnlockMutex(q->mutex);return 0; }函數SDL_LockMutex()鎖定隊列的互斥量以便于我們向隊列中添加東西,然后函數SDL_CondSignal()通過我們的條件變量為一個接收函數(如果它在等待)發出一個信號來告訴它現在已經有數據了,接著就會解鎖互斥量并讓隊列可以自由訪問。
下面是相對應的取出函數。注意函數SDL_CondWait()是如何按照我們的要求讓函數阻塞block的(例如一直等到隊列中有數據)。
int quit = 0;static int packet_queue_get(PacketQueue *q, AVPacket *pkt, int block) {AVPacketList *pkt1;int ret;SDL_LockMutex(q->mutex);for(;;) {if(quit) {ret = -1;break;}pkt1 = q->first_pkt;if (pkt1) {q->first_pkt = pkt1->next;if (!q->first_pkt)q->last_pkt = NULL;q->nb_packets--;q->size -= pkt1->pkt.size;*pkt = pkt1->pkt;av_free(pkt1);ret = 1;break;} else if (!block) {ret = 0;break;} else {SDL_CondWait(q->cond, q->mutex);}}SDL_UnlockMutex(q->mutex);return ret; }正如你所看到的,這個函數使用無限循環以便于我們用阻塞的方式來得到數據。我們通過使用SDL中的函數SDL_CondWait()來避免無限循環。基本上,所有的CondWait只等待從SDL_CondSignal()函數(或者SDL_CondBroadcast()函數)中發出的信號,然后再繼續執行。然而,雖然看起來我們陷入了我們的互斥體中-如果我們一直保持著這個鎖,我們的函數將永遠無法把數據放入到隊列中去!但是,SDL_CondWait()函數也為我們做了解鎖互斥量的動作然后才嘗試著在得到信號后去重新鎖定它。
意外情況
你們將會注意到我們有一個全局變量quit,我們用它來保證還沒有設置程序退出的信號(SDL會自動處理TERM類似的信號)。否則,這個線程將不停地運行直到我們使用kill -9來結束程序。FFmpeg同樣也提供了一個函數來進行回調并檢查我們是否需要退出一些被阻塞的函數:這個函數就是 url_set_interrupt_cb。
SDL_PollEvent(&event);switch(event.type) {case SDL_QUIT:quit = 1;我們還需確保將標志位quit置為1。
將包加入隊列
剩下的我們唯一需要做的就是配置隊列了:
PacketQueue audioq; main() { ...avcodec_open2(aCodecCtx, aCodec, NULL);packet_queue_init(&audioq);SDL_PauseAudio(0);函數SDL_PauseAudio()最終讓音頻設備開始工作。如果沒有立即供給足夠的數據,它會播放靜音。
我們已經建立好我們的隊列,現在我們準備加入包了。先看一下我們如何循環讀取包:
while(av_read_frame(pFormatCtx, &packet)>=0) { // Is this a packet from the video stream? if(packet.stream_index==videoStream) {// Decode video frame....} } else if(packet.stream_index==audioStream) {packet_queue_put(&audioq, &packet); } else {av_free_packet(&packet); }注意:我們沒有在把包放到隊列里的時候就釋放它,而是將在我們解碼之后再來釋放它。
讀取包
現在,我們最后讓聲音回調函數audio_callback從隊列中取出包。回調函數的格式必需為void callback(void *userdata, Uint8 *stream, int len),這里的userdata就是我們傳給 SDL的指針,stream是我們要把聲音數據寫入的緩沖區指針,len是緩沖區的大小。下面就是代碼:
void audio_callback(void *userdata, Uint8 *stream, int len) {AVCodecContext *aCodecCtx = (AVCodecContext *)userdata;int len1, audio_size;static uint8_t audio_buf[(AVCODEC_MAX_AUDIO_FRAME_SIZE * 3) / 2];static unsigned int audio_buf_size = 0;static unsigned int audio_buf_index = 0;while(len > 0) {if(audio_buf_index >= audio_buf_size) {/* We have already sent all our data; get more */audio_size = audio_decode_frame(aCodecCtx, audio_buf,sizeof(audio_buf));if(audio_size < 0) {/* If error, output silence */audio_buf_size = 1024;memset(audio_buf, 0, audio_buf_size);} else {audio_buf_size = audio_size;}audio_buf_index = 0;}len1 = audio_buf_size - audio_buf_index;if(len1 > len)len1 = len;memcpy(stream, (uint8_t *)audio_buf + audio_buf_index, len1);len -= len1;stream += len1;audio_buf_index += len1;} }這基本上是一個簡單的從稍后我們將要實現的audio_decode_frame()解碼函數中獲取數據的循環,這個循環把結果寫入到中間緩沖區,嘗試著向流中寫入len大小的字節數,并且在我們沒有足夠的數據的時候會獲取更多的數據或者當我們有多余數據的時候保存下來為后面使用。這個audio_buf的大小為 1.5倍FFmpeg給出的聲音最大幀的大小以便于有一個比較好的緩沖。
最后解碼音頻
讓我們去實現解碼器的真正函數,audio_decode_frame :
int audio_decode_frame(AVCodecContext *aCodecCtx, uint8_t *audio_buf,int buf_size) {static AVPacket pkt;static uint8_t *audio_pkt_data = NULL;static int audio_pkt_size = 0;static AVFrame frame;int len1, data_size = 0;for(;;) {while(audio_pkt_size > 0) {int got_frame = 0;len1 = avcodec_decode_audio4(aCodecCtx, &frame, &got_frame, &pkt);if(len1 < 0) {/* if error, skip frame */audio_pkt_size = 0;break;}audio_pkt_data += len1;audio_pkt_size -= len1;data_size = 0;if(got_frame) {data_size = av_samples_get_buffer_size(NULL, aCodecCtx->channels,frame.nb_samples,aCodecCtx->sample_fmt,1);assert(data_size <= buf_size);memcpy(audio_buf, frame.data[0], data_size);}if(data_size <= 0) {/* No data yet, get more frames */continue;}/* We have data, return it and come back for more later */return data_size;}if(pkt.data)av_free_packet(&pkt);if(quit) {return -1;}if(packet_queue_get(&audioq, &pkt, 1) < 0) {return -1;}audio_pkt_data = pkt.data;audio_pkt_size = pkt.size;} }整個過程實際上從函數的尾部開始,在這里我們調用了packet_queue_get()函數。我們從隊列中取出包,并且保存它的信息。然后,一旦我們有了可以使用的包,我們就調用函數avcodec_decode_audio2(),它的功能就像它的姐妹函數 avcodec_decode_video()一樣,唯一的區別是它的一個包里可能有不止一個聲音幀,所以你可能要調用很多次來解碼出包中所有的數據。一旦我們獲取到幀,我們只需簡單的將其復制到我們的音頻緩沖區中,確保data_size小于我們的音頻緩沖區。同時也要記住進行指針audio_buf的強制轉換,因為SDL給出的是8位整型緩沖指針而FFmpeg給出的數據是16位的整型指針。你應該也會注意到 len1和data_size的不同,data_size表示實際返回的原始聲音數據的大小。
當我們得到一些數據的時候,我們立刻返回來看一下是否仍然需要從隊列中得到更加多的數據或者我們已經完成了。如果我們仍然有更加多的數據要處理,我們把它保存到下一次。如果我們完成了一個包的處理,我們最后要釋放它。
就是這樣。我們利用主讀取循環將音頻數據傳送到隊列中,然后被audio_callback回調函數從隊列中讀取并處理,最后把數據送給SDL,于是SDL就相當于我們的聲卡。讓我們繼續并且編譯:
gcc -o tutorial03 tutorial03.c -lavutil -lavformat -lavcodec -lswscale -lz -lm \
`sdl-config --cflags --libs`
啊哈!視頻雖然還是像原來那樣快,但是聲音可以正常播放了。這是為什么呢?因為聲音信息中的采樣率-雖然我們把聲音數據盡可能快的填充到聲卡緩沖中,但是聲音設備卻會按照原來指定的采樣率來進行播放。
我們幾乎已經準備好來開始同步音頻和視頻了,但是首先我們需要稍微組織一下我們的代碼。在一個獨立的線程中,用隊列的方式來組織和播放音頻會工作的更好:它使得程序更加易于控制和模塊化。在我們開始同步音視頻之前,我們需要讓我們的代碼更加容易處理。所以下次要講的是:創建線程。
Tutorial 04:創建線程
源代碼:tutorial04.c
概要
上次我們通過SDL提供的音頻功能完成了音頻播放。 SDL啟動了一個線程以便每次需要音頻數據時通過我們定義的回調函數獲取。現在我們將對視頻顯示做同樣的事情。這使代碼更加模塊化,更易于使用 - 特別是當我們要完成音視頻同步功時。那么我們從哪里開始呢?
首先注意到我們的主功能函數處理了太多事情:它正在運行事件循環,讀取數據包和解碼音視頻。所以我們要做的是將所有這些分開:我們將有一個負責解碼數據包的線程;然后,這些數據包將被添加到對應的音視頻隊列中,并由相應的音頻和視頻線程讀取。音頻線程我們已經按照我們想要的方式設置了它;由于我們必須自己顯示視頻,視頻線程會有點復雜。我們將實際的顯示代碼添加到主循環中。但是,我們不是在每次循環時就顯示視頻,而是將視頻顯示同步到事件循環中。我們的想法是解碼視頻,將結果幀保存在另一個隊列中,然后我們創建自定義事件(FF_REFRESH_EVENT),并添加到事件系統中,最后當我們的事件循環看到此事件時,它將顯示隊列中的下一幀。這是一個手寫式流程插圖:
________ audio _______ _____ | | pkts | | | | to spkr | DECODE |----->| AUDIO |--->| SDL |--> |________| |_______| |_____|| video _______| pkts | |+---------->| VIDEO |________ |_______| _______ | | | | | | EVENT | +------>| VIDEO | to mon. | LOOP |----------------->| DISP. |--> |_______|<---FF_REFRESH----|_______|通過事件循環來動態控制視頻顯示的主要目的是采用SDL_Delay線程,這樣我們就可以精確的控制下一個視頻幀何時出現在屏幕上。 當我們最終在下一個Tutorial中完成音視頻同步時,添加何時去刷新下一幀,以便在屏幕上正確的時間去顯示正確的圖片的控制代碼,將不在是一件很麻煩的事情。
簡化代碼
我們還要稍微清理一下代碼。 音頻和視頻編解碼器信息我們已經全部獲得,我們還將創建隊列和緩沖區,誰知道還有什么。 所有這些東西都用于一個邏輯單元,即電影。 因此,我們將創建一個包含所有信息的大型結構體 — VideoState:
typedef struct VideoState {AVFormatContext *pFormatCtx;int videoStream, audioStream;AVStream *audio_st;AVCodecContext *audio_ctx;PacketQueue audioq;uint8_t audio_buf[(AVCODEC_MAX_AUDIO_FRAME_SIZE * 3) / 2];unsigned int audio_buf_size;unsigned int audio_buf_index;AVPacket audio_pkt;uint8_t *audio_pkt_data;int audio_pkt_size;AVStream *video_st;AVCodecContext *video_ctx;PacketQueue videoq;VideoPicture pictq[VIDEO_PICTURE_QUEUE_SIZE];int pictq_size, pictq_rindex, pictq_windex;SDL_mutex *pictq_mutex;SDL_cond *pictq_cond;SDL_Thread *parse_tid;SDL_Thread *video_tid;char filename[1024];int quit; } VideoState;從這個結構體定義中我們可以看到我們將要達到的目標。首先,我們看到基本信息 - 格式上下文和音頻、視頻流索引,以及相應的AVStream對象。 然后我們可以看到已經將部分音頻緩沖區移動到這個結構中。 這些(audio_buf,audio_buf_size等)都是關于已經存在的音頻(或缺乏音頻)數據的信息。 我們為視頻添加了另一個隊列,并為解碼的幀(保存為Overlay)添加了一個緩沖區(將被用作隊列;但我們不需要任何的排列功能)。 VideoPicture結構是我們自己的定義的(當我們使用它時,我們會看到它里面有什么)。 我們還注意到我們已經為我們將要創建的兩個額外線程分配了指針,以及退出標志和電影的文件名。
現在我們主函數中使用它,看看它如何改變我們的程序。讓我們設置VideoState結構:
int main(int argc, char *argv[]) {SDL_Event event;VideoState *is;is = av_mallocz(sizeof(VideoState));av_mallocz()是一個很好的函數,它將為我們分配內存并將其歸零。
然后我們初始化對顯示緩沖區(pictq)的鎖定,因為事件循環會調用我們的顯示函數,記住,將從pictq中提取預解碼的幀。 與此同時,我們的視頻解碼器將把信息放入其中 - 我們不知道誰將首先被調用。 希望你認識到這是一個典型的競爭條件。 所以我們在啟動任何線程之前就分配它。 我們還將我們電影的文件名復制到VideoState中。
av_strlcpy(is->filename, argv[1], sizeof(is->filename));is->pictq_mutex = SDL_CreateMutex(); is->pictq_cond = SDL_CreateCond();av_strlcpy是FFmpeg定義的函數,它在strncpy函數之外執行一些額外的邊界檢查。
我們的第一個線程
現在讓我們啟動線程,讓它完成工作:
schedule_refresh(is, 40);is->parse_tid = SDL_CreateThread(decode_thread, is); if(!is->parse_tid) {av_free(is);return -1; }schedule_refresh是我們稍后定義的函數。 它基本上做的是告訴系統在指定的毫秒數后推送FF_REFRESH_EVENT事件。 當我們在事件隊列中看到它時,就會輪流調用視頻刷新函數。 但是現在,讓我們看一下SDL_CreateThread()。
SDL_CreateThread()將會創建一個可以完全訪問原始進程的所有內存新線程,并啟動線程來運行我們傳遞給它的函數,它還將用戶定義的數據傳遞給該函數。 在這種情況下,我們調用decode_thread()并附加我們的VideoState結構。 功能的前半部分沒有什么新東西; 它只是打開文件并找到音頻和視頻流的索引。唯一不同的是將格式上下文保存在大結構體中。 在我們找到流索引之后,我們調用另一個我們將定義的函數stream_component_open()。 這是一種非常自然的將事情分開處理的方法。因為我們在設置視頻和音頻編解碼器方面做了很多類似的事情,所以我們把這一部分代碼重用提煉為函數。
我們將在stream_component_open()函數中找到我們需要的編解碼器,配置我們的音頻選項,將重要信息保存到我們的大結構體中,并啟動我們的音頻和視頻線程。 這里是我們還要插入其他選項的地方,例如強制配置編解碼器而不是自動檢測它等等。 代碼如下:
int stream_component_open(VideoState *is, int stream_index) {AVFormatContext *pFormatCtx = is->pFormatCtx;AVCodecContext *codecCtx;AVCodec *codec;SDL_AudioSpec wanted_spec, spec;if(stream_index < 0 || stream_index >= pFormatCtx->nb_streams) {return -1;}codec = avcodec_find_decoder(pFormatCtx->streams[stream_index]->codec->codec_id);if(!codec) {fprintf(stderr, "Unsupported codec!\n");return -1;}codecCtx = avcodec_alloc_context3(codec);if(avcodec_copy_context(codecCtx, pFormatCtx->streams[stream_index]->codec) != 0) {fprintf(stderr, "Couldn't copy codec context");return -1; // Error copying codec context}if(codecCtx->codec_type == AVMEDIA_TYPE_AUDIO) {// Set audio settings from codec infowanted_spec.freq = codecCtx->sample_rate;/* ...etc... */wanted_spec.callback = audio_callback;wanted_spec.userdata = is;if(SDL_OpenAudio(&wanted_spec, &spec) < 0) {fprintf(stderr, "SDL_OpenAudio: %s\n", SDL_GetError());return -1;}}if(avcodec_open2(codecCtx, codec, NULL) < 0) {fprintf(stderr, "Unsupported codec!\n");return -1;}switch(codecCtx->codec_type) {case AVMEDIA_TYPE_AUDIO:is->audioStream = stream_index;is->audio_st = pFormatCtx->streams[stream_index];is->audio_ctx = codecCtx;is->audio_buf_size = 0;is->audio_buf_index = 0;memset(&is->audio_pkt, 0, sizeof(is->audio_pkt));packet_queue_init(&is->audioq);SDL_PauseAudio(0);break;case AVMEDIA_TYPE_VIDEO:is->videoStream = stream_index;is->video_st = pFormatCtx->streams[stream_index];is->video_ctx = codecCtx;packet_queue_init(&is->videoq);is->video_tid = SDL_CreateThread(video_thread, is);is->sws_ctx = sws_getContext(is->video_st->codec->width, is->video_st->codec->height,is->video_st->codec->pix_fmt, is->video_st->codec->width,is->video_st->codec->height, PIX_FMT_YUV420P,SWS_BILINEAR, NULL, NULL, NULL);break;default:break;} }這與我們之前的代碼幾乎相同,只是現在它被泛化同時用于音頻和視頻。 請注意,傳遞給音頻回調函數的用戶數據是大結構體,而不是AVCodecContext。 我們還將流本身保存為audio_st和video_st。 我們還添加了視頻隊列,并與設置音頻隊列相同的方式進行設置。 最重要的是啟動視頻和音頻線程。
SDL_PauseAudio(0);break;/* ...... */is->video_tid = SDL_CreateThread(video_thread, is);我們記得上次使用過的SDL_PauseAudio(),并且SDL_CreateThread()的使用方式與之前完全相同。 我們將回到video_thread()函數。
在此之前,讓我們回到decode_thread()函數的后半部分。 它基本上只是一個for循環,它將讀入數據包并將其放在正確的隊列中:
for(;;) {if(is->quit) {break;}// seek stuff goes hereif(is->audioq.size > MAX_AUDIOQ_SIZE ||is->videoq.size > MAX_VIDEOQ_SIZE) {SDL_Delay(10);continue;}if(av_read_frame(is->pFormatCtx, packet) < 0) {if((is->pFormatCtx->pb->error) == 0) {SDL_Delay(100); /* no error; wait for user input */continue;} else {break;}}// Is this a packet from the video stream?if(packet->stream_index == is->videoStream) {packet_queue_put(&is->videoq, packet);} else if(packet->stream_index == is->audioStream) {packet_queue_put(&is->audioq, packet);} else {av_free_packet(packet);}}這里沒有什么新東西,除了我們給音頻和視頻隊列限定了一個最大值,另外我們添加一個檢測讀錯誤的函數。格式上下文里面有一個叫做pb的ByteIOContext類型結構體。這個結構體是用來保存一些低級的文件信息。
在循環以后,我們的代碼在等待其余的程序結束和告知程序已經結束。這些代碼是有指引性,因為它指示出了如何推送事件--后面我們將顯示圖像。
while(!is->quit) {SDL_Delay(100);}fail:if(1){SDL_Event event;event.type = FF_QUIT_EVENT;event.user.data1 = is;SDL_PushEvent(&event);}return 0;我們使用SDL常量SDL_USEREVENT來從用戶事件分配值。第一個用戶事件的值應當是SDL_USEREVENT,下一個是 SDL_USEREVENT+1并且依此類推。在我們的程序中FF_QUIT_EVENT被定義成SDL_USEREVENT+2。如果喜歡,我們也可以傳遞用戶數據,在這里我們傳遞的是大結構體的指針。最后我們調用SDL_PushEvent()函數。在我們的事件切換中,我們只是像以前推送SDL_QUIT_EVENT事件那部分一樣推送FF_QUIT_EVENT事件。我們將在自己的事件隊列中詳細討論,現在只是確保我們正確放入了FF_QUIT_EVENT事件,我們將在后面捕捉到它并且設置我們的退出標志quit。
得到幀:video_thread
當我們準備好解碼器后,我們啟動視頻線程。這個線程從視頻隊列中讀取包,把它解碼成視頻幀,然后調用queue_picture函數把處理好的幀放入到圖片隊列中:
int video_thread(void *arg) { VideoState *is = (VideoState *)arg; AVPacket pkt1, *packet = &pkt1; int frameFinished; AVFrame *pFrame;pFrame = av_frame_alloc();for(;;) {if(packet_queue_get(&is->videoq, packet, 1) < 0) {// means we quit getting packetsbreak;}// Decode video frameavcodec_decode_video2(is->video_st->codec, pFrame, &frameFinished, packet);// Did we get a video frame?if(frameFinished) {if(queue_picture(is, pFrame) < 0) {break;}}av_free_packet(packet); } av_free(pFrame); return 0; }在這里的很多函數應該都很熟悉吧。我們把avcodec_decode_video函數移到了這里,替換了一些參數,例如:我們把AVStream保存在我們自己的大結構體中,所以我們可以從那里得到編解碼器的信息。我們僅僅是不斷的從視頻隊列中取包一直到有人告訴我們要停止或者出錯為止。
把幀隊列化
讓我們看一下將解碼后的幀pFrame保存到圖像隊列中的函數。因為我們的圖像隊列是SDL的覆蓋集合(基本上不用讓視頻顯示函數再做計算了),我們需要把幀轉換成相應的格式。我們保存到圖像隊列中的數據是我們自己做的一個結構體。
typedef struct VideoPicture {SDL_Overlay *bmp;int width, height; /* source height & width */int allocated; } VideoPicture;我們的大結構體有一個可以保存這些的緩沖區。然而,我們需要自己來申請SDL_Overlay(注意:allocated標志會指明我們是否已經做了這個申請動作)。
為了使用這個隊列,我們有兩個指針-寫入指針和讀取指針。 我們還會跟蹤緩沖區中圖片的數量。要寫入到隊列中,我們先要等待緩沖清空以便于有位置來保存我們的VideoPicture。然后我們檢查看是否已經申請到了一個可以寫入覆蓋的索引號。如果沒有,我們將不得不分配一些空間。如果窗口大小發生變化,我們還必須重新分配緩沖區!
int queue_picture(VideoState *is, AVFrame *pFrame) {VideoPicture *vp;int dst_pix_fmt;AVPicture pict;/* wait until we have space for a new pic */SDL_LockMutex(is->pictq_mutex);while(is->pictq_size >= VIDEO_PICTURE_QUEUE_SIZE &&!is->quit) {SDL_CondWait(is->pictq_cond, is->pictq_mutex);}SDL_UnlockMutex(is->pictq_mutex);if(is->quit)return -1;// windex is set to 0 initiallyvp = &is->pictq[is->pictq_windex];/* allocate or resize the buffer! */if(!vp->bmp ||vp->width != is->video_st->codec->width ||vp->height != is->video_st->codec->height) {SDL_Event event;vp->allocated = 0;alloc_picture(is);if(is->quit) {return -1;}}讓我們看看alloc_picture()函數:
void alloc_picture(void *userdata) {VideoState *is = (VideoState *)userdata;VideoPicture *vp;vp = &is->pictq[is->pictq_windex];if(vp->bmp) {// we already have one make another, bigger/smallerSDL_FreeYUVOverlay(vp->bmp);}// Allocate a place to put our YUV image on that screenSDL_LockMutex(screen_mutex);vp->bmp = SDL_CreateYUVOverlay(is->video_st->codec->width,is->video_st->codec->height,SDL_YV12_OVERLAY,screen);SDL_UnlockMutex(screen_mutex);vp->width = is->video_st->codec->width;vp->height = is->video_st->codec->height; vp->allocated = 1; }您應該已經看到我們將SDL_CreateYUVOverlay函數從主循環移動到這里。 此代碼現在應該是能夠自我注釋說明的。 但是,現在我們有一個互斥鎖,因為兩個線程無法同時向屏幕寫入信息! 這將防止alloc_picture函數和圖片顯示函數相互競爭。 (我們已將此鎖創建為全局變量并在main()中初始化;請參閱代碼。)請記住,我們在VideoPicture結構體中保存了寬度和高度,因為某些原因我們需要確保我們的視頻大小不會更改。
OK,我們都已準備好了,并且也分配了YUV Overlay以便接收圖像。讓我們回顧一下queue_picture函數,并看一下拷貝幀到Overlay的代碼。你應該能認出其中的一部分:
int queue_picture(VideoState *is, AVFrame *pFrame) {/* Allocate a frame if we need it... *//* ... *//* We have a place to put our picture on the queue */if(vp->bmp) {SDL_LockYUVOverlay(vp->bmp);dst_pix_fmt = PIX_FMT_YUV420P;/* point pict at the queue */pict.data[0] = vp->bmp->pixels[0];pict.data[1] = vp->bmp->pixels[2];pict.data[2] = vp->bmp->pixels[1];pict.linesize[0] = vp->bmp->pitches[0];pict.linesize[1] = vp->bmp->pitches[2];pict.linesize[2] = vp->bmp->pitches[1];// Convert the image into YUV format that SDL usessws_scale(is->sws_ctx, (uint8_t const * const *)pFrame->data,pFrame->linesize, 0, is->video_st->codec->height,pict.data, pict.linesize);SDL_UnlockYUVOverlay(vp->bmp);/* now we inform our display thread that we have a pic ready */if(++is->pictq_windex == VIDEO_PICTURE_QUEUE_SIZE) {is->pictq_windex = 0;}SDL_LockMutex(is->pictq_mutex);is->pictq_size++;SDL_UnlockMutex(is->pictq_mutex);}return 0; }大部分內容只是我們之前用來填充YUV Overlay的代碼。 最后一點只是簡單地將我們的值“添加”到隊列中。 隊列的工作方式是在其未滿時可以一直往里添加東西,只要隊列有值,我們就可讀取它。 因此,一切都取決于is-> pictq_size值,要求我們鎖定它。 所以我們在這里增加寫指針(在必要的時候采用輪轉的方式),然后鎖定隊列并增加其大小。 現在讀取者可以了解到有關隊列的更多信息,并且如果隊列滿了,寫入者也將會知道。
顯示視頻
開始輪到我們的視頻線程了。現在我們看過了幾乎所有的線程,除了一個 - 記得我們調用了schedule_refresh()函數嗎?讓我們看一下實際它是如何工作的:
/* schedule a video refresh in 'delay' ms */ static void schedule_refresh(VideoState *is, int delay) {SDL_AddTimer(delay, sdl_refresh_timer_cb, is); }SDL中的函數SDL_AddTimer()會定時(計時單位毫秒)去執行用戶定義的回調函數(可以選擇性傳入用戶參數)。我們將用這個函數來定時刷新視頻 - 每次我們調用這個函數的時候,它將設置一個定時器來觸發事件,在主函數中輪流把一幀從圖像隊列中顯示到屏幕上。 但是,首先讓我們先觸發那個事件。
static Uint32 sdl_refresh_timer_cb(Uint32 interval, void *opaque) {SDL_Event event;event.type = FF_REFRESH_EVENT;event.user.data1 = opaque;SDL_PushEvent(&event);return 0; /* 0 means stop timer */ }這里推送了一個現在很熟悉的事件 - FF_REFRESH_EVENT,被定義成SDL_USEREVENT+1。要注意的一件事是當返回0的時候,SDL關閉定時器,回調就不會再被調用。
現在我們產生了一個FF_REFRESH_EVENT事件,我們需要在事件循環中處理它:
for(;;) {SDL_WaitEvent(&event);switch(event.type) {/* ... */case FF_REFRESH_EVENT:video_refresh_timer(event.user.data1);break;于是我們就進入到以下函數,在這個函數中會把數據從圖像隊列中取出:
void video_refresh_timer(void *userdata) {VideoState *is = (VideoState *)userdata;VideoPicture *vp;if(is->video_st) {if(is->pictq_size == 0) {schedule_refresh(is, 1);} else {vp = &is->pictq[is->pictq_rindex];/* Timing code goes here */schedule_refresh(is, 80);/* show the picture! */video_display(is);/* update queue for next picture! */if(++is->pictq_rindex == VIDEO_PICTURE_QUEUE_SIZE) {is->pictq_rindex = 0;}SDL_LockMutex(is->pictq_mutex);is->pictq_size--;SDL_CondSignal(is->pictq_cond);SDL_UnlockMutex(is->pictq_mutex);}} else {schedule_refresh(is, 100);} }現在,這只是一個極其簡單的函數:當隊列中有數據的時候,就從其中取出,為下一幀設置定時器,調用video_display函數來真正顯示圖像到屏幕上,然后把隊列讀索引值加1,并且把隊列的尺寸size減1。你可能會注意到在這個函數中我們并沒有真正對vp做一些實際的動作,原因是這樣的:我們將在后面處理。我們將在后面同步音頻和視頻的時候用它來訪問時間信息。你會在這里看到這個注釋信息“校時代碼”。那里我們將討論顯示下一幀視頻的時間間隔,然后把相應的值寫入到schedule_refresh()函數中。現在我們只是隨便寫入一個值80。從技術上來講,你可以猜測并驗證這個值,并且為每個電影重新編譯程序,但是:1)過一段時間它會漂移;2)這種方式是很笨的。我們將在后面來討論它。
我們幾乎做完了;我們僅僅剩下最后一件事:顯示視頻!下面就是video_display函數:
void video_display(VideoState *is) {SDL_Rect rect;VideoPicture *vp;float aspect_ratio;int w, h, x, y;int i;vp = &is->pictq[is->pictq_rindex];if(vp->bmp) {if(is->video_st->codec->sample_aspect_ratio.num == 0) {aspect_ratio = 0;} else {aspect_ratio = av_q2d(is->video_st->codec->sample_aspect_ratio) *is->video_st->codec->width / is->video_st->codec->height;}if(aspect_ratio <= 0.0) {aspect_ratio = (float)is->video_st->codec->width /(float)is->video_st->codec->height;}h = screen->h;w = ((int)rint(h * aspect_ratio)) & -3;if(w > screen->w) {w = screen->w;h = ((int)rint(w / aspect_ratio)) & -3;}x = (screen->w - w) / 2;y = (screen->h - h) / 2;rect.x = x;rect.y = y;rect.w = w;rect.h = h;SDL_LockMutex(screen_mutex);SDL_DisplayYUVOverlay(vp->bmp, &rect);SDL_UnlockMutex(screen_mutex);} }因為我們的屏幕可以是任意尺寸(我們設置為640x480并且用戶可以自己來改變尺寸),我們需要動態計算出我們顯示的圖像的矩形大小。所以一開始我們需要計算出電影的縱橫比aspect ratio,即寬度/高度。某些編解碼器會有奇數采樣縱橫比sample aspect ratio,只是簡單表示了一個像素或者一個采樣的寬度/高度的比例。因為寬度和高度在我們的編解碼器中是用像素為單位的,所以實際的縱橫比與縱橫比乘以樣本縱橫比相同。某些編解碼器會顯示縱橫比為0,這表示每個像素的縱橫比為1x1。然后我們把電影縮放到適合屏幕的盡可能大的尺寸。這里的& -3表示與-3做與運算,實際上是讓它們4字節對齊。然后我們把電影移到中心位置,接著使用屏幕互斥鎖去調用SDL_DisplayYUVOverlay()函數。
結果是什么?我們做完了嗎?嗯,我們仍然要重新改寫聲音部分的代碼來使用新的VideoStruct結構體,但是那些只是微不足道的改動,你可以參考示例代碼。我們要做的最后一件事是更改FFmpeg內部“退出”回調函數的回調:
VideoState *global_video_state;int decode_interrupt_cb(void) {return (global_video_state && global_video_state->quit); }我們在主函數中為大結構體設置了global_video_state。
這就是了!讓我們編譯它:
gcc -o tutorial04 tutorial04.c -lavutil -lavformat -lavcodec -lswscale -lz -lm \
`sdl-config --cflags --libs`
請享受一下沒有經過同步的電影!下次我們將編譯一個可以最終工作的電影播放器。
Tutorial: 05 音視頻同步
源代碼:tutorial05.c
警告
當我第一次完成本Tutorial時,所有音視頻同步代碼都是從ffplay.c中提取的。 今天,ffplay是一個完全不同的程序,ffmpeg庫(以及ffplay.c本身)的改進已經導致了一些策略的改變。 雖然本Tutorial示例代碼仍然有效,但它看起來不太好,本Tutorial也還有許多可以改進的地方。
音視頻如何同步
在前面一段時間里,我們完成了一個幾乎無用的視頻播放器。當然,它能播放視頻,也能播放音頻,但是它還不能被稱為一部電影。那么我們還要做什么呢?
PTS 和 DTS
幸運的是,音頻和視頻流里都有關于以多快速度和什么時間來播放它們的信息。音頻流有采樣率,視頻流有幀率。然而,如果我們只是簡單的通過數幀和乘以幀率的方式來同步視頻,那么就很有可能會失去音頻同步。于是作為一種補充,在流中的包有種叫做 DTS(decoding time stamp) 和 PTS(a presentation time stamp) 的機制。為了弄明白這兩個參數,你需要了解電影的存儲方式。像MPEG等格式,使用被叫做B幀(B表示雙向bidrectional)的方式。另外兩種幀被叫做I幀和P幀(I表示關鍵幀,P表示預測幀)。I幀包含了某個特定的完整圖像。P幀依賴于前面的I幀和P幀并且使用比較或者差分的方式來編碼。B幀與P幀有點類似,但是它是依賴于前面和后面幀的信息。這也就解釋了為什么我們可能在調用avcodec_decode_video以后會得不到一幀圖像。
所以對于一個電影,幀是這樣來顯示的:I B B P。現在我們需要在顯示B幀之前知道P幀中的信息。因此,幀可能會按照這樣的方式來存儲:IPBB。這就是為什么我們會有一個解碼時間戳和一個顯示時間戳的原因。解碼時間戳告訴我們什么時候需要解碼,顯示時間戳告訴我們什么時候需要顯示。所以,在這種情況下,我們的流可以是這樣的:
PTS: 1 4 2 3
DTS: 1 2 3 4
Stream: I P B B
通常PTS和DTS只有在流中有B幀的時候才會不同。
當我們調用av_read_frame()得到一個包的時候,PTS和DTS的信息也會保存在包中。但是我們真正想要的PTS是我們剛剛解碼出來的原始幀的PTS,這樣我們才能知道什么時候來顯示它。
幸運的是,FFmpeg為我們提供了"best effort"時間戳,您可以通過調用dav_frame_get_best_effort_timestamp()獲取。
同步
現在,知道了什么時候來顯示一個視頻幀真好,但是我們怎樣來實際操作呢?這里有個idea:當我們顯示了一幀以后,我們計算出下一幀顯示的時間。然后簡單的設置一個新的定時器來刷新。你可能會想,我們檢查下一幀的PTS值,然后對比系統時鐘來得到需要多久來顯示下一幀。這種方式可以工作,但是有兩種情況要處理。
首先,要知道下一個PTS是多少。現在我們將視頻幀率和PTS相結合-太對了!然而,有些電影需要幀重復。這意味著我們重復播放當前的幀。這將導致程序顯示下一幀太快了。所以我們需要計算它們。 第二,正如現在的程序,視頻和音頻播放的很快,一點也不同步。如果一切都工作得很好的話,我們不必擔心。但是,你的電腦并不是最好的,很多視頻文件也不是完好的。所以,我們有三種選擇:同步音頻到視頻,同步視頻到音頻,或者都同步到外部時鐘(例如你的電腦時鐘)。從現在開始,我們將同步視頻到音頻。
編碼:獲得幀的時間戳
現在讓我們到代碼中來做這些事情。我們將需要為我們的大結構體添加一些成員,但是我們會根據需要來添加。首先,讓我們看一下視頻線程。記住,在這里我們得到了解碼線程輸出到隊列中的包。這部分代碼我們需要做的是通過avcodec_decode_video2函數得到幀的時間戳。我們討論的第一種方式是從上次處理的包中得到DTS,這是很容易的:
double pts;for(;;) {if(packet_queue_get(&is->videoq, packet, 1) < 0) {// means we quit getting packetsbreak;}pts = 0;// Decode video framelen1 = avcodec_decode_video2(is->video_st->codec,pFrame, &frameFinished, packet);if(packet->dts != AV_NOPTS_VALUE) {pts = av_frame_get_best_effort_timestamp(pFrame);} else {pts = 0;}pts *= av_q2d(is->video_st->time_base);如果我們得不到PTS就把它設置為0。
好吧,這很容易。一個技術方面的注釋:您可能已經注意到我們正在使用int64存儲PTS。 這是因為PTS存儲為整數。 此值是一個時間戳,對應于通過流中time_base單元來度量得到幀顯示時間。例如,如果一個流每秒有24幀,那么PTS為42將指示如果每隔1/24秒我們有一個幀,那么幀應該到第42幀的位置(當然不一定是真的)。
我們可以通過除以幀率將此值轉換為秒。 流的time_base值將為1/幀率(對于固定幀率而言),因此為了以秒為單位表示PTS,我們乘以time_base。
編碼:使用PTS來同步
現在我們得到了PTS。我們要注意前面討論到的兩個同步問題。我們將定義一個函數叫做synchronize_video,它可以更新PTS以便能夠正確同步。這個函數也能處理我們得不到PTS的情況。同時我們要知道下一幀的顯示時間以便于正確設置刷新速率。于是我們需要一個內部變量video_clock來記錄視頻播放時長。我們把這些變量添加到大結構體中。
typedef struct VideoState {double video_clock; // pts of last decoded frame / predicted pts of next decoded frame下面是函數synchronize_video,它可以很好的自我解釋:
double synchronize_video(VideoState *is, AVFrame *src_frame, double pts) {double frame_delay;if(pts != 0) {/* if we have pts, set video clock to it */is->video_clock = pts;} else {/* if we aren't given a pts, set it to the clock */pts = is->video_clock;}/* update the video clock */frame_delay = av_q2d(is->video_st->codec->time_base);/* if we are repeating a frame, adjust clock accordingly */frame_delay += src_frame->repeat_pict * (frame_delay * 0.5);is->video_clock += frame_delay;return pts; }你也會注意到我們同時計算了重復的幀。
現在讓我們得到正確的PTS并且使用queue_picture來隊列化幀,添加一個新的時間戳參數pts:
// Did we get a video frame?if(frameFinished) {pts = synchronize_video(is, pFrame, pts);if(queue_picture(is, pFrame, pts) < 0) {break;}}對于queue_picture來說唯一改變的事情就是我們把時間戳值pts保存到VideoPicture結構體中,我們必需添加一個時間戳變量到結構體中,只需添加一行代碼:
typedef struct VideoPicture {...double pts; } int queue_picture(VideoState *is, AVFrame *pFrame, double pts) {... stuff ...if(vp->bmp) {... convert picture ...vp->pts = pts;... alert queue ...}現在我們的圖像隊列中的所有圖像都有了正確的時間戳值,所以讓我們看一下視頻刷新函數。你會記得上次我們用80ms的刷新時間來欺騙它。那么,現在我們將會算出實際的值。
我們的策略是通過簡單計算前一幀和現在這一幀的時間戳來預測出下一個時間戳的時間。同時,我們需要同步視頻到音頻。我們將設置一個變量 audio clock 來記錄音頻正在播放的位置。這就像任何MP3播放器上的數字讀數。既然我們把視頻同步到音頻,視頻線程使用這個值來算出是否太快還是太慢。
我們將在后面來實現這些代碼;現在我們假設我們已經有一個可以給我們音頻時間的函數get_audio_clock。一旦我們有了這個值,那么我們在音頻和視頻失去同步的時候應該做些什么呢?簡單而有點笨的辦法是試著用seek跳到正確幀,或者其它的方式來解決。不同的是,我們會調整下次刷新的值:如果PTS遠遠落后于音頻時間,我們將計算出的延遲加倍, 如果PTS遠遠超過音頻時間,我們需要盡快刷新。既然我們有了調整過的時間和延遲,我們將把它和我們通過 frame_timer計算出來的時間進行比較。這個frame_timer將會統計出電影播放中所有的延時。換句話說,這個 frame_timer就是指我們什么時候來顯示下一幀。我們簡單的在frame_time加上新的延時,把它和電腦的系統時間進行比較,然后使用這個值來做下一次刷新。這可能有點難以理解,所以請認真研究代碼:
void video_refresh_timer(void *userdata) {VideoState *is = (VideoState *)userdata;VideoPicture *vp;double actual_delay, delay, sync_threshold, ref_clock, diff;if(is->video_st) {if(is->pictq_size == 0) {schedule_refresh(is, 1);} else {vp = &is->pictq[is->pictq_rindex];delay = vp->pts - is->frame_last_pts; /* the pts from last time */if(delay <= 0 || delay >= 1.0) {/* if incorrect delay, use previous one */delay = is->frame_last_delay;}/* save for next time */is->frame_last_delay = delay;is->frame_last_pts = vp->pts;/* update delay to sync to audio */ref_clock = get_audio_clock(is);diff = vp->pts - ref_clock;/* Skip or repeat the frame. Take delay into accountFFPlay still doesn't "know if this is the best guess." */sync_threshold = (delay > AV_SYNC_THRESHOLD) ? delay : AV_SYNC_THRESHOLD;if(fabs(diff) < AV_NOSYNC_THRESHOLD) {if(diff <= -sync_threshold) {delay = 0;} else if(diff >= sync_threshold) {delay = 2 * delay;}}is->frame_timer += delay;/* computer the REAL delay */actual_delay = is->frame_timer - (av_gettime() / 1000000.0);if(actual_delay < 0.010) {/* Really it should skip the picture instead */actual_delay = 0.010;}schedule_refresh(is, (int)(actual_delay * 1000 + 0.5));/* show the picture! */video_display(is);/* update queue for next picture! */if(++is->pictq_rindex == VIDEO_PICTURE_QUEUE_SIZE) {is->pictq_rindex = 0;}SDL_LockMutex(is->pictq_mutex);is->pictq_size--;SDL_CondSignal(is->pictq_cond);SDL_UnlockMutex(is->pictq_mutex);}} else {schedule_refresh(is, 100);} }我們在這里做了很多檢查:首先,我們保證現在的時間戳和上一個時間戳之間的delay是有效的。如果無效的話,我們只能用猜測方式用上次的延遲。接著,我們有一個同步閾值,因為在同步的時候事情并不總是那么完美的。在ffplay中使用0.01作為它的值。我們也保證閾值不會比時間戳之間的間隔短。最后,我們把最小的刷新值設置為10毫秒。
我們給大結構體添加了很多的變量,所以不要忘記檢查一下代碼。同時也不要忘記在函數streame_component_open中初始化幀時間 frame_timer和前面的幀延遲frame delay:
is->frame_timer = (double)av_gettime() / 1000000.0;is->frame_last_delay = 40e-3;同步:音頻時鐘
現在讓我們看一下怎樣來得到音頻時鐘。我們可以在音頻解碼函數audio_decode_frame中更新時鐘時間。現在,請記住我們并不是每次調用這個函數的時候都在處理新的包,所以有兩個地方需要更新時鐘。第一個地方是我們得到新的包的時候:我們簡單的設置聲音時鐘為這個包的時間戳。然后,如果一個包里有許多幀,我們通過統計樣本數并乘以采樣率來計算音頻時鐘,所以當我們得到包的時候:
/* if update, update the audio clock w/pts */if(pkt->pts != AV_NOPTS_VALUE) {is->audio_clock = av_q2d(is->audio_st->time_base)*pkt->pts;}然后當我們處理這個包的時候:
/* Keep audio_clock up-to-date */pts = is->audio_clock;*pts_ptr = pts;n = 2 * is->audio_st->codec->channels;is->audio_clock += (double)data_size /(double)(n * is->audio_st->codec->sample_rate);一點細節:模板函數被改成包含pts_ptr,所以要保證你同步做了修改。pts_ptr是一個用來通知audio_callback函數當前聲音包的時間戳的指針。這將在下次用來同步聲音和視頻。
現在我們可以最后來實現我們的get_audio_clock函數。它并不像得到is->audio_clock值那樣簡單。注意我們會在每次調用它的時候設置音頻時間戳,但是如果你看了audio_callback函數,它需要時間來把數據從音頻包中移動到我們的輸出緩沖區中。這意味著我們音頻時鐘中記錄的時間比實際的要早太多。所以我們必須要檢查一下我們還有多少沒有寫入。下面是完整的代碼:
現在你應該知道為什么這個函數可以正常工作了。
讓我們編譯它:
gcc -o tutorial05 tutorial05.c -lavutil -lavformat -lavcodec -lswscale -lz -lm \
`sdl-config --cflags --libs`
最后,你可以使用我們自己的電影播放器來看電影了。下次我們將看一下如何音頻同步,然后接下來的Tutorial我們會討論seeking。
Tutorial 06: 音頻同步
源代碼:tutorial06.c
概要
現在我們已經有了一個比較像樣的播放器。所以讓我們看看還有哪些零碎的東西沒處理。前面我們只是簡單提了一下音頻同步到視頻時鐘的音視頻同步方式,我們將采用和視頻同步到音頻時鐘一樣的做法:設置一個內部視頻時鐘來記錄視頻播放時長,然后音頻據此進行同步。后面我們也來看一下如何推而廣之把音頻和視頻都同步到外部時鐘。
生成視頻時鐘
現在我們要生成一個類似于上篇Tutorial音頻時鐘的視頻時鐘:一個保存當前視頻播放時間的內部值。開始,你可能會想這和使用上一幀的時間戳來更新定時器一樣簡單。但是,不要忘了視頻幀之間的時間間隔是很長的,間隔時長是以毫秒為計量的。解決辦法是保存另外一個時間值:我們設置上一幀PTS的視頻時鐘的時間。于是當前視頻時鐘的值就是PTS_of_last_frame + (current_time - time_elapsed_since_PTS_value_was_set)。這種解決方式與我們在函數get_audio_clock中的做法很類似。所在在我們的大結構體中,我們將添加一個double 變量video_current_pts和一個int64_t變量 video_current_pts_time。時鐘更新將被放在video_refresh_timer函數中。
void video_refresh_timer(void *userdata) {/* ... */if(is->video_st) {if(is->pictq_size == 0) {schedule_refresh(is, 1);} else {vp = &is->pictq[is->pictq_rindex];is->video_current_pts = vp->pts;is->video_current_pts_time = av_gettime();不要忘了在stream_component_open函數中進行初始化。
is->video_current_pts_time = av_gettime();現在我們需要一個方法去獲取視頻時鐘:
double get_video_clock(VideoState *is) {double delta;delta = (av_gettime() - is->video_current_pts_time) / 1000000.0;return is->video_current_pts + delta; }時鐘抽象化
但是為什么要強制使用視頻時鐘呢?我們更改視頻同步代碼以致于音頻和視頻不會試著去相互同步。想像一下我們讓它像ffplay一樣有一個命令行參數。所以讓我們抽象一下:我們將創建一個新的封裝函數,get_master_clock來檢查av_sync_type變量,然后調用get_audio_clock,get_video_clock或我們想要使用的任何其他時鐘。 我們甚至可以使用系統時鐘,抽象為get_external_clock:
enum {AV_SYNC_AUDIO_MASTER,AV_SYNC_VIDEO_MASTER,AV_SYNC_EXTERNAL_MASTER, };#define DEFAULT_AV_SYNC_TYPE AV_SYNC_VIDEO_MASTERdouble get_master_clock(VideoState *is) {if(is->av_sync_type == AV_SYNC_VIDEO_MASTER) {return get_video_clock(is);} else if(is->av_sync_type == AV_SYNC_AUDIO_MASTER) {return get_audio_clock(is);} else {return get_external_clock(is);} } main() { ...is->av_sync_type = DEFAULT_AV_SYNC_TYPE; ... }同步音頻
現在是最難的部分:同步音頻到視頻時鐘。我們的策略是計算音頻播放位置,把它與視頻時間比較然后算出我們需要修正多少樣本數,也就是說:我們是否需要通過丟棄樣本的方式來加速播放還是需要通過插值樣本的方式來延緩播放?
我們將在每次處理聲音樣本的時候運行一個synchronize_audio的函數來正確的丟棄或者插值聲音樣本。然而,我們不想在每次發現有偏差的時候都進行同步,因為這樣會使調整音頻多于視頻包。所以我們為函數synchronize_audio設置一個最小連續值來限定需要同步的時刻,這樣我們就不會總是在調整了。當然,就像上次那樣,“失去同步”意味著聲音時鐘和視頻時鐘的差異大于我們的閾值。
所以我們將使用一個分數系數,比如c,所以現在假設我們已經得到了N個不同步的音頻樣本集。 不同步的數量也可能有很大差異,所以我們要計算所有樣本數的平均值。 例如,第一次調用可能表明我們不同步40ms,下一次50ms,依此類推。 但我們不會采取簡單的平均值,因為最近的值比以前的值更重要。 所以我們將使用分數系數,比如c,并將差值求和:diff_sum = new_diff + diff_sum * c。 當我們準備好去找平均差異的時候,我們只需計算avg_diff = diff_sum *(1-c)。
注釋:為什這里會有這么神奇的公式?嗯,它基本上是一個使用等比級數的加權平均值。我不知道這是否有名字(我甚至查過維基百科!),但是如果想要更多的信息,這里是一個解釋或者weightedmean.txt。
讓我們看看函數到底是什么樣的:
/* Add or subtract samples to get a better sync, return newaudio buffer size */ int synchronize_audio(VideoState *is, short *samples,int samples_size, double pts) {int n;double ref_clock;n = 2 * is->audio_st->codec->channels;if(is->av_sync_type != AV_SYNC_AUDIO_MASTER) {double diff, avg_diff;int wanted_size, min_size, max_size, nb_samples;ref_clock = get_master_clock(is);diff = get_audio_clock(is) - ref_clock;if(diff < AV_NOSYNC_THRESHOLD) {// accumulate the diffsis->audio_diff_cum = diff + is->audio_diff_avg_coef* is->audio_diff_cum;if(is->audio_diff_avg_count < AUDIO_DIFF_AVG_NB) {is->audio_diff_avg_count++;} else {avg_diff = is->audio_diff_cum * (1.0 - is->audio_diff_avg_coef);/* Shrinking/expanding buffer code.... */}} else {/* difference is TOO big; reset diff stuff */is->audio_diff_avg_count = 0;is->audio_diff_cum = 0;}}return samples_size; }現在我們已經做得很好;我們已經近似的知道如何用視頻或者其它的時鐘來調整音頻了。現在讓我們把以下代碼放在“縮小/擴大緩沖區代碼”所在的位置來計算我們需要添加或刪除的樣本數量:
if(fabs(avg_diff) >= is->audio_diff_threshold) {wanted_size = samples_size + ((int)(diff * is->audio_st->codec->sample_rate) * n);min_size = samples_size * ((100 - SAMPLE_CORRECTION_PERCENT_MAX)/ 100);max_size = samples_size * ((100 + SAMPLE_CORRECTION_PERCENT_MAX) / 100);if(wanted_size < min_size) {wanted_size = min_size;} else if (wanted_size > max_size) {wanted_size = max_size;}記住*audio_length * (sample_rate * # of channels * 2)*就是audio_length秒時間的聲音樣本數。所以,我們想要的樣本數就是我們根據聲音偏移添加或者減少后的聲音樣本數。我們也可以設置一個范圍來限定我們一次進行修正的長度,因為如果我們改變的太多,用戶會聽到刺耳的聲音。
修正樣本數
現在我們要真正的修正一下聲音。你可能會注意到我們的同步函數synchronize_audio返回了樣本數,這可以告訴我們有多少個字節被送到流中。所以我們只要調整樣本數為wanted_size就可以了。這會讓樣本更小一些。但是如果我們想讓它變大,我們不能只是讓樣本大小變大,因為在緩沖區中沒有多余的數據!所以我們必需添加上去。但是我們怎樣來添加呢?最笨的辦法就是試著來推算聲音,所以讓我們用已有的數據在緩沖的末尾添加上最后的樣本。
if(wanted_size < samples_size) {/* remove samples */samples_size = wanted_size; } else if(wanted_size > samples_size) {uint8_t *samples_end, *q;int nb;/* add samples by copying final samples */nb = (samples_size - wanted_size);samples_end = (uint8_t *)samples + samples_size - n;q = samples_end + n;while(nb > 0) {memcpy(q, samples_end, n);q += n;nb -= n;}samples_size = wanted_size; }現在我們通過這個函數得到了樣本數。現在要做的就是使用它:
void audio_callback(void *userdata, Uint8 *stream, int len) {VideoState *is = (VideoState *)userdata;int len1, audio_size;double pts;while(len > 0) {if(is->audio_buf_index >= is->audio_buf_size) {/* We have already sent all our data; get more */audio_size = audio_decode_frame(is, is->audio_buf, sizeof(is->audio_buf), &pts);if(audio_size < 0) {/* If error, output silence */is->audio_buf_size = 1024;memset(is->audio_buf, 0, is->audio_buf_size);} else {audio_size = synchronize_audio(is, (int16_t *)is->audio_buf,audio_size, pts);is->audio_buf_size = audio_size;我們要做的是把函數synchronize_audio插入進去。(同時,保證在初始化上面變量的時候檢查一下代碼,這些我沒有贅述)。
結束之前的最后一件事情:我們需要添加一個if語句來保證我們不會以視頻為主時鐘的時候還來同步視頻。
if(is->av_sync_type != AV_SYNC_VIDEO_MASTER) {ref_clock = get_master_clock(is);diff = vp->pts - ref_clock;/* Skip or repeat the frame. Take delay into accountFFPlay still doesn't "know if this is the best guess." */sync_threshold = (delay > AV_SYNC_THRESHOLD) ? delay :AV_SYNC_THRESHOLD;if(fabs(diff) < AV_NOSYNC_THRESHOLD) {if(diff <= -sync_threshold) {delay = 0;} else if(diff >= sync_threshold) {delay = 2 * delay;}} }添加后就可以了。要保證整個程序中我沒有贅述的變量都被初始化過了。然后編譯它:
gcc -o tutorial06 tutorial06.c -lavutil -lavformat -lavcodec -lswscale -lz -lm \
`sdl-config --cflags --libs`
然后你就可以運行它了。
下次我們要做的是讓你可以讓電影快退和快進。
Tutorial 07: Seeking
源代碼:tutorial07.c
處理seek命令
現在讓我們的播放器具有一些seek功能,因為如果你不能跳播一部電影是很讓人不舒服的。同時,你會了解到av_seek_frame函數是多么容易使用。
我們將在電影播放中使用左方向鍵和右方向鍵來表示向后和向前一小段,使用向上和向下鍵來表示向前和向后一大段。這里一小段是10秒,一大段是60秒。所以我們需要設置我們的主循環來捕捉鍵盤事件。然而當我們捕捉到鍵盤事件后我們不能直接調用av_seek_frame函數。我們要在循環解碼主線程decode_thread中做這些。所以,我們要添加一些變量到大結構體中,用來表示跳轉位置和一些跳轉標志:
int seek_req;int seek_flags;int64_t seek_pos;現在讓我們在主循環中捕捉按鍵:
for(;;) {double incr, pos;SDL_WaitEvent(&event);switch(event.type) {case SDL_KEYDOWN:switch(event.key.keysym.sym) {case SDLK_LEFT:incr = -10.0;goto do_seek;case SDLK_RIGHT:incr = 10.0;goto do_seek;case SDLK_UP:incr = 60.0;goto do_seek;case SDLK_DOWN:incr = -60.0;goto do_seek;do_seek:if(global_video_state) {pos = get_master_clock(global_video_state);pos += incr;stream_seek(global_video_state, (int64_t)(pos * AV_TIME_BASE), incr);}break;default:break;}break;為了檢測按鍵,我們先查了一下是否有SDL_KEYDOWN事件。然后我們使用event.key.keysym.sym來判斷哪個按鍵被按下。一旦我們知道了采用何種方式去跳轉,我們就來計算新的播放時間,方法為把增加的時間值加到從函數get_master_clock中得到的時間值上。然后我們調用 stream_seek函數來設置seek_pos等變量的值。我們把新的時間轉換成為avcodec中的內部時間戳單位。回想一下,流中的時間戳是以幀而不是秒來度量的,公式為seconds = frames * time_base(fps)。默認的avcodec值為1,000,000fps(所以2秒的內部時間戳為2,000,000)。在后面我們來看一下為什么要轉換這個值。
這就是我們的stream_seek函數。請注意如果我們倒退,我們會設置一個標志:
void stream_seek(VideoState *is, int64_t pos, int rel) {if(!is->seek_req) {is->seek_pos = pos;is->seek_flags = rel < 0 ? AVSEEK_FLAG_BACKWARD : 0;is->seek_req = 1;} }現在讓我們回到decode_thread中來實現跳轉。你會注意到我們已經在源文件做了注釋,叫做"seek stuff goes here",現在我們就在這里實現代碼。
跳轉功能是圍繞著av_seek_frame函數來實現的。這個函數用到了一個格式上下文,一個流,一個時間戳和一組標記來作為它的參數。這個函數將會跳轉到你所給的時間戳的位置。時間戳的單位是你傳遞給函數的流的time_base。然而,你并不是必需要傳給它一個流(流可以用-1來代替)。如果你這樣做了,time_base將會是avcodec中的內部時間戳單位,或者是1000000fps。這就是為什么我們在設置seek_pos的時候會把位置乘以AV_TIME_BASER的原因。
但是,如果給av_seek_frame函數的stream參數值為-1,你有時會在播放某些文件的時候遇到問題(比較少見),所以我們會取文件中的第一個流并且把它傳遞到av_seek_frame函數。不要忘記我們也要把時間戳timestamp的單位進行轉化。
if(is->seek_req) {int stream_index= -1;int64_t seek_target = is->seek_pos;if (is->videoStream >= 0) stream_index = is->videoStream;else if(is->audioStream >= 0) stream_index = is->audioStream;if(stream_index>=0){seek_target= av_rescale_q(seek_target, AV_TIME_BASE_Q,pFormatCtx->streams[stream_index]->time_base);}if(av_seek_frame(is->pFormatCtx, stream_index, seek_target, is->seek_flags) < 0) {fprintf(stderr, "%s: error while seeking\n",is->pFormatCtx->filename);} else {/* handle packet queues... more later... */這里av_rescale_q(a,b,c)函數是用來把時間戳從一個時基調整到另外一個時基。它基本上是計算a*b/c,但是這個函數還是必需的,因為直接計算會有溢出的情況發生。 AV_TIME_BASE_Q是AV_TIME_BASE的小數版本。 它們完全不同:AV_TIME_BASE * time_in_seconds = avcodec_timestamp,而AV_TIME_BASE_Q * avcodec_timestamp = time_in_seconds(但請注意,AV_TIME_BASE_Q實際上是一個AVRational對象,因此您必須在avcodec中使用特定的q函數來處理它)。
刷新緩沖區
我們已經正確設定了跳轉位置,但是我們還沒有完成。記住我們有一個存放了很多包的隊列。既然我們跳到了不同的位置,我們必需把隊列中的內容刷新否則電影是不會跳轉的。不僅如此,avcodec也有它自己的內部緩沖,也需要每次被刷新。
要實現這個,我們需要首先寫一個函數來清空我們的包隊列。然后我們需要一種命令去告訴音頻和視頻線程需要去刷新avcodec內部緩沖。我們可以在刷新隊列后把特定的包放入到隊列中,然后當它們檢測到特定的包的時候,它們就會去刷新自己的內部緩沖區。
讓我們開始寫刷新函數。其實很簡單的,所以直接看代碼:
static void packet_queue_flush(PacketQueue *q) {AVPacketList *pkt, *pkt1;SDL_LockMutex(q->mutex);for(pkt = q->first_pkt; pkt != NULL; pkt = pkt1) {pkt1 = pkt->next;av_free_packet(&pkt->pkt);av_freep(&pkt);}q->last_pkt = NULL;q->first_pkt = NULL;q->nb_packets = 0;q->size = 0;SDL_UnlockMutex(q->mutex); }現在已經刷新了隊列,我們放入“刷新包”。但是開始我們要定義和創建這個包:
AVPacket flush_pkt;main() {...av_init_packet(&flush_pkt);flush_pkt.data = "FLUSH";... }現在我們把這個包加入隊列:
} else {if(is->audioStream >= 0) {packet_queue_flush(&is->audioq);packet_queue_put(&is->audioq, &flush_pkt);}if(is->videoStream >= 0) {packet_queue_flush(&is->videoq);packet_queue_put(&is->videoq, &flush_pkt);}}is->seek_req = 0; }(這個代碼片段是接著前面decode_thread中的代碼片段的)我們也需要修改packet_queue_put函數,以便特定的“刷新包”能夠加入其中:
int packet_queue_put(PacketQueue *q, AVPacket *pkt) {AVPacketList *pkt1;if(pkt != &flush_pkt && av_dup_packet(pkt) < 0) {return -1;}然后在音頻線程和視頻線程中,我們在packet_queue_get后立即調用函數avcodec_flush_buffers:
if(packet_queue_get(&is->audioq, pkt, 1) < 0) {return -1;}if(pkt->data == flush_pkt.data) {avcodec_flush_buffers(is->audio_st->codec);continue;}上面的代碼片段與視頻線程中的一樣,只要把“audio”換成“video”。
至此,讓我們編譯我們的播放器:
gcc -o tutorial07 tutorial07.c -lavutil -lavformat -lavcodec -lswscale -lz -lm \
`sdl-config --cflags --libs`
來欣賞一下不到1000行C代碼制作的電影播放器! 當然,我們還有很多東西可以添加。
結語
我們已經有了一個可以工作的播放器,但是它肯定還不夠好。我們做了很多,但是我們還可以添加許多其他功能:
事實上,這個播放器還是很糟糕的。它所基于的ffplay.c版本已經完全過時,因此本教程需要進行重大變革。如果你想在比較正式的項目中使用FFmpeg庫,我懇請你查看最新版本的ffplay.c。
- 錯誤處理:我們代碼中的錯誤處理非常糟糕,可以更好地處理。
- 暫停:我們不能暫停電影,這是一個很有用的功能。我們可以在大結構體中使用一個內部暫停變量,當用戶暫停的時候就設置它。然后我們的音頻,視頻和解碼線程檢測到它后就不再輸出任何東西。我們也使用av_read_play來支持網絡。這很容易解釋,但是你卻不能很容易弄明白,所以把這個作為一個家庭作業,如果你想嘗試的話。提示,可以參考ffplay.c。
- 支持硬解碼
- 按字節跳轉:如果你可以按照字節而不是秒的方式來計算出跳轉位置,那么對于像VOB這種有不連續時間戳的視頻文件來說,定位會更加精確。
- 幀丟棄:如果視頻落后的太多,我們應當把下一幀丟棄而不是設置一個短的刷新時間。
- 網絡支持:此播放器無法播放網絡流媒體視頻。
- 支持像YUV原始視頻數據:如果我們的播放器支持像YUV原始視頻數據,我們必須設置一些選項,因為我們無法猜測大小或time_base。
- 全屏
- 多種選擇:例如:不同圖像格式;參考ffplay.c中的命令開關。
如果你想了解更多關于FFmpeg的信息,我們只討論了它的一部分。下一步將是研究如何編碼多媒體。一個好的起點是FFmpeg發行版中的output_example.c文件。我可能會寫另一個教程,但我可能無法完成。
UPDATE我已經很久沒有更新這個教程了,音視頻領域已經變得更加成熟。本教程僅需要簡單的API更新;在基本概念方面,實際上沒有什么變化。大多數更新實際上是簡化代碼。然而,雖然我已經完成并更新了這里的代碼,但ffplay仍然完全勝過這個toy player。讓我們坦率地說,它無法作為一個真正的電影播放器??使用。因此,如果您或未來的您希望改進本教程,請研究ffplay并找出我們遺漏的內容。我猜測它主要利用硬件,但也許我錯過了一些明顯的東西。 ffplay可能會徹底改變一些事情;但我還沒看。
但我很自豪,多年來它仍然幫助了很多人,即使你不得不去其他地方獲取代碼。 我非常感謝chelyaev,他替換了8年前寫這篇文章以來所有被棄用的函數。
好吧,我希望這個教程很有啟發性和樂趣。 如果您對本教程有任何建議,bugs,投訴,贊譽等,請發送電子郵件至 dranger at gmail dot com。 請不要在你其他FFmpeg項目中向我求助。 我收到太多這些電子郵件。
參考
- 如何用FFmpeg編寫一個簡單播放器詳細步驟介紹
- FFMPEG視音頻編解碼零基礎學習方法
總結
以上是生活随笔為你收集整理的FFmpeg+SDL,如何用少于1000行代码编写视频播放器的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: mac pro office 2016版
- 下一篇: Ubuntu 无损图片压缩