FFmpeg简易播放器的实现-音视频同步
本文为作者原创,转载请注明出处:https://www.cnblogs.com/leisure_chn/p/10284653.html
基于 FFmpeg 和 SDL 实现的简易视频播放器,主要分为读取视频文件解码和调用 SDL 显示两大部分。
FFmpeg 简易播放器系列文章如下:
[1]. FFmpeg简易播放器的实现1-最简版
[2]. FFmpeg简易播放器的实现2-视频播放
[3]. FFmpeg简易播放器的实现3-音频播放
[4]. FFmpeg简易播放器的实现4-音视频播放
[5]. FFmpeg简易播放器的实现5-音视频同步
前面四次实验,从最简入手,循序渐进,研究播放器的实现过程。第四次实验,虽然音频和视频都能播放出来,但是声音和图像无法同步,而没有音视频同步的播放器只是属于概念性质的播放器,无法实际使用。本次实验将实现音频和视频的同步,这样,一个能够实际使用的简易播放器才算初具雏形,在这个基础上,后续可再进行完善和优化。
音视频同步是播放器中比较复杂的一部分内容。前几次实验中的代码远不能满足要求,需要大幅修改。本次实验不在前几次代码上修改,而是基于 ffplay 源码进行修改。ffplay 是 FFmpeg 工程自带的一个简单播放器,尽管称为简单播放器,其代码实现仍显得过为复杂,本实验对 ffplay.c 进行删减,删掉复杂的命令选项、滤镜操作、SEEK 操作、逐帧插放等功能,仅保留最核心的音视频同步部分。
尽管不使用之前的代码,但播放器的基本原理和大致流程相同,前面几次实验仍具有有效参考价值。
1. 视频播放器基本原理
下图引用自 “雷霄骅,视音频编解码技术零基础学习方法”,因原图太小,看不太清楚,故重新制作了一张图片。
如下内容引用自 “雷霄骅,视音频编解码技术零基础学习方法”:
解协议
将流媒体协议的数据,解析为标准的相应的封装格式数据。视音频在网络上传播的时候,常常采用各种流媒体协议,例如 HTTP,RTMP,或是 MMS 等等。这些协议在传输视音频数据的同时,也会传输一些信令数据。这些信令数据包括对播放的控制(播放,暂停,停止),或者对网络状态的描述等。解协议的过程中会去除掉信令数据而只保留视音频数据。例如,采用 RTMP 协议传输的数据,经过解协议操作后,输出 FLV 格式的数据。解封装
将输入的封装格式的数据,分离成为音频流压缩编码数据和视频流压缩编码数据。封装格式种类很多,例如 MP4,MKV,RMVB,TS,FLV,AVI 等等,它的作用就是将已经压缩编码的视频数据和音频数据按照一定的格式放到一起。例如,FLV 格式的数据,经过解封装操作后,输出 H.264 编码的视频码流和 AAC 编码的音频码流。解码
将视频/音频压缩编码数据,解码成为非压缩的视频/音频原始数据。音频的压缩编码标准包含 AAC,MP3,AC-3 等等,视频的压缩编码标准则包含 H.264,MPEG2,VC-1 等等。解码是整个系统中最重要也是最复杂的一个环节。通过解码,压缩编码的视频数据输出成为非压缩的颜色数据,例如 YUV420P,RGB 等等;压缩编码的音频数据输出成为非压缩的音频抽样数据,例如 PCM 数据。音视频同步
根据解封装模块处理过程中获取到的参数信息,同步解码出来的视频和音频数据,并将视频音频数据送至系统的显卡和声卡播放出来。
2. 简易播放器的实现-音视频同步
2.1 实验平台
实验平台: openSUSE Leap 42.3
Microsoft Visual Studio 2017 (WIN10)
FFmpeg 版本: 4.1
SDL 版本: 2.0.9
本工程支持在 Linux 和 Windows 平台上运行。
Linux 下 FFmpeg 开发环境搭建可参考 “FFmpeg 开发环境构建”。
Windows 下使用 Microsoft Visual Studio 2017 打开工程目录下 ffplayer.sln 文件即可运行。
2.2 源码清单
使用如下命令下载源码:
svn checkout https://github.com/leichn/ffplayer/trunk
ffplay 所有源码集中在 ffplay.c 一个文件中,ffplay.c 代码很长。本实验将 ffplay.c 按功能点拆分为多个文件,源文件说明如下:
player.c 运行主线程,SDL 消息处理
demux.c 解复用线程
video.c 视频解码线程和视频播放线程
audio.c 音频解码线程和音频播放线程
packet.c packet 队列操作函数
frame.c frame 队列操作函数
main.c 程序入口,外部调用示例
Makefile Linux 平台下编译用 Makefile
lib_wins Windows 平台下 FFmpeg 和 SDL 编译时库和运行时库
本来想将 ffplay.c 中全局使用的大数据结构 VideoState 也拆分分散到各文件中去,但发现各文件对此数据结构的引用关系错综复杂,很难拆分,因此作罢。
2.3 源码流程分析
源码流程和 ffplay 基本相同,不同的一点是 ffplay 中视频播放和 SDL 消息处理都是在同一个线程中(主线程),本工程中将视频播放独立为一个线程。本工程源码流程如下图所示:
ffplay 的源码流程可参考 “ffplay源码分析3-代码框架”。
2.4 音视频同步
音视频同步的详细介绍可参考 “ffplay源码分析4-音视频同步”,为保证文章的完整性,本文保留此节内容。与 “ffplay源码分析4-音视频同步” 相比,本节源码及文字均作了适当精简。
音视频同步的目的是为了使播放的声音和显示的画面保持一致。视频按帧播放,图像显示设备每次显示一帧画面,视频播放速度由帧率确定,帧率指示每秒显示多少帧;音频按采样点播放,声音播放设备每次播放一个采样点,声音播放速度由采样率确定,采样率指示每秒播放多少个采样点。如果仅仅是视频按帧率播放,音频按采样率播放,二者没有同步机制,即使最初音视频是基本同步的,随着时间的流逝,音视频会逐渐失去同步,并且不同步的现象会越来越严重。这是因为:一、播放时间难以精确控制,二、异常及误差会随时间累积。所以,必须要采用一定的同步策略,不断对音视频的时间差作校正,使图像显示与声音播放总体保持一致。
音视频同步的方式基本是确定一个时钟(音频时钟、视频时钟、外部时钟)作为主时钟,非主时钟的音频或视频时钟为从时钟。在播放过程中,主时钟作为同步基准,不断判断从时钟与主时钟的差异,调节从时钟,使从时钟追赶(落后时)或等待(超前时)主时钟。按照主时钟的不同种类,可以将音视频同步模式分为如下三种:
音频同步到视频,视频时钟作为主时钟。
视频同步到音频,音频时钟作为主时钟。
音视频同步到外部时钟,外部时钟作为主时钟。
本实验采用 ffplay 默认的同步方式:视频同步到音频。ffplay 中同步模式的定义如下:
enum {
AV_SYNC_AUDIO_MASTER, /* default choice */
AV_SYNC_VIDEO_MASTER,
AV_SYNC_EXTERNAL_CLOCK, /* synchronize to an external clock */
};
2.4.1 time_base
time_base 是 PTS 和 DTS 的时间单位,也称时间基。
不同的封装格式time_base不一样,转码过程中的不同阶段time_base也不一样。
以 mpegts 封装格式为例,假设视频帧率为 25 FPS。编码数据包 packet(数据结构 AVPacket) 对应的 time_base 为 AVRational{1,90000}。原始数据帧 frame(数据结构 AVFrame) 对应的 time_base 为 AVRational{1,25}。在解码或播放过程中,我们关注的是 frame 的 time_base,定义在 AVStream 结构体中,其表示形式 AVRational{1,25} 是一个分数,值为 1/25,单位是秒。在旧的 FFmpeg 版本中,AVStream 中的 time_base 成员有如下注释:
For fixed-fps content, time base should be 1/framerate and timestamp increments should be 1.
当前新版本中已无此条注释。
2.4.2 PTS/DTS/解码过程
DTS(Decoding Time Stamp, 解码时间戳),表示 packet 的解码时间。
PTS(Presentation Time Stamp, 显示时间戳),表示 packet 解码后数据的显示时间。
音频中 DTS 和 PTS 是相同的。视频中由于 B 帧需要双向预测,B 帧依赖于其前和其后的帧,因此含 B 帧的视频解码顺序与显示顺序不同,即 DTS 与 PTS 不同。当然,不含 B 帧的视频,其 DTS 和 PTS 是相同的。
解码顺序和显示顺序相关的解释可参考 “视频编解码基础概念”,选用下图说明视频流解码顺序和显示顺序:
理解了含 B 帧视频流解码顺序与显示顺序的不同,才容易理解视频解码函数 video_decode_frame() 的处理过程:
avcodec_send_packet() 按解码顺序发送 packet。
avcodec_receive_frame() 按显示顺序输出 frame。
这个过程由解码器处理,不需要用户程序费心。
video_decode_frame() 是非常核心的一个函数,实现如下:
// 从packet_queue中取一个packet,解码生成frame
static int video_decode_frame(AVCodecContext *p_codec_ctx, packet_queue_t *p_pkt_queue, AVFrame *frame)
{
int ret;
while (1)
{
AVPacket pkt;
while (1)
{
// 3. 从解码器接收frame
// 3.1 一个视频packet含一个视频frame
// 解码器缓存一定数量的packet后,才有解码后的frame输出
// frame输出顺序是按pts的顺序,如IBBPBBP
// frame->pkt_pos变量是此frame对应的packet在视频文件中的偏移地址,值同pkt.pos
ret = avcodec_receive_frame(p_codec_ctx, frame);
if (ret < 0)
{
if (ret == AVERROR_EOF)
{
av_log(NULL, AV_LOG_INFO, "video avcodec_receive_frame(): the decoder has been fully flushed\n");
avcodec_flush_buffers(p_codec_ctx);
return 0;
}
else if (ret == AVERROR(EAGAIN))
{
av_log(NULL, AV_LOG_INFO, "video avcodec_receive_frame(): output is not available in this state - "
"user must try to send new input\n");
break;
}
else
{
av_log(NULL, AV_LOG_ERROR, "video avcodec_receive_frame(): other errors\n");
continue;
}
}
else
{
frame->pts = frame->best_effort_timestamp;
//frame->pts = frame->pkt_dts;
return 1; // 成功解码得到一个视频帧或一个音频帧,则返回
}
}
// 1. 取出一个packet。使用pkt对应的serial赋值给d->pkt_serial
if (packet_queue_get(p_pkt_queue, &pkt, true) < 0)
{
return -1;
}
if (pkt.data == NULL)
{
// 复位解码器内部状态/刷新内部缓冲区
avcodec_flush_buffers(p_codec_ctx);
}
else
{
// 2. 将packet发送给解码器
// 发送packet的顺序是按dts递增的顺序,如IPBBPBB
// pkt.pos变量可以标识当前packet在视频文件中的地址偏移
if (avcodec_send_packet(p_codec_ctx, &pkt) == AVERROR(EAGAIN))
{
av_log(NULL, AV_LOG_ERROR, "receive_frame and send_packet both returned EAGAIN, which is an API violation.\n");
}
av_packet_unref(&pkt);
}
}
}
本函数实现如下功能:
[1] 从视频 packet 队列中取一个 packet。
[2] 将取得的 packet 发送给解码器。
[3] 从解码器接收解码后的 frame,此 frame 作为函数的输出参数供上级函数处理。
注意如下几点:
[1] 含 B 帧的视频文件,其视频帧存储顺序与显示顺序不同。
[2] 解码器的输入是 packet 队列,视频帧解码顺序与存储顺序相同,是按 dts 递增的顺序。dts 是解码时间戳,因此存储顺序解码顺序都是 dts 递增的顺序。avcodec_send_packet() 就是将视频文件中的 packet 序列依次发送给解码器。发送 packet 的顺序如 IPBBPBB。
[3]. 解码器的输出是 frame 队列,frame 输出顺序是按 pts 递增的顺序。pts 是解码时间戳。pts 与 dts 不一致的问题由解码器进行了处理,用户程序不必关心。从解码器接收 frame 的顺序如 IBBPBBP。
[4]. 解码器中会缓存一定数量的帧,一个新的解码动作启动后,向解码器送入好几个 packet 后解码器才会输出第一个 packet,这比较容易理解,因为解码时帧之间有信赖关系,例如 IPB 三个帧被送入解码器后,B 帧解码需要依赖 I 帧和 P 帧,所以在 B 帧输出前,I 帧和 P 帧必须存在于解码器中而不能删除。理解了这一点,后面视频 frame 队列中对视频帧的显示和删除机制才容易理解。
[5]. 解码器中缓存的帧可以通过冲洗(flush)解码器取出。冲洗(flush)解码器的方法就是调用 avcodec_send_packet(..., NULL),然后多次调用 avcodec_receive_frame() 将缓存帧取尽。缓存帧取完后,avcodec_receive_frame() 返回 AVERROR_EOF。
如何确定解码器的输出 frame 与输入 packet 的对应关系呢?可以对比 frame->pkt_pos 和 pkt.pos 的值,这两个值表示 packet 在视频文件中的偏移地址,如果这两个变量值相等,表示此 frame 来自此 packet。调试跟踪这两个变量值,即能发现解码器输入帧与输出帧的关系。为简便,就不贴图了。
2.4.3 视频同步到音频
视频同步到音频是 ffplay 的默认同步方式。在视频播放线程中实现。
视频播放线程中有一个很重要的函数 video_refresh(),实现了视频播放(包含同步控制)核心步骤,理解起来有些难度。
相关函数关系如下:
main() -->
player_running() -->
open_video() -->
open_video_playing() -->
SDL_CreateThread(video_playing_thread, ...) 创建视频播放线程
video_playing_thread() -->
video_refresh()
视频播放线程源码如下:
static int video_playing_thread(void *arg)
{
player_stat_t *is = (player_stat_t *)arg;
double remaining_time = 0.0;
while (1)
{
if (remaining_time > 0.0)
{
av_usleep((unsigned)(remaining_time * 1000000.0));
}
remaining_time = REFRESH_RATE;
// 立即显示当前帧,或延时remaining_time后再显示
video_refresh(is, &remaining_time);
}
return 0;
}
video_refresh() 函数源码如下:
/* called to display each frame */
static void video_refresh(void *opaque, double *remaining_time)
{
player_stat_t *is = (player_stat_t *)opaque;
double time;
static bool first_frame = true;
retry:
if (frame_queue_nb_remaining(&is->video_frm_queue) == 0) // 所有帧已显示
{
// nothing to do, no picture to display in the queue
return;
}
double last_duration, duration, delay;
frame_t *vp, *lastvp;
/* dequeue the picture */
lastvp = frame_queue_peek_last(&is->video_frm_queue); // 上一帧:上次已显示的帧
vp = frame_queue_peek(&is->video_frm_queue); // 当前帧:当前待显示的帧
// lastvp和vp不是同一播放序列(一个seek会开始一个新播放序列),将frame_timer更新为当前时间
if (first_frame)
{
is->frame_timer = av_gettime_relative() / 1000000.0;
first_frame = false;
}
// 暂停处理:不停播放上一帧图像
if (is->paused)
goto display;
/* compute nominal last_duration */
last_duration = vp_duration(is, lastvp, vp); // 上一帧播放时长:vp->pts - lastvp->pts
delay = compute_target_delay(last_duration, is); // 根据视频时钟和同步时钟的差值,计算delay值
time= av_gettime_relative()/1000000.0;
// 当前帧播放时刻(is->frame_timer+delay)大于当前时刻(time),表示播放时刻未到
if (time < is->frame_timer + delay) {
// 播放时刻未到,则更新刷新时间remaining_time为当前时刻到下一播放时刻的时间差
*remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);
// 播放时刻未到,则不播放,直接返回
return;
}
// 更新frame_timer值
is->frame_timer += delay;
// 校正frame_timer值:若frame_timer落后于当前系统时间太久(超过最大同步域值),则更新为当前系统时间
if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)
{
is->frame_timer = time;
}
SDL_LockMutex(is->video_frm_queue.mutex);
if (!isnan(vp->pts))
{
update_video_pts(is, vp->pts, vp->pos, vp->serial); // 更新视频时钟:时间戳、时钟时间
}
SDL_UnlockMutex(is->video_frm_queue.mutex);
// 是否要丢弃未能及时播放的视频帧
if (frame_queue_nb_remaining(&is->video_frm_queue) > 1) // 队列中未显示帧数>1(只有一帧则不考虑丢帧)
{
frame_t *nextvp = frame_queue_peek_next(&is->video_frm_queue); // 下一帧:下一待显示的帧
duration = vp_duration(is, vp, nextvp); // 当前帧vp播放时长 = nextvp->pts - vp->pts
// 当前帧vp未能及时播放,即下一帧播放时刻(is->frame_timer+duration)小于当前系统时刻(time)
if (time > is->frame_timer + duration)
{
frame_queue_next(&is->video_frm_queue); // 删除上一帧已显示帧,即删除lastvp,读指针加1(从lastvp更新到vp)
goto retry;
}
}
// 删除当前读指针元素,读指针+1。若未丢帧,读指针从lastvp更新到vp;若有丢帧,读指针从vp更新到nextvp
frame_queue_next(&is->video_frm_queue);
display:
video_display(is); // 取出当前帧vp(若有丢帧是nextvp)进行播放
}
视频同步到音频的基本方法是:如果视频超前音频,则不进行播放,以等待音频;如果视频落后音频,则丢弃当前帧直接播放下一帧,以追赶音频。
此函数执行流程参考如下流程图:
步骤如下:
[1] 根据上一帧 lastvp 的播放时长 duration,校正等到 delay 值,duration 是上一帧理想播放时长,delay 是上一帧实际播放时长,根据delay 值可以计算得到当前帧的播放时刻
[2] 如果当前帧 vp 播放时刻未到,则继续显示上一帧 lastvp,并将延时值 remaining_time 作为输出参数供上级调用函数处理
[3] 如果当前帧 vp 播放时刻已到,则立即显示当前帧,并更新读指针
在 video_refresh() 函数中,调用了 compute_target_delay() 来根据视频时钟与主时钟的差异来调节 delay 值,从而调节视频帧播放的时刻:
// 根据视频时钟与同步时钟(如音频时钟)的差值,校正delay值,使视频时钟追赶或等待同步时钟
// 输入参数delay是上一帧播放时长,即上一帧播放后应延时多长时间后再播放当前帧,通过调节此值来调节当前帧播放快慢
// 返回值delay是将输入参数delay经校正后得到的值
static double compute_target_delay(double delay, VideoState *is)
{
double sync_threshold, diff = 0;
/* update delay to follow master synchronisation source */
if (get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER) {
/* if video is slave, we try to correct big delays by
duplicating or deleting a frame */
// 视频时钟与同步时钟(如音频时钟)的差异,时钟值是上一帧pts值(实为:上一帧pts + 上一帧至今流逝的时间差)
diff = get_clock(&is->vidclk) - get_master_clock(is);
// delay是上一帧播放时长:当前帧(待播放的帧)播放时间与上一帧播放时间差理论值
// diff是视频时钟与同步时钟的差值
/* skip or repeat frame. We take into account the
delay to compute the threshold. I still don't know
if it is the best guess */
// 若delay < AV_SYNC_THRESHOLD_MIN,则同步域值为AV_SYNC_THRESHOLD_MIN
// 若delay > AV_SYNC_THRESHOLD_MAX,则同步域值为AV_SYNC_THRESHOLD_MAX
// 若AV_SYNC_THRESHOLD_MIN < delay < AV_SYNC_THRESHOLD_MAX,则同步域值为delay
sync_threshold = FFMAX(AV_SYNC_THRESHOLD_MIN, FFMIN(AV_SYNC_THRESHOLD_MAX, delay));
if (!isnan(diff) && fabs(diff) < is->max_frame_duration) {
if (diff <= -sync_threshold) // 视频时钟落后于同步时钟,且超过同步域值
delay = FFMAX(0, delay + diff); // 当前帧播放时刻落后于同步时钟(delay+diff<0)则delay=0(视频追赶,立即播放),否则delay=delay+diff
else if (diff >= sync_threshold && delay > AV_SYNC_FRAMEDUP_THRESHOLD) // 视频时钟超前于同步时钟,且超过同步域值,但上一帧播放时长超长
delay = delay + diff; // 仅仅校正为delay=delay+diff,主要是AV_SYNC_FRAMEDUP_THRESHOLD参数的作用,不作同步补偿
else if (diff >= sync_threshold) // 视频时钟超前于同步时钟,且超过同步域值
delay = 2 * delay; // 视频播放要放慢脚步,delay扩大至2倍
}
}
av_log(NULL, AV_LOG_TRACE, "video: delay=%0.3f A-V=%f\n",
delay, -diff);
return delay;
}
compute_target_delay() 的输入参数 delay 是上一帧理想播放时长 duration,返回值 delay 是经校正后的上一帧实际播放时长。为方便描述,下面我们将输入参数记作 duration(对应函数的输入参数 delay),返回值记作 delay(对应函数返回值 delay)。
本函数实现功能如下:
[1] 计算视频时钟与音频时钟(主时钟)的偏差 diff,实际就是视频上一帧 pts 减去音频上一帧 pts。所谓上一帧,就是已经播放的最后一帧,上一帧的 pts 可以标识视频流/音频流的播放时刻(进度)。
[2] 计算同步域值 sync_threshold,同步域值的作用是:若视频时钟与音频时钟差异值小于同步域值,则认为音视频是同步的,不校正 delay;若差异值大于同步域值,则认为音视频不同步,需要校正 delay值。同步域值的计算方法如下:
若 duration < AV_SYNC_THRESHOLD_MIN,则同步域值为 AV_SYNC_THRESHOLD_MIN
若 duration > AV_SYNC_THRESHOLD_MAX,则同步域值为 AV_SYNC_THRESHOLD_MAX
若 AV_SYNC_THRESHOLD_MIN < duration < AV_SYNC_THRESHOLD_MAX,则同步域值为 duration
[3] delay 校正策略如下:
[3.1] 视频时钟落后于同步时钟且落后值超过同步域值:
[3.1.1] 若当前帧播放时刻落后于同步时钟(delay+diff<0),则 delay=0(视频追赶,立即播放);
[3.1.2] 否则 delay=duration+diff
[3.2] 视频时钟超前于同步时钟且超过同步域值:
[3.2.1] 上一帧播放时长过长(超过最大值),仅校正为 delay=duration+diff;
[3.2.2] 否则 delay=duration×2,视频播放放慢脚步,等待音频
[3.3] 视频时钟与音频时钟的差异在同步域值内,表明音视频处于同步状态,不校正 delay,则 delay=duration
对上述视频同步到音频的过程作一个总结,参考下图:
图中,小黑圆圈是代表帧的实际播放时刻,小红圆圈代表帧的理论播放时刻,小绿方块表示当前系统时间(当前时刻),小红方块表示位于不同区间的时间点,则当前时刻处于不同区间时,视频同步策略为:
[1] 当前时刻在 T0 位置,则重复播放上一帧,延时 remaining_time 后再播放当前帧
[2] 当前时刻在 T1 位置,则立即播放当前帧
[3] 当前时刻在 T2 位置,则忽略当前帧,立即显示下一帧,加速视频追赶
上述内容是为了方便理解进行的简单而形象的描述。实际过程要计算相关值,根据 compute_target_delay() 和 video_refresh() 中的策略来控制播放过程。
2.4.4 音频播放过程
音频时钟是同步主时钟,音频按照自己的节奏进行播放即可。视频播放时则要参考音频时钟。音频播放函数由 SDL 音频播放线程回调,回调函数实现如下:
// 音频处理回调函数。读队列获取音频包,解码,播放
// 此函数被SDL按需调用,此函数不在用户主线程中,因此数据需要保护
// \param[in] opaque 用户在注册回调函数时指定的参数
// \param[out] stream 音频数据缓冲区地址,将解码后的音频数据填入此缓冲区
// \param[out] len 音频数据缓冲区大小,单位字节
// 回调函数返回后,stream指向的音频缓冲区将变为无效
// 双声道采样点的顺序为LRLRLR
static void sdl_audio_callback(void *opaque, Uint8 *stream, int len)
{
player_stat_t *is = (player_stat_t *)opaque;
int audio_size, len1;
int64_t audio_callback_time = av_gettime_relative();
while (len > 0) // 输入参数len等于is->audio_hw_buf_size,是audio_open()中申请到的SDL音频缓冲区大小
{
if (is->audio_cp_index >= (int)is->audio_frm_size)
{
// 1. 从音频frame队列中取出一个frame,转换为音频设备支持的格式,返回值是重采样音频帧的大小
audio_size = audio_resample(is, audio_callback_time);
if (audio_size < 0)
{
/* if error, just output silence */
is->p_audio_frm = NULL;
is->audio_frm_size = SDL_AUDIO_MIN_BUFFER_SIZE / is->audio_param_tgt.frame_size * is->audio_param_tgt.frame_size;
}
else
{
is->audio_frm_size = audio_size;
}
is->audio_cp_index = 0;
}
// 引入is->audio_cp_index的作用:防止一帧音频数据大小超过SDL音频缓冲区大小,这样一帧数据需要经过多次拷贝
// 用is->audio_cp_index标识重采样帧中已拷入SDL音频缓冲区的数据位置索引,len1表示本次拷贝的数据量
len1 = is->audio_frm_size - is->audio_cp_index;
if (len1 > len)
{
len1 = len;
}
// 2. 将转换后的音频数据拷贝到音频缓冲区stream中,之后的播放就是音频设备驱动程序的工作了
if (is->p_audio_frm != NULL)
{
memcpy(stream, (uint8_t *)is->p_audio_frm + is->audio_cp_index, len1);
}
else
{
memset(stream, 0, len1);
}
len -= len1;
stream += len1;
is->audio_cp_index += len1;
}
// is->audio_write_buf_size是本帧中尚未拷入SDL音频缓冲区的数据量
is->audio_write_buf_size = is->audio_frm_size - is->audio_cp_index;
/* Let's assume the audio driver that is used by SDL has two periods. */
// 3. 更新时钟
if (!isnan(is->audio_clock))
{
// 更新音频时钟,更新时刻:每次往声卡缓冲区拷入数据后
// 前面audio_decode_frame中更新的is->audio_clock是以音频帧为单位,所以此处第二个参数要减去未拷贝数据量占用的时间
set_clock_at(&is->audio_clk,
is->audio_clock - (double)(2 * is->audio_hw_buf_size + is->audio_write_buf_size) / is->audio_param_tgt.bytes_per_sec,
is->audio_clock_serial,
audio_callback_time / 1000000.0);
}
}
3. 编译与验证
3.1 编译
gcc -o ffplayer ffplayer.c -lavutil -lavformat -lavcodec -lavutil -lswscale -lswresample -lSDL2
3.2 验证
选用 clock.avi 测试文件,下载工程后,测试文件在 resources 子目录下
查看视频文件格式信息:
ffprobe clock.avi
打印视频文件信息如下:
[avi @ 0x9286c0] non-interleaved AVI
Input #0, avi, from 'clock.avi':
Duration: 00:00:12.00, start: 0.000000, bitrate: 42 kb/s
Stream #0:0: Video: msrle ([1][0][0][0] / 0x0001), pal8, 320x320, 1 fps, 1 tbr, 1 tbn, 1 tbc
Stream #0:1: Audio: truespeech ([34][0][0][0] / 0x0022), 8000 Hz, mono, s16, 8 kb/s
运行测试命令:
./ffplayer clock.avi
可以听到每隔 1 秒播放一次 “嘀” 声,声音播放 12 次。时针每隔 1 秒跳动一格,跳动 12 次。声音播放正常,画面播放也正常,声音与画面基本同步。
4. 问题记录
[1] 在Windows平台上有些电脑无法播放出声音
异常现象:
在一台电脑上声音能正常播放,在另一台电脑上无法正常播放
原因分析:
原因不清楚
解决方法:
环境一个变量 SDL_AUDIODRIVER=directsound(或 winmm) 即可。
参考资料 “[12] FFplay: WASAPI can't initialize audio client”
[2] 音频播放过程中持续卡顿
异常现象:
音频播放过程中持续卡顿,类似播一下停一下
原因分析:
SDL 音频缓冲区设置过小。缓冲区小可缓存数据量少,实时性要求高,缓冲区数据被取完,又无新数据送入时,会出现播放停顿现象。
解决方法:
增大 SDL 音频缓冲区
5. 遗留问题
[1]. 启动播放瞬间,视频画面未及时播放
[2]. 点击关闭按钮关闭播放器会引起内存异常报错
6. 参考资料
[1] 雷霄骅,视音频编解码技术零基础学习方法
[2] 视频编解码基础概念, https://www.cnblogs.com/leisure_chn/p/10285829.html
[3] FFmpeg基础概念, https://www.cnblogs.com/leisure_chn/p/10297002.html
[4] 零基础读懂视频播放器控制原理:ffplay播放器源代码分析, https://cloud.tencent.com/developer/article/1004559
[5] An ffmpeg and SDL Tutorial, Tutorial 05: Synching Video
[6] 视频同步音频, https://zhuanlan.zhihu.com/p/44615401
[7] 音频同步视频, https://zhuanlan.zhihu.com/p/44680734
[8] 音视频同步(播放)原理, https://blog.csdn.net/zhuweigangzwg/article/details/25815851
[9] 对ffmpeg的时间戳的理解笔记, https://blog.csdn.net/topsluo/article/details/76239136
[10] ffmpeg音视频同步---视频同步到音频时钟, https://my.oschina.net/u/735973/blog/806117
[11] FFmpeg音视频同步原理与实现, https://www.jianshu.com/p/3578e794f6b5
[12] FFplay: WASAPI can't initialize audio client, https://stackoverflow.com/questions/46835811/ffplay-wasapi-cant-initialize-audio-client-ffmpeg-3-4-binaries
[13] WASAPI can't initialize audio client, https://blog.csdn.net/A694543965/article/details/78786230
7. 修改记录
2019-01-17 V1.0 初稿
FFmpeg简易播放器的实现-音视频同步的更多相关文章
- FFmpeg简易播放器的实现-音视频播放
本文为作者原创,转载请注明出处:https://www.cnblogs.com/leisure_chn/p/10235926.html 基于FFmpeg和SDL实现的简易视频播放器,主要分为读取视频文 ...
- FFmpeg简易播放器的实现-视频播放
本文为作者原创:https://www.cnblogs.com/leisure_chn/p/10047035.html,转载请注明出处 基于FFmpeg和SDL实现的简易视频播放器,主要分为读取视频文 ...
- FFmpeg简易播放器的实现-音频播放
本文为作者原创,转载请注明出处:https://www.cnblogs.com/leisure_chn/p/10068490.html 基于FFmpeg和SDL实现的简易视频播放器,主要分为读取视频文 ...
- FFmpeg简易播放器的实现-最简版
本文为作者原创:https://www.cnblogs.com/leisure_chn/p/10040202.html,转载请注明出处 基于FFmpeg和SDL实现的简易视频播放器,主要分为读取视频文 ...
- ffmpeg 2.3版本号, 关于ffplay音视频同步的分析
近期学习播放器的一些东西.所以接触了ffmpeg,看源代码的过程中.就想了解一下ffplay是怎么处理音视频同步的,之前仅仅大概知道通过pts来进行同步,但对于怎样实现却不甚了解,所以想借助这个机会, ...
- vlc源码分析(五) 流媒体的音视频同步
vlc播放流媒体时实现音视频同步,简单来说就是发送方发送的RTP包带有时间戳,接收方根据此时间戳不断校正本地时钟,播放音视频时根据本地时钟进行同步播放.首先了解两个概念:stream clock和sy ...
- Android 音视频同步机制
一.概述 音视频同步(avsync),是影响多媒体应用体验质量的一个重要因素.而我们在看到音视频同步的时候,最先想到的就是对齐两者的pts,但是实际使用中的各类播放器,其音视频同步机制都比这些复杂的多 ...
- WebRTC 音视频同步原理与实现
所有的基于网络传输的音视频采集播放系统都会存在音视频同步的问题,作为现代互联网实时音视频通信系统的代表,WebRTC 也不例外.本文将对音视频同步的原理以及 WebRTC 的实现做深入分析. 时间戳 ...
- <Win32_17>集音频和视频播放功能于一身的简易播放器
前段时间,在学习中科院杨老师的教学视频时,他说了一句话: "我很反对百八十行的教学程序,要来就来一个完整的程序" 对此,我很是赞同.所谓真刀真枪的做了,你才会发现其中的奥秘——然而 ...
随机推荐
- Advice from an Old Programmer
You’ve finished this book and have decided to continue with programming. Maybe it will be a career f ...
- 7.Git与项目
Git简介 Git是目前世界上最先进的分布式版本控制系统 安装 sudo apt-get install git 安装成功后,运行如下命令 git 产生 Linus在1991年创建了开源的Linux, ...
- Kali Linux渗透测试实战 1.3 渗透测试的一般化流程
1.3 渗透测试的一般化流程 凡事预则立,不预则废,做任何事情都要有一个预先的计划.渗透测试作为测试学科的一个分支,早已形成了完整的方法论.在正式开始本书的实践教学章节之前,我也想谈一谈使用Kali ...
- Windows核心编程:第12章 纤程
Github https://github.com/gongluck/Windows-Core-Program.git //第12章 纤程.cpp: 定义应用程序的入口点. // #include & ...
- WPF ListBox的进阶使用(二)
项目中经常使用需要根据搜索条件查询数据,然后用卡片来展示数据.用卡片展示数据时,界面的宽度发生变化,希望显示的卡片数量也跟随变化.WrapPanel虽然也可以实现这个功能,但是将多余的部分都留在行尾, ...
- nodejs学习(imooc课程笔记, 主讲人Scott)
课程地址: 进击Node.js基础(一) 进击Node.js基础(二) 1. nodejs创建服务器 var http = require('http'); //加载http模块 //请求进来时, 告 ...
- atom编辑器社区插件推荐
atom是github出品的文本编辑器,为开发者又提供了一款易用.牛逼的文本编译器.在开始接触前端并从工作开始一直用webstrom来进行前端开发,开始使用时,被他各种强大神奇的功能给折服:支持zen ...
- jzoj3084
發現題目函數本質是: 1.將某一數x的末尾1去掉 2.不斷將這個數/2,直到遇到新的1 我們發現一個數z可以用y步到達數x,記x二進制長度為c,分2種情況討論: 1.x是奇數,則z的前c個二進制數必須 ...
- spark踩坑——dataframe写入hbase连接异常
最近测试环境基于shc[https://github.com/hortonworks-spark/shc]的hbase-connector总是异常连接不到zookeeper,看下报错日志: 18/06 ...
- VS2013 生成事件,删除不必要的DLL
解决方案中有一个 Project 是 Windows Service,用来从消息队列中取出事件,发送通知电邮: UI是一个MVC网站,两个Project都引用了同一个类库,这个类库引用了第三方的生成P ...