voip 之音頻采集與呈現(Mac/IOS)


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,造成了一定的系統開銷,不過在可接受的范圍內,仍然達到了較高的性能。

學習過該部分以后,知道音頻編碼如何使用比特率控制模式來調整編碼速率,對於音頻部分碼率自適應的原理有了清晰的認識。

經測試,程序運行穩定,音質清晰,僅有在啟動的一兩秒內不夠穩定,音頻產生了空隙。

整個程序被精簡,改造,消化吸收,經稍適配即可模塊化的應用於線上環境中。

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM