本文已参与「新人创作礼」活动, 一起开启掘金创作之路。
自从去年7月份以来已接触离线音视频开发一年有余,到现在对音视频编解码开发有了一定的基础。做过小白,才更了解对音视频没什么基础的小白更想了解哪些内容。因此有了这篇文章。
常见概念
本文主要用通俗易懂的视角来描述几个在音视频开发中用到的概念及主要的编解码流程。这些概念包括:
- 封装
- 流
- 包、帧、采样
- 编码格式
音视频开发编解码的常见流程是:
graph LR
scene(场景)--相机/采集/采样/量化-->px2(像素点集合)--编码-->f
file(视频文件)--解封装-->p1(视频包)--解码-->f(帧)--OpenGL渲染-->px(像素点集合)-->v(画面)--显示器-->play(播放)
f--编码-->p2(视频包)--封装-->nf(视频文件)
file--解封装-->p(音频包)--解码-->s(采样/PCM数据)--SDL渲染-->w(波形振动)-->a(声音)--扬声器-->play
s--编码-->p3(音频包)--封装-->nf
sd(声音)--麦克风/采集/采样/量化-->wave(波形振动)--采样/量化-->s
视频文件——封装
一般来说,打开播放后只有声音没有图像的文件称为音频文件,而有声音又有图像的文件却更常被称为视频文件。当我还是小白的时候,我就很好奇视频文件中是怎么存放数据的,播放器是怎么把一堆二进制数的视频文件渲染成图像并播放出声音来的。
视频数据和音频数据按照特定的格式(不同的字段序列)、特定的顺序(音频数据、视频数据)存放在具有特定头部数据的文件中,并具有特定的扩展名的过程,就是封装的概念。通俗地说,就是,封装是把音频数据和视频数据按一定的形式存放的过程。那么,封装格式,例如 mp4、mkv、avi 等,就是规定了如何去存放、该用多少字节来记录多少关键信息,哪些信息在前、哪些信息再后的协议。符合封装格式协议规定的规则存放的文件,就能被成功地解封装。
封装这一概念在音视频开发中常用 mux 来表示,封装器 muxer,解封装器 demuxer。而在 ffmpeg 相关的 api 中,与封装相关的库主要是 libavformat 库,常用的结构体是 AVFormatContext,最常用的 api 是 avformat_open_input。
比如,我们有个视频文件 input.mp4,用 ffmpeg 解封装的过程如下:
// 声明一个 AVFormatContext 的指针变量,用于下一个 api 来接收打开的文件的 format context,可以理解为句柄,handler
AVFormatContext *fmt_ctx = nullptr;
// avformat_open_input 第一个参数是 AVForamtContext ** 类型,因此要传递 fmt_ctx 的地址以便改写 fmt_ctx 的值
// 这一步获取到了一些流的信息,例如音频流时长、视频流时长等,
// 尤其是有头部数据的视频文件,例如 MP4,
// 但不全面,例如像素格式、声音采样格式等信息就获取不到
// 本应该对 ret 的值是否为 0 进行判断,若不为 0,则说明发生了错误,此处不赘述,下同
int ret = avformat_open_input(&fmt_ctx, "xxx/xxx/input.mp4", nullptr, nullptr);
// 这一步获取了更详细的信息,例如像素格式、采样格式等,也修正了一些头部信息中可能存在的错误,例如帧率
// 更重要的是,这一步能够获取到一些没有头部信息的文件的音视频信息,例如 MPEG 文件
// 这一步会去读取 packet 来获取这些信息,但会回置文件读取的位置,即文件读取的 offset 不变,这一点很重要
avformat_find_stream_info(format_context_, nullptr);
流
下载一个编译好的可执行的程序 ffprobe,然后找一个有声音有图像的视频文件 input.mp4,键入以下命令 ffprobe -v quiet -show_streams -show_format -print_format json -i input.mp4,即可得到这个视频文件 input.mp4 的封装信息和流信息。
-v quiet 不显示 ffmpeg 版本号信息;-show_streams 显示流信息;-show_format 显示封装信息;-print_format json 以 json 格式显示,-i input.mp4 输入文件为 input.mp4。
"streams": [
{
"index": 0,
"codec_name": "h264",
"codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
"profile": "High",
"codec_type": "video",
"codec_time_base": "1/40",
"width": 720,
"height": 1080,
"coded_width": 720,
"coded_height": 1088,
"pix_fmt": "yuv420p",
"r_frame_rate": "20/1",
"avg_frame_rate": "20/1",
"time_base": "1/10240",
"start_pts": 0,
"start_time": "0.000000",
"duration_ts": 404480,
"duration": "39.500000",
"bit_rate": "821832",
"nb_frames": "790",
"disposition": {
// 删减
},
"tags": {
// 删减
}
},
{
"index": 1,
"codec_name": "aac",
"codec_long_name": "AAC (Advanced Audio Coding)",
"profile": "LC",
"codec_type": "audio",
"codec_time_base": "1/44100",
"sample_fmt": "fltp",
"sample_rate": "44100",
"channels": 2,
"channel_layout": "stereo",
"time_base": "1/44100",
"start_pts": 0,
"start_time": "0.000000",
"duration_ts": 1722370,
"duration": "39.056009",
"bit_rate": "128318",
"max_bit_rate": "128318",
"nb_frames": "1684",
"disposition": {
// 删减
},
"tags": {
// 删减
}
}
],
"format": {
"filename": "input.mp4",
"nb_streams": 2,
"nb_programs": 0,
"format_name": "mov,mp4,m4a,3gp,3g2,mj2",
"format_long_name": "QuickTime / MOV",
"start_time": "0.000000",
"duration": "39.500000",
"size": "4711393",
"bit_rate": "954206",
"probe_score": 100,
"tags": {
// 删减
}
}
}
然后我们就能得到如上输出(有删减)。从上述结果中,我们可以看到这个 input.mp4 文件,有两个流(streams),分别是索引为 1 的音频流,用 AAC LC 编码格式,2 声道(channels)立体声(stereo),一共有 1684 “帧”音频,比了率为 128。318k,时长 39.05 秒,采样率为 44.1kHz,时间基为 1/44100。这里的 “帧” 要加引号,是因为音频并不像视频那样是一幅一幅画面的快速切换,而是连续的采样点,而 ffmpeg 以及其他一些音视频库为了方便处理,而人为引入的 “帧” 的概念。
以及索引为 0 的 h264 视频流,可以看到视频的宽为 720, 高为 1280,颜色格式为 yuv420p,时长 11.73 秒,一共 790 帧。帧率为 1/20,时间基为 1/10240。
由此可见,视频文件的流规定了这一流的类型(视频/音频/或其他)以及一系列属性。
如果是 mkv 格式的文件,可能还会有第三个流,即 subtitle,字幕流。也就是说,视频文件中的音视频数据及其他数据是按照流的概念在文件中存储的。说到这里,不免有朋友,包括之前的我,就会疑问,文件应该是一个很长很长的二进制字符串,那它是怎么分出流来的呢?总不能是一个二维的二进制字符串,难道是先一串音频,再一串视频?
包/帧/采样
为了解决上述问题,我们就需要理解包(Packet)这个概念,以及 帧(Frame)这一概念。上面提到这个 input.mp4 文件一共有 1684 帧音频 和 790 帧视频。如果我们用这样一条命令,将可以看到这个文件中所有的包信息:ffprobe -v quiet -show_packets -i input.mp4。这一命令执行下来,会有一长串的输出到终端,以至于我们都看不到这条输出的起始在哪里。因此,可以用 > packetinfo 将输出写入 packetinfo 这一新文件中去。因此,命令改为 ffprobe -v quiet -show_packets -i input.mp4 > packetinfo。
使用 notepad++ 打开 packetinfo 这一文件,我们就能看到 input.mp4 这个文件中的所有包信息了。但是,看起来好像不是那么方便,因为它一会儿是 audio,一会儿是 video。因此,参考我之前的文章 “【AVD】用 notepad++ 和 Excel 协助分析媒体文件包” 可以将这个文本文档,整理为更直观、更方便分类梳理的 Excel 文件。
从这张图我们可以看到,视频文件的包在文件中是音视频交叉排列的,而通过右侧的 pos 和 size 的大小计算可知 pos[n] = pos[n-1] + size[n-1],这也说明,文件中的音频包和视频包是连续排列的。我们筛选一下 A 列中所有的值为 video 的项,刚好有 790 个,同样地,筛选值为 audio 的项,刚好有 1684 个。这也说明在音视频开发中的包(Packet)和帧(Frame)是一一对应的,通过深入研究可以知道,基本上,一个包(Packet)能解码出一个帧(Frame)。因此,下文将仅用 帧 这一概念。
再详细地说一下,一个 packet 属性中包含很多,这是筛选了我们所需要的信息的结果。A 列为 codec_type,表明了这个包是个音频、还是个视频,C 列是这个包所在的流的索引号信息,这与上面的流信息是匹配的。pts 为 presentation timestamp,是这一帧应该被展示(渲染)的时间戳,而 pts_time 则是将时间戳根据时间基换算成我们常用的时间单位秒之后的结果。dts 为 decode timestamp,表示的是这一帧应该被解码的时间戳,duration 是这一帧的持续时长,dits_time,duration_time 不再赘述。size 为这个包的大小,pos 为这个包在文件中的偏移量(offset),flags 则主要用于表示这一帧是否是关键帧。我们可以发现,音频帧每一帧都是关键帧,而视频帧则有关键帧和非关键帧,两个关键帧之间的距离被称为 GOP size(Group of picture)。 最后,简单解释一下 采样 (sample)这一概念。声音是波,而数字音频是使用有限的点对这个波形进行采样的结果。例如一秒钟的声音的波长,通过 44.1kHz 的采样率进行采样时,将得到 44100 个数值。其中每一个数值被称为一个采样。上文提到过,音频中的 “帧” 概念是 ffmpeg 或者一些其他音视频库人为引用的一个概念,并不十分确切,在音频编解码过程中,可能更多的采用 samples 而非 frame 来描述音频数据。
编码格式
编码其实是根据编码格式的规则(规定),将视频像素数据、音频脉冲数据(PCM)进行压缩,使其占用更小的空间并存储于文件中的过程。常见的视频编码格式有 h264、h265、v8、v9 等,常见的音频格式主要有 mp3、aac 等。 结合第一节中的封装格式,一个视频文件中一般会有一个封装格式、一个音频的编码格式以及一个视频的编码格式。