音视频开发—音视频同步

1、FFmpeg简易播放器流程图

FFmpeg简易播放器流程图

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

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

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

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

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

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

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

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

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

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

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

2、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帧适合本地存储以及视频点播,而不适用对实时性要求较高的直播系统。

3、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帧的依赖关系如下图所示: 图3 GOP模式

4、DTS和PTS

DTS(Decoding Time Stamp, 解码时间戳),表示压缩帧的解码时间。 PTS(Presentation Time Stamp, 显示时间戳),表示将压缩帧解码后得到的原始帧的显示时间。 音频中DTS和PTS是相同的。视频中由于B帧需要双向预测,B帧依赖于其前和其后的帧,因此含B帧的视频解码顺序与显示顺序不同,即DTS与PTS不同。当然,不含B帧的视频,其DTS和PTS是相同的。下图以一个开放式GOP示意图为例,说明视频流的解码顺序和显示顺序:

图4 解码和显示顺序

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

5、视频同步音频

视频同步到音频是 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)进行播放
}

视频同步到音频的基本方法是:如果视频超前音频,则不进行播放,等待音频;如果视频落后音频,则丢弃当前帧直接播放下一帧,追赶音频。此函数执行流程参考如下流程图:

video_refresh()流程图

步骤如下:

  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;

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

ffplay音视频同步示意图

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

  • 当前时刻在 T0 位置,则重复播放上一帧,延时 remaining_time 后再播放当前帧;

  • 当前时刻在 T1 位置,则立即播放当前帧;

  • 当前时刻在 T2 位置,则忽略当前帧,立即显示下一帧,加速视频追赶;

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

【学习地址】:FFmpeg/WebRTC/RTMP/NDK/Android音视频流媒体高级开发

【文章福利】:免费领取更多音视频学习资料包、大厂面试题、技术视频和学习路线图,资料包括(C/C++,Linux,FFmpeg webRTC rtmp hls rtsp ffplay srs 等等)有需要的可以点击1079654574加群领取哦~

6、音频播放

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

7、更新时钟

7.1 结构体和函数

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与系统时间的差值,有了这个差值在未来的某一刻就能够很方便地算出当前帧对于系统时刻的时钟点。

7.2 更新音频时钟

声卡虽然是以音频采样点为播放单位,但每次往声卡缓冲区送一个音频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);
}

7.3 更新视频时钟

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

8、播放控制

8.1 暂停/继续

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

函数调用关系如下:

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

stream_toggle_pause()实现状态翻转:

/* pause or resume the video */
static void stream_toggle_pause(VideoState *is)
{
    if (is->paused) {
        // 这里表示当前是暂停状态,将切换到继续播放状态。在继续播放之前,先将暂停期间流逝的时间加到frame_timer中
        is->frame_timer += av_gettime_relative() / 1000000.0 - is->vidclk.last_updated;
        if (is->read_pause_return != AVERROR(ENOSYS)) {
            is->vidclk.paused = 0;
        }
        set_clock(&is->vidclk, get_clock(&is->vidclk), is->vidclk.serial);
    }
    set_clock(&is->extclk, get_clock(&is->extclk), is->extclk.serial);
    is->paused = is->audclk.paused = is->vidclk.paused = is->extclk.paused = !is->paused;
}

在video_refresh()函数中有如下代码:

/* called to display each frame */
static void video_refresh(void *opaque, double *remaining_time)
{
    ......
    
    // 视频播放
    if (is->video_st) {
        ......
        // 暂停处理:不停播放上一帧图像
        if (is->paused)
            goto display;
        
        ......
    }
    
    ......
}

在暂停状态下,实际就是不停播放上一帧(最后一帧)图像,画面不更新。

8.2 逐帧播放

逐帧播放是用户每按一次s键,播放器播放一帧画现。逐帧播放实现的方法是:每次按了s键,就将状态切换为播放,播放一帧画面后,将状态切换为暂停。

函数调用关系如下:

main() -->
event_loop() -->
step_to_next_frame() -->
stream_toggle_pause()

实现代码比较简单,如下:

static void step_to_next_frame(VideoState *is)
{
    /* if the stream is paused unpause it, then step */
    if (is->paused)
        stream_toggle_pause(is);        // 确保切换到播放状态,播放一帧画面
    is->step = 1;
}
/* called to display each frame */
static void video_refresh(void *opaque, double *remaining_time)
{
    ......
    
    // 视频播放
    if (is->video_st) {
        ......
        if (is->step && !is->paused)
            stream_toggle_pause(is);    // 逐帧播放模式下,播放一帧画面后暂停
        ......
    }
    
    ......
}

8.3 变速变调

  • ffmpeg实现变速播放的两种方案weixin小栓的博客-CSDN博客ffmpeg 倍速播放

  • 音频变速播放原理分析及实现方案 - Tocy - 博客园 (cnblogs.com)

  • SoundTouch实现音频变速变调wkw1125的博客-CSDN博客

  • 变速变调原理与方法总结 - WELEN - 博客园 (cnblogs.com)

  • ffplay使用ffmpeg滤镜实现倍速播放CodeOfCC的博客-CSDN博客ffmpeg 倍速

  • ffplay使用sonic实现倍速播放CodeOfCC的博客-CSDN博客ffplay 倍速

  • 开源播放器 ijkplayer (二) :ijkplayer倍速变调问题解决方案 - 灰色飘零 - 博客园 (cnblogs.com)

变速变调可分为:变速不变调和变调不变速。

语音变速不变调是指保持音调和语义保持不变,语速变快或变慢。该过程表现为语谱图在时间轴上如手风琴般压缩或者扩展。那也就是说,基频值几乎不变,对应于音调不变;整个时间过程被压缩或者扩展,声门周期的数目减小或者增加,即声道运动速率发生改变,语速也随之变化。对应于语音产生模型,激励和系统经历与原始发音情况几乎相同的状态,但持续时间相比原来或长或短。

严格地讲,基频和音调是两个不同的概念,基频是指声带振动的频率,音调是指人类对基频的主观感知,但是两者变化基本一致,即基频越高,音调越高,基频越低,音调越低,音调是由基频决定的。因此,语音变调不变速就是指改变说话人基频的大小,同时保持语速和语义不变,即保持短时频谱包络(共振峰的位置和带宽)和时间过程基本不变。对应于语音产生模型,变调改变了激励源;声道模型的共振峰参数几乎不变,保证了语义和语速不变。

综上所述,变速改变声道运动速率,力求保持激励源不变;变调改变激励源,力求保持声道的共振峰信息不变。但是声源和声道不是相互独立的,在改变声源时,必然也会非线性的影响声道,同样地,改变声道时也会或多或少的影响声源,两者之间相互影响,相互作用。

语音变调在变声软件中较常用。而语音变速在播放器中常用,比如倍速播放(快播、慢播)。相对于视频基于帧的变速原理,跳帧或者插帧,音频的变速原理并不是如此简单,因为简单的抽采样点会引起声音的不连续、噪声或爆破音,主观体验较差。

目前较为常用的音频变速解决方案有两个:soundtouch和Sonic。ijkplayer使用的是soundtouch,EXOPlayer使用的是Sonic。在Android上还有一种实现方式,基于AudioTrack的变速播放。

Sonic和Soundtouch用法类似,都是提供封装好的库,将原音频的PCM数据通过接口函数处理为目标格式,比如二倍速,可能PCM采样点就减半。Soundtouch提供的接口如下所示:

参数设置类接口:

  • setChannels(int) 设置声道,1 = mono单声道, 2 = stereo立体声

  • setSampleRate(uint) 设置采样率

  • setRate(double) 指定播放速率,原始值为1.0,大快小慢

  • setTempo(double) 指定节拍,原始值为1.0,大快小慢

  • setRateChange(double)、setTempoChange(double) 在原速1.0基础上,按百分比做增量,取值(-50 … +100 %)

  • setPitch(double) 指定音调值,原始值为1.0

  • setPitchOctaves(double) 在原音调基础上以八度音为单位进行调整,取值为[-1.00,+1.00]

  • setPitchSemiTones(int) 在原音调基础上以半音为单位进行调整,取值为[-12,+12]

PCM处理类接口:

  • putSamples(const SAMPLETYPE *samples, uint nSamples) 输入采样数据

  • receiveSamples(SAMPLETYPE *output, uint maxSamples) 输出处理后的数据,需要循环执行 flush() 冲出处理管道中的最后一组“残留”的数据,应在最后执行

  • 从上述接口来看,类似于常规的解码器或者解复用器的调用逻辑。

8.3.1 SEEK操作(快进快退)

ffplay分析 (seek操作处理)“好记性不如”烂博客的博客-CSDN博客ffplay seek ffplay源码分析7-播放控制 - 叶余 - 博客园 (cnblogs.com)

8.3.2 数据结构及SEEK标志

SEEK操作就是由用户干预而改变播放进度的实现方式,比如鼠标拖动播放进度条。

相关数据变量定义如下:

typedef struct VideoState {
    ......
    int seek_req;                   // 标识一次SEEK请求
    int seek_flags;                 // SEEK标志,诸如AVSEEK_FLAG_BYTE等
    int64_t seek_pos;               // SEEK的目标位置(当前位置+增量)
    int64_t seek_rel;               // 本次SEEK的位置增量
    ......
} VideoState;

VideoState.seek_flags表示SEEK标志。SEEK标志的类型定义如下:

#define AVSEEK_FLAG_BACKWARD 1 ///< seek backward
#define AVSEEK_FLAG_BYTE     2 ///< seeking based on position in bytes
#define AVSEEK_FLAG_ANY      4 ///< seek to any frame, even non-keyframes
#define AVSEEK_FLAG_FRAME    8 ///< seeking based on frame number

SEEK目标播放点(后文简称SEEK点)的确定,根据SEEK标志的不同,分为如下几种情况:

  1. AVSEEK_FLAG_BYTE:SEEK点对应文件中的位置(字节表示)。有些解复用器可能不支持这种情况。

  2. AVSEEK_FLAG_FRAME:SEEK点对应stream中frame序号,stream由stream_index指定。有些解复用器可能不支持这种情况。

    • 如果不含上述两种标志且stream_index有效:SEEK点对应时间戳,单位是stream中的timebase,stream由stream_index指定。SEEK点的值由“目标frame中的pts(秒) × stream中的timebase”得到。

    • 如果不含上述两种标志且stream_index是-1:SEEK点对应时间戳,单位是AV_TIME_BASE。SEEK点的值由“目标frame中的pts(秒) × AV_TIME_BASE”得到。

  3. AVSEEK_FLAG_ANY:SEEK点对应帧序号,播放点可停留在任意帧(包括非关键帧)。有些解复用器可能不支持这种情况。

  4. AVSEEK_FLAG_BACKWARD:忽略。

其中AV_TIME_BASE是FFmpeg内部使用的时间基,定义如下:

/**
 * Internal time base represented as integer
 */
#define AV_TIME_BASE            1000000

AV_TIME_BASE表示1000000us。

8.3.3 SEEK的触发方式

通过异步事件机制实现[快进]/[快退]控制的。首先通过上下左右4个方向键触发[快进]/[快退]事件,其中,左右键设定[快进]/[快退]10s,上下键设定[快进]/[快退]60s。然后在main函数的事件循环处理逻辑中,通过sdl来监听捕获每个按键对应的消息,接着通过goto跳转到do_seek执行具体的事件处理逻辑。

在event_loop()函数进行的SDL消息处理中有如下代码片段:

//事件到来后唤醒主线程后,检查事件类型,执行相应操作
case SDLK_LEFT: //左键
    incr = seek_interval ? -seek_interval : -10.0; //后退10s
    goto do_seek;
case SDLK_RIGHT: //右键
    incr = seek_interval ? seek_interval : 10.0; //前进10s
    goto do_seek;
case SDLK_UP: //上键
    incr = 60.0; //前进60s
    goto do_seek;
case SDLK_DOWN: //下键
    incr = -60.0; //后退60s
do_seek://处理请求
        if (seek_by_bytes) {//通过字节方式seek
            pos = -1;
            //从frame(解码后)队列中获取当前播放到什么位置
            if (pos < 0 && cur_stream->video_stream >= 0)
                pos = frame_queue_last_pos(&cur_stream->pictq);
            if (pos < 0 && cur_stream->audio_stream >= 0)
                pos = frame_queue_last_pos(&cur_stream->sampq);
            if (pos < 0)
                pos = avio_tell(cur_stream->ic->pb);
            //根据封装格式的比特率计算一秒有多少个字节
            //计算seek的秒数相应有多少个字节
            if (cur_stream->ic->bit_rate)
                incr *= cur_stream->ic->bit_rate / 8.0; //比特除以8得到字节
            else
                incr *= 180000.0;
            //当前位置加上偏移量得到seek的位置
            pos += incr;
            //进行seek参数设置,实际seek调用是在数据读取线程read_thread()里进行
            stream_seek(cur_stream, pos, incr, 1);
        } else {//通过时间方式seek
            //获取播放当前时间(s)
            pos = get_master_clock(cur_stream);
            //get_master_clock(cur_stream)返回失败值,再用这个方法获取
            if (isnan(pos))
                pos = (double)cur_stream->seek_pos / AV_TIME_BASE;
            //当前位置加上偏移量得到seek的位置
            pos += incr; 
            //cur_stream->ic->start_time为封装文件第一帧的时间
            //如果seek值比这个小就充值为cur_stream->ic->start_time
            if (cur_stream->ic->start_time != AV_NOPTS_VALUE && pos < cur_stream->ic->start_time / (double)AV_TIME_BASE)
                pos = cur_stream->ic->start_time / (double)AV_TIME_BASE;
            //进行seek参数设置,实际seek调用是在数据读取线程read_thread()里进行
            //pos和incr均转为微秒
            stream_seek(cur_stream, (int64_t)(pos * AV_TIME_BASE), (int64_t)(incr * AV_TIME_BASE), 0);
        }
    break;

seek_by_bytes生效(对应AVSEEK_FLAG_BYTE标志)时,SEEK点对应文件中的位置,上述代码中设置了对应1秒数据量的播放增量;不生效时,SEEK点对应于播放时刻。

此函数实现如下功能:

  1. 首先确定SEEK操作的播放进度增量(SEEK增量)和目标播放点(SEEK点),seek_by_bytes不生效时,将增量设为选定值,如10.0秒(用户按“RIGHT”键的情况)。

  2. 将同步主时钟加上进度增量,即可得到SEEK点。先将相关数值记录下来,供后续SEEK操作时使用。stream_seek(cur_stream, (int64_t)(pos * AV_TIME_BASE), (int64_t)(incr * AV_TIME_BASE), 0);就是记录目标播放点和播放进度增量两个参数的,精确到微秒。

再看一下stream_seak()函数的实现,仅仅是变量赋值:

/* seek in the stream */
//进行seek参数设置,实际seek调用是在数据读取线程read_thread()里进行
static void stream_seek(VideoState *is, int64_t pos, int64_t rel, int seek_by_bytes)
{
    //当前没有seek请求才进行参数设置
    if (!is->seek_req) {
        is->seek_pos = pos; //seek到的位置(字节/微秒)
        is->seek_rel = rel; //seek的偏移(字节/微秒)
        is->seek_flags &= ~AVSEEK_FLAG_BYTE;
        if (seek_by_bytes)
            is->seek_flags |= AVSEEK_FLAG_BYTE;
        is->seek_req = 1;
        //如果数据读取线程read_thread()睡眠则唤醒
        SDL_CondSignal(is->continue_read_thread);
    }
}

8.3.4 SEEK操作的实现

在解复用线程主循环中处理了SEEK操作。

static int read_thread(void *arg)
{
    ......
    for (;;) {
        //判断是否有seek请求
        if (is->seek_req) {
            //seek_target的位置不一定对应能够播放的位置,如不是I帧,则会偏移到合适的位置
            int64_t seek_target = is->seek_pos;
            //可以接受seek_target的最小值
            int64_t seek_min    = is->seek_rel > 0 ? seek_target - is->seek_rel + 2: INT64_MIN;
            //可以接受seek_target的最大值
            int64_t seek_max    = is->seek_rel < 0 ? seek_target - is->seek_rel - 2: INT64_MAX;
            //调用avformat_seek_file()进行真正的seek操作
            //阻塞函数,等待seek完成才返回
            ret = avformat_seek_file(is->ic, -1, seek_min, seek_target, seek_max, is->seek_flags);
            if (ret < 0) {
                av_log(NULL, AV_LOG_ERROR,
                       "%s: error while seeking\n", is->ic->url);
            } else {
                //seek时要把原来的数据清空,重置解码器
                if (is->audio_stream >= 0) {
                    //清空Packet(解码前)队列的数据
                    packet_queue_flush(&is->audioq);
                    //放入flush_pkt,重新开始一个播放序列(serial)
                    //解码器读到flush_pkt会清空解码器内缓存的Packet数据,serial++
                    packet_queue_put(&is->audioq, &flush_pkt);
                }
                if (is->subtitle_stream >= 0) {
                    packet_queue_flush(&is->subtitleq);
                    packet_queue_put(&is->subtitleq, &flush_pkt);
                }
                if (is->video_stream >= 0) {
                    packet_queue_flush(&is->videoq);
                    packet_queue_put(&is->videoq, &flush_pkt);
                }
                if (is->seek_flags & AVSEEK_FLAG_BYTE) {
                   set_clock(&is->extclk, NAN, 0);
                } else {
                   set_clock(&is->extclk, seek_target / (double)AV_TIME_BASE, 0);
                }
            }
            is->seek_req = 0;
            is->queue_attachments_req = 1;
            is->eof = 0;
            //如果是暂停状态,显示下一帧就暂停
            if (is->paused)
                step_to_next_frame(is);
        }
    }
    ......
}
  1. 调用avformat_seek_file()完成解复用器中的SEEK点切换操作;

    // 函数原型
    int avformat_seek_file(AVFormatContext *s, int stream_index, int64_t min_ts, int64_t ts, int64_t max_ts, int flags);
    // 调用代码
    ret = avformat_seek_file(is->ic, -1, seek_min, seek_target, seek_max, is->seek_flags);

    这个函数会等待SEEK操作完成才返回。实际的播放点力求最接近参数ts,并确保在[min_ts, max_ts]区间内,之所以播放点不一定在ts位置,是因为ts位置未必能正常播放。函数与SEEK点相关的三个参数(实参“seek_min”,“seek_target”,“seek_max”)取值方式与SEEK标志有关(实参“is->seek_flags”),此处“is->seek_flags”值为0。

  2. 冲洗各解码器缓存帧,使当前播放序列中的帧播放完成,然后再开始新的播放序列 (播放序列由各数据结构中的“serial”变量标志)。代码如下:

    if (is->video_stream >= 0) {
        packet_queue_flush(&is->videoq);
        packet_queue_put(&is->videoq, &flush_pkt);
    }
  3. 清除本次SEEK请求标志is->seek_req = 0;

原文链接:音视频同步_海岸星的清风的博客-CSDN博客

猜你喜欢

转载自blog.csdn.net/irainsa/article/details/130372979