前言
随着技术的增强和人们对音频质量要求越来越高,各种对音频的处理模式也越来越丰富。最近做了一个音频需求是把mp3或者wav等音频解码转格式成16000采样率的pcm文件然后塞到降噪SDK里做降噪处理,并实时播放,这里做一个记录和总结。
方案
- 音频降噪
- 播放倍速
- 音频转格式
- 实时播放
一、音频降噪
我们知道声音其实是一种波,是由不同频率的正弦波叠加而成。所以音频降噪本质上就是用算法去除掉代表噪声的某些正弦波。 音频降噪的基本原理:对数字音频信号进行频谱分析,得到噪声的强度和频谱分布,然后根据这个模型就能设计一个滤波器,过滤掉噪音,得到更加突出的主体声。
我们通常用到的降噪方案大体有3类:
1、 Speex是一套主要针对语音的开源免费(BSD授权)的应用集合,其中包含了编解码器、VAD(语音检测)、AEC(回声消除)和NS(降噪)等实用模块,因而我们可以在商业应用中使用Speex进行声音降噪的处理。
2、随着WebRTC开源,其中音频降噪的部分也可以满足我们降噪使用。WebRTC中的音频降噪部分的代码是C语言实现的,因而在不同的平台(arm和x86)具有良好的兼容性。 业务上,声音降噪是跟场景强相关的,例如在安静的会议室和在嘈杂的商场,采取的降噪算法和策略会明显不同。
3、伴随着机器学习和神经网络的发展,也出现另一些对业务场景音频数据进行学习和训练,逐步优化降噪效果的降噪算法,例如RNNoise,也有人利用TensorFlow对音频降噪效果进行优化。
其中,Speex和WebRTC都是开源的,如果我们没有特殊的定制需求,这两种方案就能满足我们的基本要求。我们项目里是使用了第三种方案使用机器学习对音频降噪进行了优化,部分代码如下
- (NSData *)getOutputDenoiseData:(NSData *)inputData {
if(_nnse){
int perBufferLen = 16000;
int bufferIndex = 0;
NSMutableData *buffer = [[NSMutableData alloc]init];
while ([inputData length] - bufferIndex > perBufferLen) {
NSData *data = [inputData subdataWithRange:NSMakeRange(bufferIndex, perBufferLen)];
bufferIndex += [data length];
_packid++;
// mark 接收buffer需比进入Buffer大 暂定大1024
char outBuffer[17024] = {0};
int outSize = 0;
int ret = [_nnseWrapper do_lstm_nnse:_nnse packId:_packid voiceData:data outputData:outBuffer outputDataLen:&outSize];
if(!ret){
NSData *outPutData = [[NSData alloc]initWithBytes:outBuffer length:outSize];
[buffer appendData:outPutData];
NSLog(@"output %lu",(unsigned long)outPutData.length);
}
}
NSData *data = [inputData subdataWithRange:NSMakeRange(bufferIndex, [inputData length] - bufferIndex)];
char outBuffer[17024] = {0};
int outSize = 0;
int ret = [_nnseWrapper do_lstm_nnse:_nnse packId:_packid voiceData:data outputData:outBuffer outputDataLen:&outSize];
if(!ret){
NSData *outPutData = [[NSData alloc]initWithBytes:outBuffer length:outSize];
[buffer appendData:outPutData];
NSLog(@"output %ld",outPutData.length);
}
return buffer;
}
return NULL;
}
复制代码
二、播放倍速
1、变速不变调:保持音调和语义保持不变,语速变慢或者变快。该过程表现为语谱图在时间轴上如手风琴般压缩或者扩展。基频值几乎不变,对应于音调不变。整个时间过程被压缩或者扩展了,声门周期声纹数目增加或者减少,即声道运动的速率发生了改变,语速也随之发生了改变。对应于语音产生模型,激励和系统经历与原始发音情况几乎相同的状态,但持续时间相比原来或长或短。
2、变调不变速,指改变说话人基频的大小,同时保持语速和语义不变即保持短时频谱包络(共振峰的位置和带宽)和时间过程基本不变。对应于语音产生模型,变调改变了激励源,声道模型的共振峰参数几乎不变,保证了语义和语速不变。
iOS系统提供了播放时设置AudioUnit倍速的方式,即使用kVarispeedParam_PlaybackRate来设置倍速参数,但我们现在需要通过降噪和倍速来对PCM做处理并输出音频data,所以这种方式不适。 目前较为常用的音频变速解决方案有两个:soundtouch和Sonic。
Sonic和Soundtouch用法类似,都是提供封装好的库,将原音频的PCM数据通过接口函数处理为目标倍速。我们项目用的是Sonic,首先设置播放倍速
- (void)setRate:(CGFloat)rate {
self.audioRate = rate;
if (_sonic) {
sonicDestroyStream(_sonic);
}
_sonic = sonicCreateStream(self.config.outputFormat.mSampleRate, self.config.outputFormat.mChannelsPerFrame);
sonicSetRate(_sonic, 1);
sonicSetPitch(_sonic, 1);
sonicSetSpeed(_sonic, self.audioRate != 0?self.audioRate:1);
}
复制代码
然后在读取音频时就可以读到变换倍速之后的数据了
int ret = sonicWriteShortToStream(_sonic, _buffList->mBuffers[0].mData, 4096);
if(ret) {
int new_buffer_size = sonicReadShortFromStream(_sonic, originTmpBuffer, 4096);
tempData = [[NSData alloc] initWithBytes:&originTmpBuffer length:new_buffer_size];
memset(originTmpBuffer, 0, 4096);
}
复制代码
三、音频转格式
音频格式转换方案有很多,但大体总结下来可以归为两类:
1、使用第三方现有的解码工具完成音频格式转换,像FFmpeg做音频处理。引用wiki百科的解析,FFmpeg是一个自由软件,可以运行音频和视频多种格式的录影、转换、流功能,包含了libavcodec ─这是一个用于多个项目中音频和视频的解码器库,以及libavformat——一个音频与视频格式转换库。但我们项目只是针对音频做相应处理并且也没有集成FFmpeg,所以就把这个方案放到次要位置了。
2、使用iOS系统提供的Core Audio来处理音频。Core Audio中包含我们最常用的Audio Toolbox与Audio Unit框架,正好可以处理我们要做的音频转格式和实时播放问题。而且Core Audio集成成本相对要低很多,因此我们打算用次框架来完成当前的需求。后续我们也会详细解读如何用FFmpeg来解析音频。
下图是 Core Audio 框架结构,其功能可谓是丰富且强大,几乎涵盖了所有与音频处理相关的内容。
从上面框架结构图可以看出,我们要做音频个是转换,要用到Audio File, Converter, Codec Services。
音频转换流程:
1、使用ExtAudioFileOpenURL取本地磁盘的mp3等格式音频。
2、使用ExtAudioFileGetProperty读取磁盘音频格式,输出的类型是AudioStreamBasicDescription结构体,
struct AudioStreamBasicDescription
{
Float64 mSampleRate;
AudioFormatID mFormatID;
AudioFormatFlags mFormatFlags;
UInt32 mBytesPerPacket;
UInt32 mFramesPerPacket;
UInt32 mBytesPerFrame;
UInt32 mChannelsPerFrame;
UInt32 mBitsPerChannel;
UInt32 mReserved;
};
typedef struct AudioStreamBasicDescription AudioStreamBasicDescription;
复制代码
AudioStreamBasicDescription
描述了音频流的基本信息,包括:
- mSampleRate, 采样率
- mFormatID,数据格式类型
- mFormatFlags, 数据格式的补充说明,例如可以标记是否为交织数据、数据格式是否为 float 等。它非常的神秘,通过一些辅助的函数我们可以获取到正确的值。
- mFramesPerPacket,即一个 packet 中包含 frame 的数量,如果是 PCM 数据,则为1。压缩格式略有不同,通过一些辅助的函数我们可以获取到正确的值。
- mChannelsPerFrame,声道数
- mBitsPerChannel,采样的位深。压缩格式则设置为 0
- mBytesPerFrame,一帧数据的字节大小。如果是 PCM 格式其计算公式为
mChannelsPerFrame * mBitsPerChannel / 8
。压缩格式则设置为 0 - mBytesPerPacket,一个 packet 的字节大小,如果是 PCM 格式其值与
mBytesPerFrame
一致。压缩格式则设置为 0 - mReserved,总是为 0,用来做数据对齐的
3、使用ExtAudioFileSetProperty设置转PCM后的格式,仍然是AudioStreamBasicDescription
memset(&_outputFormat, 0, sizeof(_outputFormat));
_outputFormat.mSampleRate = 16000;//采样率
_outputFormat.mFormatID = kAudioFormatLinearPCM;//转换格式
_outputFormat.mFormatFlags = kLinearPCMFormatFlagIsSignedInteger;
_outputFormat.mBytesPerPacket = 2;
_outputFormat.mFramesPerPacket = 1;//每一个packet一侦数据
_outputFormat.mChannelsPerFrame = 1;//声道
_outputFormat.mBitsPerChannel = 16;//每个采样点16bit量化
_outputFormat.mBytesPerFrame = (_outputFormat.mBitsPerChannel/8) * _outputFormat.mChannelsPerFrame;
复制代码
4、使用AudioBufferList存放读取的音频临时数据,
struct AudioBuffer
{
UInt32 mNumberChannels;
UInt32 mDataByteSize;
void* __nullable mData;
};
struct AudioBufferList
{
UInt32 mNumberBuffers;
AudioBuffer mBuffers[1]; // this is a variable length array of mNumberBuffers elements
};
复制代码
AudioBuffer
用于存放音频数据的结构体,其中
- mNumberChannels,声道数
- mDataByteSize,音频数据数量大小
- mData,音频数据 buffer 的指针
我们可以通过mData和mDataByteSize转换成NSData数据,这样就拿到了音频data数据。
四、实时播放
取到音频数据之后就是播放了,由于在音频转格式过程中存在倍速和降噪这样比较耗时的处理,因此我们需要一个音频缓存池作为音频转格式和播放之间的媒介,这就使得播放要求的性能更高,实时性更强。Audio Unit正好可以满足我们的需要,因为使用Audio Unit主要有两大优势:
- 最快的反应速度,从采集到播放回环可以到10ms的级别。
- 动态的配置,AUGraph可以动态的进行组合,满足各种需求。
从上图可以看出,Audio Unit是iOS中音频最底层的framework,iOS提供音频处理插件,支持混合,均衡,格式转换和实时输入/输出,用于录制,播放,离线渲染和实时对话,例如VoIP(互联网协议语音)。
下面介绍Audio Unit播放过程:
1、创建Audio Unit
OSStatus status = noErr;
AudioComponentDescription audioDesc;
audioDesc.componentType = kAudioUnitType_Output;
audioDesc.componentSubType = kAudioUnitSubType_RemoteIO;
audioDesc.componentManufacturer = kAudioUnitManufacturer_Apple;
audioDesc.componentFlags = 0;
audioDesc.componentFlagsMask = 0;
AudioComponent inputComponent = AudioComponentFindNext(NULL, &audioDesc);
status = AudioComponentInstanceNew(inputComponent, &_audioUnit);
复制代码
2、分别设置Audio Unit的input scope和output scope
UInt32 flag = 1;
if (flag) {
status = AudioUnitSetProperty(_audioUnit,
kAudioOutputUnitProperty_EnableIO,
kAudioUnitScope_Output,
OUTPUT_BUS,
&flag,
sizeof(flag));
if (status != noErr && self.delegateAudioPlayer && [self.delegateAudioPlayer respondsToSelector:@selector(audioPlayError)]) {
[self.delegateAudioPlayer audioPlayError];
return;
}
_audioCanPlay = status==noErr?YES:NO;
}
AudioStreamBasicDescription outputFormat = self.config.outputFormat;
status = AudioUnitSetProperty(_audioUnit,
kAudioUnitProperty_StreamFormat,
kAudioUnitScope_Input,
OUTPUT_BUS,
&outputFormat,
sizeof(_config.outputFormat));
复制代码
-
scope: audio unit内部的编程上下文,scope概念有一点抽象,可以这样理解,比如input scope表示里面所有的element都需要一个输入。output scope 表示里面所有的element都会输出到某个地方。至于global scope应该是用来配置一些和输入输出概念无关的属性。
-
element: 当element是input/output scope的一部分时,它类似于物理音频设备中的信号总线.因此这两个术语"element, bus"在audio unit中是一个含义.本文档在强调信号流时使用“bus”,在强调音频单元的特定功能方面时使用“element”,例如I / O unit的输入和输出element.
3、回调函数中将音频传给Audio Unit
AURenderCallbackStruct playCallbackStruct;
playCallbackStruct.inputProc = PlayCallback;
playCallbackStruct.inputProcRefCon = (__bridge void *)self;
status = AudioUnitSetProperty(_audioUnit,
kAudioUnitProperty_SetRenderCallback,
kAudioUnitScope_Input,
OUTPUT_BUS,
&playCallbackStruct,
sizeof(playCallbackStruct));
#pragma mark 音频回调
OSStatus PlayCallback(void *inRefCon,
AudioUnitRenderActionFlags *ioActionFlags,
const AudioTimeStamp *inTimeStamp,
UInt32 inBusNumber,
UInt32 inNumberFrames,
AudioBufferList *ioData)
复制代码
其中回调函数PlayCallback中,
-
inRefCon: 注册回调函数时传递的指针,一般可传本类对象实例,因为回调函数是C语言形式,无法直接访问本类中属性与方法,所以将本例实例化对象传入可以间接调用本类中属性与方法.
-
ioActionFlags: 让回调函数为audio unit提供没有处理音频的提示.例如,如果应用程序是合成吉他并且用户当前没有播放音符,请执行此操作。在要为其输出静默的回调调用期间,在回调体中使用如下语句:
*ioActionFlags |= kAudioUnitRenderAction_OutputIsSilence;
当您希望产生静默时,还必须显式地将ioData参数指向的缓冲区设置为0。 -
inTimeStamp: 表示调用回调函数的时间,可以用作音频同步的时间戳.每次调用回调时, mSampleTime 字段的值都会由 inNumberFrames参数中的数字递增。例如, 如果你的应用是音序器或鼓机, 则可以使用 mSampleTime 值来调度声音。
-
inBusNumber: 调用回调函数的audio unit bus.允许你通过该值在回调函数中进行分支.另外,当audio unit注册回调函数时,可以指定不同的
inRefCon
为每个bus. -
inNumberFrames: 回调函数中提供的音频帧数.这些帧的数据保存在
ioData
参数中. -
ioData: 真正的音频数据,如果设置静音,需要将buffer中内容设置为0.
通过以上操作正常情况下我们就可以播放音频了,但正如这一模块刚开始描述的那样,由于在音频转格式过程中存在倍速和降噪这样比较耗时的处理,因此我们需要一个音频缓存池作为音频转格式和播放之间的媒介。
4、缓存播放
当我们用Extended Audio File Services把打开本地音频后,要开启一个计时器去定时seek到读取音频的位置读到PCM,然后进行降噪和倍速处理,然后存放到音频缓存池里。这里计时器使用精度较高的CADisplaylink来精确控制音频的读取和缓存池的存放,同时应该设置缓存池的阀值来控制何时存取音频。具体流程如下:
可以简单理解成如下的方式来读取音频和播放:
通过以上方式,我们就可以顺利地在Audio Unit的回调里取到缓存池里的音频了,并且在把音频传给Audio Unit后释放掉缓存池里这部分音频data。
这里我随机录了一个mp3音频播放4次,内存增长基本控制在缓存池的阀值以内,所以现在做的音频实时处理和播放不会引起内存问题。
developer.apple.com/documentati…