音视频同步

Posted 海岸星的清风

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了音视频同步相关的知识,希望对你有一定的参考价值。

音视频同步

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 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) 
        // 视频时钟与同步时钟(如音频时钟)的差异,时钟值是上一帧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,则同步域值为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;

本函数实现功能如下:

  • 计算视频时钟与音频时钟(主时钟)的偏差 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);
    

更新时钟

结构体和函数

Clock结构体:

// 时钟/同步时钟
typedef struct Clock 
    double pts;       		// 当前正在播放的帧的pts    /* clock base */
    double pts_drift;   	// 当前的pts与系统时间的差值  保持设置pts时候的差值,后面就可以利用这个差值推算下一个pts播放的时间点
    double last_updated; 	// 最后一次更新时钟的时间
    double speed;  				// 播放速度控制
    int serial;     			// 播放序列
    int paused;  					// 是否暂停
    int *queue_serial;   	// 队列的播放序列 PacketQueue中的serial
 Clock;

再结合关于时钟的几个函数看看:

// 主要由set_clock调用
static void set_clock_at(Clock *c, double pts, int serial, double time)

    c->pts = pts;
    c->last_updated = time;
    c->pts_drift = c->pts - time;
    c->serial = serial;


static void set_clock(Clock *c, double pts, int serial)

    double time = av_gettime_relative() / 1000000.0;
    set_clock_at(c, pts, serial, time);


static double get_clock(Clock *c)

    // 如果时钟的播放序列与待解码包队列的序列不一致,返回NAN,肯定就是不同步或者需要丢帧了
    if (*c->queue_serial != c->serial)
        return NAN;
    if (c->paused) 
        // 暂停状态则返回原来的pts
        return c->pts;
     else 
        double time = av_gettime_relative() / 1000000.0;
        // speed可以先忽略播放速度控制
        // 如果是1倍播放速度,c->pts_drift + time
        return c->pts_drift + time - (time - c->last_updated) * (1.0 - c->speed);
    

音频和视频每次在播放新的一帧数据时都会调用函数set_clock更新音频时钟或视频时钟。通过函数set_clock_at我们发现,就是更新了 Clock 结构体的四个变量。其中pts_drift是当前帧的pts与系统时间的差值,有了这个差值在未来的某一刻就能够很方便地算出当前帧对于系统时刻的时钟点。

更新音频时钟

声卡虽然是以音频采样点为播放单位,但每次往声卡缓冲区送一个音频frame,每送一个音频frame更新一下音频的播放时刻,即每隔一个音频frame时长更新一下音频时钟。

audio_decode_frame函数中,更新音频时钟audio_clock:

/* update the audio clock with the pts */
if (!isnan(af->pts))
  is->audio_clock = af->pts + (double) af->frame->nb_samples / af->frame->sample_rate;
else
  is->audio_clock = NAN;
is->audio_clock_serial = af->serial;

sdl_audio_callback函数中,设置音频时钟:

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);

更新视频时钟

video_refresh函数更新视频时钟:

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);

播放控制

暂停/继续

暂停/继续状态的切换是由用户按空格键实现的,每按一次空格键,暂停/继续的状态翻转一次。

函数调用关系如下:

main() -->
event_loop() -->
toggle_pause(

[SimplePlayer] 8. 音视频同步

音频与视频在播放当中可能会由于种种原因(如:音视频并非在同一时间开始播放,或视频由于解码任务繁重导致输出图像延迟等)导致音频与视频的播放时间出现偏差,这种就是音视频的同步问题,本文会对音视频同步进行讨论。

有三种音视频同步方式:

  1. 视频同步到音频时钟(synchronize video to audio clock)
  2. 音频同步到视频时钟(synchronize audio to video clock)
  3. 音视频同步到外部时钟(synchronize audio and video to external clock)

常见的实现方式是把视频同步到音频时钟。主要原因是,对于延迟以及卡顿,人的听觉较视觉更为敏感,需要尽量保持音频顺畅输出。

 

 

时钟

我们前面提到视频时钟、音频时钟指的是连续的时间。视频文件内存储的视频以及音频的时间戳都是离散的,不过如果我们假设视频与音频分别都有一个独立的时钟,各帧的时间戳代表的就是时钟上的某一时刻。如果在某一时刻,此时音频时钟与视频时钟上的刻度相同,则表明此时的音视频是完全同步的。

技术分享图片

为了得到音频或者视频的时钟,我们可以在音频或者视频输出时记录输出帧的$pts$以及此时的系统时间$CLK_{put}$,然后在进行时钟比较时取出上次输出时的$pts$以及$CLK_{put}$,并获取当前的系统时间$CLK_{get}$,最后执行以下运算就能得到音频或者视频时钟。

$CLK = pts + CLK_{get} – CLK_{put}$

 

 

延迟

实际上音视频之间总会存在延迟,如果在某一时刻,视频时钟的刻度大于音频时钟的刻度,则表明视频比音频播放早了,或者说音频延迟了。

技术分享图片

反之,如果在某一时刻,音频时钟的刻度大于视频时钟的刻度,则表明音频比视频播放早了,或者说视频延迟了。

技术分享图片

 

 

可接受的延迟范围

人对延迟的敏感度并不会很高,对于通常的视频帧率来说(24fps或者30fps,即延迟在40ms之内),一帧的延迟是人类不容易察觉,即可接受的延迟范围。因此,如果音频以及视频的时钟差值在此范围内,则不用特地进行调整。

技术分享图片

 

 

延迟调整

如果音频时钟与视频时钟之间的延迟超出可接受的范围,则需要进行时钟的调整,时钟调整主要是通过改变某一帧的播放时间来实现的,实现的方法也有多种。如果视频延迟了,则可以加快视频帧的播放速度:一解码完成,判断视频延迟,立即开始输出图像;或者直接跳过那些pts不在可接受范围内的帧。如果音频延迟了,则可以延迟视频的播放速度,或者先暂停输出后面的视频帧直到音频追上。

以上是关于音视频同步的主要内容,如果未能解决你的问题,请参考以下文章

ffmpeg 如何音视频同步

将 wav 和 mp4 与 ffmpeg 合并时,音视频同步漂移缓慢

FFmpeg concat 视频和音频不同步

FFmpeg实现音视频同步的精准片段拼接

FFmpeg实现音视频同步的精准片段拼接

FFmpeg实现音视频同步的精准片段拼接