19 视频录制项目实战:场景分析与架构设计
你好,我是展晓凯。今天我们来一起学习视频录制器的场景设计与架构分析。
前面我们用了9节课学习了音视频采集和编码方面的知识。现在是时候用一个视频录制器项目把这些知识点串联起来了。这个项目运行起来后,我们就可以采集音频和视频,最终保存成一个视频文件了。这个视频录制器是录播场景下非常重要的一个模块,之后你也可以以这个项目为基础,做一些扩展和改动(码率自适应、网络抖动处理、关键帧间隔设置等),改造成一个直播场景的视频推流器。
整个视频录制器项目,我会分为三部分来讲解,每一部分会解决一个核心问题。
- 第一部分:从场景分析入手,设计视频录制器项目的架构,让你明白视频录制器的顶层设计和模块拆分,并了解其中的关键点。
- 第二部分:基于架构设计,实现录制器中的底层核心模块,包括音频的采集和编码,视频的采集和编码,把前面第10节课到第18节课的知识点,套用到整个项目里。
- 第三部分:讲解Mux模块和中控系统,让整个录制器项目跑起来,最终分析一下整体录制器的扩展性,让你完全掌握视频录制的精髓。
接下来我们就一起来看一下视频录制器的场景分析和架构设计部分吧。
视频录制器的场景分析
我们在工作中完成一个项目或者产品的迭代时,首先要做的就是场景分析,场景分析不是要写一堆别人看不懂甚至看都不看的文档,而是站在技术角度思考这个场景的输入、输出和存在的技术风险点,目的是辅助我们设计出一个好的架构来实现这个项目。
视频录制器运行起来之后,需要展示给用户一个视频预览页面,让用户可以看到摄像头里采集到的画面,用户将预览画面调整满意之后,就可以点击录制按钮。接下来录制器需要把用户的声音和画面全都录制下来,并编码生成一个MP4文件。在录制过程中,也可以让用户选择是否需要开启背景音乐,方便用户唱歌或展示舞蹈等。
场景看上去还是挺简单的,但是针对这个场景如何设计出一个合理的架构却是一个比较复杂的工作。从录制视频的角度来讲,每个平台都有自己独特的API可供开发者调用,但是要想合理地使用这些API,还需要通过架构的手段拆分出具体的模块,定义清楚每个模块的边界或者职责,再根据平台特性为某个技术模块确定实现细节。基于业务场景分析,录制器项目可拆分成两部分,一部分是音频,另一部分是画面(视频)。
音频模块架构设计
我们先来看音频模块的架构设计示意图。
乍一看,你可能会觉得这个架构比较复杂,因为图里包含了两个平台的音频架构,同时链路里还包括了BGM的播放。不过,你别着急,我会带你来逐一分析。
我们先从最顶层来解读这张架构图,图最上边的一部分从左到右依次为Input、Output、PCM队列和Consumer,下面来逐一看一下这4个模块。
- Input代表输入模块,输入之一是麦克风,用来采集用户的声音;第二个是伴奏文件的解码器,用来解码用户选择的背景音乐,所以输入模块要由这两部分共同来实现。
- Output代表输出模块,输出之一就是利用渲染音频的API把背景音乐的声音播放出来,在iOS平台,如果用户戴着有线耳机的话,可以把用户自己发出的声音同时播放出来,来达到耳返监听的功能;第二个是记录数据,把背景音乐和用户声音的数据保存下来;那保存到哪里呢?就是接下来介绍的数据存储模块。
- PCM队列代表数据存储模块,把背景音乐和用户声音的PCM数据存入队列里,这个队列应该保证多线程访问时线程的安全性。
- Consumer代表消费者模块,负责从数据存储模块里取出PCM数据,做音频AAC的编码,最终封装到MP4文件里。从数据存储模块角度来看,它属于一个消费者的角色,所以我们也叫它消费者模块。
拆分完模块之后,接下来就是在Andorid和iOS平台确定技术选型来完成模块的职责。
Android平台的实现
架构图的第二行是Android平台的实现。注意,核心系统还是在Native层构建的。
Input模块
输入模块主要分为两部分,一部分是采集人声,一部分是解码BGM。
- 采集人声时,使用AudioRecord这个API是兼容性最好的。如果想要更低延时的能力就再配备上Oboe,然后根据黑白名单和用户的设置在这两种使用方法中切换。采集到人声之后,要放到数据存储模块里。
- 解码伴奏,我们可以基于FFmpeg写一个伴奏的解码调度器,把背景音乐文件解码成PCM数据,并放入PCM队列(解码调度器内部维护的队列)里,以供Output模块从队列里取出PCM数据播放给用户听。
Output模块
输出模块也分为两部分,一部分是人声的耳返,另一部分是伴奏BGM的播放。
- 人声耳返,Android设备上的耳返监听方案还不是特别成熟,目前业界最佳实践分为两种,一种是使用厂商的硬件耳返,这个时候必须要使用Java层的AudioRecord来采集音频。另外一种是自己实现的软件耳返,使用Oboe来实现。其实除了K歌App之外,实时耳返的需求没有这么大,你可以根据自己的场景来规划这部分的精力投入。
- 伴奏播放,要让用户听得到背景音乐,我们使用AudioTrack来播放Input模块解码好的伴奏PCM数据,同时把播放的伴奏放到数据存储模块里。
PCM队列
因为核心系统都是构建在Native层的,所以我们使用C++自己书写一个线程安全的链表,来实现队列功能,提供对应的Put、Get、Abort、Size等接口供生产者和消费者使用,为了方便消费者获取数据,可以把这个队列改造成一个Blocking Queue的形式。
Consumer模块
消费者模块需要单独开启一个线程,来从数据存储模块里获取伴奏的PCM数据和人声的PCM数据。这里可以对PCM数据做一些处理,比如给人声增加音效、AEC、人声伴奏对齐调整。然后把两个PCM的Buffer合并成一轨音频数据,接着用MediaCodec或者libfdk_aac把PCM数据编码成AAC的码流,最终利用FFmpeg的Muxer模块把编码后的AAC数据封装到MP4文件的声音轨道里。
这样Andorid平台的技术选型就说完了,你可以对照架构图里Android部分再梳理一遍。接下来,我们看iOS平台的实现。
iOS平台的实现
架构图的第三行是iOS平台的实现,相比Android平台,iOS会简单一些。
Input模块
输入模块主要分为两部分,一部分是采集人声,另一部分是解码BGM。
- 采集人声应该使用RemoteIO这个AudioUnit,启用它的InputElement采集人声数据。采集到人声之后,如果要做处理直接在AUGraph里加入音效的AudioUnit节点,最终汇入MixerAudioUnit里。
- 解码伴奏,在AUGraph里直接使用AudioFilePlayer这个AudioUnit解码伴奏,如果要加入升降调的处理,可以在这个节点之后连接上一个NewTimepitch的AudioUnit。
然后使用一个Mixer的AudioUnit把人声和伴奏两轨声音合并起来,输出给下面的Output模块。
Output模块
如果想要完成伴奏和耳返播放功能,这里使用RemoteIO这个AudioUnit的OutputElement,把MixerUnit输出的音频播放出来就可以了。接下来还需要实现把PCM数据放入PCM队列的功能,这就需要给AudioUnit注册一个回调函数,利用Converter的AudioUnit把转换成SInt16采样格式表示的PCM数据放到音频队列里。
PCM队列
我们可以使用C++自己写一个线程安全的链表,提供先入先出的接口来完成队列的功能,这个队列的代码可以和Andorid平台共享一份代码。
Consumer模块
最后是Consumer模块的实现,开启一个线程在后台将PCM队列中的数据取出来之后,使用AudioToolbox或者libfdk_aac编码成AAC码流。最后利用FFmpeg把编码后的AAC数据封装到MP4文件的声音轨道里。这个模块主要是使用C++语言调用FFmpeg的API来实现的,所以可以和Android平台共享一份代码,你可以对照架构图的iOS部分再梳理一下。
音频架构分析得差不多了,下面我们看一下视频部分的架构。
视频模块架构设计
相比于音频的架构设计,视频的架构相对简单一些,整体架构图如下:
可以看到架构图的第一行,和音频架构图类似,分为输入、输出、队列和消费者四个模块,每个模块完成的功能这里就不再重复了,接下来我们直接确定一下各个模块在两个平台的技术选型。
Android平台的实现
Input模块的实现,直接使用Android系统为我们提供的Camera来采集,需要注意的是,要以纹理回调的方式使用它。Output模块分为两部分,一部分是预览,通过使用OpenGL ES结合Java层的Surface(Texture)View来实现;另外一部分是编码,建议你优先使用硬件****编码器给视频编码。如果有兼容问题,使用libx264软件编码作为保底的方案来实现,最终编码成H264的数据。(可参考第16课和第17课的内容)
这里和第17课的不同之处是编码之后的H264数据不要直接写入文件,而是要放到第三个模块——H264的队列里。对于H264的队列,我们同样可以使用一个线程安全的链表来实现,链表里每一个节点元素都是一帧H264的数据,也就是一个Annex-B格式的NALU。
最后一个模块是消费者模块,我们在Consumer这个模块取出队列里的H264数据包,然后利用FFmpeg的Mux模块封装到MP4的视频轨道里,这就和之前封装到这个文件里的音频轨道组成了一个完整的MP4文件,也就是我们最终输出的MP4文件。
iOS平台的实现
在iOS平台上,Input模块的实现自然会使用系统提供给开发者的Camera这个API来实现。Output模块,分为预览和编码,预览的实现直接使用EAGL和OpenGL再结合自定义的一个UIView来完成;编码的实现是使用VideoToolbox进行硬件编码,直接使用我们的ELImage框架来完成即可。我们第14节课和第18节课重点讲解过,这里的不同之处在于编码之后的H264数据不要写入文件里,而是应该放到H264的队列里。
第三个模块是H264队列模块,可以使用一个线程安全的链表来实现,链表里每一个节点元素都是H264的数据包,这个模块的实现可以和Android平台共享一份代码。
最后一个模块就是消费者模块,在Consumer模块取出队列里的H264数据包,然后利用FFmpeg的Mux模块把H264的包封装到MP4的视频轨道部分,和之前封装到这个文件中的音频轨道共同组成一个完整的MP4文件。这个模块主要使用C++语言调用FFmpeg的API来实现,也可以和Android平台共享一份代码。
以上就是我们视频录制器整体架构的设计和实现,但好的架构设计还需要给出具体的风险评估,接下来我们一起看一下。
风险分析
整个架构的风险点有两个:
- 编码器方面,各平台(尤其是Android平台)硬件编码器的兼容性问题,还有如果在Android平台使用软件编码器的话,中低端机上软件编码器的性能问题。
- 音视频对齐方面,由于音视频是分开采集的,这里我们需要考虑音视频同步的问题。
最后再补充一点测试用例方面的注意事项,在测试完App的Top机型和主流系统之后,要重点测试Android平台的硬件编码覆盖面,还要测试音视频对齐的问题,这里我们可以使用自动化检测音画对齐的工具来处理。
小结
最后,我们可以一起来回顾一下。这节课我们从视频录制器的场景分析入手,设计出了一个视频录制器的架构。
音频的输入包括采集音频和解码伴奏,输出包含播放音频、把PCM数据存入队列里,消费者模块会从队列里取出PCM数据进行编码,最后合并到文件里。
视频架构会更简单一些,输入模块就是摄像头采集图像,输出模块包含两部分,一部分是让用户预览到图像,另外一部分是编码成H264的码流放到队列里,消费者模块从队列里获取数据并合并到最终的MP4文件里。
无论是音频部分还是视频部分,我们底层的设计都是Input、Output、队列和消费者模块,在音视频的架构设计中基本都包含这几个模块。其中Input、Output模块和平台的相关性是比较大的,而队列和消费者模块在两个平台的实现其实是一模一样的,所以我们可以把这部分代码抽象成统一的接口,使用C++来书写,跨平台同时运行在Android与iOS上,这样可以提升开发效率,降低维护成本。
思考题
我来考考你,如果让你基于这个架构设计一个推流器的架构,你思考一下要在此基础上加入哪些模块,这些模块的职责又是什么?欢迎把你的答案留在评论区,也欢迎你把这节课分享给更多对音视频感兴趣的朋友,我们一起交流、共同进步。下节课再见!
- 一个正直的小龙猫 👍(0) 💬(1)
老师终于等到今天了,录制器部分,后面会有讲从webrtc steam(远端) 录制相关的内容么?
2022-09-05 - peter 👍(0) 💬(2)
请教老师几个问题: Q1:耳返是什么意思? Q2:自动化检测音画对齐的工具有哪些? Q3:安卓平台,在屏幕、字体等方面的适配方面,快手是怎么做的?会采用某一个适配框架吗? 好像听说有一个滴滴或美团的适配框架。 Q4:Input、Output模块,在设计上怎么隔离平台的相关性? Q5:OpenGL ES、OpenSL ES和FFmpeg是什么关系? 理解1:OpenGL ES、OpenSL ES是基于FFmpeg,即OpenGL ES、OpenSL ES包含了FFmpeg; 理解2:OpenGL ES、OpenSL ES和FFmpeg没有关系,两者相互独立,是不同的东西。 哪种理解对?
2022-09-05