iOS 音訊-audioUnit 總結

FindCrt發表於2019-03-03

在看 LFLiveKit 程式碼的時候,看到音訊部分使用的是 audioUnit 做的,所以把 audioUnit 學習了一下。總結起來包括幾個部分:播放、錄音、音訊檔案寫入、音訊檔案讀取.

demo 放在VideoGather這個庫,裡面的 audioUnitTest 是各個功能的測試研究、singASong 是集合各種音訊處理元件來做的一個“播放伴奏+唱歌 ==> 混音合成歌曲”的功能。

###基本認識

AudioUnitHostingFundamentals這個官方文件裡有幾個不錯的圖:

audioUnitScopes_2x.png

對於通用的audioUnit,可以有1-2條輸入輸出流,輸入和輸出不一定相等,比如mixer,可以兩個音訊輸入,混音合成一個音訊流輸出。每個element表示一個音訊處理上下文(context), 也稱為bus。每個element有輸出和輸出部分,稱為 scope,分別是 input scope 和 Output scope。Global scope 確定只有一個 element,就是 element0,有些屬性只能在 Global scope 上設定。

IO_unit_2x (1).png

對於 remote_IO 型別 audioUnit,即從硬體採集和輸出到硬體的 audioUnit,它的邏輯是固定的:固定 2 個 element,麥克風經過 element1 到 APP,APP 經 element0 到揚聲器。

我們能把控的是中間的“APP 內處理”部分,結合上圖,淡黃色的部分就是APP可控的,Element1 這個元件負責連結麥克風和 APP,它的輸入部分是系統控制,輸出部分是APP控制;Element0 負責連線 APP 和揚聲器,輸入部分 APP 控制,輸出部分系統控制。

IOWithoutRenderCallback_2x (1).png

這個圖展示了一個完整的錄音+混音+播放的流程,在元件兩邊設定 stream 的格式,在程式碼裡的概念是 scope。

檔案讀取

demo 在 TFAudioUnitPlayer 這個類,播放需要音訊檔案讀取和輸出的 audioUnit。

檔案讀取使用 ExtAudioFile,這個據我瞭解,有兩點很重要:1.自帶轉碼 2.只處理 pcm。

不僅是 ExtAudioFile,包括其他 audioUnit,其實應該是流資料處理的性質,這些元件都是“輸入+輸出”的這種工作模式,這種模式決定了你要設定輸出格式、輸出格式等。

  • ExtAudioFileOpenURL使用檔案地址構建一個 ExtAudioFile 檔案裡的音訊格式是儲存在檔案裡的,不用設定,反而可以讀取出來,比如得到取樣率用作後續的處理。

  • 設定輸出格式

   AudioStreamBasicDescription clientDesc;
   clientDesc.mSampleRate = fileDesc.mSampleRate;
   clientDesc.mFormatID = kAudioFormatLinearPCM;
   clientDesc.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked;
   clientDesc.mReserved = 0;
   clientDesc.mChannelsPerFrame = 1; //2
   clientDesc.mBitsPerChannel = 16;
   clientDesc.mFramesPerPacket = 1;
   clientDesc.mBytesPerFrame = clientDesc.mChannelsPerFrame * clientDesc.mBitsPerChannel / 8;
   clientDesc.mBytesPerPacket = clientDesc.mBytesPerFrame;
複製程式碼

pcm是沒有編碼、沒有壓縮的格式,更方便處理,所以輸出這種格式。首先格式用 AudioStreamBasicDescription 這個結構體描述,這裡包含了音訊相關的知識:

  • 取樣率 SampleRate: 每秒鐘取樣的次數

  • 幀 frame:每一次取樣的資料對應一幀

  • 聲道數 mChannelsPerFrame:人的兩個耳朵對統一音源的感受不同帶來距離定位,多聲道也是為了立體感,每個聲道有單獨的取樣資料,所以多一個聲道就多一批的資料。

  • 最後是每一次取樣單個聲道的資料格式:由 mFormatFlags 和 mBitsPerChannel 確定。mBitsPerChannel 是資料大小,即取樣位深,越大取值範圍就更大,不容易資料溢位。mFormatFlags 裡包含是否有符號、整數或浮點數、大端或是小端等。有符號數就有正負之分,聲音也是波,振動有正負之分。這裡採用 s16 格式,即有符號的 16 位元整數格式。

  • 從上至下是一個包含關係:每秒有 SampleRate 次取樣,每次取樣一個 frame,每個 frame有mChannelsPerFrame 個樣本,每個樣本有 mBitsPerChannel 這麼多資料。所以其他的資料大小都可以用以上這些來計算得到。當然前提是資料時沒有編碼壓縮的

  • 設定格式:

   size = sizeof(clientDesc);
   status = ExtAudioFileSetProperty(audioFile, kExtAudioFileProperty_ClientDataFormat, size, &clientDesc);
複製程式碼

在APP這一端的是 client,在檔案那一端的是 file,帶 client 代表設定 APP 端的屬性。測試 mp3 檔案的讀取,是可以改變取樣率的,即mp3檔案取樣率是 11025,可以直接讀取輸出 44100 的取樣率資料。

  • 讀取資料 ExtAudioFileRead(audioFile, framesNum, bufferList) framesNum 輸入時是想要讀取的 frame 數,輸出時是實際讀取的個數,資料輸出到 bufferList 裡。bufferList 裡面的 AudioBuffer 的 mData 需要分配記憶體。

播放

播放使用 AudioUnit,首先由3個相關的東西:AudioComponentDescription、AudioComponent 和 AudioComponentInstance。AudioUnit 和 AudioComponentInstance是一個東西,typedef 定義的別名而已。

AudioComponentDescription 是描述,用來做元件的篩選條件,類似於 SQL 語句 where 之後的東西。

AudioComponent 是元件的抽象,就像類的概念,使用AudioComponentFindNext來尋找一個匹配條件的元件。

AudioComponentInstance 是元件,就像物件的概念,使用 AudioComponentInstanceNew 構建。

構建了 audioUnit 後,設定屬性:

  • kAudioOutputUnitProperty_EnableIO,開啟 IO。預設情況 element0,也就是從 APP 到揚聲器的IO時開啟的,而 element1,即從麥克風到 APP 的 IO 是關閉的。使用 AudioUnitSetProperty 函式設定屬性,它的幾個引數分別作用是:
    • 1.要設定的 audioUnit
    • 2.屬性名稱
    • 3.element, element0 和 element1 選一個,看你是接收音訊還是播放
    • 4.scope 也就是範圍,這裡是播放,我們要開啟的是輸出到系統的通道,使用 kAudioUnitScope_Output
    • 5.要設定的值
    • 6.值的大小。

比較難搞的就是 element 和 scope,需要理解 audioUnit 的工作模式,也就是最開始的兩張圖。

  • 設定輸入格式AudioUnitSetProperty(audioUnit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, renderAudioElement, &audioDesc, sizeof(audioDesc));,格式就用 AudioStreamBasicDescription 結構體資料。輸出部分是系統控制,所以不用管。

  • 然後是設定怎麼提供資料。這裡的工作原理是:audioUnit 開啟後,系統播放一段音訊資料,一個 audioBuffer,播完了,通過回撥來跟 APP 索要下一段資料,這樣迴圈,知道你關閉這個 audioUnit。重點就是:

    • 1.是系統主動來跟你索要,不是我們的程式去推送資料
    • 2.通過回撥函式。就像 APP 這邊是工廠,而系統是商店,他們斷貨了或者要斷貨了,就來跟我們進貨,直到你工廠倒閉了、不賣了等等

所以設定播放的回撥函式:

AURenderCallbackStruct callbackSt;
   callbackSt.inputProcRefCon = (__bridge void * _Nullable)(self);
   callbackSt.inputProc = playAudioBufferCallback;
AudioUnitSetProperty(audioUnit, kAudioUnitProperty_SetRenderCallback, kAudioUnitScope_Group, renderAudioElement, &callbackSt, sizeof(callbackSt));
複製程式碼

傳入的資料型別是 AURenderCallbackStruct 結構體,它的inputProc 是回撥函式,inputProcRefCon 是回撥函式呼叫時,傳遞給 inRefCon 的引數,這是回撥模式常用的設計,在其他地方可能叫 context。這裡把 self 傳進去,就可以拿到當前播放器物件,獲取音訊資料等。

回撥函式

回撥函式裡最主要的目的就是給 ioData 賦值,把你想要播放的音訊資料填入到 ioData 這個 AudioBufferList 裡。結合上面的音訊檔案讀取,使用 ExtAudioFileRead 讀取資料就可以實現音訊檔案的播放。

播放功能本身是不依賴資料來源的,因為使用的是回撥函式,所以檔案或者遠端資料流都可以播放。

錄音

錄音類 TFAudioRecorder,檔案寫入類 TFAudioFileWriter 和 TFAACFileWriter。為了更自由的組合音訊處理的元件,定義了 TFAudioOutput 類和 TFAudioInput 協議,TFAudioOutput 定義了一些方法輸出資料,而 TFAudioInput 接受資料。

在 TFAudioUnitRecordViewController 類的 setupRecorder 方法裡設定了4種測試:

  • pcm 流寫入到 caf 檔案
  • pcm 通過 extAudioFile 寫入,extAudioFile 內部轉換成aac格式,寫入 m4a 檔案
  • pcm 轉 aac 流,寫入到 adts 檔案
  • 比較 2 和 3 兩種方式效能
1. 使用audioUnit獲取錄音資料

和播放時一樣,構建 AudioComponentDescription 變數,使用AudioComponentFindNext尋找 audioComponent,再使用 AudioComponentInstanceNew 構建一個 audioUnit。

  • 開啟 IO:
    UInt32 flag = 1;
   status = AudioUnitSetProperty(audioUnit,kAudioOutputUnitProperty_EnableIO, // use io
                                 kAudioUnitScope_Input, // 開啟輸入
                                 kInputBus, //element1是硬體到APP的元件
                                 &flag, // 開啟,輸出YES
                                 sizeof(flag));
複製程式碼

element1是系統硬體輸入到APP的element,傳入值1標識開啟。

  • 設定輸出格式:
AudioStreamBasicDescription audioFormat;
   audioFormat = [self audioDescForType:encodeType];
   status = AudioUnitSetProperty(audioUnit,
                                 kAudioUnitProperty_StreamFormat,
                                 kAudioUnitScope_Output,
                                 kInputBus,
                                 &audioFormat,
                                 sizeof(audioFormat));
複製程式碼

audioDescForType 這個方法裡,只處理了AAC和PCM兩種格式,pcm的時候可以自己計算,也可以利用系統提供的一個函式 FillOutASBDForLPCM 計算,邏輯是跟上面的說的一樣,理解音訊裡的取樣率、聲道、取樣位數等關係就好搞了。

對 AAC 格式,因為是編碼壓縮了的,AAC 固定 1024frame 編碼成一個包(packet),許多屬性沒有用了,比如 mBytesPerFrame,但必須把他們設為0,否則未定義的值可能造成影響

  • 設定輸入的回撥函式
AURenderCallbackStruct callbackStruct;
   callbackStruct.inputProc = recordingCallback;
   callbackStruct.inputProcRefCon = (__bridge void * _Nullable)(self);
   status = AudioUnitSetProperty(audioUnit,kAudioOutputUnitProperty_SetInputCallback,
                                 kAudioUnitScope_Global,
                                 kInputBus,
                                 &callbackStruct,
                                 sizeof(callbackStruct));
複製程式碼

屬性kAudioOutputUnitProperty_SetInputCallback指定輸入的回撥,kInputBus 為 1,表示 element1。

  • 開啟 AVAudioSession
   AVAudioSession *session = [AVAudioSession sharedInstance];
   [session setPreferredSampleRate:44100 error:&error];
   [session setCategory:AVAudioSessionCategoryRecord withOptions:AVAudioSessionCategoryOptionDuckOthers
                  error:&error];
[session setActive:YES error:&error];
複製程式碼

AVAudioSessionCategoryRecord 或 AVAudioSessionCategoryPlayAndRecord 都可以,後一種可以邊播邊錄,比如錄歌的APP,播放伴奏同時錄製人聲。

  • 最後,使用回撥函式獲取音訊資料

構建 AudioBufferList,然後使用 AudioUnitRender 獲取資料。AudioBufferList 的記憶體資料需要我們自己分配,所以需要計算 buffer 的大小,根據傳入的樣本數和聲道數來計算。

2.pcm資料寫入 caf 檔案

TFAudioFileWriter 類裡,使用 extAudioFile 來做音訊資料的寫入。首先要配置 extAudioFile:

  • 構建
OSStatus status = ExtAudioFileCreateWithURL((__bridge CFURLRef _Nonnull)(recordFilePath),_fileType, &_audioDesc, NULL, kAudioFileFlags_EraseFile, &mAudioFileRef);
複製程式碼

引數分別是:檔案地址、型別、音訊格式、輔助設定(這裡是移除就檔案)、audioFile 變數。

這裡 _audioDesc 是使用-(void)setAudioDesc:(AudioStreamBasicDescription)audioDesc從外界傳入的,是上面的錄音的輸出資料格式。

  • 寫入
OSStatus status = ExtAudioFileWrite(mAudioFileRef, _bufferData->inNumberFrames, &_bufferData->bufferList);
複製程式碼

在接收到音訊的資料後,不斷的寫入,格式需要 AudioBufferList,中間引數是寫入的 frame 個數。frame 和 audioDesc 裡面的 sampleRate 共同影響音訊的時長計算,frame 傳錯,時長計算就出錯了。

3. 使用ExtAudioFile自帶轉換器來錄製aac編碼的音訊檔案

從錄製的 audioUnit 輸出pcm資料,測試是可以直接輸入給 ExtAudioFile 來錄製 AAC 編碼的音訊檔案。在構建 ExtAudioFile 的時候設定好格式:

AudioStreamBasicDescription outputDesc;
            outputDesc.mFormatID = kAudioFormatMPEG4AAC;
            outputDesc.mFormatFlags = kMPEG4Object_AAC_Main;
            outputDesc.mChannelsPerFrame = _audioDesc.mChannelsPerFrame;
            outputDesc.mSampleRate = _audioDesc.mSampleRate;
            outputDesc.mFramesPerPacket = 1024;
            outputDesc.mBytesPerFrame = 0;
            outputDesc.mBytesPerPacket = 0;
            outputDesc.mBitsPerChannel = 0;
            outputDesc.mReserved = 0;

複製程式碼

重點 是mFormatID和mFormatFlags,還有個坑是那些沒用的屬性沒有重置為0。

然後建立ExtAudioFile: OSStatus status = ExtAudioFileCreateWithURL((__bridge CFURLRef _Nonnull)(recordFilePath),_fileType, &outputDesc, NULL, kAudioFileFlags_EraseFile, &mAudioFileRef);

設定輸入的格式: ExtAudioFileSetProperty(mAudioFileRef, kExtAudioFileProperty_ClientDataFormat, sizeof(_audioDesc), &_audioDesc);

其他的不變,和寫入pcm一樣使用 ExtAudioFileWrite 迴圈寫入,只是需要在結束後呼叫 ExtAudioFileDispose 來標識寫入結束,可能跟檔案格式有關。

4. pcm 編碼 AAC

使用 AudioConverter 來處理,demo 寫在 TFAudioConvertor 類裡了。

  • 構建

OSStatus status = AudioConverterNew(&sourceDesc, &_outputDesc, &_audioConverter);

和其他元件一樣,需要配置輸入和輸出的資料格式,輸入的就是錄音 audiounit輸出的 pcm 格式,輸出希望轉化為 aac,則把 mFormatID 設為 kAudioFormatMPEG4AAC,mFramesPerPacket 設為 1024。然後取樣率 mSampleRate 和聲道數 mChannelsPerFrame 設一下,其他的都設為 0 就好。為了簡便,取樣率和聲道數可以設為和輸入的pcm資料一樣。

編碼之後資料壓縮,所以輸出大小是未知的,通過屬性 kAudioConverterPropertyMaximumOutputPacketSize 獲取輸出的 packet 大小,依靠這個給輸出 buffer 申請合適的記憶體大小。

  • 輸入和轉化

首先要確定每次轉換的資料大小:bufferLengthPerConvert = audioDesc.mBytesPerFrame*_outputDesc.mFramesPerPacket*PACKET_PER_CONVERT;

即每個 frame 的大小 *每個 packet 的 frame 數 * 每次轉換的 pcket 數目。每次轉換後多個 frame打包成一個 packet,所以 frame 數量最好是 mFramesPerPacket 的倍數。

receiveNewAudioBuffers 方法裡,不斷接受音訊資料輸入,因為每次接收的數目跟你轉碼的數目不一定相同,甚至不是倍數關係,所以一次輸入可能有多次轉碼,也可能多次輸入才有一次轉碼,還要考慮上次輸入後遺留的資料等。

所以:

  1. leftLength記錄上次輸入轉碼後遺留的資料長度,leftBuf 保留上次的遺留資料

  2. 每次輸入,先合併上次遺留的資料,然後進入迴圈每次轉換 bufferLengthPerConvert 長度的資料,直到剩餘的不足,把它們儲存到 leftBuf進行下一次處理

轉換函式本身很簡單:AudioConverterFillComplexBuffer(_audioConverter, convertDataProc, &encodeBuffer, &packetPerConvert, &outputBuffers, NULL);

引數分別是:轉換器、回撥函式、回撥函式引數 inUserData 的值、轉換的 packet 大小、輸出的資料。

資料輸入是在會掉函式裡處理,這裡輸入資料就通過"回撥函式引數 inUserData 的值"傳遞進去,也可以在回撥裡再讀取資料。

OSStatus convertDataProc(AudioConverterRef inAudioConverter,UInt32 *ioNumberDataPackets,AudioBufferList *ioData,AudioStreamPacketDescription **outDataPacketDescription,void *inUserData){
    
    AudioBuffer *buffer = (AudioBuffer *)inUserData;
    
    ioData->mBuffers[0].mNumberChannels = buffer->mNumberChannels;
    ioData->mBuffers[0].mData = buffer->mData;
    ioData->mBuffers[0].mDataByteSize = buffer->mDataByteSize;
    return noErr;
}
複製程式碼

相關文章