公安部制定的GBT 28181標準廣泛應用於安防領域,這個標準規定了傳輸的視音頻數據要封裝成PS流格式。PS格式(原名叫MPEG-PS)在很多領域已經應用了很長一段時間,特別是在安防、廣播電視、影音製作等領域,我們熟知的DVD格式(vob)就是用PS封裝的。這篇文章我打算給大家講解怎麼實現一個PS流的實時流播放器,通過這篇文章學習,大家就知道一個實時流播放器應該如何設計、如何對PS流做處理等。
一、基本概念
1)ES
ES–Elementary Streams 是直接從編碼器出來的數據流,可以是編碼過的視頻數據流(H.264,MJPEG等),音頻數據流,或其他編碼數據流的統稱。ES流經過PES打包器之後,被轉換成PES包。
ES是隻包含一種內容的數據流,如只含視頻或只含音頻等,打包之後的PES也是隻含一種性質的ES,如只含視頻ES的PES,只含音頻ES的PES等。
2)PES
PES–Packetized Elementary Streams (分組的ES),ES形成的分組稱爲PES分組,是用來傳遞ES的一種數據結構。PES流是ES流經過PES打包器處理後形成的數據流,在這個過程中完成了將ES流分組、打包、加入包頭信息等操作(對ES流的第一次打包)。PES流的基本單位是PES包。PES包由包頭和payload組成。
3)PS
PS–Program Stream(節目流)PS流由PS包組成,而一個PS包又由若干個PES包組成(到這裏,ES經過了兩層的封裝)。PS包的包頭中包含了同步信息與時鐘恢復信息。
4)PTS、DTS
PTS–PresentationTime Stamp(顯示時間標記)表示顯示單元出現在系統目標解碼器(H.264、MPEG4等)的時間。
DTS–Decoding Time Stamp(解碼時間標記)表示將存取單元全部字節從解碼緩存器移走的時間。
PTS/DTS是打在PES包的包頭裏面的,這兩個參數是解決音視頻同步顯示,防止解碼器輸入緩存上溢或下溢的關鍵。每一個I(關鍵幀)、P(預測幀)、B(雙向預測 幀)幀的包頭都有一個PTS和DTS,但PTS與DTS對於B幀不一樣。
PTS/DTS是相對SCR(系統參考)的時間戳,系統時鐘頻率爲90Khz,是以90000爲單位的,PTS/DTS到ms的轉換公式是PTS/90,轉換到秒爲PTS/90000。如果沒有B幀,PTS和DTS的順序應該是一致的,如果有B幀,則需要先解碼P幀,才能解出來B幀,所以需要PTS和DTS來控制解碼時間和顯示時間。
根據對前面概念的理解,我總結出以下幾點:
1. PS流是一種複合流,可以包含視頻流和音頻流數據,也可以只包含一種流(視頻、音頻)的數據;
2. PES流是對原始ES流進行的第一層封裝,PES流的基本單位是PES包,由包頭和payload組成。
3. ES流即音視頻裸流,是從編碼器裏面出來的原始視頻音頻流,ES流只包含一種內容,裏面是視頻或者音頻;
4. ES首先需打包成PES包,然後PES加上PS包頭,變成了標準的PS流進行存儲或傳輸;
5. PES幀是變長的,每個幀的長度可能不一樣;
6. 一般情況下是一幀數據放在一個PES包裏面,但是一個PES包的最大長度爲65535字節,因此一幀數據有可能被分爲多個PES;
7 一個PS包包含若干個PES幀,是由PS頭和一個或多個PES幀所組成。
8. PS流解碼時根據PS包裏面的DTS和PTS時間戳確定幀的解碼順序和播放的時間。
9. 解封裝PS流是一個封裝的逆過程,需要先從原始的PS包裏面去掉PS頭,分解出PES包,然後去掉PES包頭,得到ES裸流。
二、PS流碼流結構
I Frame PS_Header | PS_Map | PES |.......|PES
P Frame PS_Header | PES | .......|PES
Audio Frame PS_Header | (PS_Map) | PES (音頻一般封裝在一個PES裏面即可)
更詳細的結構圖如下:
這裏要特別注意上面的PS System Map,簡稱PSM。關於PSM的介紹:
(以下這段內容摘錄自:博客「SunkingYang」的文章《H264解碼之PS流解析》,原文鏈接:https://blog.csdn.net/y601500359/article/details/97649112)
————————————————
PSM介紹
PSM提供了對PS流中的原始流和他們之間的相互關係的描述信息;PSM是作爲一個PES分組出現,當stream_id == 0xBC時,說明此PES包是一個PSM;PSM是緊跟在系統頭部後面的;PSM是作爲PS包的payload存在的;
PSM由很多字段組成,其字節順序如下所示:
其中,最關鍵的是這兩個字段:
stream_type字段:類型字段,佔位8bit;表示原始流ES的類型;這個類型只能標誌包含在PES包中的ES流類型;值0x05是被禁止的;常見取值類型有:MPEG-4 視頻流:0x10;H.264 視頻流:0x1B;G.711 音頻流:0x90;因爲PSM只有在關鍵幀打包的時候,纔會存在,所以如果要判斷PS打包的流編碼類型,就根據這個字段來判斷;
elementary_stream_id字段:流ID字段,佔位8bit;表示此ES流所在PES分組包頭中的stream_id字段的值;其中0x(C0~DF)指音頻,0x(E0~EF)爲視頻;
PSM只有在關鍵幀打包的時候,纔會存在;IDR包含了SPS,PPS和I幀;每個IDR NALU前一般都會包含SPS、PPS等NALU,因此將SPS、PPS、IDR的NALU 封裝爲一個PS 包,包括PS頭,PS system header,PSM,PES;所以一個IDR NALU PS 包由外到內順序是:PS header| PS system header | PSM| PES。對於其它非關鍵幀的PS包,就簡單多了,直接加上PS頭和PES 頭就可以了。順序爲:PS header | PES header | h264raw data。以上是對只有視頻video 的情況,如果要把音頻Audio也打包進PS 封裝,只需將數據加上PES header 放到視頻PES 後就可以了。順序如下:PS 包=PS頭|PES(video)|PES(audio);
————————————————
簡而言之,播放器需要拿到PSM表的信息,從裏面提取出各個Stream的elementary_stream_id,stream_type,這樣就知道了哪個流對應哪種編碼格式。
三、播放器的功能
1. 播放本地PS文件(這個功能不是這個例子的重點,但是爲了便於測試,也加進來了,其實文件播放和實時流播放有些流程是一樣的)。
2. 支持從網絡接收MPEG-PS流,用UDP方式接收數據,支持接收UDP裸流或帶RTP頭的MPEG-PS流。
3. 對網絡收到的PS流進行保存。
4. 支持從內存中讀取MPEG-PS流,支持對流進行解封裝(PS-》PES,PES-》ES),最終將流轉化成ES流格式。
5. 將ES流送給FFmpeg解碼,顯示視頻。
6. 能夠獲得PS流中視音頻軌的相關信息(視頻編碼格式、視頻寬高、音頻編碼格式)。
三、播放器設計
播放器的界面如下圖:
整個播放器的處理流程圖如下:
按處理流程可分爲幾個步驟:UDP接收數據、PS拆包(PS->PES, PES->ES,最終分離出Video ES和Audio ES)、解碼視頻、解碼音頻。爲了提高併發效率,我採用了多線程機制,其中接收和PS拆包位於一個線程,而視頻解碼用另外一條線程處理,之間有個隊列將接收線程分離出來的ES幀Push到隊列裏作臨時存儲;而解碼線程則從隊列裏拿數據(Pop Frame),拿到一幀就解一幀。上述的過程如下圖所示:
注意:我的例子中並沒有實現音頻隊列,也沒有對音頻包做處理。
接收模塊支持UDP(單播、組播),支持數據帶RTP頭和無RTP頭這兩種情況;PS流的解封裝和分離放到一個動態庫裏實現,供外部調用;解碼是用FFmpeg;顯示圖像用到了GDI。
四、開發詳解
因爲PS流解封裝和分離是實現在一個動態庫(DLL)裏,我們先熟悉一下這個DLL的接口:
#include "SDKDef.h"
//初始化SDK
PLAYPT_API BOOL PT_InitSDK();
//註銷SDK
PLAYPT_API BOOL PT_UnitSDK();
//獲取SDK版本號
PLAYPT_API LONG PT_GetSDKVersion();
//設置緩衝的條件,根據幀數或字節數
//下面兩個條件只能一個生效,如果nBufferFrames非0則根據幀數緩衝;如果nBufferFrames爲0,BufferBytes不爲0,則根據字節數緩衝。
//參數:
//nBufferFrames -- 緩衝要達到的幀數
//BufferBytes -- 緩衝要達到的字節數
PLAYPT_API void PT_SetBufferStreamParams(UINT nBufferFrames, UINT BufferBytes); //設置緩衝參數
//打開PS/TS格式的流,支持從文件或內存讀取流
//參數:
//srcType -- 流來自於文件或內存,_FILE_SOURCE--來自於文件,_MEM_SOURCE---來自於內存
// lpszFilePath -- 文件路徑,如果是內存流,傳NULL
// nFileType --流類型(1--PS, 2--TS)
// bParseESStream -- 是否解析視音頻流,提取信息。如果視頻流格式不是MPEG2/MPEG4/H264這幾種之一,則將該參數設爲FALSE
// handle -- 返回的這個句柄來調用其他函數;
// dwError -- 失敗時返回的錯誤碼;
//
PLAYPT_API BOOL PT_OpenFile(SOURCE_TYPE srcType, LPCTSTR lpszFilePath, int nFileType, BOOL bParseESStream, int & handle, DWORD & dwError);
PLAYPT_API BOOL PT_CloseFile(int handle); //關閉文件或內存流
PLAYPT_API BOOL PT_Pause(int handle); //暫停播放
PLAYPT_API BOOL PT_Play(int handle); //開始播放
PLAYPT_API BOOL PT_IsRunning(int handle); //是否正在播放
PLAYPT_API BOOL PT_AddStreamData(int handle, BYTE * pData, int nBytes ); //插入PS/TS流
PLAYPT_API BOOL PT_SetFrameCallback(int handle, EsFrameCallback lpFrameCB); //設置數據回調(回調分離出來的ES流數據)
//獲取視頻流的信息。說明:當PT_OpenFile函數傳入參數bParseESStream爲True時才能調用該函數返回視頻流的信息
PLAYPT_API BOOL PT_GetVideoInfo(int handle, VideoEncodeFormat & videoformat, int& nWidth, int& nHeight, int & nTrackNum);
//獲取音頻流的信息。說明:當PT_OpenFile函數傳入參數bParseESStream爲True時才能調用該函數返回音頻流的信息
PLAYPT_API BOOL PT_GetAudioInfo(int handle, AudioEncodeFormat & nType, int & nTrackNum);
PLAYPT_API BOOL PT_GetFileDuration(int handle, __int64 & llDuration); //獲取文件播放時長(只對文件有效)
PLAYPT_API BOOL PT_Seek(int handle, __int64 llPos); //跳到某個播放時間點(只對文件有效)
PLAYPT_API BOOL PT_GetPlayPos(int handle, __int64 & llPos); //獲取當前播放時間點
//以下針對TS流
PLAYPT_API BOOL PT_IsPMTFound(int handle); //流中是否找到PMT表(Program Map Table)信息
PLAYPT_API BOOL PT_GetTSProgramSize(int handle, int & nProgramSize); //獲取TS流中的節目個數
PLAYPT_API BOOL PT_GetTSProgramInfo(int handle, int nProgramNum, int& nStreamNum, ESStreamInfo streams[4]); //獲取節目流信息
//以下針對PS流
PLAYPT_API BOOL PT_GetPSStreamNum(int handle, int & nStreamNum); //獲取流的數目(視頻流+音頻流)
PLAYPT_API BOOL PT_GetPSStreamInfo(int handle, int& nStreamNum, ESStreamInfo streams[4]); //獲取每個流的信息(PES ID和StreamType)
將PS包的解封包、分離等處理放到一個DLL中實現是爲了隱藏其內部複雜性,方便外部調用者使用,並且封裝成一個模塊也便於以後重用。
由於接口裏用到了某些自定義類型,所以還需要包含頭文件:SDKDef.h,SDKDef.h文件的內容如下:
typedef int (WINAPI * EsFrameCallback)(int handle, BYTE * pBuf, int nBufSize, int nTrackNum, int nStreamType, __int64 llPts, int nFrameType);
enum SOURCE_TYPE
{
_FILE_SOURCE, //文件流
_MEM_SOURCE, //內存流
};
//視頻編碼類型
enum VideoEncodeFormat
{
_VIDEO_NONE = 0,
_VIDEO_MPEG1 = 1,
_VIDEO_MPEG2,
_VIDEO_MPEG4,
_VIDEO_H264,
//_VIDEO_H265,
};
//音頻編碼類型
enum AudioEncodeFormat
{
AUDIO_DEFAULT = 20,
AUDIO_PCMA,
AUDIO_PCMU,
AUDIO_MP3,
AUDIO_MP2,
AUDIO_AAC,
AUDIO_UNSUPPORT
};
typedef struct _ESStreamInfo_
{
BYTE streamType;
WORD pid;
}ESStreamInfo;
其實,我們看了SDK的接口大概能知道每個函數的作用,並且結合註釋說明,基本上已經清楚函數怎麼使用。但是大概的調用流程還有一些函數的使用注意細節我還是需要跟大家講一下。
首先,我們需要調用PT_InitSDK初始化SDK,接着調用PT_SetBufferStreamParams函數設置緩衝區參數(這一步驟可選),然後,調用PT_OpenFile打開一個文件或流。PT_OpenFile函數既支持從文件讀取也支持從內存讀取。如果是從內存讀取,則需要調用另外一個接口:PT_AddStreamData,這個函數不停地向緩衝區中插入數據,保證SDK裏面的讀線程有數據可讀。PT_OpenFile會返回一個句柄,表示文件或這個流的唯一實例ID,通過該句柄來調用該實例的其他接口函數。PT_OpenFile傳入的參數中有個參數:bParseESStream,這是一個很重要的參數,該參數會影響到內部對視音頻流的解析。如果要獲得音視頻流的信息(音視頻編碼格式、視頻分辨率等),則將該參數設置成True,但是打開該參數會增加函數的處理工作量,增加PT_OpenFile函數調用的時間,關於更多這個參數的說明和使用注意事項,後面還會提到。再下一步,要設置回調函數接口接收分離出來的ES數據,設置回調的接口是:PT_SetFrameCallback(int handle, EsFrameCallback lpFrameCB); 其中回調函數EsFrameCallback 的原型是:
typedef int (WINAPI * EsFrameCallback)(int handle, BYTE * pBuf, int nBufSize, int nTrackNum, int nStreamType, __int64 llPts, int nFrameType);
這個回調函數會傳遞分離出來的ES幀(視頻、音頻),各個參數意義:
// int handle -- SDK的播放句柄;
// BYTE * pBuf -- ES幀的數據起始地址;
// int nBufSize -- 數據的大小;
// int nTrackNum--流ID號,區別不同流的唯一標識符;
// int nStreamType -- 流類型, 0x1b -- H264; 0x02 --MPEG2, 0x10 --MPEG4
// __int64 llPts -- 時間戳,以10000000L爲單位;
// int nFrameType -- 幀的類型。nFrameType = 0, 未能獲取類型; nFrameType = 1, I幀;nFrameType = 2, P幀; nFrameType = 3, B幀
上面的參數:流ID--nTrackNum有什麼用呢?前文說到:PS流的每個流有elementary_stream_id和stream_type,這個elementary_stream_id就是我們這裏的nTrackNum,在PS流的格式定義中,視頻流的ID和音頻流的ID有個範圍(看前文PSM介紹一節)。一般的情況,視頻流TrackNum是0xE0,音頻流是0xC0。通過TrackNum我們就能知道回調的ES幀是視頻流還是音頻流,更具體些通過SreamType可以判斷出流的類型和流的編碼格式,每個類型值對應一種編碼格式,比如視頻:0x1b -- H264; 0x02 --MPEG2, 0x10 --MPEG4。但是,這個SDK目前還沒有實現獲取流的StreamType,回調返回的StreamType爲0的,這樣怎麼獲得視音頻流的格式呢?那就要通過另外的接口:PT_GetVideoInfo/PT_GetAudioInfo。
打開一個文件或流後,我們可以調用PT_GetVideoInfo、PT_GetAudioInfo分別獲得視頻流和音頻流的格式信息,讓我們看看這兩個函數的原型:
//獲取視頻流的信息。
PLAYPT_API BOOL PT_GetVideoInfo(int handle, VideoEncodeFormat & videoformat, int& nWidth, int& nHeight, int & nTrackNum);
//獲取音頻流的信息。
PLAYPT_API BOOL PT_GetAudioInfo(int handle, AudioEncodeFormat & nType, int & nTrackNum);
對於視頻流,可返回視頻的編碼格式、分辨率、視頻軌道ID信息;對於音頻,可返回音頻的編碼格式,音頻軌道ID信息。
但是目前這個SDK只能解析少數幾種視音頻格式,支持的格式可以看SDKDef.h文件裏枚舉類型VideoEncodeFormat和AudioEncodeFormat的定義,其中視頻只支持MPEG1/MPEG2/MPEG4/H264。如果是別的格式怎麼辦呢?比如PS流裏封裝的視頻流是H265或SVC編碼,那SDK能解析嗎?因爲PS容器裏能包含的視音頻格式有很多種,我不可能對每一種都支持,那工作量是非常大的,但是因爲SDK主要做的工作是PS解包以及分離出視音頻的ES幀,本來跟流的編碼格式無關,所以我設計SDK的時候是允許容器中的流是任何類型的編碼格式。爲了能支持這一點,我在SDK內部不會對每一種格式的流都會進行解析,只對MPEG1/MPEG2/MPEG4/H264格式進行解析和提取信息。在SDK接口上,提供一個參數:bParseESStream,這個參數就是前面的打開流接口:PT_OpenFile的第4個參數,這個參數讓用戶設置是否讓SDK解析流的格式,如果是MPEG1/MPEG2/MPEG4/H264格式,建議將該變量設爲True,如果是別的格式,就設成False。對於非SDK內部支持的格式,用戶需要知道他們接收的PS數據中各個ES流是哪一種編碼格式,並在應用程序中實現對這種格式的信息提取和解碼處理。
自此,我們已經說了SDK的幾個接口的使用方法,從調用PT_OpenFile函數,到設置回調,再到調用獲取視音頻格式的接口:PT_GetVideoInfo/PT_GetAudioInfo。這裏還要補充幾點。
1. 調用PT_OpenFile函數前必須先調用PT_AddStreamData向SDK插入數據,因爲SDK內部實現了一個緩衝區(默認是2M字節大小),在打開流之前需要從緩衝區預讀一段數據,根據讀到的數據初始化內部一些變量,並獲取PS流的格式信息,以及每個流的編碼格式信息(如果bParseESStream參數爲True)。如果填充的數據不夠,則PT_OpenFile函數會返回False,表示打開流失敗。我們可設置緩衝區要緩存多少數據才結束,可通過調用PT_SetBufferStreamParams接口來設置,其中第一個參數是緩衝的幀數,第二個參數是要緩衝的字節數。注意這兩個變量只能同時有一個生效,優先是按幀數,其次是按字節數(建議按幀數緩衝,因爲不會受碼率大小影響)。比如我設置了緩衝5幀,則緩衝區至少要收到5個視頻的PES幀才初始化成功,並返回。默認情況,PT_OpenFile函數會等到緩衝4個視頻PES幀才返回。
2. 如果PT_OpenFile函數返回失敗,則可能沒有緩衝夠足夠的數據,或超時,或解析視音頻格式的信息出錯了(當bParseESStream = True)。這個函數有個等待時間,如果超過5秒還沒有達到緩衝的條件,則退出並返回失敗;如果流有損壞或格式不正確,也會導致PT_OpenFile函數返回失敗。
3. 調用PT_OpenFile函數只是預讀數據,並沒有開始運行和輸出ES幀,所以ES回調函數還沒有執行。
要從PS流裏分離出ES幀,我們必須調用開始運行任務的接口:PT_Play接口,這個接口調用之後,ES回調函數(就是前面設置的EsFrameCallback回調)就會被觸發,開發者在應用層可獲取到分離出來的ES數據。
之後,我們不停地調用PT_AddStreamData向SDK寫數據,這樣SDK就會讀數據,然後拆包、解封裝,調用回調函數嚮應用層傳遞ES幀數據。這裏有個問題:就是寫數據和讀數據如何同步的?因爲數據是從UDP接收線程那裏先獲得,如果發送端發送流的速度很快,那麼就會以很快的速度向SDK寫數據,如果讀線程處理有延時或讀得慢,那麼就會造成緩衝區很快滿。我設計的緩衝區是一個環形的可循環讀寫的內存塊,目前緩衝區大小是2M,讀寫指針應保持一個安全距離,如果寫指針和讀指針距離很接近或前者超越後者,則表示緩衝區滿了,並存在數據丟失的情況。這是做實時流播放器經常遇到的一個問題。因爲我們不知道發送端以多快速度將數據發送過來,接收線程是一收到數據就向SDK寫數據的,所以SDK最好的處理策略是:也以最快的速度去讀數據,即一收到數據就馬上解析,儘量保證處理低延時。SDK要實現內部操作儘量不阻塞不難,但是因爲分離出ES,SDK還要調回調函數傳數據給應用層,上層應用在回調函數裏可能做了一些延時大的操作,比如解碼、顯示圖像等。所以,要保證不阻塞SDK的內部讀線程,我們還需要應用層的配合,要求應用層在回調函數裏儘快返回,將延時大的操作放在別的地方。這時候,大家自然會想到多線程處理,還有緩衝隊列的解決方案。沒錯,本人也是按照這個方案來解決的。這就是我前面的播放器設計一節裏的流程圖所表達的思路:數據接收、PS拆包處理、輸出ES幀放在一個線程,而解碼ES幀和顯示放在另外一個線程。其實,確切的說,接收線程跟PS包的讀數據是分開兩個不同線程的,前者是在應用程序在創建UDP Socket時創建,後者是調用SDK接口PT_OpenFile由SDK創建,兩者通過緩衝區交換數據。而現在我們需要第2個緩衝區,就是SDK解包之後分離出來ES幀放到一個緩衝隊列裏(就是流程圖中的視頻包隊列),在SDK輸出ES幀給應用層的時候(在回調函數)把數據扔到這個隊列,一旦Push完數據就繼續其他處理,這樣SDK的讀線程在整個處理流程中就不會有阻塞,保證了較快的處理速度。因爲緩衝隊列是動態增長的,不像固定長度的緩衝區,沒有長度限制,如果插入數據突然很快(網絡抖動),頂多表現爲緩衝隊列越來越長,內存佔用升高,而後面如果插入數據速度恢復正常,緩衝隊列累積的幀也會很快被消耗掉,最終恢復平衡。
好了,PS流SDK部分就講解完了。我下面說說應用層的一些處理工作。
在應用層的主窗口中,我們定義以下幾個對象:
int m_PlaySDKHandle; //SDK句柄
HANDLE m_hDecodeThread; //解碼線程句柄
CDecodeVideo m_VideoWindow; //解碼視頻
CImagePainter m_wndPainter; //顯示圖像
CStreamSocket m_StreamSocket; //UDP接收數據
其中, m_PlaySDKHandle就是前面說的SDK實例句柄。接收UDP數據由CStreamSocket類型的對象m_StreamSocket負責處理,而解碼視頻、顯示圖像分別由m_VideoWindow、m_wndPainter負責處理。
下面按照應用程序的執行流程,講解一下幾個重要的步驟:
1. 創建UDP接收線程、打開一個流
//從內存中讀取流
int nFileFormat = _PS; //在這裏修改格式
UINT nRecvPort = 1234; //本地接收端口
//創建UDP Socket從網絡接收數據
m_StreamSocket.SetController(this);
m_StreamSocket.SetUDPLocalPort(nRecvPort); //該端口必須未必佔用
m_StreamSocket.SetRecvRTP(FALSE); //是否包含RTP包頭
m_StreamSocket.StartReceiving(); //開始接收
//PT_SetBufferStreamParams(10, 0); //設置緩衝區參數
//打開流(從內存讀數據)
if(!PT_OpenFile(_MEM_SOURCE, NULL, nFileFormat, TRUE, m_PlaySDKHandle, dwError))
{
ASSERT(0);
OutputDebugString("PT_OpenFile failed \n");
return 1;
}
2. 獲取每個流的格式以及初始化解碼器
int nPrograms = 0;
int nStreamNum = 0;
ESStreamInfo streamInfo[4];
int v_stream_type = 0;
int v_track_id = 0;
VideoEncodeFormat vEncodeFmt = _VIDEO_NONE;
BOOL bRet;
int cx, cy;
//獲得視頻的編碼格式、圖像寬、高、軌道ID信息
//PT_GetVideoInfo正常情況下會返回TRUE,但如果上一步調用PT_OpenFile函數傳入的第4個參數(bParseESStream)爲False,則PT_GetVideoInfo函數(和PT_GetAudioInfo函數)始終返回FALSE。這時候,要用另外一種方法獲取視頻/音頻流的信息
if(!PT_GetVideoInfo(m_PlaySDKHandle, vEncodeFmt, cx, cy, v_track_id))
{
if(nFileFormat == _PS)
{
//獲取流的數目和每個流的StreamType
//!!!注意:SDK暫時還不能獲得PS流的StreamType,不能通過此判斷編碼格式,但是能獲得流的PES ID
//bRet = PT_GetPSStreamNum(m_PlaySDKHandle, nStreamNum); //獲取TS流中的節目個數
bRet = PT_GetPSStreamInfo(m_PlaySDKHandle, nStreamNum, streamInfo); //獲取指定節目的個數
TRACE("nStreamNum: %d \n", nStreamNum);
for(int i=0; i<nStreamNum; i++)
{
TRACE("pid = %d, streamtype = %x \n", streamInfo[i].pid, streamInfo[i].streamType);
//switch(streamInfo[i].streamType)
//{
//case 0x10: //MPEG-4
// v_stream_type = streamInfo[i].streamType;
// vEncodeFmt = _VIDEO_MPEG4;
// break;
//case 0x02: //MPEG-2
// v_stream_type = streamInfo[i].streamType;
// vEncodeFmt = _VIDEO_MPEG2;
// break;
//case 0x1b: //H264
// v_stream_type = streamInfo[i].streamType;
// vEncodeFmt = _VIDEO_H264;
// break;
//default: //其他
// break;
//}
if(streamInfo[i].pid >= 0xe0 && streamInfo[i].pid < 0xf0) //視頻的PES ID一般落在這個範圍內
{
v_track_id = streamInfo[i].pid;
vEncodeFmt = _VIDEO_H264; //固定格式,暫時沒有別的辦法!看前面注意:
}
}//for
}
}
m_nVStreamID = v_track_id;
if(vEncodeFmt != _VIDEO_NONE)
{
m_VideoFmt = vEncodeFmt;
m_VideoWindow.StartDecode(vEncodeFmt);
}
else
{
OutputDebugString("未知解碼格式 \n");
ASSERT(0);
}
3. 設置數據回調、開始運行SDK任務、創建解碼線程。
PT_SetFrameCallback(m_PlaySDKHandle, SDKEsFrameCallback);
PT_Play(m_PlaySDKHandle);
#if 1
m_decoding_thread_run = true;
DWORD threadID = 0;
m_hDecodeThread = CreateThread(NULL, 0, decoding_thread, this, 0, &threadID);
#endif
下面我們看看CStreamSocket類是如何從網絡接收數據的(Socket創建和初始化的過程就省略掉了)。
CHAR * buff = new CHAR[SAMPLE_SIZE];
ASSERT(buff != NULL);
memset(buff, 0, SAMPLE_SIZE);
int buffsize = 0;
const int minsize = 4*1024; //如果收到的數據長度小於這個值,則繼續接收,直到收到的數據長度累加到超過該值時才向上層傳遞數據(回調),這樣可以保證上層對包的處理不會過於頻繁
DWORD tTimeLast = GetTickCount();
DWORD dwTotal = 0;
int len = 0;
char rtpPacket[16*1024] = {0};
int fromlen = sizeof(addr);
HANDLE m_fp = NULL; //寫文件句柄,保存收到的流到文件(Debug模式下用)
TCHAR szDumpFile[256] = {0};
int nFIndex = 0;
DWORD dwStartTick = GetTickCount();
#ifdef _DEBUG
SYSTEMTIME systime;
::GetLocalTime(&systime);
_stprintf(szDumpFile, _T("D:\\%04d-%02d-%02d-%02d%02d%02d(%d).ts"),
systime.wYear, systime.wMonth, systime.wDay, systime.wHour, systime.wMinute, systime.wSecond, nFIndex++);
#endif
BOOL bHasData = FALSE;
RTP_FIXED_HEADER * rtp_hdr = NULL;
fd_set sel_old,sel_use;
struct timeval tv_rcv = {0};
FD_ZERO( &sel_old);
FD_SET( m_Socket, &sel_old);
while(!m_bExit)
{
sel_use = sel_old;
tv_rcv.tv_sec = 0;
tv_rcv.tv_usec = 10000;
int sel_count = select( 0, &sel_use, NULL, NULL, &tv_rcv);
if( sel_count <= 0 )
{
Sleep(1);
continue;
}
if( !FD_ISSET(m_Socket, &sel_use))
{
continue;
}
if(m_bRTP)
{
len = recvfrom(m_Socket, rtpPacket, 8192, 0,
(SOCKADDR*)&addr, &fromlen);
}
else
{
len = recvfrom(m_Socket, &buff[buffsize], 8192, 0,
(SOCKADDR*)&addr, &fromlen);
}
if(len <= 0)
{
Sleep(2);
TRACE(TEXT("recvfrom error: %d \n"), ::WSAGetLastError());
if(bHasData)
{
TRACE(_T("接收超時或斷開連接!\n"));
}
break;
}
if(m_bRTP)
{
rtp_hdr = (RTP_FIXED_HEADER*)&rtpPacket[0];
//TRACE("版本號 : %d\n", rtp_hdr->version);
//TRACE("結束標誌位 : %d\n", rtp_hdr->marker);
//TRACE("負載類型:%d\n", rtp_hdr->payload);
//TRACE("包號 : %d \n", htons(rtp_hdr->seq_no));
//TRACE("時間戳 : %d\n", htonl(rtp_hdr->timestamp));
//TRACE("同步標識符 : %d\n", htonl(rtp_hdr->ssrc));
if(len > RTP_HEADER_LEN)
{
memcpy(buff + buffsize, rtpPacket+ RTP_HEADER_LEN, len - RTP_HEADER_LEN); //去掉RTP包頭
buffsize += len - RTP_HEADER_LEN;
}
}
else
{
buffsize += len;
}
if(buffsize >= minsize) //收到的數據長度大於minsize則向下傳遞
{
bHasData = TRUE;
#ifdef _DEBUG
if(m_bRecordFile)
{
if(m_fp == NULL)
{
m_fp = CreateFile(szDumpFile, GENERIC_WRITE, 0, /*&saAttr*/NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
TRACE(_T("錄製開始:%s \n"), szDumpFile);
}
if(m_fp != INVALID_HANDLE_VALUE)
{
DWORD dw;
::WriteFile(m_fp, buff, buffsize, &dw, NULL);
}
}
#endif
m_pDownStreamController->InputStreamData((BYTE*)buff, buffsize); //向下級組件傳數據
buffsize = 0;
}
else
{
continue;
}
}//while
delete buff;
buff = NULL;
#ifdef _DEBUG
if(m_fp != INVALID_HANDLE_VALUE && m_fp != NULL)
::CloseHandle(m_fp);
#endif
上面這個循環裏,CStreamSocket類檢測UDP Socket是否有數據可讀,如果有,則調用winsock api: receivefrom函數獲得數據,並且它會根據類成員變量m_bRTP來判斷是否要去掉RTP包頭來得到真正的Payload數據,關鍵代碼如下:
if(m_bRTP)
{
rtp_hdr = (RTP_FIXED_HEADER*)&rtpPacket[0];
//TRACE("版本號 : %d\n", rtp_hdr->version);
//TRACE("結束標誌位 : %d\n", rtp_hdr->marker);
//TRACE("負載類型:%d\n", rtp_hdr->payload);
//TRACE("包號 : %d \n", htons(rtp_hdr->seq_no));
//TRACE("時間戳 : %d\n", htonl(rtp_hdr->timestamp));
//TRACE("同步標識符 : %d\n", htonl(rtp_hdr->ssrc));
if(len > RTP_HEADER_LEN)
{
memcpy(buff + buffsize, rtpPacket+ RTP_HEADER_LEN, len - RTP_HEADER_LEN); //去掉RTP包頭
buffsize += len - RTP_HEADER_LEN;
}
}
注意,這個接收函數並不是一收到數據就向上層傳遞數據,因爲RTP包的數據包一般很小(小於1400),所以我們可以湊齊多一點數據再往下傳,這樣可以提高處理效率。關鍵代碼如下:
if(buffsize >= minsize) //收到的數據長度大於minsize則向下傳遞
{
bHasData = TRUE;
m_pDownStreamController->InputStreamData((BYTE*)buff, buffsize); //向下級組件傳數據
buffsize = 0;
}
else
{
continue;
}
上面的m_pDownStreamController->InputStreamData函數做了哪些工作呢?這裏的 m_pDownStreamController指針變量其實是CMainFrame *類型,它指向的對象是應用程序的父窗口。讓我們看看InputStreamData怎麼實現的:
//傳入PS or TS包數據
BOOL CMainFrame:: InputStreamData(PBYTE pBuf, DWORD Buflen)
{
if(m_PlaySDKHandle != NULL)
{
return PT_AddStreamData(m_PlaySDKHandle, pBuf, Buflen);
}
return FALSE;
}
它其實調用了SDK的接口函數PT_AddStreamData向SDK插入PS數據。
接着,我們看看SDK輸出數據的地方:即回調函數,CMainFrame定義了一個接收ES幀數據的回調函數,函數如下:
//回調ES幀數據,分視頻幀和音頻幀,
int WINAPI SDKEsFrameCallback(int handle, BYTE * pBuf, int nBufSize, int nTrackNum, int nStreamType,__int64 llPts, int nFrameType)
{
if(gpMainFrame->m_nVStreamID == nTrackNum) //視頻
{
TRACE("nTrackNum = %d, Data Len = %d, Pts = %I64d, nFrameType = %d \n", nTrackNum, nBufSize, llPts, nFrameType);
#if 1
gpMainFrame->PushPacket(pBuf, nBufSize, llPts, nFrameType);
#else
gpMainFrame->OnVideoFrame(pBuf, nBufSize, nFrameType, llPts);
#endif
if(m_bRecording) //是否保存分離出來的ES流(.h264, .mp4v, .mpeg2)
{
if(m_fp != INVALID_HANDLE_VALUE)
{
DWORD dw;
::WriteFile(m_fp, pBuf, nBufSize, &dw, NULL);
}
}
}
else//音頻
{
if(gpMainFrame->m_nAudioType == AUDIO_DEFAULT) //未初始化音頻格式
{
int nATrackNo = 0;
AudioEncodeFormat aFormat;
if(PT_GetAudioInfo(handle, aFormat, nATrackNo))
{
gpMainFrame->m_nAudioType = aFormat;
}
}
}
return 0;
}
上面的回調函數對視頻和音頻做了分開處理,區別的標誌是通過nTrackNum參數,就是流的ID號。
對於視頻流,上面還調用了一個子函數PushPacket,這個函數的作用就是把ES數據扔到視頻隊列裏。PushPacket函數的代碼如下:
void CMainFrame::PushPacket(BYTE * pBuf, int nBufSize, __int64 llPts, int nFrameType)
{
m_cs.Lock();
int nPkgListLen = gpMainFrame->m_videopacketList.size();
m_cs.Unlock();
#ifdef _READ_STREAM_FROM_FILE
if(nPkgListLen > MAX_PACKET_COUNT) //太多包要處理,等待一下
{
for(int i = 0; i< nPkgListLen/2; i++)
{
if(!m_decoding_thread_run)
break;
Sleep(20);
}
}
#else
// if(nPkgListLen > MAX_PACKET_COUNT) //太多包要處理
//{
// //扔掉這些包
// ReleasePackets();
// return;
//}
#endif
//將收到的包放到隊列
PacketNode_t temNode;
temNode.length = nBufSize;
temNode.buf = new uint8_t[nBufSize];
memcpy(temNode.buf, pBuf, nBufSize);
temNode.pts = llPts;
temNode.frameType = nFrameType;
m_cs.Lock();
m_videopacketList.push_back(temNode);
m_cs.Unlock();
}
上面是往隊列裏寫數據,那麼讀數據包呢? 讀隊列是位於解碼視頻的線程裏:
DWORD WINAPI CMainFrame::decoding_thread(void* param)
{
TRACE("decoding_thread began! \n");
CMainFrame * pThisDlg = (CMainFrame*)param;
PacketNode_t pkt;
while(pThisDlg->m_decoding_thread_run)
{
int nRet = pThisDlg->ReadVideoPacket(&pkt);
if(nRet <= 0)
{
Sleep(2);
continue;
}
pThisDlg->OnVideoFrame(pkt.buf, pkt.length, pkt.frameType, pkt.pts); //視頻
delete[] pkt.buf; //釋放內存
pkt.buf = NULL;
}
return 0;
}
int CMainFrame::ReadVideoPacket(PacketNode_t * pOutNode)
{
int nsize = 0;
m_cs.Lock();
#ifdef _DEBUG
static DWORD dwLastTick = GetTickCount();
if(GetTickCount() - dwLastTick > 2000)
{
TRACE("視頻隊列長度: %d \n", m_videopacketList.size());
dwLastTick = GetTickCount();
}
#endif
if (!m_videopacketList.empty())
{
list<PacketNode_t>::iterator itr = m_videopacketList.begin();
if (itr != m_videopacketList.end())
{
*pOutNode = *itr; //複製Packet成員變量
nsize = itr->length;
//delete[] itr->buf; //釋放內存
m_videopacketList.pop_front();
//break;
}
else
{
ASSERT(0);
}
}
else
{
nsize = -1; //表示沒有數據可讀
}
m_cs.Unlock();
return nsize;
}
基本上所有的關鍵流程都講完了。
做這個實時流播放器耗費了作者一個星期的時間,雖然是業餘時間做,但平均每天基本上花2-3個鐘在開發上面,代碼改了一遍又一遍,解決了N多個Bug。雖然自己接觸PS格式已經有幾年了,之前在公司也做過PS拆包、分離的工作,但是之前寫的東西一直很不完善,自己覺得掌握的知識也不夠深刻,現在做出一個比較完善的、自己滿意的PS流實時流播放器,自己還是挺有成就感的,哈哈!
多謝大家閱讀到這裏,最後貼上這個例子的下載地址:https://download.csdn.net/download/zhoubotong2012/11971737
(下載說明:該例子只有界面程序的代碼,PS包處理(即SDK)的代碼沒有開源,只有DLL接口,大家下載前請謹慎考慮!)