FFplay源码分析-音视频同步1

本系列 以 ffmpeg4.2 源码为准,下载地址:链接:百度网盘 提取码:g3k8

FFplay 源码分析系列以一条简单的命令开始,ffplay -i a.mp4。a.mp4下载链接:百度网盘,提取码:nl0s 。


之前的文章已经讲解完 3 个线程的内部逻辑。

  • read_thread(),packet 读取线程,不断往 PacketQueue 写数据,直至队列写满。
  • audio_thread(),音频解码线程,从 音频PacketQueue 拿数据,解码出Frame,不断往 FrameQueue写数据,直至队列写满。
  • video_thread(),视频解码线程,从 视频PacketQueue 拿数据,解码出Frame,不断往 FrameQueue写数据,直至队列写满。

3个线程的逻辑已经讲完,后续的文章会开始讲播放线程 跟 音视频同步算法。

本文先从 音频播放线程讲起,音视频同步方式是 AV_SYNC_AUDIO_MASTER,以音频为主时钟。

音频播放线程分析开始:

音频播放线程函数 是 sdl_audio_callback() ,在 audio_open() 的时候用 wanted_spec.callback 指定了 SDL 的音频回调函数。

wanted_spec.samples = FFMAX(SDL_AUDIO_MIN_BUFFER_SIZE, 2 << av_log2(wanted_spec.freq / SDL_AUDIO_MAX_CALLBACKS_PER_SEC));
wanted_spec.callback = sdl_audio_callback; //指定回调函数。
wanted_spec.userdata = opaque;

ffpaly 限制了 sdl_audio_callbakc() 每秒回调次数不超过 SDL_AUDIO_MAX_CALLBACKS_PER_SEC,也就是不超过30次。

下面对 sdl_audio_callback() 的参数做一些讲解:

static void sdl_audio_callback(void *opaque, Uint8 *stream, int len){...}
  • void *opaque ,调用层参数,ffpaly是传了一个 struct VideoState 进去。在 wanted_spec.userdata 里指定的。
  • Uint8 *stream,SDL 播放内存的指针,往这个指针写数据,SDL 就能播放数据。
  • int len ,需要写多少数据进去 stream 指针。

len的计算方式其实非常有趣,就是根据 wanted_spec.samples 计算来的。在本文命令里面,wanted_spec.samples 赋值为 2048,每次回调callback,需要往 stream 指针写 2048个采样。那2048个样本又是多少字节?

由于 audio_open() 打开音频设备的时候指定用 16位的格式 AUDIO_S16SYS 存储一个采样,所以一个采样是2个字节。然后因为文件音频是双声道的,每个声道都取2048个样本,那就是 2048 * 2 * 2 = 8196 字节。有兴趣可以打印一下 callback 里面的len变量,在本文命令下,一直都是8196字节。


sdl_audio_callback() 的流程图如下:

FFplay源码分析-音视频同步1

流程图非常简洁,但其实 sdl_audio_callback() 里的逻辑是比较复杂的,流程图我画不出来,只能简洁,用文字来补充。

在讲 callback 的内部逻辑之前,需要先介绍一下struct VideoState 里面音频相关的一些字段。

struct VideoState 音频播放线程重要字段变量:

1,audio_hw_buf_size:hw是硬件的意思,这个字段代表SDL 线程执行 sdl_audio_callback() 的时候,SDL 硬件内部还有多少字节音频的数据没有播放。没错,SDL 线程并不是没有音频数据可以播放了才调 sdl_audio_callback() 来拿数据,而是他内部还剩 audio_hw_buf_size 长度的数据就调 sdl_audio_callback() 来拿数据,是提前拿数据的,这个概念特别重要。这个 audio_hw_buf_size 其实是等于 sdl_audio_callback() 里面的len变量的,也是为什么后面set_clock_at() 设置音频时钟pts的时候会用 audio_hw_buf_size 乘以2 。画个图加深理解。

FFplay源码分析-音视频同步1

如上图所示,SDL 内部是有两块内存的,一块红色的是它内部未播放的音频数据,红色部分我们的程序是无法操作那部分内存的,绿色部分就是 回调函数里面的 stream 指针,绿色部分的数据我们程序可以操作。audio_hw_buf_size 是在 audio_open() 的时候赋值的,请看代码:

if ((ret = audio_open(is, channel_layout, nb_channels, sample_rate, &is->audio_tgt)) < 0)
	goto fail;
is->audio_hw_buf_size = ret; //注意这里

2,audio_buf :音频数据,从 AVframe 的 data[] 里面得到,为什么要创建这样一个字段,是因为,回调函数有时候并不需要把 AVframe 的 data[] 全部写入到 stream 指针。例如回调的时候 SDL 需要 1500 个样本,一个AVFrame里面有 1024 个样本,那第二个Frame只需要拿476个样本就行,剩下的样本会放在 audio_buf 里面等待下次回调再继续读取。

3,audio_buf1 : 重采样临时存储地址,重采样的时候使用,暂时不需要关注,因为本文命令没有跑进去重采样的逻辑。后续音频向视频同步会重采样,到时候再仔细讲解。

4,audio_buf_size:表明 audio_buf 里面有多少字节数据。

5,audio_buf_index:audio_buf 的数据已经使用了多少。

6,audio_write_buf_size:audio_buf 的数据还剩下多少。

struct VideoState 里面有 还有两个字段比较容易混淆。需要重点讲解以下。

1,audio_filter_src:这个字段存的是 刚从解码器出来的 AVFrame 的采样率,格式等数据。

2,audio_src :这个字段存的是 刚从 filter_graph 出来的 AVFrame 的采样率,格式等数据。因为经过filter转换之后,采样率跟格式可能会改变。

audio_src 是 在 audio_open() 之后进行第一次赋值,赋值为打开的音频设备的采样,格式。

 if ((ret = audio_open(is, channel_layout, nb_channels, sample_rate, &is->audio_tgt)) < 0)
 	goto fail;
 is->audio_hw_buf_size = ret;
 is->audio_src = is->audio_tgt; //注意这里

然后 audio_src 会跟 从 filter_graph 出来的 frame的采样率等做比较,如果不一致就会执行重采样,audio_src 第二次赋值为 刚从 filter_graph 出来的 AVFrame 的采样率,格式等。

FFplay源码分析-音视频同步1


然后因为 audio_decode_frame() 函数里面用了 AVFrame::extended_data ,所以仔细讲一下,我刚学ffmpeg的时候也很迷惑,网上有些代码读数据用 extended_data ,有些用 data。现在就来讲下 AVFrame 里面 extended_data 跟data的区别。

对于视频帧而言,extended_data 跟 data是完全一样的,没有区别。

extended_data 是为音频帧准备的,因为 AVFrame::data 是一个 8 长度的数组,只能存到 8个声道,如果音频流是9声道,10声道怎么办?这就是 extended_data的作用,存储更多的声道数据。


sdl_audio_callback() 的流程描述如下:

1,一开始就是一个 while循环 ,while (len > 0),回调的时候必须往 stream 写入 len 大小的音频数据。

2,调用 audio_decode_frame() 把音频数据转移到 is->audio_buf。audio_decode_frame() 函数的内部逻辑非常复杂,后面会仔细讲。

3,音频数据转移到 is->audio_buf 之后,程序就开始拷贝数据了,代码里有非常多的零碎逻辑,实际上做的事情就是,判断 audio_buf 的音频数据需不需全部拷贝给 SDL 的内存,如果需要,全部拷贝就完事了。如果只是拷贝一部分到 SDL 的内存就够用了,就拷贝一部分,剩下的还在 audio_buf。如果全部拷贝完 audio_buf 还拼不够 len 长度,就会继续调 audio_decode_frame() 读取下一帧音频数据转移到 is->audio_buf,继续搞。

4,拷贝完 len 长度的数据给 SDL 内存 stream 之后,就会调 set_clock_at() 设置当前音频数据播放到哪里了。这个 set_clock_at() 比较难懂,需要仔细讲解,请看代码。

ffplay.c 2481行
set_clock_at(&is->audclk,
   is->audio_clock - (double)(2 * is->audio_hw_buf_size + is->audio_write_buf_size) / is->audio_tgt.bytes_per_sec
                  ,is->audio_clock_serial, audio_callback_time / 1000000.0);

这里 set_clock_at() 函数的第二个参数 pts 的计算非常复杂。

pts = is->audio_clock - (double)(2 * is->audio_hw_buf_size + is->audio_write_buf_size) / is->audio_tgt.bytes_per_sec

首先,is->audio_clock 是从 audio_decode_frame() 里面计算得到的,因为音频是主时钟,is->audio_clock 的计算方式还没那么绕,后续讲到 视频是同步主时钟的时候,is->audio_clock 会更难理解。

ffplay.c 2426行
is->audio_clock = af->pts + (double) af->frame->nb_samples / af->frame->sample_rate;

从上面的代码可以看到 is->audio_clock 赋值为当前帧的pts + duration,也就是说,当SDL里面 audio_hw_buf_size 长度的红色音频数据 跟 我们后续写入 stream 指针的绿色音频数据,再加上 is->audio_write_buf_size 的数据,这 3块音频数据都播放完的时候,音频时钟的 pts 就等于 当前帧的pts + duration,为什么要加上 audio_write_buf_size ,是因为当前帧的 数据不一定全部会拷贝到绿色内存,可能塞不下。

FFplay源码分析-音视频同步1

所以,简洁来说,audio_hw_buf_size + len + audio_write_buf_size 这3块数据都播放完,音频时钟的 pts 就等于 is->audio_clock了。但是,此时此刻,这3块数据还在内存里面,还没播放呢。

所以,此时此刻,音频时钟的pts 就等于 is->audio_clock 减去那3块数据的时间。因为 len 等于 audio_hw_buf_size,所以这条公式就出来了。

pts = is->audio_clock - (double)(2 * is->audio_hw_buf_size + is->audio_write_buf_size) / is->audio_tgt.bytes_per_sec

还有 set_clock_at() 最后一个参数 audio_callback_time,这个参数也需要注意,因为 audio_callback_time 是在 入口就初始化了,为什么要入口初始化呢?

因为,sdl_audio_callback() 函数开始执行的时候,SDL 里面还有 audio_write_buf_size 长度的数据没播放,但是 sdl_audio_callback() 并不会阻塞SDL 的音频播放,sdl_audio_callback()里面是有重采样处理的,在重采样消耗时间的时候,红色内存的数据也在同时播放的。

所以 set_clock_at() 设置的是 在 sdl_audio_callback() 开始执行的那一刻,音频数据播放到哪里了。并不是 sdl_audio_callback() 快执行完了 ,音频数据播放到哪里了。


接下来讲解以下 clock 里面 的 serial 有何作用。

typedef struct Clock {
 	..省略代码..
    int serial;           /* clock is based on a packet with this serial */
    int *queue_serial;    /* pointer to the current packet queue serial, used for obsolete clock detection */
} Clock;

struct Clock 里面有两个序列号,serial 是每次从FrameQueue 里面拿frame的时候,就会把 serial 赋值为 frame 的序列号。

queue_serial 就是一直指向 PacketQueue 队列的序列号,之前说过,如果seek了,PacketQueue 队列的序列号 会 +1 ,这时候 Clock 的 serial 就会不等于 queue_serial,get_clock() 函数会返回 NAN 的时间。

static double get_clock(Clock *c)
{
    if (*c->queue_serial != c->serial)
        return NAN;
}
注意第二个参数,初始化 queue_serial 为 PacketQueue 队列的序列号
init_clock(&is->vidclk, &is->videoq.serial);
init_clock(&is->audclk, &is->audioq.serial);
init_clock(&is->extclk, &is->extclk.serial);

sdl_audio_callback() 里面还有一个重要函数没有讲解,那就是 audio_decode_frame()。

audio_decode_frame() 这个函数命名不是特别好,audio_decode_frame 里面其实并没有执行解码操作,解码操作都是在 audio_thread() 线程里面做的。

/**
 * Decode one audio frame and return its uncompressed size.
 *
 * The processed audio frame is decoded, converted if required, and
 * stored in is->audio_buf, with size in bytes given by the return
 * value.
 */
static int audio_decode_frame(VideoState *is){...}

audio_decode_frame() 函数上方的注释已经讲明了这个函数的功能,就是把音频数据放到 is->audio_buf,返回数据大小,让别人来取。

不过 audio_decode_frame() 内部实现还是非常复杂的,需要仔细讲解一下。

入口就是一个针对 32 位做的特殊处理。

#if defined(_WIN32)
        while (frame_queue_nb_remaining(&is->sampq) == 0) {
            if ((av_gettime_relative() - audio_callback_time) > 1000000LL * is->audio_hw_buf_size / is->audio_tgt.bytes_per_sec / 2)
                return -1;
            av_usleep (1000);
        }
#endif

这里为什么会针对 32 位的系统做sleep呢?我估计是因为,32位的系统普遍硬件比较差,解码速度比较慢。

1000000LL * is->audio_hw_buf_size / is->audio_tgt.bytes_per_sec / 2 其实等于 二分之一的 callback 时间,例如本文命令是 0.04 s 回调一次,那 1000000LL * is->audio_hw_buf_size / is->audio_tgt.bytes_per_sec / 2 就等于 0.02s。

等待1/2 的时间是为什么呢?我估计是让 硬件差的环境在临界时间点不要轻易返回静音数据,延迟音频帧的播放。

举个例子,先说下背景,播放的还是 a.mp4 ,每0.04s秒 调一次 audio_decode_frame() 函数。

在 14:00 的时候,下午2点的时候,SDL 回调了 audio_decode_frame() 函数,因为系统慢,此刻 FrameQueue 里面没有数据可以拿。14:00:005 的时候才解码出数据放进去 FrameQueue ,如果 不sleep,立即就返回了。因为下次回调要等 0.04s,就会导致音频设备多播放了 0.04s 的静音数据。如果sleep 就可以在 14:00:005 左右的时刻把数据写进 SDL 的内存,也就是说本来应该播放的数据,如果因为解码慢,不sleep,延迟了0.04s才播放。

所以ffplay针对 32位的系统做了优化,折中一下,如果没有解码出数据,等待 1/2 的回调时间,不至于延迟那么大。

重要知识点: 1000000LL 是一百万的意思,后面的LL是转换成长整形数据类型,Long Long

然后,因为本文命令没有跑进去重采样逻辑,暂时不讲解里面的重采样逻辑,后续音频向视频同步的时候,要重采样做样本补偿,统一讲解。

没有重采样逻辑之后,audio_decode_frame() 剩下的逻辑就非常简单了,后面几乎没什么逻辑,就两点。

  1. 把 AVFrame 的data 转移到 is->audio_buf。

    is->audio_buf = af->frame->data[0];
    
  2. 设置 is->audio_clock。

    is->audio_clock = af->pts + (double) af->frame->nb_samples / af->frame->sample_rate;
    

ffplay 源码分析,sdl_audio_callback() 音频播放线程分析完毕。

©版权所属:知识星球:弦外之音,QQ:2338195090。

由于笔者的水平有限, 加之编写的同时还要参与开发工作,文中难免会出现一些错误或者不准确的地方,恳请读者批评指正。如果读者有任何宝贵意见,可以加我微信 Loken1。

推荐一个零声学院免费公开课程,个人觉得老师讲得不错,分享给大家:Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,立即学习

上一篇:面向对象


下一篇:为什么你的项目要花这么长时间?