FFmpeg 开发(06):FFmpeg 播放器实现音视频同步的三种方式
该文章首发于微信公众号:字节流动
FFmpeg 开发系列连载:
前文中,我们基于 FFmpeg 利用 OpenGL ES 和 OpenSL ES 分别实现了对解码后视频和音频的渲染,本文将实现播放器的最后一个重要功能:音视频同步。
老人们经常说,播放器对音频和视频的播放没有绝对的静态的同步,只有相对的动态的同步,实际上音视频同步就是一个“你追我赶”的过程。
音视频的同步方式有 3 种,即:音视频向系统时钟同步、音频向视频同步及视频向音频同步。
音视频解码器结构
在实现音视频同步之前,我们先简单说下本文播放器的大致结构,方便后面实现不同的音视频同步方式。
如上图所示,音频解码和视频解码分别占用一个独立线程,线程里有一个解码循环,解码循环里不断对音视频编码数据进行解码,音视频解码帧不设置缓存 Buffer , 进行实时渲染,极大地方便了音视频同步的实现。
音视频解码线程独立分离的播放器模式,简单灵活,代码量小,面向初学者,可以很方便实现音视频同步。
音视和视频解码流程非常相似,所以我们可以将二者的解码器抽象为一个基类:
class DecoderBase : public Decoder {public: DecoderBase() {}; virtual~ DecoderBase() {}; //开始播放 virtual void Start(); //暂停播放 virtual void Pause(); //停止 virtual void Stop(); //获取时长 virtual float GetDuration() { //ms to s return m_Duration * 1.0f / 1000; } //seek 到某个时间点播放 virtual void SeekToPosition(float position); //当前播放的位置,用于更新进度条和音视频同步 virtual float GetCurrentPosition(); virtual void ClearCache() {}; virtual void SetMessageCallback(void* context, MessageCallback callback) { m_MsgContext = context; m_MsgCallback = callback; } //设置音视频同步的回调 virtual void SetAVSyncCallback(void* context, AVSyncCallback callback) { m_AVDecoderContext = context; m_AudioSyncCallback = callback; }protected: void * m_MsgContext = nullptr; MessageCallback m_MsgCallback = nullptr; virtual int Init(const char *url, AVMediaType mediaType); virtual void UnInit(); virtual void OnDecoderReady() = 0; virtual void OnDecoderDone() = 0; //解码数据的回调 virtual void OnFrameAvailable(AVFrame *frame) = 0; AVCodecContext *GetCodecContext() { return m_AVCodecContext; }private: int InitFFDecoder(); void UnInitDecoder(); //启动解码线程 void StartDecodingThread(); //音视频解码循环 void DecodingLoop(); //更新显示时间戳 void UpdateTimeStamp(); //音视频同步 void AVSync(); //解码一个packet编码数据 int DecodeOnePacket(); //线程函数 static void DoAVDecoding(DecoderBase *decoder); //封装格式上下文 AVFormatContext *m_AVFormatContext = nullptr; //解码器上下文 AVCodecContext *m_AVCodecContext = nullptr; //解码器 AVCodec *m_AVCodec = nullptr; //编码的数据包 AVPacket *m_Packet = nullptr; //解码的帧 AVFrame *m_Frame = nullptr; //数据流的类型 AVMediaType m_MediaType = AVMEDIA_TYPE_UNKNOWN; //文件地址 char m_Url[MAX_PATH] = {0}; //当前播放时间 long m_CurTimeStamp = 0; //播放的起始时间 long m_StartTimeStamp = -1; //总时长 ms long m_Duration = 0; //数据流索引 int m_StreamIndex = -1; //锁和条件变量 mutex m_Mutex; condition_variable m_Cond; thread *m_Thread = nullptr; //seek position volatile float m_SeekPosition = 0; volatile bool m_SeekSuccess = false; //解码器状态 volatile int m_DecoderState = STATE_UNKNOWN; void* m_AVDecoderContext = nullptr; AVSyncCallback m_AudioSyncCallback = nullptr;//用作音视频同步};
篇幅有限,代码贴多了容易导致视觉疲劳,完整实现代码见阅读原文,这里只贴出几个关键函数。
解码循环。
void DecoderBase::DecodingLoop() { LOGCATE("DecoderBase::DecodingLoop start, 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::DecodingLoop waiting, 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::DecodingLoop end");}
获取当前时间戳。
void DecoderBase::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; } else if (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 的编码数据。
int DecoderBase::DecodeOnePacket() { int result = 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? int frameCount = 0; while (avcodec_receive_frame(m_AVCodecContext, m_Frame) == 0) { //更新时间戳 UpdateTimeStamp(); //同步 AVSync(); //渲染 LOGCATE("DecoderBase::DecodeOnePacket 000 m_MediaType=%d", m_MediaType); OnFrameAvailable(m_Frame); LOGCATE("DecoderBase::DecodeOnePacket 0001 m_MediaType=%d", m_MediaType); frameCount ++; } LOGCATE("BaseDecoder::DecodeOneFrame frameCount=%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); return result;}
音视频向系统时钟同步
音视频向系统时钟同步,顾名思义,系统时钟的更新是按照时间的增加而增加,获取音视频解码帧时与系统时钟进行对齐操作。
简而言之就是,当前音频或视频播放时间戳大于系统时钟时,解码线程进行休眠,直到时间戳与系统时钟对齐。
音视频向系统时钟同步。
void DecoderBase::AVSync() { LOGCATE("DecoderBase::AVSync"); long curSysTime = GetSysCurrentTime(); //基于系统时钟计算从开始播放流逝的时间 long elapsedTime = curSysTime - m_StartTimeStamp; //向系统时钟同步 if(m_CurTimeStamp > elapsedTime) { //休眠时间 auto sleepTime = static_cast<unsigned int>(m_CurTimeStamp - elapsedTime);//ms av_usleep(sleepTime * 1000); }}
音视频向系统时钟同步可以最大限度减少丢帧跳帧现象,但是前提是系统时钟不能受其他耗时任务影响。
音频向视频同步
音频向视频同步,就是音频的时间戳向视频的时间戳对齐。由于视频有固定的刷新频率,即 FPS ,我们根据 PFS 确定每帧的渲染时长,然后以此来确定视频的时间戳。
当音频时间戳大于视频时间戳,或者超过一定的阈值,音频播放器一般插入静音帧、休眠或者放慢播放。反之,就需要跳帧、丢帧或者加快音频播放。
void DecoderBase::AVSync() { LOGCATE("DecoderBase::AVSync"); if(m_AVSyncCallback != nullptr) { //音频向视频同步,传进来的 m_AVSyncCallback 用于获取视频时间戳 long elapsedTime = m_AVSyncCallback(m_AVDecoderContext); LOGCATE("DecoderBase::AVSync m_CurTimeStamp=%ld, elapsedTime=%ld", m_CurTimeStamp, elapsedTime); if(m_CurTimeStamp > elapsedTime) { //休眠时间 auto sleepTime = static_cast<unsigned int>(m_CurTimeStamp - elapsedTime);//ms av_usleep(sleepTime * 1000); } }}
音频向视频同步时,解码器设置。
//创建解码器m_VideoDecoder = new VideoDecoder(url);m_AudioDecoder = new AudioDecoder(url);//设置渲染器m_VideoDecoder->SetVideoRender(OpenGLRender::GetInstance());m_AudioRender = new OpenSLRender();m_AudioDecoder->SetVideoRender(m_AudioRender);//设置视频时间戳回调m_AudioDecoder->SetAVSyncCallback(m_VideoDecoder, VideoDecoder::GetVideoDecoderTimestampForAVSync);
音频向视频同步方式的优点是,视频可以将每一帧播放出来,画面流畅度最优。
但是由于人耳对声音相对眼睛对图像更为敏感,音频在与视频对齐时,插入静音帧、丢帧或者变速播放操作,用户可以轻易察觉,体验较差。
视频向音频同步
视频向音频同步的方式比较常用,刚好利用了人耳朵对声音变化比眼睛对图像变化更为敏感的特点。
音频按照固定的采样率播放,为视频提供对齐基准,当视频时间戳大于音频时间戳时,渲染器不进行渲染或者重复渲染上一帧,反之,进行跳帧渲染。
void DecoderBase::AVSync() { LOGCATE("DecoderBase::AVSync"); if(m_AVSyncCallback != nullptr) { //视频向音频同步,传进来的 m_AVSyncCallback 用于获取音频时间戳 long elapsedTime = m_AVSyncCallback(m_AVDecoderContext); LOGCATE("DecoderBase::AVSync m_CurTimeStamp=%ld, elapsedTime=%ld", m_CurTimeStamp, elapsedTime); if(m_CurTimeStamp > elapsedTime) { //休眠时间 auto sleepTime = static_cast<unsigned int>(m_CurTimeStamp - elapsedTime);//ms av_usleep(sleepTime * 1000); } }}
音频向视频同步时,解码器设置。
//创建解码器m_VideoDecoder = new VideoDecoder(url);m_AudioDecoder = new AudioDecoder(url);//设置渲染器m_VideoDecoder->SetVideoRender(OpenGLRender::GetInstance());m_AudioRender = new OpenSLRender();m_AudioDecoder->SetVideoRender(m_AudioRender);//设置音频时间戳回调m_VideoDecoder->SetAVSyncCallback(m_AudioDecoder, AudioDecoder::GetAudioDecoderTimestampForAVSync);
结语
播放器实现音视频同步的这三种方式中,选择哪一种方式合适要视具体的使用场景而定,比如你对画面流畅度要求很高,可以选择音频向视频同步;你要单独实现视频或音频播放,直接向系统时钟同步更为方便。