ffplay是ffmpeg源码中一个自带的开源播放器实例,同时支持本地视频文件的播放以及在线流媒体播放,功能非常强大。
FFplay: FFplay is a very simple and portable media player using the FFmpeg libraries and the SDL library. It is mostly used as a testbed for the various FFmpeg APIs.
ffplay中的代码充分调用了ffmpeg中的函数库,因此,想学习ffmpeg的使用,或基于ffmpeg开发一个自己的播放器,ffplay都是一个很好的切入点。
由于ffmpeg本身的开发文档比较少,且ffplay播放器源码的实现相对复杂,除了基础的ffmpeg组件调用外,还包含视频帧的渲染、音频帧的播放、音视频同步策略及线程调度等问题。
因此,这里我们以ffmpeg官网推荐的一个ffplay播放器简化版本的开发例程为基础,在此基础上循序渐进由浅入深,最终探讨实现一个视频播放器的完整逻辑。
在上篇文章中,我们讨论了一个播放器的基础架构,梳理了组成播放器的基本组件及后台数据队列,并对代码结构进行了调整。本文在上篇文章的基础上,讨论音视频同步的相关内容,首先介绍与音视频同步相关的时间戳概念,然后介绍音视频同步涉及的原理及策略,最后重点讲述关键代码的实现过程。
1、时间戳
时间戳的概念贯穿音视频开发始终,重要性不言而喻。时间戳告诉我们在什么时候,用多快的速度去播哪一帧,其中,DTS(decoding timestamp)告诉我们何时解码,PTS(presentation timestamp)告诉我们何时播放
那么,为什么要有DTS和PTS?首先需要理解编码后的数据是如何存储的。
1.1 帧分组(Group of picture)
视频帧(这里主要以H.264编码为例)以序列为单位进行组织,一个序列是一段图像编码后的数据流,从关键帧I帧开始,包含了与组内I帧相关联的P帧和B帧,到下一个I帧结束,一个序列为一个帧组(group of picture),如下图所示。
![](https://img-blog.csdnimg.cn/img_convert/c2b40fa8e69dc9e9703f41e1070a28ec.webp?x-oss-process=image/format,png)
一个序列的长短,和序列呈现的视频内容有关。当前后帧视频画面变化较小时,一个序列可以很长,因为画面变化小意味着不需要太多的数据,即可对后续的画面进行描述,仅需提供首帧画面(I帧)及针对该画面的的变化内容(预测结果),即可恢复一个帧组的全部画面。
I帧具有以下特点:
解码时仅用I帧的数据就可重构完整图像,I帧不需要参考其他画面而生成
I帧是P帧和B帧的参考帧(其质量直接影响到同组中以后各帧的质量)
在一组中只有一个I帧,I帧所占数据的信息量比较大
I帧不需要考虑运动矢量
P帧为自己与帧组内的I帧或其他P帧的残差图编码形成,解码时需要用之前缓存的画面,叠加上本帧残差画面生成最终画面。
P帧具有以下特点:
P帧是I帧后面相隔1~2帧的编码帧
P帧采用运动补偿的方法传送它与前面的I或P帧的差值及运动矢量(预测误差)
P帧属于前向预测的帧间编码,它只参考前面最靠近它的I帧或P帧
B帧是双向预测内插编码帧,B帧记录的是本帧与前后帧的差别,要解码B帧,不仅要取得之前的缓存画面,还要本帧之后的画面,通过前后画面与本帧数据的差异取得最终的画面。
B帧压缩率高,但因为解码时需要前后帧的共同配合,因此可能需要等待更长的解码时间,因为此时后面的帧可能还未接收或解析成功。因为B帧的这个特点,在一些实时性要求高的流媒体传输协议中,会要求编码器去除B帧数据编码,只保留I帧和P帧编码,保证解码的实时性要求。
B帧具有以下特点:
B帧是双向预测编码帧,B帧是由前面的I或P帧和后面的P帧来进行预测的
B帧传送的是它与前面的I或P帧和后面的P帧之间的预测误差及运动矢量
B帧压缩比最高(可达到50倍的压缩率)
帧分组图中的箭头连线,反应了I/P/B帧之间的依赖关系,可以看出,P帧仅依赖I帧,B帧依赖它前后的I帧与P帧
1.2 PTS和DTS
我们继续在帧组概念基础上讨论时间戳,以下图为例,在帧组中因为有P/B帧的存在,使得视频帧的解码顺序和播放顺序存在不一致的情况,先解码的帧可能后播放,而后解码的帧也可能先播放。因此,我们采用解码时间戳DTS和显示时间戳PTS来解决解码时序与播放时序不一致的问题。
图中第一行为视频流的接收时序,其中每个帧都携带了DTS和PTS,ffmpeg会根据这些信息,将解码出来的音视频原始数据,按照最终显示的时序进行重组后,交给调用者显示播放。
这里需要提一下,图中的DTS与PTS值仅用来说明时间戳的工作原理,并不是一个真实的取值,在ffmpeg的返回值中,DTS与PTS的取值也是一个相对值,可以看作每帧图像在时间轴上的投影相对位置,并不直接对应现实世界中的绝对时间,因此,在音视频同步过程中,还需要对时间戳的值进行换算,才能得到每帧图像确切的播放时间。
2、视频同步策略分析
有了时间戳的基础,我们就能确定在什么时候,用多快的速度去播哪一帧了,这样就能完成整个视频的播放了吗?可能仍然不行,原因在于,即使我们能够得到每一个视频及音频帧的显示播放时间,并依次按照指定的时间播放,在理想情况下能够达到音视频同步的目的,但实际上,这种同步状态很难维持太长时间。
有太多的因素,如关键帧接收延迟,线程调度,每帧解码的时间不同等,影响着音视频的同步,在毫秒级别上,这种延迟会随着播放的进行被逐渐放大,进而完全失去同步。因此,我们需要一个机制,能够动态的调节每帧数据的播放,约束音视频帧间的时差,达到一种动态同步的结果。
既然要将原本不同步的音视频帧协调到一起,那么我们需要一个同步的基准,类似于弹钢琴时用到的节拍器一样,左右手都根据同一个节拍有条不紊的弹着。
类似的,在音视频同步中,也需要这样一个时间基准,有3种时间可以作为同步的时间基准,它们分别是系统时间,音频时间和视频时间。我们可选择将视频同步到音频上,将音频同步到视频上,或者将音视频都同步到系统时间上,每种方式的效果会有所不同。
本次内容先介绍将音频同步到视频上的方法,后续的内容会继续介绍其他两种同步方式,并给出最终的方案。
2.1 音频时间戳计算
既然我们选择以音频时间作为同步的时间基准,那么先来看看如何计算音频时间戳。音频时间戳的计算方式和视频时间戳有所差别,通常每个音频帧占用的缓存空间比较小,一般情况下会将多帧音频数据打包到一起发送,如通过rtp等流媒体协议,将多个aac音频帧打包到一起由推流端发送到接收端。
打包好的音频帧通过解封装解析到AVPacket后,会得到一个音频帧组,而这一组音频帧只对应一个时间戳(pkt->pts),这种情况下在解码后,就需要根据音频的采样频率,声道数以及每声道字节数等信息,来估算每个解码后音频采样数据对应的时间了。
另一方面,音频数据的播放是通过回调函数的方式,周期性的将一组音频数据送入声卡中播放的,回忆下我们之前在[ffmpeg播放器实现详解 - 音频播放]中讨论过的内容,每次送入声卡的缓存长度,和解码后音频数据的缓存长度是不一致的,因此需要根据每个packet对应的时间戳,以及采样频率,声道数等信息估算出每次送到声卡中缓存片段的时间值。
下面我们来看下具体的实现。首先看下在VideoState中新增的几个相关字段。
typedef struct VideoState {
...
//video/audio_clock save pts of last decoded frame/predicted pts of next decoded frame
double video_clock;//keep track of how much time has passed according to the video
double audio_clock;
double frame_timer;//视频播放到当前帧时的累计已播放时间
double frame_last_pts;//上一帧图像的显示时间戳,用于在video_refersh_timer中保存上一帧的pts值
double frame_last_delay;//上一帧图像的动态刷新延迟时间
} VideoState;// Since we only have one decoding thread, the Big Struct can be global in case we need it.
audio_clock & video_clock 分别用于追踪音频和视频播放的时间戳位置,后面会多次用到。我们在音频解码函数中,增加了时间戳相关的内容,并在解码函数返回前,根据pkt->pts及返回的数据长度,推算出这段音频数据相对于pkt->pts的时间,更新到音频时钟audio_clock上。
从下图中可以看到,解码函数,音频时钟获取函数,解码缓存及时间戳的关系。于此同时,随着解码缓存长度的递增,播放时间呈现出同比线性增长趋势。图中的红线可以看作由pcm采样数据还原出的音频信号。
![](https://img-blog.csdnimg.cn/img_convert/06eb65d24d402f98fdb8d2019e8feaf9.webp?x-oss-process=image/format,png)
int audio_decode_frame(VideoState *is, double *pts_ptr) {
...
double pts;//音频播放时间戳
...
pts=is->audio_clock;//用每次更新的音频播放时间更新音频PTS
*pts_ptr=pts;
/*---------------------
* 当一个packet中包含多个音频帧时
* 通过[解码后音频原始数据长度]及[采样率]来推算一个packet中其他音频帧的播放时间戳pts
* 采样频率44.1kHz,量化位数16位,意味着每秒采集数据44.1k个,每个数据占2字节
--------------------*/
pcm_bytes=2*is->audio_st->codec->channels;//计算每组音频采样数据的字节数=每个声道音频采样字节数*声道数
/*----更新audio_clock---
* 一个pkt包含多个音频frame,同时一个pkt对应一个pts(pkt->pts)
* 因此,该pkt中包含的多个音频帧的时间戳由以下公式推断得出
* bytes_per_sec=pcm_bytes*is->audio_st->codec->sample_rate
* 从pkt中不断的解码,推断(一个pkt中)每帧数据的pts并累加到音频播放时钟
--------------------*/
is->audio_clock+=(double)data_size/(double)(pcm_bytes*is->audio_st->codec->sample_rate);
// We have data, return it and come back for more later
return data_size;//返回解码数据原始数据长度
}
...
// If update, update the audio clock w/pts
if (pkt->pts != AV_NOPTS_VALUE) {//检查音频播放时间戳
//获得一个新的packet的时候,更新audio_clock,用packet中的pts更新audio_clock(一个pkt对应一个pts)
is->audio_clock=pkt->pts*av_q2d(is->audio_st->time_base);//更新音频已经播的时间
}
}
}
下面是计算音频时间戳的代码,注释的已经很详细了,原理见上面的分析。
double get_audio_clock(VideoState *is) {
double pts=is->audio_clock;//Maintained in the audio thread,取得解码操作完成时的当前播放时间戳
//还未(送入声卡)播放的剩余原始音频数据长度,等于解码后的多帧原始音频数据长度-累计送入声卡的长度
int hw_buf_size=is->audio_buf_size-is->audio_buf_index;//计算当前音频解码数据缓存索引位置
int bytes_per_sec=0;//每秒的原始音频字节数
int pcm_bytes=is->audio_st->codec->channels*2;//每组原始音频数据字节数=声道数*每声道数据字节数
if (is->audio_st) {
bytes_per_sec=is->audio_st->codec->sample_rate*pcm_bytes;//计算每秒的原始音频字节数
}
if (bytes_per_sec) {//检查每秒的原始音频字节数是否有效
pts-=(double)hw_buf_size/bytes_per_sec;//根据送入声卡缓存的索引位置,往前倒推计算当前时刻的音频播放时间戳pts
}
return pts;//返回当前正在播放的音频时间戳
}
2.2 视频同步实现
有了音频时间戳,也就有了音视频同步的基准,下面我们来讨论下音视频同步的策略。
因为声音播放的速度通常恒定,试想下如果把一段一分钟的录音在半分钟内播放完,而且前快后慢,你听到的声音可能就不那么自然了。而视频的播放速度只要维持fps在25帧左右,多一帧或少一帧人眼一般感知不出来,因此,通过将视频同步到音频的方式,可以较好的达到音视频同步的效果,在视频播放超前于音频时,增加视频帧显示的时间,当视频播放滞后于音频时,减少视频帧显示时间,加速视频的刷新速度,通俗的解释就是面多了加水,水多了加面,这就是音视频同步的策略。
本次内容的代码仍以上一篇文章中的代码框架为基础,代码架构及运行时序见[ffmpeg播放器实现详解 - 创建线程]。
video_refresh_timer是视频同步的核心函数,上篇内容中的video_refresh_timer函数实现比较简单,仅以40ms的固定周期对画面进行刷新,本篇中的实现则复杂的多。下面将代码的核心部分贴出来,可以看到,这里比较当前帧与主时钟的时差,并用该时差与一个阈值进行比较,慢了delay设为0尽快显示,快了加倍延迟。
然后将每次计算出的实际延迟时间delay,即下一帧的刷新间隔时间更新到frame_timer上,并根据该值确定下一帧更新的绝对时间(delay本身是一个相对时间)。frame_timer本身是以系统时间为基准的,因此这里要减去此时的系统时间。
ffmpeg的时间戳和同步机制相对复杂,大家可以根据代码的注释,加入调试信息把程序跑一遍,体会其中的原理和细节。
void video_refresh_timer(void *userdata) {
...
// Update delay to sync to audio,取得声音播放时间戳(作为视频同步的参考时间)
if (is->av_sync_type != AV_SYNC_VIDEO_MASTER) {//检查主同步时钟源
ref_clock = get_master_clock(is);//根据主时钟来判断Video播放的快慢,以主时钟为基准时间
diff = vp->pts - ref_clock;//计算图像帧显示与主时钟的时间差
//根据时间差调整播放下一帧的延迟时间,以实现同步 Skip or repeat the frame,Take delay into account
sync_threshold = (delay > AV_SYNC_THRESHOLD) ? delay : AV_SYNC_THRESHOLD;
//判断音视频不同步条件,即[画面-声音]时间差&[画面-画面]时间差<10ms阈值,若>该阈值则为快进模式,不存在音视频同步问题
if (fabs(diff) < AV_NOSYNC_THRESHOLD) {
if (diff <= -sync_threshold) {//慢了delay设为0尽快显示
//下一帧画面显示的时间和当前的声音很近的话加快显示下一帧(即后面video_display显示完当前帧后开启定时器很快去显示下一帧
delay=0;
} else if (diff>=sync_threshold) {//快了加倍延迟
delay=2*delay;
}
}//如果diff(明显)大于AV_NOSYNC_THRESHOLD,即快进的模式了,画面跳动太大,不存在音视频同步的问题了
}
//更新视频播放到当前帧时的已播放时间值(所有图像帧动态播放累计时间值-真实值),frame_timer一直累加在播放过程中我们计算的延时
is->frame_timer+=delay;