AudioUnit录制音频+耳返(四)

前言

视频直播,K歌应用等等都会有音频录制的功能,音频录制时还可以带有耳返效果,那这些是如何实现的呢?如果仅仅是录制音频,那使用IOS的AudioQueue框架实现即可,但是在直播这些实时性要求比较高、特效比较多(比如混音,变声等)的应用中,AudioQueue可能满足不了要求了,AudioUnit可以完成这些功能。
本文将介绍用AudioUnit完成音频采集,耳返效果,保存裸PCM音频文件

AudioUnit音频系列

AudioUnit之-播放裸PCM音频文件(一)
AudioUnit之-录制音频+耳返(二)
AudioUnit之-录制音频保存为m4a/CAF/WAV文件和播放m4a/CAF/WAV文件(三)
AudioUnit之-录制音频并添加背景音乐(四)
AudioUnit之-generic output(离线)混合音频文件(五)

实现思路

先看一张图片,该图片来自官方关于AudioUnit Augraph的说明文档中,官网文档

IO_unit_2x.png

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截图

1563099390571.jpg

对应代码位置截图
1563099465040.jpg

1563099470971.jpg
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容