iOS音频播放(六) AudioQueue学习与实战

3,575 阅读8分钟

AudioQueue理论学习

AudioQueue是iOS提供的又一套实现音频播放和录制的框架,是AudioToolBox.framework中的一员。

在文档中Apple推荐开发者使用AudioQueue来实现app中的播放和录音功能。这里我们会针对播放功能进行介绍。

它支持PCM数据、iOS/MacOSX平台支持的压缩格式(MP3、AAC等)、其他用户可以自行提供解码器的音频数据(对于这一条,我的理解就是把音频格式自行解码成PCM数据后再给AudioQueue播放 )。

AudioQueue的工作原理

当有音频数据需要被播放时首先需要被memcpy到AudioQueueBufferRef的mAudioData中(mAudioData所指向的内存已经被分配,之前AudioQueueAllocateBuffer所做的工作),并给mAudioDataByteSize字段赋值传入的数据大小。完成之后需要调用AudioQueueEnqueueBuffer把存有音频数据的Buffer插入到AudioQueue内置的Buffer队列中。在Buffer队列中有buffer存在的情况下调用AudioQueueStart,此时AudioQueue就会按照Enqueue顺序逐个使用Buffer队列中的buffer进行播放,每当一个Buffer使用完毕之后就会从Buffer队列中被移除并且在使用者指定的RunLoop上触发一个回调来告诉使用者,某个AudioQueueBufferRef对象已经使用完成,你可以继续重用这个对象来存储后面的音频数据。如此循环往复音频数据就会被逐个播放直到结束。

首先看一下官方给的AudioQueue工作流程图。

这里给出的是播放本地路径下的音频文件,流程总结如下:

  • 读取音频文件,在音频文件的回调中给buffers填充数据
  • 将填充满的buffers给AudioQueue播放
  • AudioQueue播放完一个Buffer后,把这个buffer还给AudioQueue的回调继续填充
  • 循环2和3直到音频播放完

创建AudioQueue

OSStatus AudioQueueNewOutput(const AudioStreamBasicDescription *inFormat, AudioQueueOutputCallback inCallbackProc, void *inUserData, CFRunLoopRef inCallbackRunLoop, CFStringRef inCallbackRunLoopMode, UInt32 inFlags, AudioQueueRef  _Nullable *outAQ);

该方法用于创建一个用于输出音频的AudioQueue

參数及返回说明例如以下:

  • inFormat:该參数指明了即将播放的音频的数据格式
  • inCallbackProc:该回调用于当AudioQueue已使用完一个缓冲区时通知用户,用户能够继续填充音频数据
  • inUserData:由用户传入的数据指针,用于传递给回调函数
  • inCallbackRunLoop:指明回调事件发生在哪个RunLoop之中,假设传递NULL,表示在AudioQueue所在的线程上运行该回调事件,普通情况下,传递NULL就可以。
  • inCallbackRunLoopMode:指明回调事件发生的RunLoop的模式,传递NULL相当于kCFRunLoopCommonModes,通常情况下传递NULL就可以
  • outAQ:该AudioQueue的引用实例
void AudioQueueOutput_Callback(void *inClientData,AudioQueueRef inAQ,AudioQueueBufferRef inBuffer)

这个是AudioQueue的回调函数,会将已经播放完的buffer还回来。

Buffer相关API

1.创建Buffer

OSStatus AudioQueueAllocateBuffer(AudioQueueRef inAQ, UInt32 inBufferByteSize, AudioQueueBufferRef  _Nullable *outBuffer);

该方法的作用是为存放音频数据的缓冲区开辟空间

參数及返回说明例如以下:

  • inAQ:AudioQueue的引用实例
  • inBufferByteSize:须要开辟的缓冲区的大小
  • outBuffer:开辟的缓冲区的引用实例
  1. 销毁Buffer
OSStatus AudioQueueFreeBuffer(AudioQueueRef inAQ,AudioQueueBufferRef inBuffer);

注意这个方法一般只在需要销毁特定某个buffer时才会被用到(因为dispose方法会自动销毁所有buffer),并且这个方法只能在AudioQueue不在处理数据时才能使用。所以这个方法一般不太能用到。

  1. 插入Buffer
OSStatus AudioQueueEnqueueBuffer(AudioQueueRef inAQ,
                                 AudioQueueBufferRef inBuffer,
                                 UInt32 inNumPacketDescs,
                                 const AudioStreamPacketDescription * inPacketDescs);

该方法用于将已经填充数据的AudioQueueBuffer入队到AudioQueue

參数及返回说明例如以下:

  • inAQ:AudioQueue的引用实例
  • inBuffer:须要入队的缓冲区实例
  • inNumPacketDescs:缓冲区中共存在有多少帧音频数据
  • inPacketDescs:缓冲区中每一帧的相关信息。用户须要指明当中每一帧在缓冲区中数据的偏移值,通过字段mStartOffset来指定

控制相关

  1. 开始播放
OSStatus AudioQueueStart(AudioQueueRef inAQ,const AudioTimeStamp * inStartTime);

第二个参数可以用来控制播放开始的时间,一般情况下直接开始播放传入NULL即可。

  1. 解码数据
OSStatus AudioQueuePrime(AudioQueueRef inAQ,
                          UInt32 inNumberOfFramesToPrepare,
                          UInt32 * outNumberOfFramesPrepared);  

这个方法并不常用,因为直接调用AudioQueueStart会自动开始解码(如果需要的话)。参数的作用是用来指定需要解码帧数和实际完成解码的帧数;

  1. 暂停播放
OSStatus AudioQueuePause(AudioQueueRef inAQ);

需要注意的是这个方法一旦调用后播放就会立即暂停,这就意味着AudioQueueOutputCallback回调也会暂停,这时需要特别关注线程的调度以防止线程陷入无限等待。

  1. 停止播放
OSStatus AudioQueueStop(AudioQueueRef inAQ, Boolean inImmediate);

第二个参数如果传入true的话会立即停止播放(同步),如果传入false的话AudioQueue会播放完已经Enqueue的所有buffer后再停止(异步)。使用时注意根据需要传入适合的参数。

  1. Flush
OSStatus
AudioQueueFlush(AudioQueueRef inAQ);

调用后会播放完Enqueu的所有buffer后重置解码器状态,以防止当前的解码器状态影响到下一段音频的解码(比如切换播放的歌曲时)。如果和AudioQueueStop(AQ,false)一起使用并不会起效,因为Stop方法的false参数也会做同样的事情。

  1. 重置
OSStatus AudioQueueReset(AudioQueueRef inAQ);

重置AudioQueue会清除所有已经Enqueue的buffer,并触发AudioQueueOutputCallback,调用AudioQueueStop方法时同样会触发该方法。这个方法的直接调用一般在seek时使用,用来清除残留的buffer(seek时还有一种做法是先AudioQueueStop,等seek完成后重新start)。

  1. 获取播放时间
OSStatus AudioQueueGetCurrentTime(AudioQueueRef inAQ,
                                  AudioQueueTimelineRef inTimeline,
                                  AudioTimeStamp * outTimeStamp,
                                  Boolean * outTimelineDiscontinuity);

传入的参数中,第一、第四个参数是和AudioQueueTimeline相关的我们这里并没有用到,传入NULL。调用后的返回AudioTimeStamp,从这个timestap结构可以得出播放时间,计算方法如下:

AudioTimeStamp time = ...; //AudioQueueGetCurrentTime方法获取
NSTimeInterval playedTime = time.mSampleTime / _format.mSampleRate;

在使用这个时间获取方法时有两点必须注意:

1、 第一个需要注意的时这个播放时间是指实际播放的时间和一般理解上的播放进度是有区别的。举个例子,开始播放8秒后用户操作slider把播放进度seek到了第20秒之后又播放了3秒钟,此时通常意义上播放时间应该是23秒,即播放进度;而用GetCurrentTime方法中获得的时间为11秒,即实际播放时间。所以每次seek时都必须保存seek的timingOffset:

AudioTimeStamp time = ...; //AudioQueueGetCurrentTime方法获取
NSTimeInterval playedTime = time.mSampleTime / _format.mSampleRate; //seek时的播放时间

NSTimeInterval seekTime = ...; //需要seek到哪个时间
NSTimeInterval timingOffset = seekTime - playedTime;

seek后的播放进度需要根据timingOffset和playedTime计算:

NSTimeInterval progress = timingOffset + playedTime;

2、 第二个需要注意的是GetCurrentTime方法有时候会失败,所以上次获取的播放时间最好保存起来,如果遇到调用失败,就返回上次保存的结果。

销毁AudioQueue

AudioQueueDispose(AudioQueueRef inAQ,  Boolean inImmediate);

销毁的同时会清除其中所有的buffer,第二个参数的意义和用法与AudioQueueStop方法相同。

这个方法使用时需要注意当AudioQueueStart调用之后AudioQueue其实还没有真正开始,期间会有一个短暂的间隙。如果在AudioQueueStart调用后到AudioQueue真正开始运作前的这段时间内调用AudioQueueDispose方法的话会导致程序卡死。这个问题是我在使用AudioStreamer时发现的,在iOS 6必现(iOS 7我倒是没有测试过,当时发现问题时iOS 7还没发布),起因是由于AudioStreamer会在音频EOF时就进入Cleanup环节,Cleanup环节会flush所有数据然后调用Dispose,那么当音频文件中数据非常少时就有可能出现AudioQueueStart调用之时就已经EOF进入Cleanup,此时就会出现上述问题。

要规避这个问题第一种方法是做好线程的调度,保证Dispose方法调用一定是在每一个播放RunLoop之后(即至少是一个buffer被成功播放之后)。第二种方法是监听kAudioQueueProperty_IsRunning属性,这个属性在AudioQueue真正运作起来之后会变成1,停止后会变成0,所以需要保证Start方法调用后Dispose方法一定要在IsRunning为1时才能被调用。

实战

这里只讲几个比较重要的细节,其他的可以参考demo中的代码。

  • AudioQueueNewOutput在创建的时候有2个runloop相关的参数,这里直接传NULL就行,不要取当前的runloop和model
  • AudioQueueOutput_Callback里面在标记可使用的buffer时要加锁,不然音频无法正常播放
  • 记得设置AVAudioSession的category
  • 读取音频数据时使用while循环,比使用计时器优雅
  • kAudioFileStreamProperty_DataFormat这个属性是必须要获取到的,在创建AudioQueue的时候需要传入
  • 填装数据的时候要判断对当前buffer的可用填装空间,如果装不下了就别再装啦。。。
  • AudioQueueEnqueueBuffer给AudioQueue塞完数据后,需要判断下一个buffer是否可用,不可用的话得一直等着,直到可用为止。

这里(github.com/Nicholas86/…)是代码。