美文网首页音频专题Android音视频学习
ffplay.c源码阅读之音频、视频、字幕同步原理(四)

ffplay.c源码阅读之音频、视频、字幕同步原理(四)

作者: 仙人掌__ | 来源:发表于2021-10-01 16:04 被阅读0次

    前言

    • 音视频同步

    所谓音视频同步,是指视频画面和音频声音给用户的感觉是差不多的,一致的。对于已经压缩好的音视频流(不管是保存在本地的MP4文件还是来自远程的rtsp中的直播流)往往都是同步好了的音视频,所以音视频同步主要存在于播放端。先看如下一张图:


    image.png

    假设上图中视频播放时刻表分别为t11、t12、t13、t14、t15、t16,对应的音频的播放时刻表分别为t21、t22、t23、t24、t25、t26,只有当每次渲染对应时刻的音视频时间差在一定范围内才是音视频同步的。那么为什么会产生音视频不同步的现象呢?可能因为网络不稳定、解码抖动、渲染抖动等等原因造成视频或者音频渲染变慢,即从某一时刻开始,之后所有的音视频时间差都大于了某个阈值,给人的直观感受就是人声和画面不一致了。

    既然抖动在所难免,那该如何保持音视频同步呢?答案就是

    以某一个时间线作为基准,不停的检测最近一帧音视频之间的时间差,发现视频慢了就丢掉一些视频帧,音频慢了就丢掉一些音频帧,直到最新的音视频时间差小于阈值。

    以上就是音视频同步产生的原因以及同步的原理,业界常用的音视频同步方式有三种:
    1、视频同步音频:即以音频时间线为基准,不停检测视频和音频时间差,大于阈值丢弃视频帧直到最新的音视频时间差小于阈值才渲染此视频帧
    2、音频同步视频:即以视频时间线为基准,不停检测视频和音频时间差,大于阈值丢弃音频帧直到最新的音视频时间差小于阈值才渲染此音频帧
    3、音视频同步系统时钟:即以系统时钟为基准,不停检测视频和音频时间差,大于阈值丢弃对应的音视频直到最新的音视频时间差小于阈值才渲染此音视频帧

    由于音频丢帧很容易造成声音滋滋滋滋的现象,1秒内丢个几帧视频的人也感觉不到,所以通常选择方案一

    • 字幕同步

    字幕同步就是当前字幕的显示要和视频和音频对应,由于一段字幕往往在视频画面上显示几秒,这个时间已经足够长了,所以字幕和视频同步也就和音频同步了。以视频帧时间线为基准,检测某一时刻帧视频和对应时刻的字幕时间差,小于阈值即可渲染。

    ffplay.c视频同步音频或者系统时钟的实现

    ffplay.c默认就是视频同步音频的方案,不过视频同步音频或者视频同步系统时钟的代码都在这一块。首先进入视频渲染的函数,这里只贴出了音视频同步的关键代码,其它代码省略

    static void video_refresh(void *opaque, double *remaining_time)
    {
        
        。。。。。省略代码。。。。。。。。
        if (is->video_st) {
    retry:
                。。。。。省略代码。。。。。。。。
                // lastvp代表上一帧已渲染的视频,vp代表即将要渲染的本帧视频;last_duration = 本帧pts - 上一帧pts
                last_duration = vp_duration(is, lastvp, vp);
                /** 这里实现了视频同步音频或者同步系统时钟的关键代码
                 *  本帧视频是否能够播放的条件为 is->frame_timer (上一帧视频的播放时间) + delay(本帧视频的播放延迟) >= 当前系统时间
                 *  delay是基于音频时钟或者系统时钟计算出来的播放延迟时间(它的理论值就是本帧pts-上帧pts)
                 *  delay>=0 值越小代表视频播的太慢了,本帧越需要尽快播放,越大则代表视频播的太快了,本帧需要延后播放 为0 则视频有可能慢了音频至少一个帧
                 */
                delay = compute_target_delay(last_duration, is);
                
                // frame_timer表示上一帧的播放时刻(这个时刻比非实际显示到屏幕的时刻提前一点点时间)
                time= av_gettime_relative()/1000000.0;
                if (time < is->frame_timer + delay) {   // 如果本帧的播放时刻(即上一帧的播放时刻+以音频时钟为基准计算出的本帧的等候时长)大于当前时刻,代表本帧的播放时刻还未到来
                    // 渲染时间未到来则继续播放上一帧(由如下goto语句进行跳转),然后将remain_time赋值为本帧的播放时刻与当前时刻的时间差值
                    *remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);
                    goto display;   //继续播放上一帧
                }
                
                // 同步上一帧的播放时间
                /** 疑问:这里is->frame_timer += delay;为什么是这样写的,而不是直接is->frame_timer = time;呢?
                 *  分析:如果直接用is->frame_timer = time;进行赋值,那么视频帧因为某种原因累积了很多帧未播放时,那么会导致多出来的视频帧无法丢弃
                 *  实际上ffplay有两条时钟,一条时钟音视频时钟,用于音视频同步用,即计算这里的delay值,另一条时钟frame_timer用来记录上一帧的播放时间
                 *  同时用于计算是否满足丢帧的条件
                 */
                is->frame_timer += delay;
                if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)    // 处理首帧播放和音视频帧同时出现解码时间过程导致的抖动,这里就需要重新更新上一帧播放时间为当前时间了
                    is->frame_timer = time;
                
                /** ffplay.c里面有三个时间
                 *  1、frame_timer:保存在VideoState结构体里面,用以记录视频播放的时间点,该时间点基于系统时钟
                 *  2、pts:保存在视频Clock结构体里面,等同于视频帧的pts
                 *  3、pts_drift:视频帧的pts与视频播放时刻的时间差
                 */
                SDL_LockMutex(is->pictq.mutex);
                if (!isnan(vp->pts))
                    update_video_pts(is, vp->pts, vp->pos, vp->serial);
                SDL_UnlockMutex(is->pictq.mutex);
    
                // 如果本帧的pts+duration < time(当前时间)则丢弃该帧(说明队列中有大量还未渲染的视频帧,必须得丢掉一些了)
                if (frame_queue_nb_remaining(&is->pictq) > 1) {
                    Frame *nextvp = frame_queue_peek_next(&is->pictq);
                    duration = vp_duration(is, vp, nextvp);
                    if(!is->step && (framedrop>0 || (framedrop && get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER)) && time > is->frame_timer + duration){
                        is->frame_drops_late++;
                        frame_queue_next(&is->pictq);
                        goto retry;
                    }
                }
    
                // FrameQueue队列的当前读取指针rindex的值+1(即指向本帧的索引),并且删除上一帧的Frame数据(因为已经不需要了)
                frame_queue_next(&is->pictq);
                is->force_refresh = 1;
    
                if (is->step && !is->paused)
                    stream_toggle_pause(is);
            }
    display:
            /* display picture */
            // 执行渲染本帧的工作
            if (!display_disable && is->force_refresh && is->show_mode == SHOW_MODE_VIDEO && is->pictq.rindex_shown)
                video_display(is);
    }
    

    这里音视频同步的核心关键代码为:
    1、last_duration = vp_duration(is, lastvp, vp);求出本帧(表示即将要渲染的视频帧称为本帧)播放的等待时间,默认就是last_duration = 本帧pts - 上一帧pts
    2、delay = compute_target_delay(last_duration, is);基于音频时钟或者系统时钟计算出来的本帧的播放等待时间(即对last_duration的纠偏之后的值,它的理论值就是本帧pts-上帧pts);delay>=0 值越小代表视频播的太慢了,本帧越需要尽快播放,越大则代表视频播的太快了,本帧需要延后播放 为0 则视频有可能慢了音频至少一个帧,得立即渲染
    3、根据time < is->frame_timer + delay 判断本帧是否应该渲染;如果本帧的播放时刻(即上一帧的播放时刻+以音频时钟或者系统时钟为基准计算出的本帧的等候时长)大于当前时刻,代表本帧的播放时刻还未到来,等候一定时长后继续渲染上一帧,否则渲染本帧
    4、接下来同步视频播放相关时间等等,下一帧继续同样的方式判断
    5、如果前面计算出视频渲染过慢并且带渲染的视频帧还有很多,则丢弃本帧的渲染,重新从步骤1开始继续下一帧

    接下来是compute_target_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+上一帧渲染时刻到目前时间的差
             *  分析:通过下面的代码最终diff = 上一帧视频的pts-上一帧音频的pts- (上一帧视频实际播放时刻-上一帧音频实际播放时刻);假设不带上音视频的实际播放时刻那么diff = 视频的pts-音频的pts ,
             *  这将是音视频播放时间的理论值,显然是不能反映实际情况的,所以需要带上音视频的实际播放时间
             *  上面为视频同步到音频时的计算结果,如果为音视频同步到系统时钟那么有两种可能(0或者结果同视频同步到音频)
             */
            // 求出上一帧视频播放时刻基于音频或者系统时钟的时间差;为正代表视频比音频快,为负代表视频比音频慢,0则代表两者速度一样
            diff = get_clock(&is->vidclk) - get_master_clock(is);
    
            /* 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 */
            /** 疑问:视频基于音频同步的原理是什么?
             *  分析:假设视频第一帧的播放时间点为P0,那么所有视频的理论播放时间点为P0+T1,P0+T2,P0+T3,P0+T4.........(其中T0,T1分别代表视频第1帧到最后一帧的pts);
             *  同理,假设音频第一帧的播放时间点为P1,那么所有视频的理论播放时间点为P1+T1,P1+T2,P1+T3,P1+T4.........(其中T0,T1分别代表音频第1帧到最后一帧的pts);
             *  某一帧视频是否能够播放的条件为 frame_timer (上一帧视频的播放时间) + delay(本帧视频的等待时长) >= 当前时间,delay在理论情况下的值就是上一帧视频的时长
             *  而实际情况下由于音视频解码渲染等等的不稳定会导致这里的frame_timer与理论值不一样,可能快了也可能慢了,这里就用delay根据音频的时钟进行校准,然后来
             *  控制视频的播放速度。diff代表视频时钟和音频时钟的差值,这个差值在一定范围内(一般为一个视频帧的时长)则不需要校准,当diff为正代表视频比音频快,则delay
             *  增加为2倍延迟播放;为负代表视频比音频慢,则delay减小,加快播放。
             */
            // !isnan(diff) 表示首帧视频则不需要校准时间
            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减小,加快播放。
                    delay = FFMAX(0, delay + diff);
                else if (diff >= sync_threshold && delay > AV_SYNC_FRAMEDUP_THRESHOLD) // 当diff为正代表视频比音频快,这里快了0.1秒 则等待实际的时间再播放
                    delay = delay + diff;
                else if (diff >= sync_threshold)  // // 当diff为正代表视频比音频快,这里快了一个视频帧的时间 则等待两个视频帧的时间再播放
                    delay = 2 * delay;
            }
        }
    
        av_log(NULL, AV_LOG_TRACE, "video: delay=%0.3f A-V=%f\n",
                delay, -diff);
    
        return delay;
    }
    

    1、diff = get_clock(&is->vidclk) - get_master_clock(is);求出上一帧视频和上一帧音频pts之差和实际渲染时间之差的差值
    2、sync_threshold = FFMAX(AV_SYNC_THRESHOLD_MIN, FFMIN(AV_SYNC_THRESHOLD_MAX, delay));允许的误差阈值,sync_threshold的值为0.04(25fps视频的一帧时长),如果视频为>=25帧,否则就是就是一帧的视频时长
    3、进行纠偏,原则为:

    • diff为负并且绝对值大于阈值sync_threshold,代表视频比音频慢,delay = FFMAX(0, delay + diff);则delay减小,加快播放
    • diff为正代表视频比音频快,又分两种情况,delay 时间过长超过了0.1秒,则根据delay = delay + diff进行纠偏,否则根据delay = 2 * delay 进行纠偏

    ffplay.c音频同步视频的实现

    /* prepare a new audio buffer */
    static void sdl_audio_callback(void *opaque, Uint8 *stream, int len)
    {
        VideoState *is = opaque;
        int audio_size, len1;
    
        audio_callback_time = av_gettime_relative();
        
        /** 学习:音频渲染线程,它是通过SDL内部自驱动的一个回调函数,被周期性的回调,只需要不停的往里面填充音频即可进行音频的渲染了。每一次调用称为一个音频渲染周期
         *  len:代表需要填充的音频数据长度;stream代表填充音频的buffer地址
         *
         *  is->audio_buf_index:表示当前渲染周期内已拷贝的音频数据字节的索引,即下一块音频数据放入stream+is->audio_buf_index的位置
         *  is->audio_buf_size:表示当前音频Frame的字节数
         */
        while (len > 0) {
            if (is->audio_buf_index >= is->audio_buf_size) {
               // 音频同步视频或者音视频同步系统时钟会在此方法中最终调用synchronize_audio()进行同步
               audio_size = audio_decode_frame(is);
               if (audio_size < 0) {
                    /* if error, just output silence */
                   is->audio_buf = NULL;
                   is->audio_buf_size = SDL_AUDIO_MIN_BUFFER_SIZE / is->audio_tgt.frame_size * is->audio_tgt.frame_size;
               } else {
                   if (is->show_mode != SHOW_MODE_VIDEO)
                       update_sample_display(is, (int16_t *)is->audio_buf, audio_size);
                   is->audio_buf_size = audio_size;
               }
               is->audio_buf_index = 0;
            }
            len1 = is->audio_buf_size - is->audio_buf_index;
            if (len1 > len)
                len1 = len;
            if (!is->muted && is->audio_buf && is->audio_volume == SDL_MIX_MAXVOLUME)
                memcpy(stream, (uint8_t *)is->audio_buf + is->audio_buf_index, len1);
            else {
                memset(stream, 0, len1);
                if (!is->muted && is->audio_buf)
                    SDL_MixAudioFormat(stream, (uint8_t *)is->audio_buf + is->audio_buf_index, AUDIO_S16SYS, len1, is->audio_volume);
            }
            len -= len1;
            stream += len1;
            is->audio_buf_index += len1;
        }
        is->audio_write_buf_size = is->audio_buf_size - is->audio_buf_index;
        /* Let's assume the audio driver that is used by SDL has two periods. */
        // 更新音频时钟和系统时钟的值
        if (!isnan(is->audio_clock)) {
            set_clock_at(&is->audclk, is->audio_clock - (double)(2 * is->audio_hw_buf_size + is->audio_write_buf_size) / is->audio_tgt.bytes_per_sec, is->audio_clock_serial, audio_callback_time / 1000000.0);
            sync_clock_to_slave(&is->extclk, &is->audclk);
        }
    }
    

    这里会通过方法audio_decode_frame()调用synchronize_audio()来进行音频同步视频或者系统时钟,代码如下:

    /** 音频同步视频或者音视频同步系统时钟的原理:
     *  根据上一帧音频和视频之间渲染的时间差值纠偏判断是否在阈值内,如果超过阈值则丢弃部分音频采样,所以这里返回的wanted_nb_samples
     *  就是丢弃部分音频采样后的采样数
     */
    static int synchronize_audio(VideoState *is, int nb_samples)
    {
        int wanted_nb_samples = nb_samples;
    
        /* if not master, then we try to remove or add samples to correct the clock */
        if (get_master_sync_type(is) != AV_SYNC_AUDIO_MASTER) {
            double diff, avg_diff;
            int min_nb_samples, max_nb_samples;
            // 求出上一帧音频渲染时刻基于视频或者系统时钟的时间差;为正代表音频比视频快,为负代表音频比视频慢,0则代表两者速度一样
            diff = get_clock(&is->audclk) - get_master_clock(is);
            
            /** 音频同步视频或者音视频同步系统时钟时丢采样数的计算策略,它会先计算
             * 1、is->audio_diff_cum:音视频时间差的累积值
             * 2、is->audio_diff_avg_coef :计算上述累积值得一个系数,= exp(log(0.01) / AUDIO_DIFF_AVG_NB);
             * 3、is->audio_diff_avg_count:计算平均差值的分母为AUDIO_DIFF_AVG_NB
             */
            if (!isnan(diff) && fabs(diff) < AV_NOSYNC_THRESHOLD) { // 上一帧音视频时间差超过10秒了,不正常重置重新开始
                // 音视频时间差的累积值
                is->audio_diff_cum = diff + is->audio_diff_avg_coef * is->audio_diff_cum;
                if (is->audio_diff_avg_count < AUDIO_DIFF_AVG_NB) { // 为了便于计算平均时间差值,这里从之后20次开始计算
                    /* not enough measures to have a correct estimate */
                    is->audio_diff_avg_count++;
                } else {
                    /* estimate the A-V difference */
                    // 计算音视频差值的平均差值
                    avg_diff = is->audio_diff_cum * (1.0 - is->audio_diff_avg_coef);
                    // 当平均差值超过音视频允许差值的阈值时,决定丢弃部分音频采样数。跟视频同步做丢去不一样的是音频会取最近二十次(音视频渲染时刻差值的平均值)做决定丢弃采样数的,所以从这里可以看出音频渲染还是比较敏感的
                    if (fabs(avg_diff) >= is->audio_diff_threshold) {
                        wanted_nb_samples = nb_samples + (int)(diff * is->audio_src.freq);
                        min_nb_samples = ((nb_samples * (100 - SAMPLE_CORRECTION_PERCENT_MAX) / 100));
                        max_nb_samples = ((nb_samples * (100 + SAMPLE_CORRECTION_PERCENT_MAX) / 100));
                        wanted_nb_samples = av_clip(wanted_nb_samples, min_nb_samples, max_nb_samples);
                    }
                    av_log(NULL, AV_LOG_TRACE, "diff=%f adiff=%f sample_diff=%d apts=%0.3f %f\n",
                            diff, avg_diff, wanted_nb_samples - nb_samples,
                            is->audio_clock, is->audio_diff_threshold);
                }
            } else {
                /* too big difference : may be initial PTS errors, so
                   reset A-V filter */
                is->audio_diff_avg_count = 0;
                is->audio_diff_cum       = 0;
            }
        }
    
        return wanted_nb_samples;
    }
    

    1、diff = get_clock(&is->audclk) - get_master_clock(is);求出上一帧音频渲染时刻基于视频或者系统时钟的时间差;为正代表音频比视频快,为负代表音频比视频慢,0则代表两者速度一样
    2、音频同步视频或者音视频同步系统时钟时丢采样数的计算策略,它会先计算:

    • is->audio_diff_cum:音视频时间差的累积值
    • is->audio_diff_avg_coef :计算上述累积值得一个系数,= exp(log(0.01) / AUDIO_DIFF_AVG_NB);
    • is->audio_diff_avg_count:计算平均差值的分母为AUDIO_DIFF_AVG_NB

    通过avg_diff = is->audio_diff_cum * (1.0 - is->audio_diff_avg_coef);计算平均差值然后根据这个平均差值和音视频允许的时间差阈值进行比较,如果大于该阈值则丢弃本帧音频中的部分采样然后再进行渲染

    跟视频同步做丢去不一样的是音频会取最近二十次(音视频渲染时刻差值的平均值)做决定丢弃采样数的,所以从这里可以看出音频渲染还是比较敏感的

    ffplay.c字幕同步的实现

    这个就比较简单了,它就在视频渲染的代码里面

    static void video_refresh(void *opaque, double *remaining_time)
    {
        .....省略代码........
    
                if (is->subtitle_st) {
                    // 步骤1、字幕FrameQueue队列中是否有字幕帧,没有则退出循环
                    while (frame_queue_nb_remaining(&is->subpq) > 0) {
                        // 步骤2、获取当前待渲染字幕帧sp以及下一个待渲染字幕帧sp2(如果有的话)
                        sp = frame_queue_peek(&is->subpq);
    
                        if (frame_queue_nb_remaining(&is->subpq) > 1)
                            sp2 = frame_queue_peek_next(&is->subpq);
                        else
                            sp2 = NULL;
                        
                        // 步骤3、决定当前字幕帧是否需要被渲染。一帧字幕开始显示时间=pts+start_display_time,结束显示时间=pts+end_display_time
                        /** 学习:视频和字幕同步
                         *  分析:视频帧和字幕帧的同步主要以视频的时钟为准进行同步,这里is->vidclk.pts表示即将要渲染的视频帧的时间,当它大于(晚于)当前要渲染字
                         *  幕帧结束时间或者下一个要渲染字幕帧开始时间表示字幕显示已经落后于视频了,赶紧渲染当前字幕帧;否则就退出字幕帧渲染循环
                         */
                        // sp->serial != is->subtitleq.serial 用于首帧字幕渲染
                        /** 疑问:既然当前字幕帧都落后于即将要渲染的字幕帧了直接丢弃不就好了么?为撒要渲染上去呢?
                         *  分析:知悉分析就发现,下面这个if语句写法保证字幕帧在其显示时间内只被渲染一次。这样有利于效率提升
                         */
                        if (sp->serial != is->subtitleq.serial
                                || (is->vidclk.pts > (sp->pts + ((float) sp->sub.end_display_time / 1000)))
                                || (sp2 && is->vidclk.pts > (sp2->pts + ((float) sp2->sub.start_display_time / 1000))))
                        {
                            if (sp->uploaded) {
                                int i;
                                for (i = 0; i < sp->sub.num_rects; i++) {
                                    AVSubtitleRect *sub_rect = sp->sub.rects[i];
                                    uint8_t *pixels;
                                    int pitch, j;
    
                                    if (!SDL_LockTexture(is->sub_texture, (SDL_Rect *)sub_rect, (void **)&pixels, &pitch)) {
                                        for (j = 0; j < sub_rect->h; j++, pixels += pitch)
                                            memset(pixels, 0, sub_rect->w << 2);
                                        SDL_UnlockTexture(is->sub_texture);
                                    }
                                }
                            }
                            frame_queue_next(&is->subpq);
                        } else {
                            break;
                        }
                    }
                }
                
                // FrameQueue队列的当前读取指针rindex的值+1(即指向本帧的索引),并且删除上一帧的Frame数据(因为已经不需要了)
                frame_queue_next(&is->pictq);
                is->force_refresh = 1;
    
                if (is->step && !is->paused)
                    stream_toggle_pause(is);
            }
    display:
            /* display picture */
            // 执行渲染本帧的工作
            if (!display_disable && is->force_refresh && is->show_mode == SHOW_MODE_VIDEO && is->pictq.rindex_shown)
                video_display(is);
        }
        is->force_refresh = 0;
       ......省略代码.........
    }
    

    上面页注释了,决定当前字幕帧是否需要被渲染。一帧字幕开始显示时间=pts+start_display_time,结束显示时间=pts+end_display_time。视频帧和字幕帧的同步主要以视频的时钟为准进行同步,这里is->vidclk.pts表示即将要渲染的视频帧的时间,当它大于(晚于)当前要渲染字幕帧结束时间或者下一个要渲染字幕帧开始时间表示字幕显示已经落后于视频了,赶紧渲染当前字幕帧;否则就退出字幕帧渲染循环

    疑问:既然当前字幕帧都落后于即将要渲染的字幕帧了直接丢弃不就好了么?为撒要渲染上去呢?
    分析:知悉分析就发现,下面这个if语句写法保证字幕帧在其显示时间内只被渲染一次。这样有利于效率提升

    相关文章

      网友评论

        本文标题:ffplay.c源码阅读之音频、视频、字幕同步原理(四)

        本文链接:https://www.haomeiwen.com/subject/xrpxnltx.html