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

本文详细介绍了VoIP音频采集与实时耳返的实现过程,包括使用AudioUnit采集麦克风数据,通过变速单元降低采样率以匹配扬声器,以及构建AUGraph实现数据流。通过RingBuffer确保数据实时性,保证音频数据在固定时间范围内,实现高实时性的耳返效果。此外,还探讨了相关技术点,如音频单元配置、数据格式转换、缓冲区管理和时间戳处理。
摘要由CSDN通过智能技术生成

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来存储。以下为其结构及知识点:

structAudioBuffer

{

UInt32 mNumberChannels; // 和数据是否交错有关,交错数据则为通道数,非交错数据则为1

UInt32 mDataByteSize; // buffer的大小void *mData; // 存储音频数据的buffer,通常缓冲区要自己分配

};structAudioBufferList

{

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(intnum)

{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,63cmovz eax ecx

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

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

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

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值