FFmpeg简易播放器流程图

音视频同步的目的是为了使播放的声音和显示的画面保持一致。

视频按帧播放,图像显示设备每次显示一帧画面,视频播放速度由帧率确定,帧率指示每秒显示多少帧;

音频按采样点播放,声音播放设备每次播放一个采样点,声音播放速度由采样率确定,采样率指示每秒播放多少个采样点。

如果仅仅是视频按帧率播放,音频按采样率播放,二者没有同步机制,即使最初音视频是基本同步的,随着时间的流逝,音视频会逐渐失去同步,并且不同步的现象会越来越严重。

这是因为:一、播放时间难以精确控制,二、异常及误差会随时间累积。所以,必须要采用一定的同步策略,不断对音视频的时间差作校正,使图像显示与声音播放总体保持一致。

音视频同步的方式基本是确定一个时钟(音频时钟、视频时钟、外部时钟)作为主时钟,非主时钟的音频或视频时钟为从时钟。在播放过程中,主时钟作为同步基准,不断判断从时钟与主时钟的差异,调节从时钟,使从时钟追赶(落后时)或等待(超前时)主时钟。

按照主时钟的不同种类,可以将音视频同步模式分为如下三种:

  • 音频同步到视频,视频时钟作为主时钟;
  • 视频同步到音频,音频时钟作为主时钟;
  • 音视频同步到外部时钟,外部时钟作为主时钟;

ffplay默认的同步方式:视频同步到音频

I帧/IDR帧/P帧/B帧

I帧:I帧(Intra-codedpicture,帧内编码帧,常称为关键帧)包含一幅完整的图像信息,属于帧内编码图像,不含运动矢量,在解码时不需要参考其他帧图像。因此在I帧图像处可以切换频道,而不会导致图像丢失或无法解码。I帧图像用于阻止误差的累积和扩散。在闭合式GOP中,每个GOP的第一个帧一定是I帧,且当前GOP的数据不会参考前后GOP的数据

IDR帧:IDR帧(InstantaneousDecodingRefreshpicture,即时解码刷新帧)是一种特殊的I帧。当解码器解码到IDR帧时,会将DPB(DecodedPictureBuffer,指前后向参考帧列表)清空,将已解码的数据全部输出或抛弃,然后开始一次全新的解码序列。IDR帧之后的图像不会参考IDR帧之前的图像,因此IDR帧可以阻止视频流中的错误传播,同时IDR帧也是解码器、播放器的一个安全访问点。

P帧:P帧(Predictive-codedpicture,预测编码图像帧)是帧间编码帧,利用之前的I帧或P帧进行预测编码。

B帧:B帧(Bi-directionallypredictedpicture,双向预测编码图像帧)是帧间编码帧,利用之前和(或)之后的I帧或P帧进行双向预测编码。B帧不可以作为参考帧。B帧具有更高的压缩率,但需要更多的缓冲时间以及更高的CPU占用率,因此B帧适合本地存储以及视频点播,而不适用对实时性要求较高的直播系统。

GOP

GOP(Group Of Pictures,图像组)是一组连续的图像,由一个I帧和多个B/P帧组成,是编解码器存取的基本单位。GOP结构常用的两个参数M和N,M指定GOP中两个anchor frame(anchor frame指可被其他帧参考的帧,即I帧或P帧)之间的距离,N指定一个GOP的大小。例如M=3,N=15,GOP结构为:IBBPBBPBBPBBPBB

GOP有两种:闭合式GOP和开放式GOP。

  • 闭合式GOP:闭合式GOP只需要参考本GOP内的图像即可,不需参考前后GOP的数据。这种模式决定了,闭合式GOP的显示顺序总是以I帧开始,以P帧结束;
  • 开放式GOP:开放式GOP中的B帧解码时可能要用到其前一个GOP或后一个GOP的某些帧。码流里面包含B帧的时候才会出现开放式GOP。

在开放式GOP中,普通I帧和IDR帧功能是有差别的,需要明确区分两种帧类型。在闭合式GOP中,普通I帧和IDR帧功能没有差别,可以不作区分。

开放式GOP和闭合式GOP中I帧、P帧、B帧的依赖关系如下图所示:

DTS和PTS

  • DTS(Decoding Time Stamp, 解码时间戳),表示压缩帧的解码时间。
  • PTS(Presentation Time Stamp, 显示时间戳),表示将压缩帧解码后得到的原始帧的显示时间。

音频中DTS和PTS是相同的。视频中由于B帧需要双向预测,B帧依赖于其前和其后的帧,因此含B帧的视频解码顺序与显示顺序不同,即DTS与PTS不同。当然,不含B帧的视频,其DTS和PTS是相同的。下图以一个开放式GOP示意图为例,说明视频流的解码顺序和显示顺序:

以图中“B[1]”帧为例进行说明,“B[1]”帧解码时需要参考“I[0]”帧和“P[3]”帧,因此“P[3]”帧必须比“B[1]”帧先解码。这就导致了解码顺序和显示顺序的不一致,后显示的帧需要先解码。

video_decode_frame() 函数:

本函数实现如下功能:

  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。调试跟踪这两个变量值,即能发现解码器输入帧与输出帧的关系。

视频同步音频

视频同步到音频是 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 queuereturn;}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->ptsdelay = 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更新到nextvpframe_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) {// 视频时钟与同步时钟(如音频时钟)的差异,时钟值是上一帧pts值(实为:上一帧pts + 上一帧至今流逝的时间差)diff = get_clock(&is->vidclk) - get_master_clock(is);// delay是上一帧播放时长:当前帧(待播放的帧)播放时间与上一帧播放时间差理论值// diff是视频时钟与同步时钟的差值// 若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,则同步域值为delaysync_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+diffelse 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;
}

本函数实现功能如下:

  • 计算视频时钟与音频时钟(主时钟)的偏差 diff,实际就是视频上一帧 pts 减去音频上一帧 pts。所谓上一帧,就是已经播放的最后一帧,上一帧的 pts 可以标识视频流/音频流的播放时刻(进度)。
  • 计算同步域值 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
  • delay 校正策略如下:
    • 视频时钟落后于同步时钟且落后值超过同步域值:若当前帧播放时刻落后于同步时钟(delay+diff<0),则 delay=0(视频追赶,立即播放);否则 delay=duration+diff;
    • 视频时钟超前于同步时钟且超过同步域值:上一帧播放时长过长(超过最大值),仅校正为 delay=duration+diff;否则 delay=duration×2,视频播放放慢脚步,等待音频;
    • 视频时钟与音频时钟的差异在同步域值内,表明音视频处于同步状态,不校正 delay,则 delay=duration;

对上述视频同步到音频的过程作一个总结,参考下图:

图中,小黑圆圈是代表帧的实际播放时刻,小红圆圈代表帧的理论播放时刻,小绿方块表示当前系统时间(当前时刻),小红方块表示位于不同区间的时间点,则当前时刻处于不同区间时,视频同步策略为:

  • 当前时刻在 T0 位置,则重复播放上一帧,延时 remaining_time 后再播放当前帧;
  • 当前时刻在 T1 位置,则立即播放当前帧;
  • 当前时刻在 T2 位置,则忽略当前帧,立即显示下一帧,加速视频追赶;

上述内容是为了方便理解进行的简单而形象的描述。实际过程要计算相关值,根据 compute_target_delay() 和 video_refresh() 中的策略来控制播放过程。

音频播放

音频时钟是同步主时钟,音频按照自己的节奏进行播放即可,视频播放时则要参考音频时钟。音频播放函数由 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);}
}

FFmpeg和SDL实现视频播放器之 ⌈音视频同步⌋相关推荐

  1. ffmpeg入门小结(二)——音视频同步

    1.H264格式简介(视频解码同步相关) ---------------------- 前言 ----------------------- H264是新一代的编码标准,以高压缩高质量和支持多种网络的 ...

  2. FFmpeg和SDL实现视频播放器之 ⌈音频播放⌋

    实现播放视频文件中的声音,不显示图像. 使用FFmpeg库实现音频播放的流程图: 整体流程 音频播放的FFmpeg库函数的使用和视频播放的基本一样. // A1.1 打开视频文件:读取文件头,将文件格 ...

  3. FFmpeg 音视频同步

    原地址:http://www.jianshu.com/p/27279255f67e 音视频播放器的工作的具体流程如下图所示: 播放器工作流程 简单的来说包括:解协议,解封装,对音频和视频分别进行解码, ...

  4. NDK开发——FFmpeg实现视频转YUV、视频转RGB显示、音频转PCM、音频播放、音视频同步

    项目演示 前提准备 编译FFmpeg+CMake并能运行,详细可见我博客 下载libyuv库并编译成libyuv.so库,用于实现转换RGB格式功能 FFmpeg库简介 avcodec:编解码,包含 ...

  5. FFmpeg开发(五)——Qt视频播放器之封装FFmpeg类(参考了暴风影音、迅雷影音)

    FFmpeg开发(五)--Qt视频播放器之封装FFmpeg类(参考了暴风影音.迅雷影音) 上一篇介绍了,使用Qt和FFmpeg写的播放器.页面大家可以点进去查看和下载. FFmpeg开发(四)--Qt ...

  6. FFmpeg开发(七)——Qt视频播放器之播放列表类(参考了暴风影音、迅雷影音)

    FFmpeg开发(七)--Qt视频播放器之播放列表类(参考了暴风影音.迅雷影音) 上一篇分享了音频类的封装. FFmpeg开发(六)--Qt视频播放器之封装音频类(参考了暴风影音.迅雷影音) 本播放器 ...

  7. ffmpeg java 播放视频_Javacv使用ffmpeg实现音视频同步播放

    最近用javaCV的ffmpeg包的FFmpegFrameGrabber帧捕捉器对捕捉到的音频帧和视频帧做了同步的播放.采用的同步方法是视频向音频同步. 具体的思路如下: (1)首先介绍ffmpeg是 ...

  8. FFmpeg开发(八)——Qt视频播放器之多线程的使用(参考了暴风影音、迅雷影音)

    FFmpeg开发(八)--Qt视频播放器之多线程的使用(参考了暴风影音.迅雷影音) 上篇文章介绍了: FFmpeg开发(七)--Qt视频播放器之播放列表类(参考了暴风影音.迅雷影音) 本播放器系列相关 ...

  9. FFmpeg开发(九)——Qt视频播放器之快进滑动条(参考了暴风影音、迅雷影音)

    FFmpeg开发(九)--Qt视频播放器之快进滑动条(参考了暴风影音.迅雷影音) 上一篇介绍了使用多线程 FFmpeg开发(八)--Qt视频播放器之多线程的使用(参考了暴风影音.迅雷影音) 本播放器系 ...

最新文章

  1. 事件绑定及解除事件绑定
  2. 复方丙酸氯倍他索软膏購買技巧
  3. 手机基带芯片激荡 30 年!
  4. Python科学计算——Numpy.genfromtxt
  5. 一款简约好看的html5音乐播放器,一款好看又简洁的HTML5音乐播放器skPlayer
  6. python 源码分析 基本篇
  7. Excel 批量删除空白行,你用了 2 小时,同事 3 分钟就搞定了
  8. JAVA并发系列十九:深入理解ThreadLocal(三)–详解ThreadLocal内存泄漏问题
  9. [c/c++]trivial/POD类型和standard layout
  10. mysql存储指纹,mysql - ZKTeco 4500指纹验证 - 堆栈内存溢出
  11. 软工实践(五)——获小黄衫有感
  12. 耗时十个月的德国APS,教会我的学习方法
  13. 程序员喜爱的壁纸,需要自取
  14. C语言学习6:数据类型 -> 基本类型 -> 整型类型(int、short int、long int、char等)
  15. 转载分享一批老外的超牛25行代码参赛作品的Flash源文件
  16. 需求分析之Xmind构建思维导图
  17. Good Softwares and Tools
  18. Qt Quick使用简介
  19. Java方法重载实现原理及代码实例
  20. 几本.Net的经典书籍(转载)

热门文章

  1. IPSG和snooping配置记录
  2. JAVA类之间的构造顺序
  3. 点云bin格式和pcd格式相互转化
  4. AutoCAD的显示界面调佣
  5. ACM第二站——2018年第九届山东省省赛
  6. 如何在CSDN获得积分
  7. vm虚拟机安装mac os系统
  8. 中医五行简要图片----中医哲学理论基础学习
  9. 控制文件和数据文件丢失,有全备份恢复数据库的方法
  10. Android:安装包的反编译