1. 介紹
代碼參考自蘋果官方,對於代碼的深刻理解有助於掌握VoIp的核心技術。該項目采用AudioUnit采集音頻,采樣率為192000hz,采用變速單元降低采樣率,使其符合揚聲器的速率以44100hz輸出聲音,達到實時耳返的效果。
更加詳細的說明:
使用音頻輸入單元控制麥克風獲取數據,使用變速單元對麥克風進行降速,使用音頻輸出單元將數據實時輸出。
由於麥克風通常是44100及以上的采樣率,且不支持指定的采樣率輸出,本次使用的麥克風是192000hz的采樣率,而揚聲器通常支持不同的采樣率的數據輸出,因此使用降速單元對揚聲器采集到的數據進行降速,使之匹配揚聲器的數據輸入格式,以達到錄音實時耳返的效果。
以上三個音頻單元:輸入、變速、輸出單元使用AUGraph組合在一起工作。
另外,使用RingBuffer環形緩沖區存儲數據,以確保數據的實時性能,達到以下效果:
數據超時未被Fetch掉則被丟棄,獲取過時的數據將得到靜音;
數據存儲使用固定大小的內存,過早的獲取數據也將得到靜音;
以上,即可保證揚聲器呈現出來的數據實效是在固定的時間范圍內的,因此能保證較高的實時性。
前置知識:
C/C++基礎;
Apple CoreAudio 中 AudioUnit 、AUGraph的概念;
數據結構之順序存儲的循環隊列;
PCM等音頻格式,對應於 CoreAudio 中的 AudioStreamBasicDescription結構體;
CoreAudio中音頻輸入、輸出設備的基本操作;
2.1 初始化AudioUnit,構建AUGraph
網上最常見的是該圖:

但是直到看到下面這張圖才明白AudioUnit輸入單元和輸出單元的關系,如此一來一切變的很清晰:

對於使能輸入、失能輸出端這些基本操作,參考上圖就夠了,在此不在贅述。
查找默認音頻輸入設備作為輸入單元的Component、默認音頻輸出設備作為輸出單元的Component,另外創建變速單元:
componentType = kAudioUnitType_FormatConverter;
componentSubType = kAudioUnitSubType_Varispeed;
將以上三個單元添加至AUGraph,以上,就基本完成了AudioUnit耳返的AUGraph構建。
其中,數據通路為:
a. 在音頻輸入單元(麥克風)的回調函數里面獲取數據,通過AudioUnitRender(mInputBuffer)將獲取到的PCM數據Store到RingBuffer里面;
b. 在音頻輸出單元(揚聲器)的回調函數里面傳入數據,將原始PCM數據Fetch出來給揚聲器。
另外,需要設置音頻單元的 asbd 以及音頻緩沖區:
a. 獲取音頻輸入單元的bufferSizeFrames,並計算出輸出數據的緩沖區大小:bufferSizeBytes = bufferSizeFrames * sizeof(Float32);
以上bufferSizeBytes是每次通過mInputBuffer從麥克風回調函數獲取到的數據的大小。
b. 獲取音頻輸入單元的數據輸入格式(麥克風硬件支持的PCM格式)asbd1,獲取音頻輸入單元的數據輸出格式(麥克風回調函數中輸出的數據)asbd2,
獲取音頻輸出單元的數據輸出格式(揚聲器硬件支持的PCM格式)asbd3,將以上格式處理為asbd4,使其符合以下條件:
asbd4的通道數以麥克風輸入通道數、揚聲器輸出通道數中較小的為准(44100hz);
asbd4的采樣率以音頻輸入單元的硬件采樣率為准(192000hz);
asbd4的其他格式以音頻輸入單元的數據輸出格式為准;
將asbd4設置到音頻輸入單元的數據輸出端、變速單元的數據輸出入端;
將以上asbd打印發現,麥克風支持的數據格式如下:
mSampleRate: 192000 mFormatID: lpcm mFormatFlags: 29 mBytesPerPacket: 4 mFramesPerPacket: 1 mBytesPerFrame: 4 mChannelsPerFrame: 2 mBitsPerChannel: 32
c. 為輸出設備設置正確的采樣率,但保持通道計數不變
d. 為其他音頻單元設置正確的asbd。更改asbd4的采樣率為麥克風硬件支持的采樣率(192000)
將asbd4設置到變速單元的數據輸出端、音頻輸出單元的數據輸入端。
這一步是銜接降速單元,使其發揮作用的關鍵。
在CoreAudio的編程中,音頻的緩沖區用AudioBufferList來存儲。以下為其結構及知識點:
struct AudioBuffer { UInt32 mNumberChannels; // 和數據是否交錯有關,交錯數據則為通道數,非交錯數據則為1 UInt32 mDataByteSize; // buffer的大小 void *mData; // 存儲音頻數據的buffer,通常緩沖區要自己分配 }; struct AudioBufferList { UInt32 mNumberBuffers; // 非交錯數據時完全等同於通道數 AudioBuffer mBuffers[1]; // 柔性數組,又叫變長數組。和數據是否交錯、通道數個數有關 };
以上AudioBufferList的大小通常用offsetof來計算分配:
propsize = offsetof(AudioBufferList, mBuffers[0]) + (sizeof(AudioBuffer) * asbd.mChannelsPerFrame);
也可以用其它方式計算分配。因為該數據結構支持C/C++不同環境下的條件編譯,使用官方推薦的這種做法更靠譜些。
真正的數據緩沖區分配是在AudioBuffer中,根據實際情況去獲取、計算,大小通常是 packets number * mBytesPerPaket,同樣,和數據是否交錯以及通道數有關。
在以上的步驟中,完成了各個音頻單元的 asbd 的設置,以及buffer的參數獲取及設置,然后再將RingBuffer構造好並分配初始空間。
補充:
在 CoreAudio 中,關於時間戳,官方推薦的做法是以采樣數 Frame number 作為 Timestamp,甚至都很少看見去使用系統時間得到的TimeStamp的參數去計算PTS、DTS之類的。事實上,這樣的做法和時間刻度的概念是一樣的,采樣時間間隔受到硬件精確的時鍾頻率的控制,所以當做timestamp來用是沒有任何問題的。
根據個人的調試發現,僅有當程序啟動的那兩三秒,采樣的速率是不穩定的。在我們的代碼中,通常情況下和硬件,傳感器進行數據交互的時候,對於這一點的處理要仔細,避免誤差的積累。
2.2 RingBuffer的構造
① buffer的結構
通過對數據結構代碼的解讀,發現RingBuffer的數據存儲部分作為一個類似於二維數組的成員變量,采用如下方式存儲:
buffer被定義為:Byte **mBuffers;
地址部分記錄對應的數據部分的所在起始地址。每一個數據段都算是一個獨立的子Buffer。
之所以不使用二位動態數組去存儲,而是地址和數據分開存儲的方式去存儲的原因是:使用了Mask取模運算控制循環,不涉及下標訪問,所以可以不使用數組。RingBuffer的大小是不可能無限增長的,通常是某一范圍內的大小,前面地址部分占用空間較少,后面數據部分易擴展,根據RingBuffer的場景構造出這樣的數據結構體就很容易理解了。
② Buffer的內存分配
根據傳入的 frames number構造RingBuffer的Buffer緩沖區大小:bytesPerFrame * frames number * cahnnels number,另外還需要加上前面的地址占用的空間;
另外,為了結合取模運算控制循環,frames number 向上取2的指數次冪,如 輸入 frames number 是 9~16 則統一取16為 frames number,17~32統一取32,依次類推。
該運算使用了gcc內置的函數:__builtin_clz(),計算前導零:
Uint32 Log2Ceil = 32 - __builtin_clz(x - 1); UInt32 NextPowerOfTow = 1 << Log2Ceil;
x就是輸入數據,x-1 是為了防止輸入數據已經是2的n次冪的情況下計算錯誤。
原理就是:取一個數的二進制位高位有多少個連續的0,比如有m個,32 - m 就是除去這些高位的0位,剩下的位數,並且2的指數次冪一定是有且只有一位為1,如此一來,只需要將1左移Log2Ceil位就可以得到x的指數次冪上取整的數值了。
補充:gcc提供的內置函數:__builtin_ffs、__builtin_popcount、__builtin_ctz,參考
另外,還可以自己寫程序來完成替代以上功能:
int PowerOf2(int num) { float x =num; int count = 0; while (x > 1) { x /= 2; count++; } return pow(2, count); }
經測試,自己寫的該函數,雖然可讀性強一些,但是效率確實不夠高,造成了一點點人耳可感受到的微弱延遲,可見在實時應用軟件中程序優化的重要性。
官方給出了在Windows上使用匯編完成__builtin_ctz功能的代碼:
Uint32 tmp; // 存儲前導零的結果 __asm{ bsr eax, arg mov ecx, 63 cmovz eax ecx xor eax, 31 mov tmp, eax }
注意:該程序使用雙通道的數據輸入,所以上圖中地址、數據段最多各有兩個。據此分配內存。
③ TimeBoundsQueue
使用位運算作為循環控制,對數據的存儲時間、獲取時間進行更新、計算,確保數據的時效性。
時間按隊列 TimeBoundsQueue 的節點是一個結構體:
struct { SInt64 mStartTime; SInt64 mEndTime; UInt32 mUpdateCounter; }; UInt32 mTimeBoundsQueuePtr; SInt64 starttime = mTimeBoundsQueue[mTimeBoundsQueuePtr & TimeBoundsQueueMask].mStartTime; SInt64 endtime = mTimeBoundsQueue[mTimeBoundsQueuePtr & TimeBoundsQueueMask].mEndTime;
mTimeBoundsQueuePtr 作為存儲數據計數的游標,用來和Mask相與,起到類似於取模的效果,來控制RingBuffer的循環。
TimeBoundsQueue的大小被指定為了固定的32個元素。32 / 1920000 * 1000 = 0.16666 ms,也就是RingBuffer的時間窗口在0.1666ms內,存儲數據的速率基本是固定的,如果Fetch獲取數據的速度慢了,那么舊的數據將被覆蓋。當然,考慮到AudioUnit的輸入、輸出緩沖區的大小,時延的計算也是有多種因素需要考慮的,並不只是這里。
mTimeBoundsQueuePtr 采用CAS的操作進行+1,是為了RingBuffer在多線程環境下的可靠性。該函數是Mac平台的系統函數:
OSAtomicCompareAndSwap32Barrier((int32_t)mTimeBoundsQueuePtr, (int32_t)mTimeBoundsQueuePtr + 1, (int32_t*)&mTimeBoundsQueuePtr);
④ RingBuffer 關鍵代碼解析
下面對整個數據存儲進RingBuffer的過程進行解析,這一步最重要,也最復雜,需要更新RingBuffer的時間界限(更新之前判斷比較原來的時間界限)、更新數據、處理RingBuffer至第二次循環的情況。RingBuffer的數據存儲和獲取都是 AudioBufferList 的結構體,對此需要非常了解才行,前面已經簡單介紹過。
CARingBufferError CARingBuffer::Store(const AudioBufferList *abl, UInt32 framesToWrite, SampleTime startWrite) { // 時間就是總的幀數累加值,畢竟每次采樣的時間是非常精確的,就用幀數作為時間刻度 // EndTime()是獲取當前緩沖區的結束時間/幀標記 // 思路:數據進來以后,先計算有效的時間范圍(SetTimeBounds),按照該范圍寫入相應的數據(offset0/offset1寫到緩沖區的起始、結束位置) if (framesToWrite == 0) return kCARingBufferError_OK; if (framesToWrite > mCapacityFrames) return kCARingBufferError_TooMuch; // too big! SampleTime endWrite = startWrite + framesToWrite; // 幀數和時間戳相加!那么說明時間戳是按照幀數打的! if (startWrite < EndTime()) // 數據來的晚了,數據過期了 { SetTimeBounds(startWrite, startWrite); // 倒退,把所有的東西都扔掉,以傳進來的startWrite為准 } else if (endWrite - StartTime() <= mCapacityFrames) // 數據沒有過期,並且要寫進去的幀數在容量范圍內。 { //緩沖區尚未包裝,也不需要包裝 } else // 數據沒有過期,要寫的數據超過了緩沖區容量限制 { // 將開始時間提升(advance)超過要覆蓋的區域。處理start過長(過期)和end不夠的情況。 SampleTime newStart = endWrite - mCapacityFrames; // 關鍵,把進來的數據從后往前截取到和緩沖區一樣長,丟掉前面更早的數據 SampleTime newEnd = std::max(newStart, EndTime()); // end以較長的為准???這里是否會導致數據混亂產生雜音??? SetTimeBounds(newStart, newEnd); } // 到此,SetTimeBounds以后,對於數據的時間范圍計算就完成了,下面把這個時間范圍內的數據寫進去就OK了。緩沖區的時間范圍已經更新了 // 寫新的 frames Byte **buffers = mBuffers; int nchannels = mNumberChannels; int offset0 = 0, offset1 = 0, nbytes = 0; SampleTime curEnd = EndTime(); // 傳進來的開始時間比緩沖區當前結束時間要大,說明數據進來的時間剛好或晚了一點,這里就可能產生了間隙。 // 分析了這么多,就是計算傳入數據的start位置對應到緩沖區buffers中,和舊數據的重合度!!!!!然后更新offset // startWrite > curEnd就兩種情況:有間隙則將間隙清空,沒有間隙就接着舊數據存儲 if (startWrite > curEnd) // 緊接、產生空隙 { // 我們正在跳過一些樣本,所以將跳過的范圍歸零。返回的由幀數計算的字節偏移量 offset0 = FrameOffset(curEnd); // 計算出當前buffer按照幀數/時間計算的offset(字節數,有效范圍內) offset1 = FrameOffset(startWrite); // 傳入新數據的開始位於當前buffer的位置(start前面不可能包含無效數據,上面比較過時間了) // printf("1 -- offset1: %ld offset0: %ld\n", offset1, offset0); if (offset0 < offset1) // 前提:新數據的開始大於舊數據的結束時間,判斷:舊數據的結束位置小於新數據開始,產生空隙 { printf("空隙\n"); ZeroRange(buffers, nchannels, offset0, offset1 - offset0); // 把舊數據至新數據之間空隙清空 } else // 舊end大於等於新start,新數據剛好接着舊數據或新數據的start覆蓋掉就數據的結尾一部分 { // 這里還是能執行到的,為什么?緩沖區循環滿了造成的???應該是的 printf("覆蓋-1\n"); ZeroRange(buffers, nchannels, offset0, mCapacityBytes - offset0); // 把舊數據的空余空間清空 ZeroRange(buffers, nchannels, 0, offset1); // 再給新數據清空出來對應大小的空間 } offset0 = offset1; // 重用 offset0 來記錄新數據的起始位置 } else // 覆蓋舊數據。這種也好處理,直接用新數據的start覆蓋舊數據的end。 { // printf("2 -- offset1: %ld offset0: %ld\n", offset1, offset0); // printf("覆蓋-2\n"); // 覆蓋了好。也可以保留舊數據,截斷新數據,但是這樣實時性好 offset0 = FrameOffset(startWrite); // 沒有間隙則offset0就按新數據的offset來就可以接上了 } // 然后計算offset1,endWrite是新數據對應到buffers中的結束位置。該位置直接和offset0比較 // StoreABL: 把abl寫到buffers中(起始位置是參數2),abl的起始位置是參數4, 把abl中nbytes(最后一個參數)寫進去。 offset1 = FrameOffset(endWrite); if (offset0 < offset1) // 正常的情況,直接寫入 { // printf("正常寫入\n"); StoreABL(buffers, offset0, abl, 0, offset1 - offset0); // abl里面的幀數應該是當作時間戳計算好傳過來的 } else // 這是什么情況???注意是環形覆蓋的情況。。。。 { nbytes = mCapacityBytes - offset0; // if (nbytes < 0) printf("Error....%d\n", __LINE__); // printf("環形覆蓋 nbytes: %d\n", nbytes); // 128 StoreABL(buffers, offset0, abl, 0, nbytes); // 覆蓋環形???對的,和隊列大小基本一致的。 StoreABL(buffers, 0, abl, nbytes, offset1); } // 現在更新結束時間 SetTimeBounds(StartTime(), endWrite); // mTimeBoundsQueuePtr++ // printf("mCapacityBytes: %ld mCapacityFrames: %ld\n", mCapacityBytes, mCapacityFrames); // 65536 16384 return kCARingBufferError_OK; // success }
以上是Store的部分,另外Fetch的部分原理基本相同,代碼結構稍微簡單一些,在此不再贅述。
3. 應用場景拓展
將RingBuffer拆分用於網絡傳輸,結合UDP(RTP等)構成真正的VoIp通話程序。
添加混音單元,實時混音輸出背景音樂的伴奏。
添加AAC編碼用於音頻推流、錄制。
4. 總結
實時采集音頻並經過變速處理,利用RingBuffer保證時效性,用到諸多技術點,使程序優化到比較好的性能。
同時發現如果在Store函數中向控制台打印 log,會嚴重影響音頻的連續性,標准輸出對程序性能造成了一定影響,可見音頻對程序性能的要求之高。
另外,在音視頻開發中,有這樣的說法:拷貝就是犯罪。不到萬不得已的情況下,盡可能少的對大塊的內存數據進行拷貝、移動等操作。
經過檢查,該程序的RingBuffer中由於固定大小的buffer為保證時效性會被輕易覆蓋,故在Store、Fetch數據時采用了memcpy,造成了一定的系統開銷,不過在可接受的范圍內,仍然達到了較高的性能。
學習過該部分以后,知道音頻編碼如何使用比特率控制模式來調整編碼速率,對於音頻部分碼率自適應的原理有了清晰的認識。
經測試,程序運行穩定,音質清晰,僅有在啟動的一兩秒內不夠穩定,音頻產生了空隙。
整個程序被精簡,改造,消化吸收,經稍適配即可模塊化的應用於線上環境中。

