[音视频] FFmpeg 时间系统和 FFplay 音画同步逻辑

659 阅读12分钟

[音视频] FFmpeg 时间系统和 FFplay 音画同步逻辑

FFmpeg 在音视频中的地位不用多描述,目前我还没有见过它解不了的编码格式。FFplayFFmpeg 官方基于 FFmpeg 开发的一款跨平台的播放器,不仅仅支持本地文件,还支持多种流媒体协议,在 Android 平台上非常知名的 ijkplayer 也是基于 FFplay 魔改支持 Android 的(不过 ijkplayer 已经很久不更新了),所以 FFplay 是一个非常好的播放器的样板代码,学习的价值也很高。

FFmpeg 的时间系统

有理数结构体

/**
 * Rational number (pair of numerator and denominator).
 */
typedef struct AVRational{
    int num; ///< Numerator
    int den; ///< Denominator
} AVRational;

这个结构体很简单,num 表示分子,den 表示分母。相当于用分数来表示有理数,相对于直接用 double 来表示,这种方法永远是不会有精度损失的。当有的时候我们需要使用上面的结构体来完成算数运算时,可以通过 av_q2d() 方法来将 AVRational 转换成浮点数。不过这里有一个小小的坑,有的时候 den 也就是分母有可能是 0,我之前在取视频的 fps 的时候就遇到过,就是 FFmpeg 没有猜出来这个 fps 具体是多少,所以在做运算的时候判断一下分母是一个好习惯。

比如前面说到的视频 fps 和后面要讲到的 time_base 他们都是使用 AVRational 结构体来表示,当你遇到这个结构体了希望你能够不用再懵逼。

FFmpeg 中的时间换算

在使用 FFmpeg 的过程中我们会遇到很多与时间有关的参数,比如音视频流的 duration,编解码相关的 ptsdts。但是这里有一个问题,这些时间都需要一个单位,我们自己常用的时间单位是毫秒,秒,分钟等等。但是在 FFmpeg 中专门有一个参数来描述这种单位,就是上面讲到的 time_base(不过我个人认为叫 time_unit 可能没有那么容易让人费解),也就是我们需要的时间 * time_base 后就得到了秒,通过秒我们就可以转换成别的时间单位了。

所以假如我们想要获取解码后帧的 pts 的毫秒的表示代码就可以这么写:

long ptsInMillis = -1L;
if (frame->pts != AV_NOPTS_VALUE) {
    ptsInMillis = (long) ((double)frame->pts * av_q2d(frame->time_base) * 1000.0);
}

如果 pts / dts 的值为 AV_NOPTS_VALUE,就表示不可用,最好做一下判断。

其中有的时候你没有办法获取 time_base,你可以去拿 AV_TIME_BASE_Q,他也是 AVRational 格式,而 AV_TIME_BASEAV_TIME_BASE_Q 的倒数,它是一个整型。

如果使用 AV_TIME_BASE_Q 来把多媒体文件时长换算成毫秒,代码就如下:

this->duration = ((double) format_ctx->duration) * av_q2d(AV_TIME_BASE_Q) * 1000.0;

如果使用 AV_TIME_BASE 就不能用乘了,就要用除了,这里需要注意下:

this->duration = (double) format_ctx->duration / (double)AV_TIME_BASE * 1000.0;

这里再举一个毫秒转换成 FFmpeg 时间单位的例子,seek 操作就是一个很好的例子,seek 我们传入的是毫秒的单位的时间我们需要转换成 FFmpeg 时间单位:

int64_t seekTs = targetPtsInMillis * AV_TIME_BASE / 1000L;
int ret = avformat_seek_file(format_ctx, -1, INT64_MIN, seekTs, INT64_MAX, AVSEEK_FLAG_BACKWARD);

有的时候我们需要一个时间点来做基准时间点,比如我们来通过以下代码来计算某段时间的耗时:

val start = System.currentTimeMillis()
Thread.sleep(100)
val end = System.currentTimeMillis()
val cost = end - start

这个代码其实不好,因为 System.currentTimeMillis() 这个拿的是系统时间,他是 1970-01-01 到现在的时长,当用户修改系统时间后这个值就会改变,如果正好在上面的 sleep 时用户修改系统时间,那么上面代码的计算就会出错。

Android 中我们可以通过 SystemClock.uptimeMillis() 来优化上面的代码,就不会出现问题:

val start = SystemClock.uptimeMillis()
Thread.sleep(100)
val end = SystemClock.uptimeMillis()
val cost = end - start

SystemClock.uptimeMillis() 它是系统开机后到现在的时间,就算用户修改系统时间,也不会影响这个值。

我们回到 FFmpeg 中,FFmpeg 也有类似于 SystemClock.uptimeMillis() 这样的时间,通过以下方法就能够获取:

av_gettime_relative();

在做音画同步时这个时间很有用,同样的如果要转换成毫秒,也要做上面的类似的操作。

FFplay 音画同步逻辑

首先要明确一点,音频流是不需要同步的,你只需要往播放器中喂解码好的数据,解码成功后播放器会回调给你,无论是 SDL 还是 OpenSL 他们当中都有这样的回调,收到这个回调后我们就能够直接当前音频播放的 pts。而视频就不一样,我们需要在正确的时间去完成渲染,所谓的正确的时间也就是要和声音同步,不能过快,也不能过慢。

开始之前必须先了解下 FFplayClock 结构体:

typedef struct Clock {
    double pts;           /* clock base */
    double pts_drift;     /* clock base minus time at which we updated the clock */
    double last_updated;
    double speed;
    int serial;           /* clock is based on a packet with this serial */
    int paused;
    int *queue_serial;    /* pointer to the current packet queue serial, used for obsolete clock detection */
} Clock;
  • pts: 上次完成渲染的 pts
  • last_updated: 上次更新 pts 的时间,也就是用的上面说的 av_gettime_relative() 方法,不过被换算成了秒。
  • pts_drift: 也就是 pts - last_update
  • speed: 速度控制,默认为 1

FFplay 中有三个 Clock 的实例,分别是 audclkvidclkextclkaudclkvidclk 分别会在音频完成渲染,视频完成渲染更新;而 extclk 音频和视频完成渲染后都会更新。

我们看看 Clock 更新的代码:

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

朴实无华的代码,和我上面描述 Clock 参数时一样。

我们再看看获取时钟值的方法:

static double get_clock(Clock *c)
{
    if (*c->queue_serial != c->serial)
        return NAN;
    if (c->paused) {
        return c->pts;
    } else {
        double time = av_gettime_relative() / 1000000.0;
        return c->pts_drift + time - (time - c->last_updated) * (1.0 - c->speed);
    }
}

如果是暂停的话,直接返回 pts;其他情况下计算的逻辑就是 pts - last_updated + time - (time - last_updated) * (1.0 - speed),默认情况下 speed 是 1,那么最终获取到的值就是 pts 加上当前时间和上次更新时间的间隔,如果是 speed 为 0 到 1,那么值就偏大,如果 speed 大于 1 那么值就偏小.

前面说到只有视频渲染需要同步,而音频流不需要同步,同步的方式有三种:

enum {
    AV_SYNC_AUDIO_MASTER, /* default choice */
    AV_SYNC_VIDEO_MASTER,
    AV_SYNC_EXTERNAL_CLOCK, /* synchronize to an external clock */
};

分别表示基于上面的三种时钟做同步,默认是基于音频的时钟做同步,如果是选择视频的时钟做同步那么就是不做音画同步,也就是放飞自我,很有可能出现音画不同步的情况,后面我们会看到这部分代码。

有一说一这个代码的命名政治不是特别正确,基准的时钟他称为 master,被同步的时钟被称为 slave(后面我们会看到这部分代码)。

前面我们说到只有视频需要做同步,所以我们移步到视频渲染的逻辑:

static void refresh_loop_wait_event(VideoState *is, SDL_Event *event) {
    double remaining_time = 0.0;
    SDL_PumpEvents();
    while (!SDL_PeepEvents(event, 1, SDL_GETEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT)) {
        if (!cursor_hidden && av_gettime_relative() - cursor_last_shown > CURSOR_HIDE_DELAY) {
            SDL_ShowCursor(0);
            cursor_hidden = 1;
        }
        if (remaining_time > 0.0)
            // Sleep 到正确的渲染时间,这里 remaining_time 转换成了 ffmpeg 的时间单位
            av_usleep((int64_t)(remaining_time * 1000000.0));
        // 默认的渲染等待时间是取的刷新率,默认是 10ms
        remaining_time = REFRESH_RATE;
        if (is->show_mode != SHOW_MODE_NONE && (!is->paused || is->force_refresh))
            // 做渲染操作,注意下一次的 Sleep 等待时间的地址作为参数传进去了
            video_refresh(is, &remaining_time);
        SDL_PumpEvents();
    }
}

上面代码很简单,就通过 av_usleep() 方法来等待到需要渲染的时间,等待的时间是 remaining_time 参数,默认取指是 10ms,然后调用 video_refresh() 方法执行渲染操作和设置下一次的渲染的等待时间。

我们继续看看 video_refresh() 的逻辑:

/* called to display each frame */
static void video_refresh(void *opaque, double *remaining_time)
{
    // ...

    if (is->video_st) {
retry:
        // 如果当前可以渲染的视频帧为 0 跳过渲染
        if (frame_queue_nb_remaining(&is->pictq) == 0) {
            // nothing to do, no picture to display in the queue
        } else {
            double last_duration, duration, delay;
            Frame *vp, *lastvp;

            /* dequeue the picture */
            // 取出上次渲染的帧
            lastvp = frame_queue_peek_last(&is->pictq);
            // 取出当前要渲染的帧
            vp = frame_queue_peek(&is->pictq);

            // 如果序列号发生改变,跳过当前帧,重新取。
            if (vp->serial != is->videoq.serial) {
                frame_queue_next(&is->pictq);
                goto retry;
            }

            // 上次渲染的序列号和当前的序列号不一样,重置 frame_timer
            if (lastvp->serial != vp->serial)
                is->frame_timer = av_gettime_relative() / 1000000.0;

            if (is->paused)
                goto display;

            /* compute nominal last_duration */
            // 计算当前要渲染的帧和上一次渲染的帧的间隔
            last_duration = vp_duration(is, lastvp, vp);
            // 根据时钟计算下一帧需要 delay 的时间
            delay = compute_target_delay(last_duration, is);

            time= av_gettime_relative()/1000000.0;
            if (time < is->frame_timer + delay) {
                // 这里表示当前的帧渲染的时间是大于当前时间的,所以不应该渲染,需要重新再 delay 一会儿。
                *remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);
                goto display;
            }

            // 更新 frame_timer.
            is->frame_timer += delay;
            // 如果当前的 frame_timer 落后当前时间 100ms 以上,重置 frame_timer 为当前时间.
            if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)
                is->frame_timer = time;

            SDL_LockMutex(is->pictq.mutex);
            if (!isnan(vp->pts))
                // 更新 video 的时钟
                update_video_pts(is, vp->pts, vp->serial);
            SDL_UnlockMutex(is->pictq.mutex);

            // 检查下一帧
            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;
                }
            }

            // ...

            // 从队列中移除当前渲染的帧
            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;
    
    // ...
}

上面就是渲染 delay 和丢弃帧的逻辑,我删除了部分不相关的代码,添加了部分说明,我再理一下关键的逻辑。

  • 计算当前需要渲染的帧和上一次渲染的帧间隔
    帧间隔的计算通过 vp_duration() 方法:
static double vp_duration(VideoState *is, Frame *vp, Frame *nextvp) {
    if (vp->serial == nextvp->serial) {
        double duration = nextvp->pts - vp->pts;
        if (isnan(duration) || duration <= 0 || duration > is->max_frame_duration)
            return vp->duration;
        else
            return duration;
    } else {
        return 0.0;
    }
}

上面代码很简单其实就是两帧的 pts 做差,如果两帧的序列号不同,直接返回 0。

  • 根据前后两帧的间隔再计算 delay
    delay 的计算通过 compute_target_delay() 方法:
static double compute_target_delay(double delay, VideoState *is)
{
    double sync_threshold, diff = 0;

    /* update delay to follow master synchronisation source */
    // 如果同步方式是 video master 那么就不做同步,直接用两帧之间的间隔作为 delay
    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 */
        // 获取 video 时钟和对应的 master 时钟之间的差值
        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 */
        // 通过 delay 来计算 sync_threshold,最大值为 100ms,最小值为 40ms
        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)
                // 这里表示当前 video 时钟落后于 master 时钟大于 sync_threshold,需要减小 delay 值,追赶 master 时钟。
                delay = FFMAX(0, delay + diff);
            else if (diff >= sync_threshold && delay > AV_SYNC_FRAMEDUP_THRESHOLD)
                // 这里表示 video 时钟快于 master 时钟大于 sync_threshold,并且 delay 大于 100ms,需要增加 delay 值,等待 master 时钟
                delay = delay + diff;
            else if (diff >= sync_threshold)
                // 这里表示 video 时钟快于 master 时钟大于 sync_threshold,并且 delay 小于 100ms,需要增加 delay 值,等待 master 时钟
                delay = 2 * delay;
        }
        // 其他情况不对 delay 做修正
    }

    av_log(NULL, AV_LOG_TRACE, "video: delay=%0.3f A-V=%f\n",
            delay, -diff);

    return delay;
}


static int get_master_sync_type(VideoState *is) {
    if (is->av_sync_type == AV_SYNC_VIDEO_MASTER) {
        if (is->video_st)
            return AV_SYNC_VIDEO_MASTER;
        else
            return AV_SYNC_AUDIO_MASTER;
    } else if (is->av_sync_type == AV_SYNC_AUDIO_MASTER) {
        if (is->audio_st)
            return AV_SYNC_AUDIO_MASTER;
        else
            return AV_SYNC_EXTERNAL_CLOCK;
    } else {
        return AV_SYNC_EXTERNAL_CLOCK;
    }
}
  • 根据计算的 delay 来获取当前帧渲染的时间,如果大于当前时间,那么跳过渲染,需要再 Sleep 一会儿再渲染。

  • 根据计算的 delay 值,更新 frame_timer

  • 更新 video 时钟。
    时钟的更新调用 update_video_pts() 方法:

static void update_video_pts(VideoState *is, double pts, int serial)
{
    /* update current video pts */
    set_clock(&is->vidclk, pts, serial);
    sync_clock_to_slave(&is->extclk, &is->vidclk);
}


static void sync_clock_to_slave(Clock *c, Clock *slave)
{
    double clock = get_clock(c);
    double slave_clock = get_clock(slave);
    if (!isnan(slave_clock) && (isnan(clock) || fabs(clock - slave_clock) > AV_NOSYNC_THRESHOLD))
        set_clock(c, slave_clock, slave->serial);
}

注意这里更新 video 时钟时,还会同步更新 external 时钟,上面的 slave 就是 video 时钟。

  • 检查下一帧,如果下一帧的渲染时间也落后于当前时间,直接丢弃。

  • 移除当前渲染帧,执行渲染。

最后

希望本篇文章对你理解 FFmpeg 的时间系统和播放器做音画同步有所帮助