Audio Queue 採集音訊實戰(支援不同格式)

小東邪發表於2019-05-11

需求

iOS中使用Audio Queue實現音訊資料採集,直接採集PCM無損資料或AAC及其他壓縮格式資料.


實現原理

使用Audio Queue採集硬體輸入端,如麥克風,其他外接具備麥克風功能裝置(帶麥的耳機,話筒等,前提是其本身要和蘋果相容).


閱讀前提

本文直接為實戰篇,如需瞭解理論基礎參考上述連結中的內容,本文側重於實戰中注意點.

本專案實現低耦合,高內聚,所以直接將相關模組拖入你的專案設定引數就可直接使用.


GitHub地址(附程式碼) : Audio Queue Capture

簡書地址 : Audio Queue Capture

部落格地址 : Audio Queue Capture

掘金地址 : Audio Queue Capture


具體實現

1.程式碼結構

1

如上所示,我們總體分為兩大類,一個是負責採集的類,一個是負責做音訊錄製的類,你可以根據需求在適當時機啟動,關閉Audio Queue, 並且在Audio Queue已經啟動的情況下可以進行音訊檔案錄製,前面需求僅僅需要如下四個API即可完成.

// Start / Stop Audio Queue
[[XDXAudioQueueCaptureManager getInstance] startAudioCapture];
[[XDXAudioQueueCaptureManager getInstance] stopAudioCapture];

// Start / Stop Audio Record
[[XDXAudioQueueCaptureManager getInstance] startRecordFile];
[[XDXAudioQueueCaptureManager getInstance] stopRecordFile];
複製程式碼

2.定義類中常量變數

  • 以下兩個引數描述在採集PCM資料時對於iOS平臺而言必須填入的資訊
#define kXDXAudioPCMFramesPerPacket 1
#define kXDXAudioPCMBitsPerChannel  16
複製程式碼
  • 定義一個結構體儲存音訊相關屬性,包括音訊流格式,Audio Queue引用及Audio Queue佇列中所使用的所有buffer組成的資料.
struct XDXRecorderInfo {
    AudioStreamBasicDescription  mDataFormat;
    AudioQueueRef                mQueue;
    AudioQueueBufferRef          mBuffers[kNumberBuffers];
};
typedef struct XDXRecorderInfo *XDXRecorderInfoType;
複製程式碼
  • 定義一個全域性變數判斷當前Audio Queue是否正在工作.另一個變數為當前是否正在錄製
@property (nonatomic, assign, readonly) BOOL isRunning;
@property (nonatomic, assign) BOOL isRecordVoice;
複製程式碼
  • 注意

因為Audio Queue中本身就是用純C語言實現的,所以它會直接呼叫一些函式,我們必須要理解函式跟OC方法的區別,以及指標的概念,因為函式中會出現一些類似&運算子,這裡可以簡單給大家介紹下以便小白閱讀. &就是獲取某個物件的記憶體地址,使用它主要為了滿足讓Audio Queue的API可以將其查詢到的值直接賦給這段記憶體地址,比如下面會講到的AudioSessionGetProperty查詢方法中就是這樣將查詢出來的值賦值給我們定義的全域性靜態變數的.

2.初始化並啟動Audio Queue

  • 本例通過XDXSingleton實現單例模式,即標頭檔案中使用SingletonH,實現檔案中使用SingletonM即可,關於單例的實現自行百度.

為什麼使用單例,因為iPhone中輸入端只能接收一個音訊輸入裝置,所以如果使用Audio Queue採集,該採集物件在應用程式宣告週期內應該是單一存在的,所以使用單例實現.

  • 首先為記錄音訊資訊的指向結構體的指標分配記憶體空間
+ (void)initialize {
    m_audioInfo = malloc(sizeof(struct XDXRecorderInfo));
}

複製程式碼
  • 下面定義了公共啟動介面,你可以直接在其中設定你需要的音訊引數,如音訊資料格式為PCM還是AAC,取樣率大小,聲道數,取樣時間等.
- (void)startAudioCapture {
    [self startAudioCaptureWithAudioInfo:m_audioInfo
                                 formatID:kAudioFormatMPEG4AAC // kAudioFormatLinearPCM
                               sampleRate:44100
                             channelCount:1
                              durationSec:0.05
                                isRunning:&_isRunning];
}

複製程式碼

3. 設定音訊流資料格式

  • 注意點

需要注意的是,音訊資料格式與硬體直接相關,如果想獲取最高效能,最好直接使用硬體本身的取樣率,聲道數等音訊屬性,所以,如取樣率,當我們手動進行更改後,Audio Queue會在內部自行轉換一次,雖然程式碼上沒有感知,但一定程式上還是降低了效能.

iOS中不支援直接設定雙聲道,如果想模擬雙聲道,可以自行填充音訊資料,具體會在以後的文章中講到,喜歡請持續關注.

  • 獲取音訊屬性值

理解AudioSessionGetProperty函式,該函式表明查詢當前硬體指定屬性的值,如下,kAudioSessionProperty_CurrentHardwareSampleRate為查詢當前硬體取樣率,kAudioSessionProperty_CurrentHardwareInputNumberChannels為查詢當前採集的聲道數.因為本例中使用手動賦值方式更加靈活,所以沒有使用查詢到的值.

  • 設定不同格式定製的屬性

首先,你必須瞭解未壓縮格式(PCM...)與壓縮格式(AAC...). 使用iOS直接採集未壓縮資料是可以直接拿到硬體採集到的資料,而如果直接設定如AAC這樣的壓縮資料格式,其原理是Audio Queue在內部幫我們做了一次轉換,具體原理在本文開篇中的閱讀前提中去查閱.

使用PCM資料格式必須設定取樣值的flag:mFormatFlags,每個聲道中取樣的值換算成二進位制的位寬mBitsPerChannel,iOS中每個聲道使用16位的位寬,每個包中有多少幀mFramesPerPacket,對於PCM資料而言,因為其未壓縮,所以每個包中僅有1幀資料.每個包中有多少位元組數(即每一幀中有多少位元組數),可以根據如下簡單計算得出

注意,如果是其他壓縮資料格式,大多數不需要單獨設定以上引數,預設為0.這是因為對於壓縮資料而言,每個音訊取樣包中壓縮的幀數以及每個音訊取樣包壓縮出來的位元組數可能是不同的,所以我們無法預知進行設定,就像mFramesPerPacket引數,因為壓縮出來每個包具體有多少幀只有壓縮完成後才能得知.

audioInfo->mDataFormat = [self getAudioFormatWithFormatID:formatID
                                                   sampleRate:sampleRate
                                                 channelCount:channelCount];
                                                 
                                                 
-(AudioStreamBasicDescription)getAudioFormatWithFormatID:(UInt32)formatID sampleRate:(Float64)sampleRate channelCount:(UInt32)channelCount {
    AudioStreamBasicDescription dataFormat = {0};
    
    UInt32 size = sizeof(dataFormat.mSampleRate);
    // Get hardware origin sample rate. (Recommended it)
    Float64 hardwareSampleRate = 0;
    AudioSessionGetProperty(kAudioSessionProperty_CurrentHardwareSampleRate,
                            &size,
                            &hardwareSampleRate);
    // Manual set sample rate
    dataFormat.mSampleRate = sampleRate;
    
    size = sizeof(dataFormat.mChannelsPerFrame);
    // Get hardware origin channels number. (Must refer to it)
    UInt32 hardwareNumberChannels = 0;
    AudioSessionGetProperty(kAudioSessionProperty_CurrentHardwareInputNumberChannels,
                            &size,
                            &hardwareNumberChannels);
    dataFormat.mChannelsPerFrame = channelCount;
    
    // Set audio format
    dataFormat.mFormatID = formatID;
    
    // Set detail audio format params
    if (formatID == kAudioFormatLinearPCM) {
        dataFormat.mFormatFlags     = kLinearPCMFormatFlagIsSignedInteger | kLinearPCMFormatFlagIsPacked;
        dataFormat.mBitsPerChannel  = kXDXAudioPCMBitsPerChannel;
        dataFormat.mBytesPerPacket  = dataFormat.mBytesPerFrame = (dataFormat.mBitsPerChannel / 8) * dataFormat.mChannelsPerFrame;
        dataFormat.mFramesPerPacket = kXDXAudioPCMFramesPerPacket;
    }else if (formatID == kAudioFormatMPEG4AAC) {
        dataFormat.mFormatFlags = kMPEG4Object_AAC_Main;
    }

    NSLog(@"Audio Recorder: starup PCM audio encoder:%f,%d",sampleRate,channelCount);
    return dataFormat;
}
複製程式碼

4. 初始化併為Audio Queue分配記憶體

上面步驟中我們已經拿到音訊流資料格式,使用AudioQueueNewInput函式可以將建立出來的Audio Queue物件賦值給我們定義的全域性變數,另外還指定了CaptureAudioDataCallback採集音訊資料回撥函式的名稱.回撥函式的定義必須遵從如下格式.因為系統會將採集到值賦值給此函式中的引數,函式名稱可以自己指定.

typedef void (*AudioQueueInputCallback)(
                                    void * __nullable               inUserData,
                                    AudioQueueRef                   inAQ,
                                    AudioQueueBufferRef             inBuffer,
                                    const AudioTimeStamp *          inStartTime,
                                    UInt32                          inNumberPacketDescriptions,
                                    const AudioStreamPacketDescription * __nullable inPacketDescs);
複製程式碼
// New queue
    OSStatus status = AudioQueueNewInput(&audioInfo->mDataFormat,
                                         CaptureAudioDataCallback,
                                         (__bridge void *)(self),
                                         NULL,
                                         kCFRunLoopCommonModes,
                                         0,
                                         &audioInfo->mQueue);
    
    if (status != noErr) {
        NSLog(@"Audio Recorder: AudioQueueNewInput Failed status:%d \n",(int)status);
        return NO;
    }
    
複製程式碼

以下是AudioQueueNewInput函式的定義

  • inFormat: 音訊流格式
  • inCallbackProc: 設定回撥函式
  • inUserData: 開發者自己定義的任何資料,一般將本類的例項傳入,因為回撥函式中無法直接呼叫OC的屬性與方法,此引數可以作為OC與回撥函式溝通的橋樑.即傳入本類物件.
  • inCallbackRunLoop: 回撥函式在哪個迴圈中被呼叫.設定為NULL為預設值,即回撥函式所在的執行緒由audio queue內部控制.
  • inCallbackRunLoopMode: 回撥函式執行迴圈模式通常使用kCFRunLoopCommonModes.
  • inFlags: 系統保留值,只能為0.
  • outAQ:將建立好的audio queue賦值給填入物件.
extern OSStatus             
AudioQueueNewInput(                 const AudioStreamBasicDescription *inFormat,
                                    AudioQueueInputCallback         inCallbackProc,
                                    void * __nullable               inUserData,
                                    CFRunLoopRef __nullable         inCallbackRunLoop,
                                    CFStringRef __nullable          inCallbackRunLoopMode,
                                    UInt32                          inFlags,
                                    AudioQueueRef __nullable * __nonnull outAQ)          API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));
複製程式碼
5. 獲取設定的音訊流格式

用以下方法驗證獲取到音訊格式是否與我們設定的相符.

// Set audio format for audio queue
    UInt32 size = sizeof(audioInfo->mDataFormat);
    status = AudioQueueGetProperty(audioInfo->mQueue,
                                   kAudioQueueProperty_StreamDescription,
                                   &audioInfo->mDataFormat,
                                   &size);
    if (status != noErr) {
        NSLog(@"Audio Recorder: get ASBD status:%d",(int)status);
        return NO;
    }
複製程式碼

6. 計算Audio Queue中每個buffer的大小

該計算要區分壓縮與未壓縮資料.

  • 壓縮資料

只能進行估算,即用取樣率與取樣時間相乘,但是需要注意因為直接設定採集壓縮資料(如AAC),相當於是Audio Queue在內部自己進行一次轉換,而像AAC這樣的壓縮資料,每次至少需要1024個取樣點(即取樣時間最小為23.219708 ms)才能完成一個壓縮,所以我們不能將buffer size設定過小,不信可以自己嘗試,如果設定過小直接crash.

而我們計算出來的這個大小隻是原始資料的大小,經過壓縮後往往低於我們計算出來的這個值.可以在回撥中列印檢視.

  • 未壓縮資料

對於未壓縮資料,我們時可以通過計算精確得出取樣的大小. 即如下公式

    // Set capture data size
    UInt32 bufferByteSize;
    if (audioInfo->mDataFormat.mFormatID == kAudioFormatLinearPCM) {
        int frames = (int)ceil(durationSec * audioInfo->mDataFormat.mSampleRate);
        bufferByteSize = frames*audioInfo->mDataFormat.mBytesPerFrame*audioInfo->mDataFormat.mChannelsPerFrame;
    }else {
        // AAC durationSec MIN: 23.219708 ms
        bufferByteSize = durationSec * audioInfo->mDataFormat.mSampleRate;
        
        if (bufferByteSize < 1024) {
            bufferByteSize = 1024;
        }
    }
複製程式碼

7. 記憶體分配,入隊

關於audio queue,可以理解為一個佇列的資料結構,buffer就是佇列中的每個結點.具體設計請參考文中閱讀前提中的概念篇.

官方建議我們將audio queue中的buffer設定為3個,因為,一個用於準備去裝資料,一個正在使用的資料以及如果出現I/0快取時還留有一個備用資料,設定過少,採集效率可能變低,設定過多浪費記憶體,3個剛剛好.

如下操作就是先為佇列中每個buffer分配記憶體,然後將分配好記憶體的buffer做入隊操作,準備接收音訊資料

// Allocate and Enqueue
    for (int i = 0; i != kNumberBuffers; i++) {
        status = AudioQueueAllocateBuffer(audioInfo->mQueue,
                                              bufferByteSize,
                                          &audioInfo->mBuffers[i]);
        if (status != noErr) {
            NSLog(@"Audio Recorder: Allocate buffer status:%d",(int)status);
        }
        
        status = AudioQueueEnqueueBuffer(audioInfo->mQueue,
                                         audioInfo->mBuffers[i],
                                         0,
                                         NULL);
        if (status != noErr) {
            NSLog(@"Audio Recorder: Enqueue buffer status:%d",(int)status);
        }
    }
複製程式碼

8. 啟動Audio Queue

第二個引數設定為NULL表示立即開始採集資料.

    status = AudioQueueStart(audioInfo->mQueue, NULL);
    if (status != noErr) {
        NSLog(@"Audio Recorder: Audio Queue Start failed status:%d \n",(int)status);
        return NO;
    }else {
        NSLog(@"Audio Recorder: Audio Queue Start successful");
        *isRunning = YES;
        return YES;
    }
複製程式碼

9. 回撥函式中接收音訊資料.

如果上面的操作全部執行成功,最終系統會將採集到的音訊資料以回撥函式形式返回給開發者,如下.

  • inUserData: 註冊回撥函式時傳入的開發者自定義的物件
  • inAQ: 當前使用的Audio Queue
  • inBuffer: Audio Queue產生的音訊資料
  • inStartTime其中包含音訊資料產生的時間戳
  • inNumberPacketDescriptions: 資料包描述引數.如果你正在錄製VBR格式,音訊佇列會提供此引數的值.如果錄製檔案需要將其傳遞給AudioFileWritePackets函式.CBR格式不使用此引數(值為0).
  • inPacketDescs: 音訊資料中一組packet描述.如果是VBR格式資料,如果錄製檔案需要將此值傳遞給AudioFileWritePackets函式

通過回撥函式,就可以拿到當前採集到的音訊資料,你可以對資料做你需要的任何自定義操作.以下以寫入檔案為例,我們在拿到音訊資料後,將其寫入音訊檔案.

static void CaptureAudioDataCallback(void *                                 inUserData,
                                     AudioQueueRef                          inAQ,
                                     AudioQueueBufferRef                    inBuffer,
                                     const AudioTimeStamp *                 inStartTime,
                                     UInt32                                 inNumPackets,
                                     const AudioStreamPacketDescription*    inPacketDesc) {
    
    XDXAudioQueueCaptureManager *instance = (__bridge XDXAudioQueueCaptureManager *)inUserData;
    
    /*  Test audio fps
    static Float64 lastTime = 0;
    Float64 currentTime = CMTimeGetSeconds(CMClockMakeHostTimeFromSystemUnits(inStartTime->mHostTime))*1000;
    NSLog(@"Test duration - %f",currentTime - lastTime);
    lastTime = currentTime;
    */
    
    // NSLog(@"Test data: %d,%d,%d,%d",inBuffer->mAudioDataByteSize,inNumPackets,inPacketDesc->mDataByteSize,inPacketDesc->mVariableFramesInPacket);
    
    if (instance.isRecordVoice) {
        UInt32 bytesPerPacket = m_audioInfo->mDataFormat.mBytesPerPacket;
        if (inNumPackets == 0 && bytesPerPacket != 0) {
            inNumPackets = inBuffer->mAudioDataByteSize / bytesPerPacket;
        }
        
        [[XDXAudioFileHandler getInstance] writeFileWithInNumBytes:inBuffer->mAudioDataByteSize
                                                      ioNumPackets:inNumPackets
                                                          inBuffer:inBuffer->mAudioData
                                                      inPacketDesc:inPacketDesc];
    }
    
    if (instance.isRunning) {
        AudioQueueEnqueueBuffer(inAQ, inBuffer, 0, NULL);
    }
}
複製程式碼

10. 停止Audio Queue並回收記憶體

  • AudioQueueStop: 停止當前audio queue
  • AudioQueueFreeBuffer: 釋放audio queue中每個buffer
  • AudioQueueDispose: 釋放audio queue

以下函式呼叫具有先後順序,我們必須先停掉audio queue,才能釋放其中buffer的記憶體,最後再將整個audio queue徹底釋放.

-(BOOL)stopAudioQueueRecorderWithAudioInfo:(XDXRecorderInfoType)audioInfo isRunning:(BOOL *)isRunning {
    if (*isRunning == NO) {
        NSLog(@"Audio Recorder: Stop recorder repeat \n");
        return NO;
    }
    
    if (audioInfo->mQueue) {
        OSStatus stopRes = AudioQueueStop(audioInfo->mQueue, true);
        
        if (stopRes == noErr){
            for (int i = 0; i < kNumberBuffers; i++)
                AudioQueueFreeBuffer(audioInfo->mQueue, audioInfo->mBuffers[i]);
        }else{
            NSLog(@"Audio Recorder: stop AudioQueue failed.");
            return NO;
        }
        
        OSStatus status = AudioQueueDispose(audioInfo->mQueue, true);
        if (status != noErr) {
            NSLog(@"Audio Recorder: Dispose failed: %d",status);
            return NO;
        }else {
            audioInfo->mQueue = NULL;
            *isRunning = NO;
            //        AudioFileClose(mRecordFile);
            NSLog(@"Audio Recorder: stop AudioQueue successful.");
            return YES;
        }
    }
    
    return NO;
}

複製程式碼

11. 音訊檔案錄製

此部分可參考另一篇文章: 音訊檔案錄製

補充

當音訊資料為壓縮資料時,本來可以通過一個函式求出每個音訊資料包中最大的音訊資料大小,以進一步求出buffer size,但不知為何呼叫一直失敗,所以在上述第6步中我才換了種方式估算.如果有人知道可以評論補充下,感謝.

            UInt32 propertySize = sizeof(maxPacketSize);
            OSStatus status     = AudioQueueGetProperty(audioQueue,
                                                        kAudioQueueProperty_MaximumOutputPacketSize,
                                                        &maxPacketSize,
                                                        &propertySize);
            if (status != noErr) {
                NSLog(@"%s: get max output packet size failed:%d",__func__,status);
            }
複製程式碼

相關文章