voip 之音频采集与呈现(Mac/IOS)

时间:2024-02-25 07:54:31

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,造成了一定的系统开销,不过在可接受的范围内,仍然达到了较高的性能。

学习过该部分以后,知道音频编码如何使用比特率控制模式来调整编码速率,对于音频部分码率自适应的原理有了清晰的认识。

经测试,程序运行稳定,音质清晰,仅有在启动的一两秒内不够稳定,音频产生了空隙。

整个程序被精简,改造,消化吸收,经稍适配即可模块化的应用于线上环境中。