[音视频] FFmpeg 时间系统和 FFplay 音画同步逻辑
FFmpeg
在音视频中的地位不用多描述,目前我还没有见过它解不了的编码格式。FFplay
是 FFmpeg
官方基于 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
,编解码相关的 pts
和 dts
。但是这里有一个问题,这些时间都需要一个单位,我们自己常用的时间单位是毫秒,秒,分钟等等。但是在 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_BASE
是 AV_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
。而视频就不一样,我们需要在正确的时间去完成渲染,所谓的正确的时间也就是要和声音同步,不能过快,也不能过慢。
开始之前必须先了解下 FFplay
中 Clock
结构体:
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
的实例,分别是 audclk
,vidclk
,extclk
。audclk
,vidclk
分别会在音频完成渲染,视频完成渲染更新;而 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
的时间系统和播放器做音画同步有所帮助