iOS开发 AudioUnit+AUGraph实现录音耳返功能

2,003 阅读8分钟

前言:


这算是我进公司实习期间完成的第一个比较完整的项目吧,耗时大约2个月,也是我第一次接触iOS音频开发,目前还未接触过视频开发,但以后我也应该会往音视频方向发展,不得不承认于我个人而言,音视频开发确实有一定难度,直到现在我感觉自己对iOS的音频也是一知半解,所以写这篇东西仅仅是想要分享与交流,自己也有一些问题希望能得到解决。文后会放上demo源代码的地址以及我在学习音频开发过程中参考过的大牛的文章供参考。

需求分析

  • 能通过麦克风录音并在本地生成pcm文件。
  • 耳返(录音的同时将录制的声音播放出来)

使用AudioUnit的原因

首先分享ObjC中国上一篇关于iOS所有音频API的简介https://objccn.io/issue-24-4/,相信大家看完这篇简介后结合自己的项目需求就大概知道自己需要使用哪一个API了吧。

再说回我自己的项目需求,其实光是录音+耳返这个需求,AudioUnit并不是最简单的选择,使用AVAudioEngine会更简单,至于能不能使用更简单的API实现我目前还不得而知。那为什么我要使用AudioUnit呢?因为其实我公司的项目需求远不止是录音+耳返,还牵扯到音效处理和混声类似于唱吧或者全民k歌这种软件,所以只能使用最底层的AudioUnit。但该篇文章暂时只讨论录音+耳返这个较为简单的需求。

使用AUGraph的原因

上面iOS所有音频API的简介里面并没有提到AUGraph,所以就简单介绍一下AUGraph。

AUGraph连接一组 audio unit 之间的输入和输出,构成一张图,同时也为audio unit 的输入提供了回调。AUGraph抽象了音频流的处理过程,子结构可以作为一个AUNode嵌入到更大的结构里面进行处理。AUGraph可以遍历整个图的信息,每个节点都是一个或者多个AUNode,音频数据在点与点之间流通,并且每个图都有一个输出节点。输出节点可以用来启动、停止整个处理过程。

虽然实际工程中更多使用的是AUGraph的方式进行AudioUnit的初始化,但其实光使用AudioUnit同样可以实现录音+耳返的功能,但是我在实际项目中出现了问题,导致我不得不配合AUGraph使用,这个问题将在后文详述。

另外,苹果官方已经声称将要淘汰AUGraph这个API并在源码中备注API_TO_BE_DEPRECATED,而且建议开发者改为使用AVAudioEngine,AVAudioEngine同样可以配合AudioUnit使用但我还未深入研究,在网上搜索了一下AVAudioEngine的教程资料也是比较少的,如果有机会的话我以后会出一些关于AVAudioEngine的教程,其实要想实现复杂的例如混音功能,我相信重点依然是AudioUnit而不是AUGraph,AUGraph和现在的AVAudioEngine仅仅只是起到辅助管理作用。

具体实现步骤

  1. 新建一个普通的iOS工程,并新建Cocoa Touch Class命名为GSNAudioUnitManager。

image

  1. 宏定义,在Class-continuation分类中声明全局变量。
#define kInputBus 1
#define kOutputBus 0
FILE *file = NULL;
@implementation GSNAudioUnitManager {
    AVAudioSession *audioSession;
    AUGraph auGraph;
    AudioUnit remoteIOUnit;
    AUNode remoteIONode;
    AURenderCallbackStruct inputProc;
}
复制代码
  1. 配置AudioSession。AVAudioSessionCategoryPlayAndRecord是指既支持录音也支持播放,AVAudioSessionCategoryOptionAllowBluetoothA2DP是指支持譬如Airpods之类的蓝牙耳机;setPreferredIOBufferDuration方法是设置每一次录音的时长, 而时长影响录音的buffer大小 。
- (void)initAudioSession {
    audioSession = [AVAudioSession sharedInstance];

    NSError *error;
    // set Category for Play and Record
    // [audioSession setCategory:AVAudioSessionCategoryPlayAndRecord error:&error];
    [audioSession setCategory:AVAudioSessionCategoryPlayAndRecord withOptions:AVAudioSessionCategoryOptionAllowBluetoothA2DP error:&error];
    // [audioSession setPreferredIOBufferDuration:0.01 error:&error];
}
复制代码
  1. 新建并打开AUGraph。
- (void)newAndOpenAUGraph {
    CheckError(NewAUGraph(&auGraph),"couldn't NewAUGraph");
    CheckError(AUGraphOpen(auGraph),"couldn't AUGraphOpen");
}
复制代码
  1. 初始化AudioComponentDescription并将remoteIONode(AUNode)添加到AUGraph以及从remoteIONode获取romoteIOUnit(AudioUnit)。
- (void)initAudioComponent {
    AudioComponentDescription componentDesc;
    componentDesc.componentType = kAudioUnitType_Output;
    componentDesc.componentSubType = kAudioUnitSubType_RemoteIO;
    componentDesc.componentManufacturer = kAudioUnitManufacturer_Apple;
    componentDesc.componentFlags = 0;
    componentDesc.componentFlagsMask = 0;

    CheckError (AUGraphAddNode(auGraph,&componentDesc,&remoteIONode),"couldn't add remote io node");
    CheckError(AUGraphNodeInfo(auGraph,remoteIONode,NULL,&remoteIOUnit),"couldn't get remote io unit from node");
}
复制代码
  1. 设置各种AudioUnit的属性以及音频格式。
- (void)initFormat {
    //set BUS
    UInt32 oneFlag = 1;
    CheckError(AudioUnitSetProperty(remoteIOUnit,
                                    kAudioOutputUnitProperty_EnableIO,
                                    kAudioUnitScope_Output,
                                    kOutputBus,
                                    &oneFlag,
                                    sizeof(oneFlag)),"couldn't kAudioOutputUnitProperty_EnableIO with kAudioUnitScope_Output");

    CheckError(AudioUnitSetProperty(remoteIOUnit,
                                    kAudioOutputUnitProperty_EnableIO,
                                    kAudioUnitScope_Input,
                                    kInputBus,
                                    &oneFlag,
                                    sizeof(oneFlag)),"couldn't kAudioOutputUnitProperty_EnableIO with kAudioUnitScope_Input");

    AudioStreamBasicDescription mAudioFormat;
    mAudioFormat.mSampleRate         = 44100.0;//采样率
    mAudioFormat.mFormatID           = kAudioFormatLinearPCM;//PCM采样
    mAudioFormat.mFormatFlags        = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked;
    mAudioFormat.mReserved           = 0;
    mAudioFormat.mChannelsPerFrame   = 1;//1单声道,2立体声,但不是改为2就是立体声
    mAudioFormat.mBitsPerChannel     = 16;//语音每采样点占用位数
    mAudioFormat.mFramesPerPacket    = 1;//每个数据包多少帧
    mAudioFormat.mBytesPerFrame      = (mAudioFormat.mBitsPerChannel / 8) * mAudioFormat.mChannelsPerFrame; // 每帧的bytes数
    mAudioFormat.mBytesPerPacket     = mAudioFormat.mBytesPerFrame;//每个数据包的bytes总数,每帧的bytes数*每个数据包的帧数

    UInt32 size = sizeof(mAudioFormat);

    CheckError(AudioUnitSetProperty(remoteIOUnit,
                                    kAudioUnitProperty_StreamFormat,
                                    kAudioUnitScope_Output,
                                    kInputBus,
                                    &mAudioFormat,
                                    size),"couldn't set kAudioUnitProperty_StreamFormat with kAudioUnitScope_Output");

    CheckError(AudioUnitSetProperty(remoteIOUnit,
                                    kAudioUnitProperty_StreamFormat,
                                    kAudioUnitScope_Input,
                                    kOutputBus,
                                    &mAudioFormat,
                                    size),"couldn't set kAudioUnitProperty_StreamFormat with kAudioUnitScope_Input");
}
复制代码
  1. 初始化输入回调。
- (void)initInputCallBack {
    inputProc.inputProc = inputCallBack;
    inputProc.inputProcRefCon = (__bridge void *)(self);

    CheckError(AUGraphSetNodeInputCallback(auGraph, remoteIONode, 0, &inputProc),"Error setting io input callback");
}
复制代码
  1. 初始化并更新AUGraph。
- (void)initAndUpdateAUGraph {
    CheckError(AUGraphInitialize(auGraph),"couldn't AUGraphInitialize" );
    CheckError(AUGraphUpdate(auGraph, NULL),"couldn't AUGraphUpdate" );
}
复制代码
  1. 提供一个公开的接口audioUnitInit供其它类调用。
- (void)audioUnitInit
{
  // 设置需要生成pcm的文件路径
    self.pathStr = [self documentsPath:@"/mixRecord.pcm"];

    [self initAudioSession];

    [self newAndOpenAUGraph];

    [self initAudioComponent];

    [self initFormat];

    [self initInputCallBack];

    [self initAndUpdateAUGraph];
}
复制代码
  1. 再公开另外两个接口分别是启动AUGraph和停止AUGraph。
- (void)audioUnitStartRecordAndPlay {
    CheckError(AUGraphStart(auGraph),"couldn't AUGraphStart");
    CAShow(auGraph);
}

- (void)audioUnitStop {
    CheckError(AUGraphStop(auGraph), "couldn't AUGraphStop");
}
复制代码
  1. C语言函数CheckError。
static void CheckError(OSStatus error, const char *operation)
{
    if (error == noErr) return;
    char str[20];
    // see if it appears to be a 4-char-code
    *(UInt32 *)(str + 1) = CFSwapInt32HostToBig(error);
    if (isprint(str[1]) && isprint(str[2]) && isprint(str[3]) && isprint(str[4])) {
        str[0] = str[5] = '\'';
        str[6] = '\0';
    } else
        // no, format it as an integer
        sprintf(str, "%d", (int)error);

    fprintf(stderr, "Error: %s (%s)\n", operation, str);
    exit(1);
}
复制代码
  1. 将数据流写到本地.
- (void)writePCMData:(char *)buffer size:(int)size {
    if (!file) {
        file = fopen(self.pathStr.UTF8String, "w");
    }
    fwrite(buffer, size, 1, file);
}
复制代码
  1. 回调函数,每一次回调录音数据都会存储在ioData中,于是便可以将录音数据通过writePCMData写到本地。
static OSStatus inputCallBack(
                         void                        *inRefCon,
                         AudioUnitRenderActionFlags     *ioActionFlags,
                         const AudioTimeStamp         *inTimeStamp,
                         UInt32                         inBusNumber,
                         UInt32                         inNumberFrames,
                         AudioBufferList             *ioData)
{
    GSNAudioUnitManager *THIS=(__bridge GSNAudioUnitManager*)inRefCon;

    OSStatus renderErr = AudioUnitRender(THIS->remoteIOUnit,
                                         ioActionFlags,
                                         inTimeStamp,
                                         1,
                                         inNumberFrames,
                                         ioData);

    [THIS writePCMData:ioData->mBuffers->mData size:ioData->mBuffers->mDataByteSize];
    return renderErr;
}
复制代码
  1. 其它私有方法。
- (NSString *)documentsPath:(NSString *)fileName {
    NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
    NSString *documentsDirectory = [paths objectAtIndex:0];
    return [documentsDirectory stringByAppendingPathComponent:fileName];
}
复制代码

GSNAudioUnitGraphDemo使用方法

  1. 该Demo克隆或下载到本地后可直接通过xcode打开使用。
  2. 录音模块是GSNAudioUnitGraph.h和GSNAudioUnitGraph.m两个文件,只需要将两个文件拖到你的工程中,具体使用方式也很简单参考demo就好。

出现过的问题及思考

  1. 上文中有提到仅使用AudioUnit同样可以实现录音+耳返,但其中出现了一个很大的问题导致我不得不使用AUGraph,这个问题就是在仍保留有3.5mm耳机接口的iPhone(苹果从iPhone7开始取消3.5mm耳机接口,仅能通过lightning接口使用有线耳机)上默认(即不改变preferredIOBufferDuration)情况下每一次回调的mDataByteSize是2048,而在使用lightning耳机接口的iPhone上默认情况下每一次回调的mDataByteSize是1880,居然不是2的整数幂!因为仅使用AudioUnit的情况下必须要指明音频buffer的大小,而且必须是2的整数次幂,不然就会报“ AudioUnitRender error:-50 ”的错误。

  2. image

    这张图对于理解输入输出通道会有很大的帮助,就好比如我一开始不理解为什么这里kAudioUnitScope_Output对应的却是kInputBus(1),为什么不应该是kOutputBus(0),结合上图就会发现它其实就是想设置浅黄色部分也就是输出音频的格式。

CheckError(AudioUnitSetProperty(remoteIOUnit,
                                    kAudioUnitProperty_StreamFormat,
                                    kAudioUnitScope_Output,
                                    kInputBus,
                                    &mAudioFormat,
                                    size),"couldn't set kAudioUnitProperty_StreamFormat with kAudioUnitScope_Output");

    CheckError(AudioUnitSetProperty(remoteIOUnit,
                                    kAudioUnitProperty_StreamFormat,
                                    kAudioUnitScope_Input,
                                    kOutputBus,
                                    &mAudioFormat,
                                    size),"couldn't set kAudioUnitProperty_StreamFormat with kAudioUnitScope_Input");
复制代码
  1. 这个项目录音和播放均是单声道,并且我也尚未研究出AudioUnit实现真正的双声道播放方式(此处的双声道是指立体声,不仅仅是指两个耳机都能出声)。
  2. 提供的demo有将录音保存到本地的功能,但是没有直接播放的功能,如何使用AudioUnit播放PCM文件我有机会再写。
  3. 如果想要去除耳返功能或者想要给录音添加其它效果怎么办?其实在inputCallBack回调里面在AudioUnitRender之后,就拿到了ioData->mBuffers[0].mData也就是每一次回调的录音数据,如果想去除耳返就用memset将ioData->mBuffers[0].mData置空,想要添加其它效果就对ioData->mBuffers[0].mData处理。

后记

其实公司项目需求远不止这么简单,只是其它功能或多或少调用了公司内部的SDK所以不太好说,另外在我学习的过程中我觉得网上关于录音+耳返的通俗易懂的资料还是比较少的,但我并没有详细介绍AudioUnit或者AUGraph,因为网上已经有大牛写了很详尽的文章去介绍,从最基本的音频原理到实践,文后我也会贴出相应的链接,建议参阅,当然贴出来的仅仅只是我看过文章的一小部分,也是我觉得比较有价值的一部分。