iOS開發 AudioUnit+AUGraph實現錄音耳返功能

前言

這算是我進公司實習期間完成的第一個比較完整的項目吧,耗時大約2個月,也是我第一次接觸iOS音頻開發,目前還未接觸過視頻開發,但之後我也應該會往音視頻方向發展,不得不認可於我我的而言,音視頻開發確實有必定難度,直到如今我感受本身對iOS的音頻也是隻知其一;不知其二,因此寫這篇東西僅僅是想要分享與交流,本身也有一些問題但願能獲得解決。文後會放上demo源代碼的地址以及我在學習音頻開發過程當中參考過的大牛的文章供參考。node

需求分析

  • 能經過麥克風錄音並在本地生成pcm文件。
  • 耳返(錄音的同時將錄製的聲音播放出來)

使用AudioUnit的緣由

首先分享ObjC中國上一篇關於iOS全部音頻API的簡介https://objccn.io/issue-24-4/,相信你們看完這篇簡介後結合本身的項目需求就大概知道本身須要使用哪個API了吧。git

再說回我本身的項目需求,其實光是錄音+耳返這個需求,AudioUnit並非最簡單的選擇,使用AVAudioEngine會更簡單,至於能不能使用更簡單的API實現我目前還不得而知。那爲何我要使用AudioUnit呢?由於其實我公司的項目需求遠不止是錄音+耳返,還牽扯到音效處理和混聲相似於唱吧或者全民k歌這種軟件,因此只能使用最底層的AudioUnit。但該篇文章暫時只討論錄音+耳返這個較爲簡單的需求。github

使用AUGraph的緣由

上面iOS全部音頻API的簡介裏面並無提到AUGraph,因此就簡單介紹一下AUGraph。objective-c

AUGraph鏈接一組 audio unit 之間的輸入和輸出,構成一張圖,同時也爲audio unit 的輸入提供了回調。AUGraph抽象了音頻流的處理過程,子結構能夠做爲一個AUNode嵌入到更大的結構裏面進行處理。AUGraph能夠遍歷整個圖的信息,每一個節點都是一個或者多個AUNode,音頻數據在點與點之間流通,而且每一個圖都有一個輸出節點。輸出節點能夠用來啓動、中止整個處理過程。xcode

雖然實際工程中更多使用的是AUGraph的方式進行AudioUnit的初始化,但其實光使用AudioUnit一樣能夠實現錄音+耳返的功能,可是我在實際項目中出現了問題,致使我不得不配合AUGraph使用,這個問題將在後文詳述。app

另外,蘋果官方已經聲稱將要淘汰AUGraph這個API並在源碼中備註API_TO_BE_DEPRECATED,並且建議開發者改成使用AVAudioEngine,AVAudioEngine一樣能夠配合AudioUnit使用但我還未深刻研究,在網上搜索了一下AVAudioEngine的教程資料也是比較少的,若是有機會的話我之後會出一些關於AVAudioEngine的教程,其實要想實現複雜的例如混音功能,我相信重點依然是AudioUnit而不是AUGraph,AUGraph和如今的AVAudioEngine僅僅只是起到輔助管理做用。函數

具體實現步驟

  1. 新建一個普通的iOS工程,並新建Cocoa Touch Class命名爲GSNAudioUnitManager。

  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供其它類調用,也能夠直接重寫init方法,在init方法中調用audioUnitInit方法,demo中使用的方法是後者。
- (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. 這張圖對於理解輸入輸出通道會有很大的幫助,就比如如我一開始不理解爲何這裏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,由於網上已經有大牛寫了很詳盡的文章去介紹,從最基本的音頻原理到實踐,文後我也會貼出相應的連接,建議參閱,固然貼出來的僅僅只是我看過文章的一小部分,也是我以爲比較有價值的一部分。post

若是有問題能夠隨時向我提出,個人郵箱是yanghao2019@yeah.net。學習

Demo地址

github.com/Grayson2019…ui

參考文章

小東邪(強烈推薦)移動端音視頻從零到上手 juejin.im/post/5d29d8…

落影loyinglin AUGraph結合RemoteI/O Unit與Mixer Unit www.jianshu.com/p/f8bb0cc10…