1. 程式人生 > 實用技巧 >FFmpeg 開發(06):FFmpeg 播放器實現音視訊同步的三種方式

FFmpeg 開發(06):FFmpeg 播放器實現音視訊同步的三種方式

該文章首發於微信公眾號:位元組流動

FFmpeg 開發系列連載:

FFmpeg 開發(01):FFmpeg 編譯和整合
FFmpeg 開發(02):FFmpeg + ANativeWindow 實現視訊解碼播放
FFmpeg 開發(03):FFmpeg + OpenSLES 實現音訊解碼播放
FFmpeg 開發(04):FFmpeg + OpenGLES 實現音訊視覺化播放
FFmpeg 開發(05):FFmpeg + OpenGLES 實現視訊解碼播放和視訊濾鏡

前文中,我們基於 FFmpeg 利用 OpenGL ES 和 OpenSL ES 分別實現了對解碼後視訊和音訊的渲染,本文將實現播放器的最後一個重要功能:音視訊同步。

老人們經常說,播放器對音訊和視訊的播放沒有絕對的靜態的同步,只有相對的動態的同步,實際上音視訊同步就是一個“你追我趕”的過程。

音視訊的同步方式有 3 種,即:音視訊向系統時鐘同步、音訊向視訊同步及視訊向音訊同步。

音視訊解碼器結構

在實現音視訊同步之前,我們先簡單說下本文播放器的大致結構,方便後面實現不同的音視訊同步方式。

播放器結構

如上圖所示,音訊解碼和視訊解碼分別佔用一個獨立執行緒,執行緒裡有一個解碼迴圈,解碼迴圈裡不斷對音視訊編碼資料進行解碼,音視訊解碼幀不設定快取 Buffer , 進行實時渲染,極大地方便了音視訊同步的實現。

音視訊解碼執行緒獨立分離的播放器模式,簡單靈活,程式碼量小,面向初學者,可以很方便實現音視訊同步。

音視和視訊解碼流程非常相似,所以我們可以將二者的解碼器抽象為一個基類:

classDecoderBase:publicDecoder{
public:
DecoderBase()
{};
virtual~DecoderBase()
{};
//開始播放
virtualvoidStart();
//暫停播放
virtualvoidPause();
//停止
virtualvoidStop();
//獲取時長
virtualfloatGetDuration()
{
//mstos
returnm_Duration*1.0f/1000;
}
//seek到某個時間點播放
virtualvoidSeekToPosition(floatposition);
//當前播放的位置,用於更新進度條和音視訊同步

virtualfloatGetCurrentPosition();
virtualvoidClearCache()
{};
virtualvoidSetMessageCallback(void*context,MessageCallbackcallback)
{
m_MsgContext=context;
m_MsgCallback=callback;
}
//設定音視訊同步的回撥
virtualvoidSetAVSyncCallback(void*context,AVSyncCallbackcallback)
{
m_AVDecoderContext=context;
m_AudioSyncCallback=callback;
}

protected:
void*m_MsgContext=nullptr;
MessageCallbackm_MsgCallback=nullptr;
virtualintInit(constchar*url,AVMediaTypemediaType);
virtualvoidUnInit();
virtualvoidOnDecoderReady()=0;
virtualvoidOnDecoderDone()=0;
//解碼資料的回撥
virtualvoidOnFrameAvailable(AVFrame*frame)=0;

AVCodecContext*GetCodecContext(){
returnm_AVCodecContext;
}

private:
intInitFFDecoder();
voidUnInitDecoder();
//啟動解碼執行緒
voidStartDecodingThread();
//音視訊解碼迴圈
voidDecodingLoop();
//更新顯示時間戳
voidUpdateTimeStamp();
//音視訊同步
voidAVSync();
//解碼一個packet編碼資料
intDecodeOnePacket();
//執行緒函式
staticvoidDoAVDecoding(DecoderBase*decoder);

//封裝格式上下文
AVFormatContext*m_AVFormatContext=nullptr;
//解碼器上下文
AVCodecContext*m_AVCodecContext=nullptr;
//解碼器
AVCodec*m_AVCodec=nullptr;
//編碼的資料包
AVPacket*m_Packet=nullptr;
//解碼的幀
AVFrame*m_Frame=nullptr;
//資料流的型別
AVMediaTypem_MediaType=AVMEDIA_TYPE_UNKNOWN;
//檔案地址
charm_Url[MAX_PATH]={0};
//當前播放時間
longm_CurTimeStamp=0;
//播放的起始時間
longm_StartTimeStamp=-1;
//總時長ms
longm_Duration=0;
//資料流索引
intm_StreamIndex=-1;
//鎖和條件變數
mutexm_Mutex;
condition_variablem_Cond;
thread*m_Thread=nullptr;
//seekposition
volatilefloatm_SeekPosition=0;
volatileboolm_SeekSuccess=false;
//解碼器狀態
volatileintm_DecoderState=STATE_UNKNOWN;
void*m_AVDecoderContext=nullptr;
AVSyncCallbackm_AudioSyncCallback=nullptr;//用作音視訊同步
};

篇幅有限,程式碼貼多了容易導致視覺疲勞,完整實現程式碼見閱讀原文,這裡只貼出幾個關鍵函式。

解碼迴圈。

voidDecoderBase::DecodingLoop(){
LOGCATE("DecoderBase::DecodingLoopstart,m_MediaType=%d",m_MediaType);
{
std::unique_lock<std::mutex>lock(m_Mutex);
m_DecoderState=STATE_DECODING;
lock.unlock();
}

for(;;){
while(m_DecoderState==STATE_PAUSE){
std::unique_lock<std::mutex>lock(m_Mutex);
LOGCATE("DecoderBase::DecodingLoopwaiting,m_MediaType=%d",m_MediaType);
m_Cond.wait_for(lock,std::chrono::milliseconds(10));
m_StartTimeStamp=GetSysCurrentTime()-m_CurTimeStamp;
}

if(m_DecoderState==STATE_STOP){
break;
}

if(m_StartTimeStamp==-1)
m_StartTimeStamp=GetSysCurrentTime();

if(DecodeOnePacket()!=0){
//解碼結束,暫停解碼器
std::unique_lock<std::mutex>lock(m_Mutex);
m_DecoderState=STATE_PAUSE;
}
}
LOGCATE("DecoderBase::DecodingLoopend");
}

獲取當前時間戳。

voidDecoderBase::UpdateTimeStamp(){
LOGCATE("DecoderBase::UpdateTimeStamp");
//參照ffplay
std::unique_lock<std::mutex>lock(m_Mutex);
if(m_Frame->pkt_dts!=AV_NOPTS_VALUE){
m_CurTimeStamp=m_Frame->pkt_dts;
}elseif(m_Frame->pts!=AV_NOPTS_VALUE){
m_CurTimeStamp=m_Frame->pts;
}else{
m_CurTimeStamp=0;
}

m_CurTimeStamp=(int64_t)((m_CurTimeStamp*av_q2d(m_AVFormatContext->streams[m_StreamIndex]->time_base))*1000);

}

解碼一個 packet 的編碼資料。

intDecoderBase::DecodeOnePacket(){
intresult=av_read_frame(m_AVFormatContext,m_Packet);
while(result==0){
if(m_Packet->stream_index==m_StreamIndex){
if(avcodec_send_packet(m_AVCodecContext,m_Packet)==AVERROR_EOF){
//解碼結束
result=-1;
goto__EXIT;
}

//一個packet包含多少frame?
intframeCount=0;
while(avcodec_receive_frame(m_AVCodecContext,m_Frame)==0){
//更新時間戳
UpdateTimeStamp();
//同步
AVSync();
//渲染
LOGCATE("DecoderBase::DecodeOnePacket000m_MediaType=%d",m_MediaType);
OnFrameAvailable(m_Frame);
LOGCATE("DecoderBase::DecodeOnePacket0001m_MediaType=%d",m_MediaType);
frameCount++;
}
LOGCATE("BaseDecoder::DecodeOneFrameframeCount=%d",frameCount);
//判斷一個packet是否解碼完成
if(frameCount>0){
result=0;
goto__EXIT;
}
}
av_packet_unref(m_Packet);
result=av_read_frame(m_AVFormatContext,m_Packet);
}

__EXIT:
av_packet_unref(m_Packet);
returnresult;
}

音視訊向系統時鐘同步

音視訊向系統時鐘同步,顧名思義,系統時鐘的更新是按照時間的增加而增加,獲取音視訊解碼幀時與系統時鐘進行對齊操作。

簡而言之就是,當前音訊或視訊播放時間戳大於系統時鐘時,解碼執行緒進行休眠,直到時間戳與系統時鐘對齊。

音視訊向系統時鐘同步。

voidDecoderBase::AVSync(){
LOGCATE("DecoderBase::AVSync");
longcurSysTime=GetSysCurrentTime();
//基於系統時鐘計算從開始播放流逝的時間
longelapsedTime=curSysTime-m_StartTimeStamp;

//向系統時鐘同步
if(m_CurTimeStamp>elapsedTime){
//休眠時間
autosleepTime=static_cast<unsignedint>(m_CurTimeStamp-elapsedTime);//ms
av_usleep(sleepTime*1000);
}
}

音視訊向系統時鐘同步可以最大限度減少丟幀跳幀現象,但是前提是系統時鐘不能受其他耗時任務影響。

音訊向視訊同步

音訊向視訊同步,就是音訊的時間戳向視訊的時間戳對齊。由於視訊有固定的重新整理頻率,即 FPS ,我們根據 PFS 確定每幀的渲染時長,然後以此來確定視訊的時間戳。

當音訊時間戳大於視訊時間戳,或者超過一定的閾值,音訊播放器一般插入靜音幀、休眠或者放慢播放。反之,就需要跳幀、丟幀或者加快音訊播放。

voidDecoderBase::AVSync(){
LOGCATE("DecoderBase::AVSync");
if(m_AVSyncCallback!=nullptr){
//音訊向視訊同步,傳進來的m_AVSyncCallback用於獲取視訊時間戳
longelapsedTime=m_AVSyncCallback(m_AVDecoderContext);
LOGCATE("DecoderBase::AVSyncm_CurTimeStamp=%ld,elapsedTime=%ld",m_CurTimeStamp,elapsedTime);

if(m_CurTimeStamp>elapsedTime){
//休眠時間
autosleepTime=static_cast<unsignedint>(m_CurTimeStamp-elapsedTime);//ms
av_usleep(sleepTime*1000);
}
}
}

音訊向視訊同步時,解碼器設定。

//建立解碼器
m_VideoDecoder=newVideoDecoder(url);
m_AudioDecoder=newAudioDecoder(url);

//設定渲染器
m_VideoDecoder->SetVideoRender(OpenGLRender::GetInstance());
m_AudioRender=newOpenSLRender();
m_AudioDecoder->SetVideoRender(m_AudioRender);

//設定視訊時間戳回撥
m_AudioDecoder->SetAVSyncCallback(m_VideoDecoder,VideoDecoder::GetVideoDecoderTimestampForAVSync);

音訊向視訊同步方式的優點是,視訊可以將每一幀播放出來,畫面流暢度最優。

但是由於人耳對聲音相對眼睛對影象更為敏感,音訊在與視訊對齊時,插入靜音幀、丟幀或者變速播放操作,使用者可以輕易察覺,體驗較差。

視訊向音訊同步

視訊向音訊同步的方式比較常用,剛好利用了人耳朵對聲音變化比眼睛對影象變化更為敏感的特點。

音訊按照固定的取樣率播放,為視訊提供對齊基準,當視訊時間戳大於音訊時間戳時,渲染器不進行渲染或者重複渲染上一幀,反之,進行跳幀渲染。

voidDecoderBase::AVSync(){
LOGCATE("DecoderBase::AVSync");
if(m_AVSyncCallback!=nullptr){
//視訊向音訊同步,傳進來的m_AVSyncCallback用於獲取音訊時間戳
longelapsedTime=m_AVSyncCallback(m_AVDecoderContext);
LOGCATE("DecoderBase::AVSyncm_CurTimeStamp=%ld,elapsedTime=%ld",m_CurTimeStamp,elapsedTime);

if(m_CurTimeStamp>elapsedTime){
//休眠時間
autosleepTime=static_cast<unsignedint>(m_CurTimeStamp-elapsedTime);//ms
av_usleep(sleepTime*1000);
}
}
}

音訊向視訊同步時,解碼器設定。

//建立解碼器
m_VideoDecoder=newVideoDecoder(url);
m_AudioDecoder=newAudioDecoder(url);

//設定渲染器
m_VideoDecoder->SetVideoRender(OpenGLRender::GetInstance());
m_AudioRender=newOpenSLRender();
m_AudioDecoder->SetVideoRender(m_AudioRender);

//設定音訊時間戳回撥
m_VideoDecoder->SetAVSyncCallback(m_AudioDecoder,AudioDecoder::GetAudioDecoderTimestampForAVSync);

結語

播放器實現音視訊同步的這三種方式中,選擇哪一種方式合適要視具體的使用場景而定,比如你對畫面流暢度要求很高,可以選擇音訊向視訊同步;你要單獨實現視訊或音訊播放,直接向系統時鐘同步更為方便。

聯絡與交流

技術交流獲取原始碼可以新增我的微信:Byte-Flow