ffmpeg播放器实现详解 - 视频同步控制

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),如下图所示。

一个序列的长短,和序列呈现的视频内容有关。当前后帧视频画面变化较小时,一个序列可以很长,因为画面变化小意味着不需要太多的数据,即可对后续的画面进行描述,仅需提供首帧画面(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采样数据还原出的音频信号。

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;
	
  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值