FFPLAY的原理(七)
同步音频
现在我们已经有了一个比较像样的播放器。所以让我们看一下还有哪些零碎的东西没处理。上次,我们掩饰了一点同步问题,也就是同步音频到视频而不是其它的同 步方式。我们将采用和视频一样的方式:做一个内部视频时钟来记录视频线程播放了多久,然后同步音频到上面去。后面我们也来看一下如何推而广之把音频和视频 都同步到外部时钟。
生成一个视频时钟
现在我们要生成一个类似于上次我们的声音时钟的视频时钟:一个给出当前视频播放时间的内部值。开始,你可能会想这和使用上一帧的时间戳来更新定时器一样简 单。但是,不要忘了视频帧之间的时间间隔是很长的,以毫秒为计量的。解决办法是跟踪另外一个值:我们在设置上一帧时间戳的时候的时间值。于是当前视频时间 值就是PTS_of_last_frame + (current_time - time_elapsed_since_PTS_value_was_set)。这种解决方式与我们在函数get_audio_clock中的方式很类 似。
所在在我们的大结构体中,我们将放上一个双精度浮点变量video_current_pts和一个64位宽整型变量video_current_pts_time。时钟更新将被放在video_refresh_timer函数中。
1 void video_refresh_timer(void *userdata) { 2 if(is->video_st) { 3 if(is->pictq_size == 0) { 4 schedule_refresh(is, 1); 5 } else { 6 vp = &is->pictq[is->pictq_rindex]; 7 is->video_current_pts = vp->pts; 8 is->video_current_pts_time = av_gettime();
不要忘记在stream_component_open函数中初始化它:
is->video_current_pts_time = av_gettime();
现在我们需要一种得到信息的方式:
1 double get_video_clock(VideoState *is) { 2 double delta; 3 delta = (av_gettime() - is->video_current_pts_time) / 1000000.0; 4 return is->video_current_pts + delta; 5 }
提取时钟
但是为什么要强制使用视频时钟呢?我们更改视频同步代码以致于音频和视频不会试着去相互同步。想像一下我们让它像ffplay一样有一个命令行参数。所以 让我们抽象一样这件事情:我们将做一个新的封装函数get_master_clock,用来检测av_sync_type变量然后决定调用 get_audio_clock还是get_video_clock或者其它的想使用的获得时钟的函数。我们甚至可以使用电脑时钟,这个函数我们叫做 get_external_clock:
1 enum { 2 AV_SYNC_AUDIO_MASTER, 3 AV_SYNC_VIDEO_MASTER, 4 AV_SYNC_EXTERNAL_MASTER, 5 }; 6 #define DEFAULT_AV_SYNC_TYPE AV_SYNC_VIDEO_MASTER 7 double get_master_clock(VideoState *is) { 8 if(is->av_sync_type == AV_SYNC_VIDEO_MASTER) { 9 return get_video_clock(is); 10 } else if(is->av_sync_type == AV_SYNC_AUDIO_MASTER) { 11 return get_audio_clock(is); 12 } else { 13 return get_external_clock(is); 14 } 15 } 16 main() { 17 ... 18 is->av_sync_type = DEFAULT_AV_SYNC_TYPE; 19 ... 20 }
同步音频
现在是最难的部分:同步音频到视频时钟。我们的策略是测量声音的位置,把它与视频时间比较然后算出我们需要修正多少的样本数,也就是说:我们是否需要通过丢弃样本的方式来加速播放还是需要通过插值样本的方式来放慢播放?
我们将在每次处理声音样本的时候运行一个synchronize_audio的函数来正确的收缩或者扩展声音样本。然而,我们不想在每次发现有偏差的时候 都进行同步,因为这样会使同步音频多于视频包。所以我们为函数synchronize_audio设置一个最小连续值来限定需要同步的时刻,这样我们就不 会总是在调整了。当然,就像上次那样,“失去同步”意味着声音时钟和视频时钟的差异大于我们的阈值。
所以我们将使用一个分数系数,叫c,所以现在可以说我们得到了N个失去同步的声音样本。失去同步的数量可能会有很多变化,所以我们要计算一下失去同步的长 度的均值。例如,第一次调用的时候,显示出来我们失去同步的长度为40ms,下次变为50ms等等。但是我们不会使用一个简单的均值,因为距离现在最近的 值比靠前的值要重要的多。所以我们将使用一个分数系统,叫c,然后用这样的公式来计算差异:diff_sum = new_diff + diff_sum*c。当我们准备好去找平均差异的时候,我们用简单的计算方式:avg_diff = diff_sum * (1-c)。
注意:为什么会在这里?这个公式看来很神奇!嗯,它基本上是一个使用等比级数的加权平均值。我不知道这是否有名字(我甚至查过维基百科!),但是如果想要更多的信息,这里是一个解释http://www.dranger.com/ffmpeg/weightedmean.html 或者在http://www.dranger.com/ffmpeg/weightedmean.txt 里。
下面是我们的函数:
1 int synchronize_audio(VideoState *is, short *samples, 2 int samples_size, double pts) { 3 int n; 4 double ref_clock; 5 n = 2 * is->audio_st->codec->channels; 6 if(is->av_sync_type != AV_SYNC_AUDIO_MASTER) { 7 double diff, avg_diff; 8 int wanted_size, min_size, max_size, nb_samples; 9 ref_clock = get_master_clock(is); 10 diff = get_audio_clock(is) - ref_clock; 11 if(diff < AV_NOSYNC_THRESHOLD) { 12 // accumulate the diffs 13 is->audio_diff_cum = diff + is->audio_diff_avg_coef 14 * is->audio_diff_cum; 15 if(is->audio_diff_avg_count < AUDIO_DIFF_AVG_NB) { 16 is->audio_diff_avg_count++; 17 } else { 18 avg_diff = is->audio_diff_cum * (1.0 - is->audio_diff_avg_coef); 19 } 20 } else { 21 is->audio_diff_avg_count = 0; 22 is->audio_diff_cum = 0; 23 } 24 } 25 return samples_size; 26 }
现在我们已经做得很好;我们已经近似的知道如何用视频或者其它的时钟来调整音频了。所以让我们来计算一下要在添加和砍掉多少样本,并且如何在“Shrinking/expanding buffer code”部分来写上代码:
1 if(fabs(avg_diff) >= is->audio_diff_threshold) { 2 wanted_size = samples_size + 3 ((int)(diff * is->audio_st->codec->sample_rate) * n); 4 min_size = samples_size * ((100 - SAMPLE_CORRECTION_PERCENT_MAX) 5 / 100); 6 max_size = samples_size * ((100 + SAMPLE_CORRECTION_PERCENT_MAX) 7 / 100); 8 if(wanted_size < min_size) { 9 wanted_size = min_size; 10 } else if (wanted_size > max_size) { 11 wanted_size = max_size; 12 }
记住audio_length * (sample_rate * # of channels * 2)就是audio_length秒时间的声音的样本数。所以,我们想要的样本数就是我们根据声音偏移添加或者减少后的声音样本数。我们也可以设置一个范 围来限定我们一次进行修正的长度,因为如果我们改变的太多,用户会听到刺耳的声音。
修正样本数
现在我们要真正的修正一下声音。你可能会注意到我们的同步函数synchronize_audio返回了一个样本数,这可以告诉我们有多少个字节被送到流 中。所以我们只要调整样本数为wanted_size就可以了。这会让样本更小一些。但是如果我们想让它变大,我们不能只是让样本大小变大,因为在缓冲区 中没有多余的数据!所以我们必需添加上去。但是我们怎样来添加呢?最笨的办法就是试着来推算声音,所以让我们用已有的数据在缓冲的末尾添加上最后的样本。
1 if(fabs(avg_diff) >= is->audio_diff_threshold) { 2 wanted_size = samples_size + 3 ((int)(diff * is->audio_st->codec->sample_rate) * n); 4 min_size = samples_size * ((100 - SAMPLE_CORRECTION_PERCENT_MAX) 5 / 100); 6 max_size = samples_size * ((100 + SAMPLE_CORRECTION_PERCENT_MAX) 7 / 100); 8 if(wanted_size < min_size) { 9 wanted_size = min_size; 10 } else if (wanted_size > max_size) { 11 wanted_size = max_size; 12 }
现在我们通过这个函数返回的是样本数。我们现在要做的是使用它:
1 void audio_callback(void *userdata, Uint8 *stream, int len) { 2 VideoState *is = (VideoState *)userdata; 3 int len1, audio_size; 4 double pts; 5 while(len > 0) { 6 if(is->audio_buf_index >= is->audio_buf_size) { 7 audio_size = audio_decode_frame(is, is->audio_buf, sizeof(is->audio_buf), &pts); 8 if(audio_size < 0) { 9 is->audio_buf_size = 1024; 10 memset(is->audio_buf, 0, is->audio_buf_size); 11 } else { 12 audio_size = synchronize_audio(is, (int16_t *)is->audio_buf, 13 audio_size, pts); 14 is->audio_buf_size = audio_size;
我们要做的是把函数synchronize_audio插入进去。(同时,保证在初始化上面变量的时候检查一下代码,这些我没有赘述)。
结束之前的最后一件事情:我们需要添加一个if语句来保证我们不会在视频为主时钟的时候也来同步视频。
1 if(is->av_sync_type != AV_SYNC_VIDEO_MASTER) { 2 ref_clock = get_master_clock(is); 3 diff = vp->pts - ref_clock; 4 sync_threshold = (delay > AV_SYNC_THRESHOLD) ? delay : 5 AV_SYNC_THRESHOLD; 6 if(fabs(diff) < AV_NOSYNC_THRESHOLD) { 7 if(diff <= -sync_threshold) { 8 delay = 0; 9 } else if(diff >= sync_threshold) { 10 delay = 2 * delay; 11 } 12 } 13 }
添加后就可以了。要保证整个程序中我没有赘述的变量都被初始化过了。然后编译它:
gcc -o tutorial06 tutorial06.c -lavutil -lavformat -lavcodec -lz -lm`sdl-config --cflags --libs`
然后你就可以运行它了。
快进快退
处理快进快退命令
现在我们来为我们的播放器加入一些快进和快退的功能,因为如果你不能全局搜索一部电影是很让人讨厌的。同时,这将告诉你av_seek_frame函数是多么容易使用。
我们将在电影播放中使用左方向键和右方向键来表示向后和向前一小段,使用向上和向下键来表示向前和向后一大段。这里一小段是10秒,一大段是60 秒。所以我们需要设置我们的主循环来捕捉键盘事件。然而当我们捕捉到键盘事件后我们不能直接调用av_seek_frame函数。我们要主要的解码线程 decode_thread的循环中做这些。所以,我们要添加一些变量到大结构体中,用来包含新的跳转位置和一些跳转标志:
1 int seek_req; 2 int seek_flags; 3 int64_t seek_pos;
现在让我们在主循环中捕捉按键:
1 for(;;) { 2 double incr, pos; 3 SDL_WaitEvent(&event); 4 switch(event.type) { 5 case SDL_KEYDOWN: 6 switch(event.key.keysym.sym) { 7 case SDLK_LEFT: 8 incr = -10.0; 9 goto do_seek; 10 case SDLK_RIGHT: 11 incr = 10.0; 12 goto do_seek; 13 case SDLK_UP: 14 incr = 60.0; 15 goto do_seek; 16 case SDLK_DOWN: 17 incr = -60.0; 18 goto do_seek; 19 do_seek: 20 if(global_video_state) { 21 pos = get_master_clock(global_video_state); 22 pos += incr; 23 stream_seek(global_video_state, 24 (int64_t)(pos * AV_TIME_BASE), incr); 25 } 26 break; 27 default: 28 break; 29 } 30 break;
为了检测按键,我们先查了一下是否有SDL_KEYDOWN事件。然后我们使用event.key.keysym.sym来判断哪个按键被按下。一旦我们 知道了如何来跳转,我们就来计算新的时间,方法为把增加的时间值加到从函数get_master_clock中得到的时间值上。然后我们调用 stream_seek函数来设置seek_pos等变量。我们把新的时间转换成为avcodec中的内部时间戳单位。在流中调用那个时间戳将使用帧而不 是用秒来计算,公式为seconds = frames * time_base(fps)。默认的avcodec值为1,000,000fps(所以2秒的内部时间戳为2,000,000)。在后面我们来看一下为 什么要把这个值进行一下转换。
这就是我们的stream_seek函数。请注意我们设置了一个标志为后退服务:
1 void stream_seek(VideoState *is, int64_t pos, int rel) { 2 if(!is->seek_req) { 3 is->seek_pos = pos; 4 is->seek_flags = rel < 0 ? AVSEEK_FLAG_BACKWARD : 0; 5 is->seek_req = 1; 6 } 7 }
现在让我们看一下如果在decode_thread中实现跳转。你会注意到我们已经在源文件中标记了一个叫做“seek stuff goes here”的部分。现在我们将把代码写在这里。
跳转是围绕着av_seek_frame函数的。这个函数用到了一个格式上下文,一个流,一个时间戳和一组标记来作为它的参数。这个函数将会跳转到你所给 的时间戳的位置。时间戳的单位是你传递给函数的流的时基time_base。然而,你并不是必需要传给它一个流(流可以用-1来代替)。如果你这样做了, 时基time_base将会是avcodec中的内部时间戳单位,或者是1000000fps。这就是为什么我们在设置seek_pos的时候会把位置乘 以AV_TIME_BASER的原因。
但是,如果给av_seek_frame函数的stream参数传递传-1,你有时会在播放某些文件的时候遇到问题(比较少见),所以我们会取文件中的第一个流并且把它传递到av_seek_frame函数。不要忘记我们也要把时间戳timestamp的单位进行转化。
1 if(is->seek_req) { 2 int stream_index= -1; 3 int64_t seek_target = is->seek_pos; 4 if (is->videoStream >= 0) stream_index = is->videoStream; 5 else if(is->audioStream >= 0) stream_index = is->audioStream; 6 if(stream_index>=0){ 7 seek_target= av_rescale_q(seek_target, AV_TIME_BASE_Q, 8 pFormatCtx->streams[stream_index]->time_base); 9 } 10 if(av_seek_frame(is->pFormatCtx, stream_index, 11 seek_target, is->seek_flags) < 0) { 12 fprintf(stderr, "%s: error while seeking\n", 13 is->pFormatCtx->filename); 14 } else {
这里av_rescale_q(a,b,c)是用来把时间戳从一个时基调整到另外一个时基时候用的函数。它基本的动作是计算a*b/c,但是这个函数还是 必需的,因为直接计算会有溢出的情况发生。AV_TIME_BASE_Q是AV_TIME_BASE作为分母后的版本。它们是很不相同 的:AV_TIME_BASE * time_in_seconds = avcodec_timestamp而AV_TIME_BASE_Q * avcodec_timestamp = time_in_seconds(注意AV_TIME_BASE_Q实际上是一个AVRational对象,所以你必需使用avcodec中特定的q函数 来处理它)。
清空我们的缓冲
我们已经正确设定了跳转位置,但是我们还没有结束。记住我们有一个堆放了很多包的队列。既然我们跳到了不同的位置,我们必需把队列中的内容清空否则电影是不会跳转的。不仅如此,avcodec也有它自己的内部缓冲,也需要每次被清空。
要实现这个,我们需要首先写一个函数来清空我们的包队列。然后我们需要一种命令声音和视频线程来清空avcodec内部缓冲的办法。我们可以在清空队列后把特定的包放入到队列中,然后当它们检测到特定的包的时候,它们就会把自己的内部缓冲清空。
让我们开始写清空函数。其实很简单的,所以我直接把代码写在下面:
1 static void packet_queue_flush(PacketQueue *q) { 2 AVPacketList *pkt, *pkt1; 3 SDL_LockMutex(q->mutex); 4 for(pkt = q->first_pkt; pkt != NULL; pkt = pkt1) { 5 pkt1 = pkt->next; 6 av_free_packet(&pkt->pkt); 7 av_freep(&pkt); 8 } 9 q->last_pkt = NULL; 10 q->first_pkt = NULL; 11 q->nb_packets = 0; 12 q->size = 0; 13 SDL_UnlockMutex(q->mutex); 14 }
既然队列已经清空了,我们放入“清空包”。但是开始我们要定义和创建这个包:
1 AVPacket flush_pkt; 2 main() { 3 ... 4 av_init_packet(&flush_pkt); 5 flush_pkt.data = "FLUSH"; 6 ... 7 }
现在我们把这个包放到队列中:
1 } else { 2 if(is->audioStream >= 0) { 3 packet_queue_flush(&is->audioq); 4 packet_queue_put(&is->audioq, &flush_pkt); 5 } 6 if(is->videoStream >= 0) { 7 packet_queue_flush(&is->videoq); 8 packet_queue_put(&is->videoq, &flush_pkt); 9 } 10 } 11 is->seek_req = 0; 12 }
(这些代码片段是接着前面decode_thread中的代码片段的)我们也需要修改packet_queue_put函数才不至于直接简单复制了这个包:
1 int packet_queue_put(PacketQueue *q, AVPacket *pkt) { 2 AVPacketList *pkt1; 3 if(pkt != &flush_pkt && av_dup_packet(pkt) < 0) { 4 return -1; 5 }
然后在声音线程和视频线程中,我们在packet_queue_get后立即调用函数avcodec_flush_buffers:
1 if(packet_queue_get(&is->audioq, pkt, 1) < 0) { 2 return -1; 3 } 4 if(packet->data == flush_pkt.data) { 5 avcodec_flush_buffers(is->audio_st->codec); 6 continue; 7 }
上面的代码片段与视频线程中的一样,只要把“audio”换成“video”。
就这样,让我们编译我们的播放器:
gcc -o tutorial07 tutorial07.c -lavutil -lavformat -lavcodec -lz -lm`sdl-config --cflags --libs`
试一下!我们几乎已经都做完了;下次我们只要做一点小的改动就好了,那就是检测ffmpeg提供的小的软件缩放采样。
软件缩放
软件缩放库libswscale
近来ffmpeg添加了新的接口:libswscale来处理图像缩放。
但是在前面我们使用img_convert来把RGB转换成YUV12,我们现在使用新的接口。新接口更加标准和快速,而且我相信里面有了MMX优化代码。换句话说,它是做缩放更好的方式。
我们将用来缩放的基本函数是sws_scale。但一开始,我们必需建立一个SwsContext的概念。这将让我们进行想要的转换,然后把它传递给 sws_scale函数。类似于在SQL中的预备阶段或者是在Python中编译的规则表达式regexp。要准备这个上下文,我们使用 sws_getContext函数,它需要我们源的宽度和高度,我们想要的宽度和高度,源的格式和想要转换成的格式,同时还有一些其它的参数和标志。然后 我们像使用img_convert一样来使用sws_scale函数,唯一不同的是我们传递给的是SwsContext:
1 #include <ffmpeg/swscale.h> // include the header! 2 int queue_picture(VideoState *is, AVFrame *pFrame, double pts) { 3 static struct SwsContext *img_convert_ctx; 4 ... 5 if(vp->bmp) { 6 SDL_LockYUVOverlay(vp->bmp); 7 dst_pix_fmt = PIX_FMT_YUV420P; 8 pict.data[0] = vp->bmp->pixels[0]; 9 pict.data[1] = vp->bmp->pixels[2]; 10 pict.data[2] = vp->bmp->pixels[1]; 11 pict.linesize[0] = vp->bmp->pitches[0]; 12 pict.linesize[1] = vp->bmp->pitches[2]; 13 pict.linesize[2] = vp->bmp->pitches[1]; 14 // Convert the image into YUV format that SDL uses 15 if(img_convert_ctx == NULL) { 16 int w = is->video_st->codec->width; 17 int h = is->video_st->codec->height; 18 img_convert_ctx = sws_getContext(w, h, 19 is->video_st->codec->pix_fmt, 20 w, h, dst_pix_fmt, SWS_BICUBIC, 21 NULL, NULL, NULL); 22 if(img_convert_ctx == NULL) { 23 fprintf(stderr, "Cannot initialize the conversion context!\n"); 24 exit(1); 25 } 26 } 27 sws_scale(img_convert_ctx, pFrame->data, 28 pFrame->linesize, 0, 29 is->video_st->codec->height, 30 pict.data, pict.linesize);
我们把新的缩放器放到了合适的位置。希望这会让你知道libswscale能做什么。
就这样,我们做完了!编译我们的播放器:
gcc -o tutorial08 tutorial08.c -lavutil -lavformat -lavcodec -lz -lm `sdl-config --cflags --libs`
享受我们用C写的少于1000行的电影播放器吧。
当然,还有很多事情要做。
现在还要做什么?
我们已经有了一个可以工作的播放器,但是它肯定还不够好。我们做了很多,但是还有很多要添加的性能:
·错误处理。我们代码中的错误处理是无穷的,多处理一些会更好。
·暂停。我们不能暂停电影,这是一个很有用的功能。我们可以在大结构体中使用一个内部暂停变量,当用户暂停的时候就设置它。然后我们的音频,视频和解码线 程检测到它后就不再输出任何东西。我们也使用av_read_play来支持网络。这很容易解释,但是你却不能明显的计算出,所以把这个作为一个家庭作 业,如果你想尝试的话。提示,可以参考ffplay.c。
·支持视频硬件特性。一个参考的例子,请参考Frame Grabbing在Martin的旧的指导中的相关部分。http://www.inb.uni-luebeck.de/~boehme/libavcodec_update.html
·按字节跳转。如果你可以按照字节而不是秒的方式来计算出跳转位置,那么对于像VOB文件一样的有不连续时间戳的视频文件来说,定位会更加精确。
·丢弃帧。如果视频落后的太多,我们应当把下一帧丢弃掉而不是设置一个短的刷新时间。
·支持网络。现在的电影播放器还不能播放网络流媒体。
·支持像YUV文件一样的原始视频流。如果我们的播放器支持的话,因为我们不能猜测出时基和大小,我们应该加入一些参数来进行相应的设置。
·全屏。
·多种参数,例如:不同图像格式;参考ffplay.c中的命令开关。
·其它事情,例如:在结构体中的音频缓冲区应该对齐。