20 如何实现视频录制器的底层模块?(上)
你好,我是展晓凯。今天我们来一起学习视频录制器底层核心模块的实现。
上一节课我们从视频录制器的场景分析入手,设计出了一个视频录制器的架构,并且详细讲解了每个模块的职责与技术选型。接下来这两节课,我们就来分别实现音视频两个大模块下面的子模块。这节课我们要实现的是音频模块中的音频队列,目标是把音频(采集到的声音+BGM)的PCM数据放到PCM队列里。
音频模块
具体如何采集音频,你可以回顾一下前面第10节课和第11节课的内容。这节课我们主要讲解音频队列在Android平台和iOS平台的实现,还有如何把采集的音频数据放到队列里的问题,最重要的还有一部分是如何在音频模块里加入播放背景音乐的功能。其中音频队列的实现在双端我们采用一套代码,都使用C++来实现。
音频队列的实现
队列元素定义
讲解队列的具体实现之前,我们先来看一下队列里存放的元素,结构体定义如下:
typedef struct AudioPacket {
short * buffer;
int size;
AudioPacket() {
buffer = NULL;
size = 0;
}
~AudioPacket() {
if (NULL != buffer) {
delete[] buffer;
buffer = NULL;
}
}
} AudioPacket;
这个结构体定义了一个AudioPacket,每采集一段时间的PCM音频数据,就封装成一个这样的结构体对象,然后放到PCM队列里。上一节课我们也提到过自己写一个链表来实现这个队列,链表的节点定义如下:
typedef struct AudioPacketList {
AudioPacket *pkt;
struct AudioPacketList *next;
AudioPacketList(){
pkt = NULL;
next = NULL;
}
} AudioPacketList;
队列实现
为了保证线程的安全性,需要定义以下两个变量:
然后定义一个mFirst节点来指向头部节点,定义一个mLast节点指向尾部节点。队列提供的两个最重要的接口就是push和pop,我们分别定义为put和get方法,put函数定义如下:
在这个put方法的实现中,会先判断队列是否被abort掉了,如果被abort掉了就代表队列不再工作,直接返回;如果不是abort状态,就把调用端传递进来的AudioPacket实例组装成一个链表节点放入链表中。当然为了保证线的程安全性,在放入链表的过程中要先上锁,再操作链表。
当放入链表结束之后发出一个signal指令。原因是get方法有可能被block住了(队列是一个Blocking Queue),所以要通过发送这个指令让wait线程继续从队列里取得元素,最后释放锁。核心代码如下:
if (mAbortRequest) {
delete pkt;
return -1;
}
AudioPacketList *pkt1 = new AudioPacketList();
if (!pkt1)
return -1;
pkt1->pkt = pkt;
pkt1->next = NULL;
pthread_mutex_lock(&mLock);
if (mLast == NULL) {
mFirst = pkt1;
} else {
mLast->next = pkt1;
}
mLast = pkt1;
pthread_cond_signal(&mCondition);
pthread_mutex_unlock(&mLock);
return 0;
接下来是get接口,方法原型如下:
get方法的主要实现是把mFirst指向的节点拿出来返回调用端,并把mFirst指向它的下一级节点。如果当前队列为空的话,就block住(用来实现Blocking Queue的特性),等有元素后再放进来,或者这个队列被abort掉之后,才可以返回,实现代码如下:
AudioPacketList *pkt1;
int ret = 0;
pthread_mutex_lock(&mLock);
for (;;) {
if (mAbortRequest) {
ret = -1;
break;
}
pkt1 = mFirst;
if (pkt1) {
mFirst = pkt1->next;
if (!mFirst)
mLast = NULL;
mNbPackets--;
*pkt = pkt1->pkt;
delete pkt1;
pkt1 = NULL;
ret = 1;
break;
else {
pthread_cond_wait(&mCondition, &mLock);
}
=}
pthread_mutex_unlock(&mLock);
return ret;
最核心的两个方法已经实现了,剩下的就是abort方法了,这个方法需要把我们的布尔型变量mAbortRequest设置为true,并且同时要发出一个signal指令,防止别的线程会被block在取数据的接口中(即get方法中);还有一个销毁方法,就是把队列中所有的元素遍历出来,然后释放掉。
到这里,队列实现就完成了。
Android平台音频模块的实现
接下来我们看Android平台上音频模块的实现,下图是音频模块的结构图。
图里AudioRecord音频采集的代码,第11节课我们已经详细讲解过了,你可以先回顾一下。这节课我们会根据项目做一下结构调整,最重要的是添加背景音乐播放模块,最终把采集到的人声和解码后的背景音乐这两部分PCM音频数据分别入队。
伴奏的解码与播放
因为要给录制的视频增加背景音乐,所以我们要先看一下如何解码并播放伴奏。在视频播放器中我们详细讲解过,背景音乐的播放相当于一个音频播放器,工程实现步骤如下:
- 解码控制器
基于FFmpeg建立一个伴奏的解码器,负责把背景音乐解码成PCM的数据。建立一个解码控制器来开启一个线程不断调用解码器进行解码,解码出的音频数据放到一个缓冲队列中,当队列元素到达一个阈值的时候就暂停解码,如果收到signal指令就继续调用解码器解码。同时解码控制器给客户端提供一个readSamples方法,方法完成两个职责:
- 不断从队列中取出解码好的伴奏PCM数据并返回给调用端。
- 要监测缓冲队列大小,当队列小于某个阈值的时候,发送一个signal指令让解码线程继续工作。
- 伴奏音频渲染
业务层把AudioTrack作为渲染PCM数据的技术选型,客户端会创建一个播放器线程,从而不断地调用解码控制器的readSamples方法,最后把获取出来的PCM数据提供给AudioTrack进行播放。当停止播放的时候,先停掉解码控制器,然后停止并释放AudioTrack的资源。
这就是背景音乐播放器的整体流程,总体来讲就是,解码控制器中的解码线程作为生产者把解码的PCM数据放到一个解码队列里,而在客户端中AudioTrack所在的播放线程则作为消费者(调用readSamples方法),不断从解码队列中拿出PCM数据播放给用户。
- 伴奏音频入队
在当前场景下,除了让用户可以听到伴奏,同时还要把用户听到的伴奏存储下来,那就要在背景音乐播放器的基础上做一些改动。先修改初始化方法,在初始化方法中添加一个伴奏音频队列的初始化。注意这个队列和上述的解码队列不是同一个队列,解码队列可以作为解码线程和播放器这一对生产者和消费者之间的桥梁,而这里的队列是指把播放器当作生产者,把Consumer模块的编码线程当作消费者。
为了全局都可以访问到这个队列,要把队列放到一个单例模式设计的池子中,初始化两个队列的代码如下:
packetPool = LiveCommonPacketPool::GetInstance();
packetPool->initDecoderAccompanyPacketQueue();
packetPool->initAccompanyPacketQueue(sampleRate, CHANNEL);
接下来要修改readSamples方法,因为这个方法的职责发生了变化,不再单单给上层的播放器PCM提供数据,同时也要作为伴奏音频队列的生产者。所以我们要把这个解码控制器中的方法名readSamples修改为readSamplesAndProducePacket。在方法的实现中,把这个伴奏的AudioPacket中的数据拷贝给客户端之后,再它放到伴奏音频队列里。代码如下:
这样就在播放背景音乐的同时,把伴奏放到了队列中,是不是很简单呢?接下来我们看一下采集的人声如何放入队列中。
音频入队
前面第11节课我们把AudioRecord采集出来的PCM数据,直接写入文件中了,但是在当前的场景中不应该写入文件中,而要放到人声的PCM队列里。在Native层新建一个RecordProcessor类,负责接收PCM数据并把这些数据放入队列中,同时维护声音的编码线程。下面来看一下这个类里的方法和实现,首先是初始化方法:
这个方法主要是把采样率和队列里每一个元素的大小作为参数传入进来。在这个方法的实现中,要把这两个参数赋值给全局变量,并按照这个audioBufferSize分配一块audioBuffer的存储空间,用来积攒采集到的音频数据。最后构造出编码器,并初始化这个编码器。
接下来看第二个接受人声PCM数据的方法定义:
这个方法用来积攒本来要在Java层送入的PCM的数据,并把数据放入队列中。为什么要积攒呢?因为在不同的设备上Java层的AudioRecorder采集出来的buffer大小有可能不同,所以要在这个方法内部做一个积攒,当积攒到初始化方法中设定的audioBufferSize的时候,再把这一段buffer构造成一个AudioPacket放到人声队列里。实现代码如下:
void pushAudioBufferToQueue(short* samples, int size) {
int samplesCursor = 0;
int samplesCnt = size;
while (samplesCnt > 0) {
if ((audioSamplesCursor + samplesCnt) < audioBufferSize) {
cpyToAudioSamples(samples + samplesCursor, samplesCnt);
audioSamplesCursor += samplesCnt;
samplesCursor += samplesCnt;
samplesCnt = 0;
} else {
int subFullSize = audioBufferSize – audioSamplesCursor;
cpyToAudioSamples(samples + samplesCursor, subFullSize);
audioSamplesCursor += subFullSize;
samplesCursor += subFullSize;
samplesCnt -= subFullSize;
flushAudioBufferToQueue();
}
}
}
在代码中先定义一个游标来表示访问到samples这个buffer的哪个位置,然后进入一个循环, 把这个buffer的数据全部都拷贝出来,直到全都使用完了就退出这个循环。
循环内部首先判断在全局的audioBuffer中是否有足够的空间来存放有效数据(就是buffer中还没被取出的数据)。如果可以存放,就全部都拷贝到全局audioBuffer中,然后给全局的audioBuffer游标和当前buffer的游标增加对应的数值,把sampleCnt设置为0,标志当前buffer使用完毕了;如果全局的audioBuffer存放不了的话,那么就先计算出全局的audioBuffer还能存放多少,代码如下:
cpyToAudioSamples这个方法可以把对应数量(subFullSize)个sample从当前buffer中拷贝到全局的audioBuffer中,然后给全局audioBuffer对应的游标增加对应数量,当前buffer的有效采样数目减去对应数量,最后再把全局的audioBuffer封装成一个AudioPacket放入人声队列中。把audioBuffer封装成AudioPacket并且入队的代码如下:
short* packetBuffer = new short[audioSamplesCursor];
memcpy(packetBuffer, audioSamples, audioSamplesCursor * sizeof(short));
AudioPacket * audioPacket = new AudioPacket();
audioPacket->buffer = packetBuffer;
audioPacket->size = audioSamplesCursor;
packetPool->pushAudioPacketToQueue(audioPacket);
上述代码就是flushAudioBufferToQueue方法的具体实现,实际上就是分配一个新的内存空间,将全局变量audioBuffer的内容拷贝进去,然后封装成AudioPacket,最终放到队列中。
这个类的最后一个方法就是销毁方法,这个方法的实现非常简单,首先要把在初始化方法中分配的全局audioBuffer这块内存给释放掉,然后调用编码器的销毁方法,最终再释放掉编码器这个对象。
到这里我们就完成了伴奏的播放、伴奏和人声PCM数据入队的Android平台的实现,接下来我们看一下iOS平台的实现。
iOS平台的实现
这个部分我们要实现在iOS平台采集音频、播放伴奏,同时把采集的人声PCM数据和播放的伴奏PCM数据合并成一轨PCM数据存到队列里。我们先来看一下整体架构图。
图里的人声采集,第10节课详细讲解过,当时是把采集的声音直接编码到磁盘的文件中了。这节课我们会对人声采集部分进行相应的改造,而播放伴奏的部分,你可以回想一下我们之前留的MixerUnit,就是为了后续扩展使用的。而扩展的地方就在这里,播放伴奏就是把AudioFilePlayer这个AudioUnit也连接上MixerUnit,这样用户听到的就是伴奏和人声合并到一起的声音了。
为了支持开发的App可以在后续扩展出Mix一轨伴奏这个功能,我们需要额外在AUGraph中增加MultiChannelMixer这个AudioUnit。——第10节课内容回顾
伴奏的解码与播放
这个部分我们重点来看一下伴奏的解码以及与MixerUnit的连接操作,具体的入队操作我们下节课会介绍。我们在第10节课类的基础上进行改造,从构造AUGraph的类中找到方法addAudioUnitNodes,添加以下代码:
AudioComponentDescription playerDescription;
bzero(&playerDescription, sizeof(playerDescription));
playerDescription.componentManufacturer = kAudioUnitManufacturer_Apple;
playerDescription.componentType = kAudioUnitType_Generator;
playerDescription.componentSubType = kAudioUnitSubType_AudioFilePlayer;
AUGraphAddNode(_auGraph, &playerDescription, &mPlayerNode);
这段代码是向AUGraph中加入AudioFilePlayer这个AUNode。接着在getUnitsFromNode方法中加入下面代码:
上述代码是找出mPlayerNode对应的AudioUnit,并赋值给全局变量mPlayerUnit,以便后续给它设置参数。接着在setUnitProperties方法的最后一行给新找出来的AudioUnit设置声音格式。
AudioUnitSetProperty(mPlayerUnit, kAudioUnitProperty_StreamFormat,
kAudioUnitScope_Output, 0, &_clientFormat32float,
sizeof(_clientFormat32float));
接下来就将这个mPlayerUnit连接到Mixer上,首先更改Mixer这个AudioUnit中输入Unit的数目,也就是把这个函数中定义的局部变量mixerElementCount更改为2,代表有两路输入要给Mixer这个AUNode。然后把mPlayerNode连接到mixerNode的bus为1的输入端,代码如下:
在执行完AUGraph初始化方法之后,要给Player这个AudioUnit来配置参数,下面是配置过程。
首先要把想要播放的文件路径设置给Player Unit,代码如下:
AudioFileID musicFile;
CFURLRef songURL = (__bridge CFURLRef) _playPath;
AudioFileOpenURL(songURL, kAudioFileReadPermission, 0, &musicFile);
AudioUnitSetProperty(mPlayerUnit, kAudioUnitProperty_
ScheduledFileIDs, kAudioUnitScope_Global, 0, &musicFile,
sizeof(musicFile));
设置完播放的文件之后,要看一个对AudioFilePlayer这个AudioUnit非常重要的概念,即ScheduledAudioFileRegion,它是AudioToolbox里提供的一个结构体,从名字上看是计划对AudioFile进行访问的区域,其实这个结构体就是用来控制AudioFilePlayer的,结构体里面可以设置的内容如下:
- mAudioFile:要播放的音频文件的AudioFileID。
- mFramesToPlay:要播放的音频帧数目,通过获取出要播放的AudioFile的Format和总的Packet数目来计算。
- mLoopCount:设置循环播放的次数。
- mStartTime:设置开始播放的时间,拖动(Seek)功能一般通过设置这个参数来实现。
- mCompletionProc:播放音频完成之后的回调函数。
- mCompletionProcUserData:用来设置回调函数的上下文。
配置好这个结构体之后,把它设置给AudioFilePlayer这个Unit。
AudioUnitSetProperty(mPlayerUnit, kAudioUnitProperty_ScheduledFileRegion,
kAudioUnitScope_Global, 0,&rgn, sizeof(rgn))
最后给出AudioFilePlayer最后一部分的配置。
UInt32 defaultVal = 0;
AudioUnitSetProperty(mPlayerUnit, kAudioUnitProperty_ScheduledFilePrime,
kAudioUnitScope_Global, 0, &defaultVal, sizeof(defaultVal));
AudioTimeStamp startTime;
memset (&startTime, 0, sizeof(startTime));
startTime.mFlags = kAudioTimeStampSampleTimeValid;
startTime.mSampleTime = -1;
AudioUnitSetProperty(mPlayerUnit, kAudioUnitProperty_ScheduleStartTimeStamp,
kAudioUnitScope_Global, 0, &startTime, sizeof(startTime));
注意,这个配置必须在AUGraph初始化之后,否则是不生效的,因为在构造的AUGraph初始化之后,内部才会真正地初始化AudioFilePlayer这个AudioUnit,所以配置放到这个位置,它设置的参数才是有效的。这一点类似于把AUGraph里的AUNode找出来赋值给AudioUnit,如果AUGraph没有被打开的话,就相当于这个Graph里面的AUNode还没有被实例化,我们也不可能找出对应的AudioUnit。
在播放的过程中,可以通过获取kAudioUnitProperty_CurrentPlayTime得到播放时长(是相对于设置的开始时间的时间),计算出当前播放到的位置。其实,播放器的配置时机正确是最重要的,具体的配置信息比较简单,你可以对照着整个项目中的代码示例梳理一遍。
第10节课,我们已经把RemoteIO这个AudioUnit采集的音频直接编码到了文件中,但这节课不能直接写入文件中,而是要封装成AudioPacket放入队列里,那应该如何实现呢?
音频入队
首先找到给RemoteIO设置的回调函数,我们原来的操作是从前一级MixerNode里取出数据写文件,现在要把这个函数里关于写文件的操作都去掉,把取出来的数据封装成AudioPacket放到队列里。但队列要求PCM数据是SInt16格式的,而从MixerNode里取出来的是Float32格式的数据,如何转换呢?答案也非常简单,就是使用ConvertNode。
先在MixerNode后面添加一个Float32转换为SInt16的ConvertNode。但是,不可以直接把这个ConvertNode连接到RemoteIO上。因为SInt16的表示格式和RemoteIO要求的输入格式不匹配,所以需要在这个ConvertNode之后再添加一级ConvertNode,用来把SInt16格式转换成Float32格式。然后把第二个ConvertNode连接到原来的RemoteIO上。
获取数据的话,可以在第一个ConvertNode对应的Unit上添加一个RenderNotify回调函数,在函数里把数据封装成AudioPacket送入队列。接下来看一下具体实现。
首先构建Float32转换SInt16的ConvertNode,代码如下:
AudioComponentDescription convertDescription;
bzero(&convertDescription, sizeof(convertDescription));
convertDescription.componentManufacturer = kAudioUnitManufacturer_Apple;
convertDescription.componentType = kAudioUnitType_FormatConverter;
convertDescription.componentSubType = kAudioUnitSubType_AUConverter;
AUGraphAddNode(_auGraph, &convertDescription, &_c32fTo16iNode);
然后取出这个AUNode对应的AudioUnit,分别构造Float32的Format和SInt16的Format,把这两个Format作为参数分别设置给AudioUnit的输入和输出,代码如下:
AudioUnitSetProperty(_c32fTo16iUnit,
kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, 0,
&c16iFmt, sizeof(c16iFmt));
AudioUnitSetProperty(_c32fTo16iUnit,
kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, 0,
&c32fFmt, sizeof(c32fFmt));
接下来给这个ConvertNode的AudioUnit增加一个RenderNotify。注意,RenderNotify和之前使用的InputCallback是不一样的,InputCallback是下一级节点需要数据的时候会调用的函数,让配置的这个函数来填充数据;但RenderNotify的调用机制不同,它从它的上一级节点获取到数据之后才会在这个节点调用这个函数,并且它可以让开发者做一些额外的操作(比如音频处理或编码文件),所以在这个场景下使用RenderNotify这个方法会更合理。设置代码如下:
在mixerRenderNotify这个回调函数里,拿到的PCM数据就是SInt16格式的了,之后将PCM数据封装成AudioPacket放到音频队列里,代码如下:
AudioBuffer buffer = ioData->mBuffers[0];
int sampleCount = buffer.mDataByteSize / 2;
short *packetBuffer = new short[sampleCount];
memcpy(packetBuffer, buffer.mData, buffer.mDataByteSize);
AudioPacket *audioPacket = new AudioPacket();
audioPacket->buffer = packetBuffer;
audioPacket->size = buffer.mDataByteSize / 2;
packetPool->pushAudioPacketToQueue(audioPacket);
接下来,构建将SInt16格式转换成Float32格式的ConvertNode,构建AUNode的方法和之前是一样的,但设置的参数和之前恰好是相反的,代码如下:
AudioUnitSetProperty(_c16iTo32fUnit, kAudioUnitProperty_StreamFormat,
kAudioUnitScope_Output, 0, &c32fFmt, sizeof(c32fFmt));
AudioUnitSetProperty(_c16iTo32fUnit, kAudioUnitProperty_StreamFormat,
kAudioUnitScope_Input, 0, &c16iFmt, sizeof(c16iFmt));
最后让c32fTo16iNode连接上c16iTo32fNode,并将c16iTo32fNode连接到RemoteIO上,代码如下:
AUGraphConnectNodeInput(_auGraph, _c32fTo16iNode, 0, _c16iTo32fNode, 0);
AUGraphConnectNodeInput(_auGraph, _c16iTo32fNode, 0, _ioNode, 0);
通过这一系列改造,就可以把PCM数据取出来并存入到音频队列里,同时用户也可以听到伴奏以及自己声音的耳返。注意当用户没有插入有线耳机的时候,要把MixerUnit对人声的这一路mute掉,否则会出现啸叫的现象。
那PCM数据存入音频队列之后,又该如何处理呢?答案是编码,下节课我们会进行音频编码模块的实现。
小结
最后,我们可以一起来回顾一下音频模块的具体实现。
在音频队列的实现中,最重要的就是解码伴奏以及实现控制器,其中包括使用FFmpeg做解码、线程的控制,以及实现一个伴奏播放器,同时又把采集到的人声PCM和播放出来的伴奏PCM存到队列里。现在这两种PCM数据已经放到PCM队列里了,下节课我们会继续实现音频编码模块,这个模块会从PCM队列里获取出PCM数据,然后编码成AAC的码流,存入AAC队列里。
思考题
音频模块的底层实现已经学完了,我们一起来思考一个问题,在播放背景音乐同时开录音,可能会漏音,降低了整体音频的音质,这个时候我们应该如何处理呢?欢迎把你的答案留在评论区,也欢迎你把这节课分享给更多对音视频感兴趣的朋友,我们一起交流、共同进步。下节课再见!
- peter 👍(0) 💬(1)
请教老师几个问题: Q1:队列为什么不用类来定义? Q2:音频的缓冲队列是前面定义的音频队列吗? Q3:文中是采用AudioTrack播放音频,但用FFmpeg也可以播放吧。 Q4:有一种vep视频文件,只能用“大黄蜂播放器”播放,请问:vep文件是什么编码方法?什么封装格式?是否有其他软件可以播放?
2022-09-07