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,造成了一定的系统开销,不过在可接受的范围内,仍然达到了较高的性能。
学习过该部分以后,知道音频编码如何使用比特率控制模式来调整编码速率,对于音频部分码率自适应的原理有了清晰的认识。
经测试,程序运行稳定,音质清晰,仅有在启动的一两秒内不够稳定,音频产生了空隙。
整个程序被精简,改造,消化吸收,经稍适配即可模块化的应用于线上环境中。