前言
视频直播,K歌应用等等都会有音频录制的功能,音频录制时还可以带有耳返效果,那这些是如何实现的呢?如果仅仅是录制音频,那使用IOS的AudioQueue框架实现即可,但是在直播这些实时性要求比较高、特效比较多(比如混音,变声等)的应用中,AudioQueue可能满足不了要求了,AudioUnit可以完成这些功能。
本文将介绍用AudioUnit完成音频采集,耳返效果,保存裸PCM音频文件
AudioUnit音频系列
AudioUnit之-播放裸PCM音频文件(一)
AudioUnit之-录制音频+耳返(二)
AudioUnit之-录制音频保存为m4a/CAF/WAV文件和播放m4a/CAF/WAV文件(三)
AudioUnit之-录制音频并添加背景音乐(四)
AudioUnit之-generic output(离线)混合音频文件(五)
实现思路
先看一张图片,该图片来自官方关于AudioUnit Augraph的说明文档中,官网文档
1、图解
这里的Remote I/O Unit代表了扬声器和麦克风硬件,其中Element0代表了扬声器,Element1代表了麦克风。Element0(扬声器)的Input scope连接着app端,Output scope连接着扬声器硬解,Element1(麦克风)的Input scope连接着麦克风硬件端,Output scope连接着app端。
2、播放音频过程
系统定时从播放缓冲区(该缓冲区对app不可见)中读取音频数据输送给扬声器硬件,扬声器进行播放,所以只要app不停的往该播放缓冲区中输送音频数据(即通过Element0的Input scope输送数据),保证该缓冲区中有音频数据,那么音频将持续播放,至于用AudioUnit播放PCM音频的代码可参考前面的文章AudioUnit播放PCM音频
3、采集过程
采集过程则是播放过程的逆过程,麦克风不停的采集数据放入采集缓冲区(该缓冲区对app不可见),所以只要app不停的从该采集缓冲区中读取音频数据(即通过Element1的Out scope获取数据),那么即可完成音频的采集过程,该音频数据为裸PCM音频数据,你可以直接保存到文件中,也可以用aac编码后保存到m4a文件中,也可以直接发送到网络中等等。
4、耳返实现
所谓耳返,就是录制的音频实时输送回扬声器进行播放。有了前面2、3的分析,现在来看耳返的实现是不是很简单了,没错,就是app从Element1的Out scope获取的音频数据再通过Element0的In scope输送出去即完成了耳返功能
音频数据结构AudioBufferList解析
这个数据结构对音频存储很重要,有必要弄清楚
对于packet数据,各个声道数据依次存储在mBuffers[0]中,对于planner格式,每个声道数据分别存储在mBuffers[0],...,mBuffers[i]中
对于packet数据,AudioBuffer中mNumberChannels数目等于channels数目,对于planner则始终等于1
kAudioFormatFlagIsNonInterleaved对应的是planner格式;kAudioFormatFlagIsPacked对应的则是packet格式
struct AudioBufferList
{
UInt32 mNumberBuffers;
AudioBuffer mBuffers[1]; // this is a variable length array of mNumberBuffers elements
#if defined(__cplusplus) && defined(CA_STRICT) && CA_STRICT
public:
AudioBufferList() {}
private:
// Copying and assigning a variable length struct is problematic; generate a compile error.
AudioBufferList(const AudioBufferList&);
AudioBufferList& operator=(const AudioBufferList&);
#endif
};
typedef struct AudioBufferList AudioBufferList;
它的创建代码如下:
_bufferList = (AudioBufferList *)malloc(sizeof(AudioBufferList) + (chs - 1) * sizeof(AudioBuffer));
_bufferList->mNumberBuffers = _isPlanner?(UInt32)chs:1;
for (NSInteger i=0; i<chs; i++) {
_bufferList->mBuffers[i].mData = malloc(BufferList_cache_size);
_bufferList->mBuffers[i].mDataByteSize = BufferList_cache_size;
}
录制音频实现代码
这里只贴出部分关键代码,详细代码请参考后面Demo。
1、准备工作
配置正确的音频会话
// 2、======配置音频会话 ======//
/** 配置使用的音频硬件:
* AVAudioSessionCategoryPlayback:只是进行音频的播放(只使用听的硬件,比如手机内置喇叭,或者通过耳机)
* AVAudioSessionCategoryRecord:只是采集音频(只录,比如手机内置麦克风)
* AVAudioSessionCategoryPlayAndRecord:一边采集一遍播放(听和录同时用)
*/
[_aSession setCategory:category error:nil];
前面的文章我们播放音频,使用的是AVAudioSessionCategoryPlayback,如果要录制那么要使用AVAudioSessionCategoryRecord,如果要实现耳返就要使用AVAudioSessionCategoryPlayAndRecord。
tips:
其实我们使用一种即可AVAudioSessionCategoryPlayAndRecord,这样录制和播放都可以使用了
2、创建RemoteIO Unit
// 创建RemoteIO
_iodes = [ADUnitTool comDesWithType:kAudioUnitType_Output subType:kAudioUnitSubType_RemoteIO fucture:kAudioUnitManufacturer_Apple];
3、开启录制功能
重要,扬声器默认是开启的,麦克风默认是关闭的
// 1、开启麦克风录制功能
UInt32 flag = 1;
OSStatus status = noErr;
// 对于麦克风:第三个参数麦克风为kAudioUnitScope_Input, 第四个参数为1
// 对于扬声器:第三个参数麦克风为kAudioUnitScope_Output,第四个参数为0
// 其它参数都一样;扬声器默认是打开的
status = AudioUnitSetProperty(_ioUnit, kAudioOutputUnitProperty_EnableIO, kAudioUnitScope_Input, 1, &flag, sizeof(flag));
if (status != noErr) {
NSLog(@"AudioUnitSetProperty kAudioUnitScope_Output fail %d",status);
}
这里解释下为什么AudioUnitSetProperty()函数第三个参数对应的是kAudioUnitScope_Input,前面不是说采集音频时是从Element1的output scope读取数据吗,应该是kAudioUnitScope_Output才对吧。很容易被绕混了,其实这样理解,开启录制功能,对应的是系统麦克风硬件,所以应该是Input scope。
4、设置采集的音频数据输出格式
重要,app从采集缓冲区读取的数据格式要指定,否则app如何处理这个数据呢,对吧,比如采样率,声道数,采样格式等等。
// 录制音频的输出的数据格式
CGFloat rate = self.audioSession.currentSampleRate;
NSInteger chs = self.audioSession.currentChannels;
AudioStreamBasicDescription recordASDB = [ADUnitTool streamDesWithLinearPCMformat:flags sampleRate:rate channels:chs bytesPerChannel:_bytesPerchannel];
// 设置录制音频的输数据格式
status = AudioUnitSetProperty(_ioUnit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, 1, &recordASDB, sizeof(recordASDB));
if (status != noErr) {
NSLog(@"AudioUnitSetProperty _ioUnit kAudioUnitScope_Output fail %d",status);
}
这里又是kAudioUnitScope_Output了,有人可能会问,采集的数据格式不应该是在input scope端吗?NO,NO,NO,系统将麦克风采集的数据经过转换后放在采集缓冲区中,这里设置的就是转换后放在采集缓冲区的数据格式,所以是kAudioUnitScope_Output
5、设置采集输出的回调
这里也是重要的一步,系统将采集的音频数据放入采集缓冲区,那app如何知道缓冲区是否有数据呢?系统有一个回调函数,该回调函数被调用了说明缓冲区中有数据,所以我们只要设置该回调函数即可
AURenderCallbackStruct callback;
callback.inputProc = saveOutputCallback;
callback.inputProcRefCon = (__bridge void*)self;
/** tips:前面即使将麦克风的输出作为扬声器的输入,这里也可以再为麦克风的输出设置回调,他们是互不干扰的。但是需要在回调里面手动调用
* AudioUnitRender()函数将数据渲染出来
*/
AudioUnitSetProperty(_ioUnit, kAudioOutputUnitProperty_SetInputCallback, kAudioUnitScope_Output, 1, &callback, sizeof(callback));
那么当采集缓冲区中有数据的时候,saveOutputCallback将被调用,下面看一下该回调函数长撒样
tatic OSStatus saveOutputCallback(void *inRefCon,
AudioUnitRenderActionFlags *ioActionFlags,
const AudioTimeStamp *inTimeStamp,
UInt32 inBusNumber,
UInt32 inNumberFrames,
AudioBufferList *ioData)
{
.........
}
6、从采集缓冲区获取音频数据
第5 步app知道缓冲区中有数据了,那app如何拿到该数据呢?细心的人可能都看到前面的备注了,对的,就是通过AudioUnitRender()函数来获取数据,如下就是获取数据的代码
status = AudioUnitRender(player->ioUnit, ioActionFlags, inTimeStamp, inBusNumber, inNumberFrames, bufferList);
最终音频数据将按照前面第四步中指定的方式存储于bufferList中
第一个参数代表 Remote I/O Unit
第二,三,四,五个参数没什么好解释的,传saveOutputCallback对应的即可,告诉系统,app要指定时间,指定书目的音频数据,不要乱回数据^^。
第6个参数要注意下,它必须和前面第4步指定的输出格式对应,比如如果前面第四步是kAudioFormatFlagIsNonInterleaved,那么这里的buffList的mNumberBuffers就为声道数目
bufferList的具体创建逻辑为:
_bufferList = (AudioBufferList *)malloc(sizeof(AudioBufferList) + (chs - 1) * sizeof(AudioBuffer));
_bufferList->mNumberBuffers = _isPlanner?(UInt32)chs:1;
for (NSInteger i=0; i<chs; i++) {
_bufferList->mBuffers[i].mData = malloc(BufferList_cache_size);
_bufferList->mBuffers[i].mDataByteSize = BufferList_cache_size;
}
7、保存录制的音频
第6步拿到的音频数据放在了AudioBufferList结构体定义的结构中,它是裸的音频数据,对于planner格式和packet格式音频数据,它的存储方式也是不一样的,所以我们处理时也要分别对待。
这里以直接将裸音频数据保存到文件中为例说一下保存的区别
if (isPlanner) {
// 则需要重新排序一下,将音频数据存储为packet 格式
int singleChanelLen = bufferList->mBuffers[0].mDataByteSize;
size_t totalLen = singleChanelLen * chs;
Byte *buf = (Byte *)malloc(singleChanelLen * chs);
bzero(buf, totalLen);
for (int j=0; j<singleChanelLen/bytesPerChannel;j++) {
for (int i=0; i<chs; i++) {
Byte *buffer = bufferList->mBuffers[i].mData;
memcpy(buf+j*chs*bytesPerChannel+bytesPerChannel*i, buffer+j*bytesPerChannel, bytesPerChannel);
}
}
if (player.dataWriteForPCM) {
[player.dataWriteForPCM writeDataBytes:buf len:totalLen];
}
// 释放资源
free(buf);
buf = NULL;
} else {
AudioBuffer buffer = bufferList->mBuffers[0];
UInt32 bufferLenght = bufferList->mBuffers[0].mDataByteSize;
if (player.dataWriteForPCM) {
[player.dataWriteForPCM writeDataBytes:buffer.mData len:bufferLenght];
}
}
通过上面代码,我们看到对于planner格式,我们还要先将planner格式转换成packet格式,然后再写入文件。为什么呢?因为裸音频数据在文件中是以packet格式存放的。
耳返实现代码
上面讲解了如何录制音频,并且将录制的裸音频数据保存到文件中。那如何开启耳返功能呢?其实只需在上面步骤中增加一个步骤即可,看下代码
AUGraphConnectNodeInput(_augraph, _ioNode, 1, _ioNode, 0);
解释一下这句代码,第一二四个参数没什么好说的,主要第三个和第五个参数,它代表将麦克风的输出连接到扬声器的输入,即当采集缓冲区有数据时系统自动为我们将音频数据输出到扬声器的Element0的Input scope,那我们在录制音频的同时就可以听到我们自己的声音了(该延迟非常低,基本感觉不出来)。
有人可能会问,那我们前面第五步设置的采集音频的回调还会调用吗?答案是会的,不影响。
项目地址
Demo
对应运行Demo截图
对应代码位置截图