iOS音频播放

来源:互联网 发布:网络整合营销推广方案 编辑:程序博客网 时间:2024/06/05 16:46

原址:http://msching.github.io/blog/2014/07/07/audio-in-ios/


iOS音频播放 (一):概述

Audio Playback in iOS (Part 1) : Introduction


从事音乐相关的app开发也已经有一段时日了,在这过程中app的播放器几经修改我也因此对于iOS下的音频播放实现有了一定的研究。写这个系列的博客目的一方面希望能够抛砖引玉,另一方面也是希望能帮助国内其他的iOS开发者和爱好者少走弯路(我自己就遇到了不少的坑=。=)。

本篇为《iOS音频播放》系列的第一篇,主要将对iOS下实现音频播放的方法进行概述。


基础

先来简单了解一下一些基础的音频知识。

目前我们在计算机上进行音频播放都需要依赖于音频文件,音频文件的生成过程是将声音信息采样、量化和编码产生的数字信号的过程,人耳所能听到的声音,最低的频率是从20Hz起一直到最高频率20KHZ,因此音频文件格式的最大带宽是20KHZ。根据奈奎斯特的理论,只有采样频率高于声音信号最高频率的两倍时,才能把数字信号表示的声音还原成为原来的声音,所以音频文件的采样率一般在40~50KHZ,比如最常见的CD音质采样率44.1KHZ。

对声音进行采样、量化过程被称为脉冲编码调制(Pulse Code Modulation),简称PCM。PCM数据是最原始的音频数据完全无损,所以PCM数据虽然音质优秀但体积庞大,为了解决这个问题先后诞生了一系列的音频格式,这些音频格式运用不同的方法对音频数据进行压缩,其中有无损压缩(ALAC、APE、FLAC)和有损压缩(MP3、AAC、OGG、WMA)两种。

目前最为常用的音频格式是MP3,MP3是一种有损压缩的音频格式,设计这种格式的目的就是为了大幅度的减小音频的数据量,它舍弃PCM音频数据中人类听觉不敏感的部分,从下面的比较图我们可以明显的看到MP3数据相比PCM数据明显矮了一截(图片引自imp3论坛)。

上图为pcm数据上图为pcm数据上图为mp3数据上图为mp3数据

MP3格式中的码率(BitRate)代表了MP3数据的压缩质量,现在常用的码率有128kbit/s、160kbit/s、320kbit/s等等,这个值越高声音质量也就越高。MP3编码方式常用的有两种固定码率(Constant bitrate,CBR)和可变码率(Variable bitrate,VBR)。

MP3格式中的数据通常由两部分组成,一部分为ID3用来存储歌名、演唱者、专辑、音轨数等信息,另一部分为音频数据。音频数据部分以帧(frame)为单位存储,每个音频都有自己的帧头,如图所示就是一个MP3文件帧结构图(图片同样来自互联网)。MP3中的每一个帧都有自己的帧头,其中存储了采样率等解码必须的信息,所以每一个帧都可以独立于文件存在和播放,这个特性加上高压缩比使得MP3文件成为了音频流播放的主流格式。帧头之后存储着音频数据,这些音频数据是若干个PCM数据帧经过压缩算法压缩得到的,对CBR的MP3数据来说每个帧中包含的PCM数据帧是固定的,而VBR是可变的。


iOS音频播放概述

了解了基础概念之后我们就可以列出一个经典的音频播放流程(以MP3为例):

  1. 读取MP3文件
  2. 解析采样率、码率、时长等信息,分离MP3中的音频帧
  3. 对分离出来的音频帧解码得到PCM数据
  4. 对PCM数据进行音效处理(均衡器、混响器等,非必须)
  5. 把PCM数据解码成音频信号
  6. 把音频信号交给硬件播放
  7. 重复1-6步直到播放完成

在iOS系统中apple对上述的流程进行了封装并提供了不同层次的接口(图片引自官方文档)。

CoreAudio的接口层次CoreAudio的接口层次

下面对其中的中高层接口进行功能说明:

  • Audio File Services:读写音频数据,可以完成播放流程中的第2步;
  • Audio File Stream Services:对音频进行解码,可以完成播放流程中的第2步;
  • Audio Converter services:音频数据转换,可以完成播放流程中的第3步;
  • Audio Processing Graph Services:音效处理模块,可以完成播放流程中的第4步;
  • Audio Unit Services:播放音频数据:可以完成播放流程中的第5步、第6步;
  • Extended Audio File Services:Audio File Services和Audio Converter services的结合体;
  • AVAudioPlayer/AVPlayer(AVFoundation):高级接口,可以完成整个音频播放的过程(包括本地文件和网络流播放,第4步除外);
  • Audio Queue Services:高级接口,可以进行录音和播放,可以完成播放流程中的第3、5、6步;
  • OpenAL:用于游戏音频播放,暂不讨论

可以看到apple提供的接口类型非常丰富,可以满足各种类别类需求:

  • 如果你只是想实现音频的播放,没有其他需求AVFoundation会很好的满足你的需求。它的接口使用简单、不用关心其中的细节;

  • 如果你的app需要对音频进行流播放并且同时存储,那么AudioFileStreamer加AudioQueue能够帮到你,你可以先把音频数据下载到本地,一边下载一边用NSFileHandler等接口读取本地音频文件并交给AudioFileStreamer或者AudioFile解析分离音频帧,分离出来的音频帧可以送给AudioQueue进行解码和播放。如果是本地文件直接读取文件解析即可。(这两个都是比较直接的做法,这类需求也可以用AVFoundation+本地server的方式实现,AVAudioPlayer会把请求发送给本地server,由本地server转发出去,获取数据后在本地server中存储并转送给AVAudioPlayer。另一个比较trick的做法是先把音频下载到文件中,在下载到一定量的数据后把文件路径给AVAudioPlayer播放,当然这种做法在音频seek后就回有问题了。);

  • 如果你正在开发一个专业的音乐播放软件,需要对音频施加音效(均衡器、混响器),那么除了数据的读取和解析以外还需要用到AudioConverter来把音频数据转换成PCM数据,再由AudioUnit+AUGraph来进行音效处理和播放(但目前多数带音效的app都是自己开发音效模块来坐PCM数据的处理,这部分功能自行开发在自定义性和扩展性上会比较强一些。PCM数据通过音效器处理完成后就可以使用AudioUnit播放了,当然AudioQueue也支持直接使对PCM数据进行播放。)。下图描述的就是使用AudioFile + AudioConverter + AudioUnit进行音频播放的流程(图片引自官方文档)。



iOS音频播放 (二):AudioSession

Audio Playback in iOS (Part 2) : AudioSession


本篇为《iOS音频播放》系列的第二篇。

在实施前一篇中所述的7个步骤之前还必须面对一个麻烦的问题,AudioSession。

本篇主要介绍关于AudioSession使用、期间需要注意的地方以及可能面临的坑。


AudioSession简介

AudioSession这个玩意的主要功能包括以下几点(图片来自官方文档):

  1. 确定你的app如何使用音频(是播放?还是录音?)
  2. 为你的app选择合适的输入输出设备(比如输入用的麦克风,输出是耳机、手机功放或者airplay)
  3. 协调你的app的音频播放和系统以及其他app行为(例如有电话时需要打断,电话结束时需要恢复,按下静音按钮时是否歌曲也要静音等)

AudioSessionAudioSession

AudioSession相关的类有两个:

  1. AudioToolBox中的AudioSession
  2. AVFoundation中的AVAudioSession

其中AudioSession在SDK 7中已经被标注为depracated,而AVAudioSession这个类虽然iOS 3开始就已经存在了,但其中很多方法和变量都是在iOS 6以后甚至是iOS 7才有的。所以各位可以依照以下标准选择:

  • 如果最低版本支持iOS 5,可以使用AudioSession,也可以使用AVAudioSession
  • 如果最低版本支持iOS 6及以上,请使用AVAudioSession

下面以AudioSession类为例来讲述AudioSession相关功能的使用(很不幸我需要支持iOS 5。。T-T,使用AVAudioSession的同学可以在其头文件中寻找对应的方法使用即可,需要注意的点我会加以说明).

注意:在使用AVAudioPlayer/AVPlayer时可以不用关心AudioSession的相关问题,Apple已经把AudioSession的处理过程封装了,但音乐打断后的响应还是要做的(比如打断后音乐暂停了UI状态也要变化,这个应该通过KVO就可以搞定了吧。。我没试过瞎猜的>_<)。

注意:在使用MPMusicPlayerController时不必关心AudioSession的问题。


初始化AudioSession

使用AudioSession类首先需要调用初始化方法:

1234
extern OSStatus AudioSessionInitialize(CFRunLoopRef inRunLoop,                                       CFStringRef inRunLoopMode,                                       AudioSessionInterruptionListener inInterruptionListener,                                       void *inClientData);

前两个参数一般填NULL表示AudioSession运行在主线程上(但并不代表音频的相关处理运行在主线程上,只是AudioSession),第三个参数需要传入一个AudioSessionInterruptionListener类型的方法,作为AudioSession被打断时的回调,第四个参数则是代表打断回调时需要附带的对象(即回到方法中的inClientData,如下所示,可以理解为UIView animation中的context)。

1
typedef void (*AudioSessionInterruptionListener)(void * inClientData, UInt32 inInterruptionState);

这才刚开始,坑就来了。这里会有两个问题:

第一,AudioSessionInitialize可以被多次执行,但AudioSessionInterruptionListener只能被设置一次,这就意味着这个打断回调方法是一个静态方法,一旦初始化成功以后所有的打断都会回调到这个方法,即便下一次再次调用AudioSessionInitialize并且把另一个静态方法作为参数传入,当打断到来时还是会回调到第一次设置的方法上。

这种场景并不少见,例如你的app既需要播放歌曲又需要录音,当然你不可能知道用户会先调用哪个功能,所以你必须在播放和录音的模块中都调用AudioSessionInitialize注册打断方法,但最终打断回调只会作用在先注册的那个模块中,很蛋疼吧。。。所以对于AudioSession的使用最好的方法是生成一个类单独进行管理,统一接收打断回调并发送自定义的打断通知,在需要用到AudioSession的模块中接收通知并做相应的操作。

Apple也察觉到了这一点,所以在AVAudioSession中首先取消了Initialize方法,改为了单例方法sharedInstance。在iOS 5上所有的打断都需要通过设置id<AVAudioSessionDelegate> delegate并实现回调方法来实现,这同样会有上述的问题,所以在iOS 5使用AVAudioSession下仍然需要一个单独管理AudioSession的类存在。在iOS 6以后Apple终于把打断改成了通知的形式。。这下科学了。

第二,AudioSessionInitialize方法的第四个参数inClientData,也就是回调方法的第一个参数。上面已经说了打断回调是一个静态方法,而这个参数的目的是为了能让回调时拿到context(上下文信息),所以这个inClientData需要是一个有足够长生命周期的对象(当然前提是你确实需要用到这个参数),如果这个对象被dealloc了,那么回调时拿到的inClientData会是一个野指针。就这一点来说构造一个单独管理AudioSession的类也是有必要的,因为这个类的生命周期和AudioSession一样长,我们可以把context保存在这个类中。


监听RouteChange事件

如果想要实现类似于“拔掉耳机就把歌曲暂停”的功能就需要监听RouteChange事件:

12345678
extern OSStatus AudioSessionAddPropertyListener(AudioSessionPropertyID inID,                                                AudioSessionPropertyListener inProc,                                                void *inClientData);                                              typedef void (*AudioSessionPropertyListener)(void * inClientData,                                             AudioSessionPropertyID inID,                                             UInt32 inDataSize,                                             const void * inData);

调用上述方法,AudioSessionPropertyID参数传kAudioSessionProperty_AudioRouteChange,AudioSessionPropertyListener参数传对应的回调方法。inClientData参数同AudioSessionInitialize方法。

同样作为静态回调方法还是需要统一管理,接到回调时可以把第一个参数inData转换成CFDictionaryRef并从中获取kAudioSession_AudioRouteChangeKey_Reason键值对应的value(应该是一个CFNumberRef),得到这些信息后就可以发送自定义通知给其他模块进行相应操作(例如kAudioSessionRouteChangeReason_OldDeviceUnavailable就可以用来做“拔掉耳机就把歌曲暂停”)。

1234567891011
//AudioSession的AudioRouteChangeReason枚举enum {      kAudioSessionRouteChangeReason_Unknown = 0,      kAudioSessionRouteChangeReason_NewDeviceAvailable = 1,      kAudioSessionRouteChangeReason_OldDeviceUnavailable = 2,      kAudioSessionRouteChangeReason_CategoryChange = 3,      kAudioSessionRouteChangeReason_Override = 4,      kAudioSessionRouteChangeReason_WakeFromSleep = 6,      kAudioSessionRouteChangeReason_NoSuitableRouteForCategory = 7,      kAudioSessionRouteChangeReason_RouteConfigurationChange = 8  };
123456789101112
//AVAudioSession的AudioRouteChangeReason枚举typedef NS_ENUM(NSUInteger, AVAudioSessionRouteChangeReason){  AVAudioSessionRouteChangeReasonUnknown = 0,  AVAudioSessionRouteChangeReasonNewDeviceAvailable = 1,  AVAudioSessionRouteChangeReasonOldDeviceUnavailable = 2,  AVAudioSessionRouteChangeReasonCategoryChange = 3,  AVAudioSessionRouteChangeReasonOverride = 4,  AVAudioSessionRouteChangeReasonWakeFromSleep = 6,  AVAudioSessionRouteChangeReasonNoSuitableRouteForCategory = 7,  AVAudioSessionRouteChangeReasonRouteConfigurationChange NS_ENUM_AVAILABLE_IOS(7_0) = 8}

注意:iOS 5下如果使用了AVAudioSession由于AVAudioSessionDelegate中并没有定义相关的方法,还是需要用这个方法来实现监听。iOS 6下直接监听AVAudioSession的通知就可以了。


这里附带两个方法的实现,都是基于AudioSession类的(使用AVAudioSession的同学帮不到你们啦)。

1、判断是否插了耳机:

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
+ (BOOL)usingHeadset{#if TARGET_IPHONE_SIMULATOR    return NO;#endif    CFStringRef route;    UInt32 propertySize = sizeof(CFStringRef);    AudioSessionGetProperty(kAudioSessionProperty_AudioRoute, &propertySize, &route);    BOOL hasHeadset = NO;    if((route == NULL) || (CFStringGetLength(route) == 0))    {        // Silent Mode    }    else    {        /* Known values of route:         * "Headset"         * "Headphone"         * "Speaker"         * "SpeakerAndMicrophone"         * "HeadphonesAndMicrophone"         * "HeadsetInOut"         * "ReceiverAndMicrophone"         * "Lineout"         */        NSString* routeStr = (__bridge NSString*)route;        NSRange headphoneRange = [routeStr rangeOfString : @"Headphone"];        NSRange headsetRange = [routeStr rangeOfString : @"Headset"];        if (headphoneRange.location != NSNotFound)        {            hasHeadset = YES;        }        else if(headsetRange.location != NSNotFound)        {            hasHeadset = YES;        }    }    if (route)    {        CFRelease(route);    }    return hasHeadset;}

2、判断是否开了Airplay(来自StackOverflow):

12345678910111213141516171819202122
+ (BOOL)isAirplayActived{    CFDictionaryRef currentRouteDescriptionDictionary = nil;    UInt32 dataSize = sizeof(currentRouteDescriptionDictionary);    AudioSessionGetProperty(kAudioSessionProperty_AudioRouteDescription, &dataSize, &currentRouteDescriptionDictionary);    BOOL airplayActived = NO;    if (currentRouteDescriptionDictionary)    {        CFArrayRef outputs = CFDictionaryGetValue(currentRouteDescriptionDictionary, kAudioSession_AudioRouteKey_Outputs);        if(outputs != NULL && CFArrayGetCount(outputs) > 0)        {            CFDictionaryRef currentOutput = CFArrayGetValueAtIndex(outputs, 0);            //Get the output type (will show airplay / hdmi etc            CFStringRef outputType = CFDictionaryGetValue(currentOutput, kAudioSession_AudioRouteKey_Type);            airplayActived = (CFStringCompare(outputType, kAudioSessionOutputRoute_AirPlay, 0) == kCFCompareEqualTo);        }        CFRelease(currentRouteDescriptionDictionary);    }    return airplayActived;}

设置类别

下一步要设置AudioSession的Category,使用AudioSession时调用下面的接口

123
extern OSStatus AudioSessionSetProperty(AudioSessionPropertyID inID,                                        UInt32 inDataSize,                                        const void *inData);

如果我需要的功能是播放,执行如下代码

1234
UInt32 sessionCategory = kAudioSessionCategory_MediaPlayback;AudioSessionSetProperty (kAudioSessionProperty_AudioCategory,                         sizeof(sessionCategory),                         &sessionCategory);

使用AVAudioSession时调用下面的接口

1234
/* set session category */- (BOOL)setCategory:(NSString *)category error:(NSError **)outError;/* set session category with options */- (BOOL)setCategory:(NSString *)category withOptions: (AVAudioSessionCategoryOptions)options error:(NSError **)outError NS_AVAILABLE_IOS(6_0);

至于Category的类型在官方文档中都有介绍,我这里也只罗列一下具体就不赘述了,各位在使用时可以依照自己需要的功能设置Category。

123456789
//AudioSession的AudioSessionCategory枚举enum {      kAudioSessionCategory_AmbientSound               = 'ambi',      kAudioSessionCategory_SoloAmbientSound           = 'solo',      kAudioSessionCategory_MediaPlayback              = 'medi',      kAudioSessionCategory_RecordAudio                = 'reca',      kAudioSessionCategory_PlayAndRecord              = 'plar',      kAudioSessionCategory_AudioProcessing            = 'proc'  };
1234567891011121314151617181920
//AudioSession的AudioSessionCategory字符串/*  Use this category for background sounds such as rain, car engine noise, etc.   Mixes with other music. */AVF_EXPORT NSString *const AVAudioSessionCategoryAmbient;  /*  Use this category for background sounds.  Other music will stop playing. */AVF_EXPORT NSString *const AVAudioSessionCategorySoloAmbient;/* Use this category for music tracks.*/AVF_EXPORT NSString *const AVAudioSessionCategoryPlayback;/*  Use this category when recording audio. */AVF_EXPORT NSString *const AVAudioSessionCategoryRecord;/*  Use this category when recording and playing back audio. */AVF_EXPORT NSString *const AVAudioSessionCategoryPlayAndRecord;/*  Use this category when using a hardware codec or signal processor while not playing or recording audio. */AVF_EXPORT NSString *const AVAudioSessionCategoryAudioProcessing;

启用

有了Category就可以启动AudioSession了,启动方法:

12345678
//AudioSession的启动方法extern OSStatus AudioSessionSetActive(Boolean active);extern OSStatus AudioSessionSetActiveWithFlags(Boolean active, UInt32 inFlags);//AVAudioSession的启动方法- (BOOL)setActive:(BOOL)active error:(NSError **)outError;- (BOOL)setActive:(BOOL)active withFlags:(NSInteger)flags error:(NSError **)outError NS_DEPRECATED_IOS(4_0, 6_0);- (BOOL)setActive:(BOOL)active withOptions:(AVAudioSessionSetActiveOptions)options error:(NSError **)outError NS_AVAILABLE_IOS(6_0);

启动方法调用后必须要判断是否启动成功,启动不成功的情况经常存在,例如一个前台的app正在播放,你的app正在后台想要启动AudioSession那就会返回失败。

一般情况下我们在启动和停止AudioSession调用第一个方法就可以了。但如果你正在做一个即时语音通讯app的话(类似于微信、易信)就需要注意在deactive AudioSession的时候需要使用第二个方法,inFlags参数传入kAudioSessionSetActiveFlag_NotifyOthersOnDeactivationAVAudioSession给options参数传入AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation)。当你的app deactive自己的AudioSession时系统会通知上一个被打断播放app打断结束(就是上面说到的打断回调),如果你的app在deactive时传入了NotifyOthersOnDeactivation参数,那么其他app在接到打断结束回调时会多得到一个参数kAudioSessionInterruptionType_ShouldResume否则就是ShouldNotResume(AVAudioSessionInterruptionOptionShouldResume),根据参数的值可以决定是否继续播放。

大概流程是这样的:

  1. 一个音乐软件A正在播放;
  2. 用户打开你的软件播放对话语音,AudioSession active;
  3. 音乐软件A音乐被打断并收到InterruptBegin事件;
  4. 对话语音播放结束,AudioSession deactive并且传入NotifyOthersOnDeactivation参数;
  5. 音乐软件A收到InterruptEnd事件,查看Resume参数,如果是ShouldResume控制音频继续播放,如果是ShouldNotResume就维持打断状态;

官方文档中有一张很形象的图来阐述这个现象:

然而现在某些语音通讯软件和某些音乐软件却无视了NotifyOthersOnDeactivationShouldResume的正确用法,导致我们经常接到这样的用户反馈:

你们的app在使用xx语音软件听了一段话后就不会继续播放了,但xx音乐软件可以继续播放啊。

好吧,上面只是吐槽一下。请无视我吧。

2014.7.14补充,7.19更新:

发现即使之前已经调用过AudioSessionInitialize方法,在某些情况下被打断之后可能出现AudioSession失效的情况,需要再次调用AudioSessionInitialize方法来重新生成AudioSession。否则调用AudioSessionSetActive会返回560557673(其他AudioSession方法也雷同,所有方法调用前必须首先初始化AudioSession),转换成string后为”!ini”即kAudioSessionNotInitialized,这个情况在iOS 5.1.x上比较容易发生,iOS 6.x 和 7.x也偶有发生(具体的原因还不知晓好像和打断时直接调用AudioOutputUnitStop有关,又是个坑啊)。

所以每次在调用AudioSessionSetActive时应该判断一下错误码,如果是上述的错误码需要重新初始化一下AudioSession。

附上OSStatus转成string的方法:

12345678910111213141516171819202122
#import <Endian.h>NSString * OSStatusToString(OSStatus status){    size_t len = sizeof(UInt32);    long addr = (unsigned long)&status;    char cstring[5];    len = (status >> 24) == 0 ? len - 1 : len;    len = (status >> 16) == 0 ? len - 1 : len;    len = (status >>  8) == 0 ? len - 1 : len;    len = (status >>  0) == 0 ? len - 1 : len;    addr += (4 - len);    status = EndianU32_NtoB(status);        // strings are big endian    strncpy(cstring, (char *)addr, len);    cstring[len] = 0;    return [NSString stringWithCString:(char *)cstring encoding:NSMacOSRomanStringEncoding];}

打断处理

正常启动AudioSession之后就可以播放音频了,下面要讲的是对于打断的处理。之前我们说到打断的回调在iOS 5下需要统一管理,在收到打断开始和结束时需要发送自定义的通知。

使用AudioSession时打断回调应该首先获取kAudioSessionProperty_InterruptionType,然后发送一个自定义的通知并带上对应的参数。

12345678910111213
static void MyAudioSessionInterruptionListener(void *inClientData, UInt32 inInterruptionState){    AudioSessionInterruptionType interruptionType = kAudioSessionInterruptionType_ShouldNotResume;    UInt32 interruptionTypeSize = sizeof(interruptionType);    AudioSessionGetProperty(kAudioSessionProperty_InterruptionType,                            &interruptionTypeSize,                            &interruptionType);    NSDictionary *userInfo = @{MyAudioInterruptionStateKey:@(inInterruptionState),                               MyAudioInterruptionTypeKey:@(interruptionType)};    [[NSNotificationCenter defaultCenter] postNotificationName:MyAudioInterruptionNotification object:nil userInfo:userInfo];}

收到通知后的处理方法如下(注意ShouldResume参数):

12345678910111213141516171819202122232425
- (void)interruptionNotificationReceived:(NSNotification *)notification{    UInt32 interruptionState = [notification.userInfo[MyAudioInterruptionStateKey] unsignedIntValue];    AudioSessionInterruptionType interruptionType = [notification.userInfo[MyAudioInterruptionTypeKey] unsignedIntValue];    [self handleAudioSessionInterruptionWithState:interruptionState type:interruptionType];}- (void)handleAudioSessionInterruptionWithState:(UInt32)interruptionState type:(AudioSessionInterruptionType)interruptionType{    if (interruptionState == kAudioSessionBeginInterruption)    {        //控制UI,暂停播放    }    else if (interruptionState == kAudioSessionEndInterruption)    {        if (interruptionType == kAudioSessionInterruptionType_ShouldResume)        {            OSStatus status = AudioSessionSetActive(true);            if (status == noErr)            {                //控制UI,继续播放            }        }    }}

小结

关于AudioSession的话题到此结束(码字果然很累。。)。小结一下:

  • 如果最低版本支持iOS 5,可以使用AudioSession也可以考虑使用AVAudioSession,需要有一个类统一管理AudioSession的所有回调,在接到回调后发送对应的自定义通知;
  • 如果最低版本支持iOS 6及以上,请使用AVAudioSession,不用统一管理,接AVAudioSession的通知即可;
  • 根据app的应用场景合理选择Category
  • 在deactive时需要注意app的应用场景来合理的选择是否使用NotifyOthersOnDeactivation参数;
  • 在处理InterruptEnd事件时需要注意ShouldResume的值。

iOS音频播放 (三):AudioFileStream

Audio Playback in iOS (Part 3) : AudioFileStream


本来说好是要在第三篇中讲AudioFileStreamAudioQueue,但写着写着发现光AudioFileStream就好多内容,最后还是决定分篇介绍,这篇先来说一下AudioFileStream,下一篇计划说一下和AudioFileStream类似的AudioFile,下下篇再来说AudioQueue

在本篇那种将会提到计算音频时长duration和音频seek的方法,这些方法对于CBR编码形式的音频文件可以做到比较精确而对于VBR编码形式的会存在较大的误差(关于CBR和VBR,请看本系列的第一篇),具体讲到duration和seek时会再进行说明。


AudioFileStream介绍

在第一篇中说到AudioFileStreamer时提到它的作用是用来读取采样率、码率、时长等基本信息以及分离音频帧。那么在官方文档中Apple是这样描述的:

To play streamed audio content, such as from a network connection, use Audio File Stream Services in concert with Audio Queue Services. Audio File Stream Services parses audio packets and metadata from common audio file container formats in a network bitstream. You can also use it to parse packets and metadata from on-disk files

根据Apple的描述AudioFileStreamer用在流播放中,当然不仅限于网络流,本地文件同样可以用它来读取信息和分离音频帧。AudioFileStreamer的主要数据是文件数据而不是文件路径,所以数据的读取需要使用者自行实现,

支持的文件格式有:

  • MPEG-1 Audio Layer 3, used for .mp3 files
  • MPEG-2 ADTS, used for the .aac audio data format
  • AIFC
  • AIFF
  • CAF
  • MPEG-4, used for .m4a, .mp4, and .3gp files
  • NeXT
  • WAVE

上述格式是iOS、MacOSX所支持的音频格式,这类格式可以被系统提供的API解码,如果想要解码其他的音频格式(如OGG、APE、FLAC)就需要自己实现解码器了。


初始化AudioFileStream

第一步,自然是要生成一个AudioFileStream的实例:

12345
extern OSStatus AudioFileStreamOpen (void * inClientData,                                     AudioFileStream_PropertyListenerProc inPropertyListenerProc,                                     AudioFileStream_PacketsProc inPacketsProc,                                     AudioFileTypeID inFileTypeHint,                                     AudioFileStreamID * outAudioFileStream);

第一个参数和之前的AudioSession的初始化方法一样是一个上下文对象;

第二个参数AudioFileStream_PropertyListenerProc是歌曲信息解析的回调,每解析出一个歌曲信息都会进行一次回调;

第三个参数AudioFileStream_PacketsProc是分离帧的回调,每解析出一部分帧就会进行一次回调;

第四个参数AudioFileTypeID是文件类型的提示,这个参数来帮助AudioFileStream对文件格式进行解析。这个参数在文件信息不完整(例如信息有缺陷)时尤其有用,它可以给与AudioFileStream一定的提示,帮助其绕过文件中的错误或者缺失从而成功解析文件。所以在确定文件类型的情况下建议各位还是填上这个参数,如果无法确定可以传入0(原理上应该和这篇博文近似);

1234567891011121314151617181920
//AudioFileTypeID枚举enum {        kAudioFileAIFFType             = 'AIFF',        kAudioFileAIFCType             = 'AIFC',        kAudioFileWAVEType             = 'WAVE',        kAudioFileSoundDesigner2Type   = 'Sd2f',        kAudioFileNextType             = 'NeXT',        kAudioFileMP3Type              = 'MPG3',    // mpeg layer 3        kAudioFileMP2Type              = 'MPG2',    // mpeg layer 2        kAudioFileMP1Type              = 'MPG1',    // mpeg layer 1        kAudioFileAC3Type              = 'ac-3',        kAudioFileAAC_ADTSType         = 'adts',        kAudioFileMPEG4Type            = 'mp4f',        kAudioFileM4AType              = 'm4af',        kAudioFileM4BType              = 'm4bf',        kAudioFileCAFType              = 'caff',        kAudioFile3GPType              = '3gpp',        kAudioFile3GP2Type             = '3gp2',                kAudioFileAMRType              = 'amrf'        };

第五个参数是返回的AudioFileStream实例对应的AudioFileStreamID,这个ID需要保存起来作为后续一些方法的参数使用;

返回值用来判断是否成功初始化(OSStatus == noErr)。


解析数据

在初始化完成之后,只要拿到文件数据就可以进行解析了。解析时调用方法:

1234
extern OSStatus AudioFileStreamParseBytes(AudioFileStreamID inAudioFileStream,                                          UInt32 inDataByteSize,                                          const void* inData,                                          UInt32 inFlags);

第一个参数AudioFileStreamID,即初始化时返回的ID;

第二个参数inDataByteSize,本次解析的数据长度;

第三个参数inData,本次解析的数据;

第四个参数是说本次的解析和上一次解析是否是连续的关系,如果是连续的传入0,否则传入kAudioFileStreamParseFlag_Discontinuity

这里需要插入解释一下何谓“连续”。在第一篇中我们提到过形如MP3的数据都以帧的形式存在的,解析时也需要以帧为单位解析。但在解码之前我们不可能知道每个帧的边界在第几个字节,所以就会出现这样的情况:我们传给AudioFileStreamParseBytes的数据在解析完成之后会有一部分数据余下来,这部分数据是接下去那一帧的前半部分,如果再次有数据输入需要继续解析时就必须要用到前一次解析余下来的数据才能保证帧数据完整,所以在正常播放的情况下传入0即可。目前知道的需要传入kAudioFileStreamParseFlag_Discontinuity的情况有两个,一个是在seek完毕之后显然seek后的数据和之前的数据完全无关;另一个是开源播放器AudioStreamer的作者@Matt Gallagher曾在自己的blog中提到过的:

the Audio File Stream Services hit me with a nasty bug: AudioFileStreamParseBytes will crash when trying to parse a streaming MP3.

In this case, if we pass the kAudioFileStreamParseFlag_Discontinuity flag to AudioFileStreamParseBytes on every invocation between receiving kAudioFileStreamProperty_ReadyToProducePackets and the first successful call to MyPacketsProc, then AudioFileStreamParseBytes will be extra cautious in its approach and won't crash.

Matt发布这篇blog是在2008年,这个Bug年代相当久远了,而且原因未知,究竟是否修复也不得而知,而且由于环境不同(比如测试用的mp3文件和所处的iOS系统)无法重现这个问题,所以我个人觉得还是按照Matt的work around在回调得到kAudioFileStreamProperty_ReadyToProducePackets之后,在正常解析第一帧之前都传入kAudioFileStreamParseFlag_Discontinuity比较好。

回到之前的内容,AudioFileStreamParseBytes方法的返回值表示当前的数据是否被正常解析,如果OSStatus的值不是noErr则表示解析不成功,其中错误码包括:

123456789101112131415
enum{  kAudioFileStreamError_UnsupportedFileType        = 'typ?',  kAudioFileStreamError_UnsupportedDataFormat      = 'fmt?',  kAudioFileStreamError_UnsupportedProperty        = 'pty?',  kAudioFileStreamError_BadPropertySize            = '!siz',  kAudioFileStreamError_NotOptimized               = 'optm',  kAudioFileStreamError_InvalidPacketOffset        = 'pck?',  kAudioFileStreamError_InvalidFile                = 'dta?',  kAudioFileStreamError_ValueUnknown               = 'unk?',  kAudioFileStreamError_DataUnavailable            = 'more',  kAudioFileStreamError_IllegalOperation           = 'nope',  kAudioFileStreamError_UnspecifiedError           = 'wht?',  kAudioFileStreamError_DiscontinuityCantRecover   = 'dsc!'};

大多数都可以从字面上理解,需要提一下的是kAudioFileStreamError_NotOptimized,文档上是这么说的:

It is not possible to produce output packets because the file's packet table or other defining info is either not present or is after the audio data.

它的含义是说这个音频文件的文件头不存在或者说文件头可能在文件的末尾,当前无法正常Parse,换句话说就是这个文件需要全部下载完才能播放,无法流播。

注意AudioFileStreamParseBytes方法每一次调用都应该注意返回值,一旦出现错误就可以不必继续Parse了。


解析文件格式信息

在调用AudioFileStreamParseBytes方法进行解析时会首先读取格式信息,并同步的进入AudioFileStream_PropertyListenerProc回调方法

来看一下这个回调方法的定义

1234
typedef void (*AudioFileStream_PropertyListenerProc)(void * inClientData,                                                     AudioFileStreamID inAudioFileStream,                                                     AudioFileStreamPropertyID inPropertyID,                                                     UInt32 * ioFlags);

回调的第一个参数是Open方法中的上下文对象;

第二个参数inAudioFileStream是和Open方法中第四个返回参数AudioFileStreamID一样,表示当前FileStream的ID;

第三个参数是此次回调解析的信息ID。表示当前PropertyID对应的信息已经解析完成信息(例如数据格式、音频数据的偏移量等等),使用者可以通过AudioFileStreamGetProperty接口获取PropertyID对应的值或者数据结构;

1234
extern OSStatus AudioFileStreamGetProperty(AudioFileStreamID inAudioFileStream,                                           AudioFileStreamPropertyID inPropertyID,                                           UInt32 * ioPropertyDataSize,                                           void * outPropertyData);

第四个参数ioFlags是一个返回参数,表示这个property是否需要被缓存,如果需要赋值kAudioFileStreamPropertyFlag_PropertyIsCached否则不赋值(这个参数我也不知道应该在啥场景下使用。。一直都没去理他);

这个回调会进来多次,但并不是每一次都需要进行处理,可以根据需求处理需要的PropertyID进行处理(PropertyID列表如下)。

1234567891011121314151617181920212223
//AudioFileStreamProperty枚举enum{  kAudioFileStreamProperty_ReadyToProducePackets           =    'redy',  kAudioFileStreamProperty_FileFormat                      =    'ffmt',  kAudioFileStreamProperty_DataFormat                      =    'dfmt',  kAudioFileStreamProperty_FormatList                      =    'flst',  kAudioFileStreamProperty_MagicCookieData                 =    'mgic',  kAudioFileStreamProperty_AudioDataByteCount              =    'bcnt',  kAudioFileStreamProperty_AudioDataPacketCount            =    'pcnt',  kAudioFileStreamProperty_MaximumPacketSize               =    'psze',  kAudioFileStreamProperty_DataOffset                      =    'doff',  kAudioFileStreamProperty_ChannelLayout                   =    'cmap',  kAudioFileStreamProperty_PacketToFrame                   =    'pkfr',  kAudioFileStreamProperty_FrameToPacket                   =    'frpk',  kAudioFileStreamProperty_PacketToByte                    =    'pkby',  kAudioFileStreamProperty_ByteToPacket                    =    'bypk',  kAudioFileStreamProperty_PacketTableInfo                 =    'pnfo',  kAudioFileStreamProperty_PacketSizeUpperBound            =    'pkub',  kAudioFileStreamProperty_AverageBytesPerPacket           =    'abpp',  kAudioFileStreamProperty_BitRate                         =    'brat',  kAudioFileStreamProperty_InfoDictionary                  =    'info'};

这里列几个我认为比较重要的PropertyID:

1、kAudioFileStreamProperty_BitRate

表示音频数据的码率,获取这个Property是为了计算音频的总时长Duration(因为AudioFileStream没有这样的接口。。)。

1234567
UInt32 bitRate;UInt32 bitRateSize = sizeof(bitRate);OSStatus status = AudioFileStreamGetProperty(inAudioFileStream, kAudioFileStreamProperty_BitRate, &bitRateSize, &bitRate);if (status != noErr){    //错误处理}

2014.8.2 补充:发现在流播放的情况下,有时数据流量比较小时会出现ReadyToProducePackets还是没有获取到bitRate的情况,这时就需要分离一些拼音帧然后计算平均bitRate,计算公式如下:

1
UInt32 averageBitRate = totalPackectByteCount / totalPacketCout;

2、kAudioFileStreamProperty_DataOffset

表示音频数据在整个音频文件中的offset(因为大多数音频文件都会有一个文件头之后才使真正的音频数据),这个值在seek时会发挥比较大的作用,音频的seek并不是直接seek文件位置而seek时间(比如seek到2分10秒的位置),seek时会根据时间计算出音频数据的字节offset然后需要再加上音频数据的offset才能得到在文件中的真正offset。

1234567
SInt64 dataOffset;UInt32 offsetSize = sizeof(dataOffset);OSStatus status = AudioFileStreamGetProperty(inAudioFileStream, kAudioFileStreamProperty_DataOffset, &offsetSize, &dataOffset);if (status != noErr){    //错误处理}

3、kAudioFileStreamProperty_DataFormat

表示音频文件结构信息,是一个AudioStreamBasicDescription的结构

1234567891011121314151617181920
struct AudioStreamBasicDescription{    Float64 mSampleRate;    UInt32  mFormatID;    UInt32  mFormatFlags;    UInt32  mBytesPerPacket;    UInt32  mFramesPerPacket;    UInt32  mBytesPerFrame;    UInt32  mChannelsPerFrame;    UInt32  mBitsPerChannel;    UInt32  mReserved;};AudioStreamBasicDescription asbd;UInt32 asbdSize = sizeof(asbd);OSStatus status = AudioFileStreamGetProperty(inAudioFileStream, kAudioFileStreamProperty_DataFormat, &asbdSize, &asbd);if (status != noErr){    //错误处理}  

4、kAudioFileStreamProperty_FormatList

作用和kAudioFileStreamProperty_DataFormat是一样的,区别在于用这个PropertyID获取到是一个AudioStreamBasicDescription的数组,这个参数是用来支持AAC SBR这样的包含多个文件类型的音频格式。由于到底有多少个format我们并不知晓,所以需要先获取一下总数据大小:

123456789101112131415161718192021222324
//获取数据大小Boolean outWriteable;UInt32 formatListSize;OSStatus status = AudioFileStreamGetPropertyInfo(inAudioFileStream, kAudioFileStreamProperty_FormatList, &formatListSize, &outWriteable);if (status != noErr){    //错误处理}//获取formatlistAudioFormatListItem *formatList = malloc(formatListSize);OSStatus status = AudioFileStreamGetProperty(inAudioFileStream, kAudioFileStreamProperty_FormatList, &formatListSize, formatList);if (status != noErr){    //错误处理}//选择需要的格式for (int i = 0; i * sizeof(AudioFormatListItem) < formatListSize; i += sizeof(AudioFormatListItem)){    AudioStreamBasicDescription pasbd = formatList[i].mASBD;    //选择需要的格式。。                             }free(formatList);

5、kAudioFileStreamProperty_AudioDataByteCount

顾名思义,音频文件中音频数据的总量。这个Property的作用一是用来计算音频的总时长,二是可以在seek时用来计算时间对应的字节offset。

1234567
UInt64 audioDataByteCount;UInt32 byteCountSize = sizeof(audioDataByteCount);OSStatus status = AudioFileStreamGetProperty(inAudioFileStream, kAudioFileStreamProperty_AudioDataByteCount, &byteCountSize, &audioDataByteCount);if (status != noErr){    //错误处理}

2014.8.2 补充:发现在流播放的情况下,有时数据流量比较小时会出现ReadyToProducePackets还是没有获取到audioDataByteCount的情况,这时就需要近似计算audioDataByteCount。一般来说音频文件的总大小一定是可以得到的(利用文件系统或者Http请求中的contentLength),那么计算方法如下:

123
UInt32 dataOffset = ...; //kAudioFileStreamProperty_DataOffsetUInt32 fileLength = ...; //音频文件大小UInt32 audioDataByteCount = fileLength - dataOffset;

5、kAudioFileStreamProperty_ReadyToProducePackets

这个PropertyID可以不必获取对应的值,一旦回调中这个PropertyID出现就代表解析完成,接下来可以对音频数据进行帧分离了。


计算时长Duration

获取时长的最佳方法是从ID3信息中去读取,那样是最准确的。如果ID3信息中没有存,那就依赖于文件头中的信息去计算了。

计算duration的公式如下:

1
double duration = (audioDataByteCount * 8) / bitRate

音频数据的字节总量audioDataByteCount可以通过kAudioFileStreamProperty_AudioDataByteCount获取,码率bitRate可以通过kAudioFileStreamProperty_BitRate获取也可以通过Parse一部分数据后计算平均码率来得到。

对于CBR数据来说用这样的计算方法的duration会比较准确,对于VBR数据就不好说了。所以对于VBR数据来说,最好是能够从ID3信息中获取到duration,获取不到再想办法通过计算平均码率的途径来计算duration。


分离音频帧

读取格式信息完成之后继续调用AudioFileStreamParseBytes方法可以对帧进行分离,并同步的进入AudioFileStream_PacketsProc回调方法。

回调的定义:

12345
typedef void (*AudioFileStream_PacketsProc)(void * inClientData,                                            UInt32 inNumberBytes,                                            UInt32 inNumberPackets,                                            const void * inInputData,                                            AudioStreamPacketDescription * inPacketDescriptions);

第一个参数,一如既往的上下文对象;

第二个参数,本次处理的数据大小;

第三个参数,本次总共处理了多少帧(即代码里的Packet);

第四个参数,本次处理的所有数据;

第五个参数,AudioStreamPacketDescription数组,存储了每一帧数据是从第几个字节开始的,这一帧总共多少字节。

12345678
//AudioStreamPacketDescription结构//这里的mVariableFramesInPacket是指实际的数据帧只有VBR的数据才能用到(像MP3这样的压缩数据一个帧里会有好几个数据帧)struct  AudioStreamPacketDescription{    SInt64  mStartOffset;    UInt32  mVariableFramesInPacket;    UInt32  mDataByteSize;};

下面是我按照自己的理解实现的回调方法片段:

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
static void MyAudioFileStreamPacketsCallBack(void *inClientData,                                             UInt32 inNumberBytes,                                             UInt32 inNumberPackets,                                             const void *inInputData,                                             AudioStreamPacketDescription  *inPacketDescriptions){    //处理discontinuous..    if (numberOfBytes == 0 || numberOfPackets == 0)    {        return;    }    BOOL deletePackDesc = NO;    if (packetDescriptioins == NULL)    {        //如果packetDescriptioins不存在,就按照CBR处理,平均每一帧的数据后生成packetDescriptioins        deletePackDesc = YES;        UInt32 packetSize = numberOfBytes / numberOfPackets;        packetDescriptioins = (AudioStreamPacketDescription *)malloc(sizeof(AudioStreamPacketDescription) * numberOfPackets);        for (int i = 0; i < numberOfPackets; i++)        {            UInt32 packetOffset = packetSize * i;            descriptions[i].mStartOffset = packetOffset;            descriptions[i].mVariableFramesInPacket = 0;            if (i == numberOfPackets - 1)            {                packetDescriptioins[i].mDataByteSize = numberOfBytes - packetOffset;            }            else            {                packetDescriptioins[i].mDataByteSize = packetSize;            }        }    }    for (int i = 0; i < numberOfPackets; ++i)    {        SInt64 packetOffset = packetDescriptioins[i].mStartOffset;        UInt32 packetSize   = packetDescriptioins[i].mDataByteSize;        //把解析出来的帧数据放进自己的buffer中        ...    }    if (deletePackDesc)    {        free(packetDescriptioins);    }}

inPacketDescriptions这个字段为空时需要按CBR的数据处理。但其实在解析CBR数据时inPacketDescriptions一般也会有返回,因为即使是CBR数据帧的大小也不是恒定不变的,例如CBR的MP3会在每一帧的数据后放1 byte的填充位,这个填充位也并非时时刻刻存在,所以帧的大小会有1 byte的浮动。(比如采样率44.1KHZ,码率160kbps的CBR MP3文件每一帧的大小在522字节和523字节浮动。所以不能因为有inPacketDescriptions没有返回NULL而判定音频数据就是VBR编码的)。


Seek

就音频的角度来seek功能描述为“我要拖到xx分xx秒”,而实际操作时我们需要操作的是文件,所以我们需要知道的是“我要拖到xx分xx秒”这个操作对应到文件上是要从第几个字节开始读取音频数据。

对于原始的PCM数据来说每一个PCM帧都是固定长度的,对应的播放时长也是固定的,但一旦转换成压缩后的音频数据就会因为编码形式的不同而不同了。对于CBR而言每个帧中所包含的PCM数据帧是恒定的,所以每一帧对应的播放时长也是恒定的;而VBR则不同,为了保证数据最优并且文件大小最小,VBR的每一帧中所包含的PCM数据帧是不固定的,这就导致在流播放的情况下VBR的数据想要做seek并不容易。这里我们也只讨论CBR下的seek。

CBR数据的seek一般是这样实现的(参考并修改自matt的blog):

1、近似地计算应该seek到哪个字节

1234567
double seekToTime = ...; //需要seek到哪个时间,秒为单位UInt64 audioDataByteCount = ...; //通过kAudioFileStreamProperty_AudioDataByteCount获取的值SInt64 dataOffset = ...; //通过kAudioFileStreamProperty_DataOffset获取的值double durtion = ...; //通过公式(AudioDataByteCount * 8) / BitRate计算得到的时长//近似seekOffset = 数据偏移 + seekToTime对应的近似字节数SInt64 approximateSeekOffset = dataOffset + (seekToTime / duration) * audioDataByteCount;

2、计算seekToTime对应的是第几个帧(Packet)

我们可以利用之前Parse得到的音频格式信息来计算PacketDuration。audioItem.fileFormat.mFramesPerPacket /audioItem.fileFormat.mSampleRate;

123456
//首先需要计算每个packet对应的时长AudioStreamBasicDescription asbd = ...; ////通过kAudioFileStreamProperty_DataFormat或者kAudioFileStreamProperty_FormatList获取的值double packetDuration = asbd.mFramesPerPacket / asbd.mSampleRate//然后计算packet位置SInt64 seekToPacket = floor(seekToTime / packetDuration);

3、使用AudioFileStreamSeek计算精确的字节偏移和时间

AudioFileStreamSeek可以用来寻找某一个帧(Packet)对应的字节偏移(byte offset):

  • 如果ioFlags里有kAudioFileStreamSeekFlag_OffsetIsEstimated说明给出的outDataByteOffset是估算的,并不准确,那么还是应该用第1步计算出来的approximateSeekOffset来做seek;
  • 如果ioFlags里没有kAudioFileStreamSeekFlag_OffsetIsEstimated说明给出了准确的outDataByteOffset,就是输入的seekToPacket对应的字节偏移量,我们可以根据outDataByteOffset来计算出精确的seekOffset和seekToTime;
1234567891011121314
SInt64 seekByteOffset;UInt32 ioFlags = 0;SInt64 outDataByteOffset;OSStatus status = AudioFileStreamSeek(audioFileStreamID, seekToPacket, &outDataByteOffset, &ioFlags);if (status == noErr && !(ioFlags & kAudioFileStreamSeekFlag_OffsetIsEstimated)){  //如果AudioFileStreamSeek方法找到了准确的帧字节偏移,需要修正一下时间  seekToTime -= ((approximateSeekOffset - dataOffset) - outDataByteOffset) * 8.0 / bitRate;  seekByteOffset = outDataByteOffset + dataOffset;}else{  seekByteOffset = approximateSeekOffset;}

4、按照seekByteOffset读取对应的数据继续使用AudioFileStreamParseByte进行解析

如果是网络流可以通过设置range头来获取字节,本地文件的话直接seek就好了。调用AudioFileStreamParseByte时注意刚seek完第一次Parse数据需要加参数kAudioFileStreamParseFlag_Discontinuity


关闭AudioFileStream

AudioFileStream使用完毕后需要调用AudioFileStreamClose进行关闭,没啥特别需要注意的。

1
extern OSStatus AudioFileStreamClose(AudioFileStreamID inAudioFileStream); 

小结

本篇关于AudioFileStream做了详细介绍,小结一下:

  • 使用AudioFileStream首先需要调用AudioFileStreamOpen,需要注意的是尽量提供inFileTypeHint参数帮助AudioFileStream解析数据,调用完成后记录AudioFileStreamID

  • 当有数据时调用AudioFileStreamParseBytes进行解析,每一次解析都需要注意返回值,返回值一旦出现noErr以外的值就代表Parse出错,其中kAudioFileStreamError_NotOptimized代表该文件缺少头信息或者其头信息在文件尾部不适合流播放;

  • 使用AudioFileStreamParseBytes需要注意第四个参数在需要合适的时候传入kAudioFileStreamParseFlag_Discontinuity

  • 调用AudioFileStreamParseBytes后会首先同步进入AudioFileStream_PropertyListenerProc回调来解析文件格式信息,如果回调得到kAudioFileStreamProperty_ReadyToProducePackets表示解析格式信息完成;

  • 解析格式信息完成后继续调用AudioFileStreamParseBytes会进入MyAudioFileStreamPacketsCallBack回调来分离音频帧,在回调中应该将分离出来的帧信息保存到自己的buffer中

  • seek时需要先近似的计算seekTime对应的seekByteOffset,然后利用AudioFileStreamSeek计算精确的offset,如果能得到精确的offset就修正一下seektime,如果无法得到精确的offset就用之前的近似结果

  • AudioFileStream使用完毕后需要调用AudioFileStreamClose进行关闭;


示例代码

AudioStreamer和FreeStreamer这两个优秀的开源播放器都用到AudioFileStream大家可以借鉴。我自己也写了一个简单的AudioFileStream封装。

iOS音频播放 (四):AudioFile

Audio Playback in iOS (Part 4) : AudioFile


接着第三篇的AudioStreamFile这一篇要来聊一下AudioFile。和AudioStreamFile一样AudioFileAudioToolBox framework中的一员,它也能够完成第一篇所述的第2步,读取音频格式信息和进行帧分离,但事实上它的功能远不止如此。


AudioFile介绍

按照官方文档的描述:

a C programming interface that enables you to read or write a wide variety of audio data to or from disk or a memory buffer.With Audio File Services you can:

  • Create, initialize, open, and close audio files
  • Read and write audio files
  • Optimize audio files
  • Work with user data and global information

这个类可以用来创建、初始化音频文件;读写音频数据;对音频文件进行优化;读取和写入音频格式信息等等,功能十分强大,可见它不但可以用来支持音频播放,甚至可以用来生成音频文件。当然,在本篇文章中只会涉及一些和音频播放相关的内容(打开音频文件、读取格式信息、读取音频数据,其实我也只对这些方法有一点了解,其余的功能没用过。。>_<).


AudioFile的打开“姿势”

AudioFile提供了两个打开文件的方法:

1、 AudioFileOpenURL

12345678910
enum {  kAudioFileReadPermission      = 0x01,  kAudioFileWritePermission     = 0x02,  kAudioFileReadWritePermission = 0x03};extern OSStatus AudioFileOpenURL (CFURLRef inFileRef,                                  SInt8 inPermissions,                                  AudioFileTypeID inFileTypeHint,                                  AudioFileID * outAudioFile);

从方法的定义上来看是用来读取本地文件的:

第一个参数,文件路径;

第二个参数,文件的允许使用方式,是读、写还是读写,如果打开文件后进行了允许使用方式以外的操作,就得到kAudioFilePermissionsError错误码(比如Open时声明是kAudioFileReadPermission但却调用了AudioFileWriteBytes);

第三个参数,和AudioFileStream的open方法中一样是一个帮助AudioFile解析文件的类型提示,如果文件类型确定的话应当传入;

第四个参数,返回AudioFile实例对应的AudioFileID,这个ID需要保存起来作为后续一些方法的参数使用;

返回值用来判断是否成功打开文件(OSSStatus == noErr)。


2、 AudioFileOpenWithCallbacks

1234567
extern OSStatus AudioFileOpenWithCallbacks (void * inClientData,                                            AudioFile_ReadProc inReadFunc,                                            AudioFile_WriteProc inWriteFunc,                                            AudioFile_GetSizeProc inGetSizeFunc,                                            AudioFile_SetSizeProc inSetSizeFunc,                                            AudioFileTypeID inFileTypeHint,                                            AudioFileID * outAudioFile);

看过第一个Open方法后,这个方法乍看上去让人有点迷茫,没有URL的参数如何告诉AudioFile该打开哪个文件?还是先来看一下参数的说明吧:

第一个参数,上下文信息,不再多做解释;

第二个参数,当AudioFile需要读音频数据时进行的回调(调用Open和Read方式后同步回调);

第三个参数,当AudioFile需要写音频数据时进行的回调(写音频文件功能时使用,暂不讨论);

第四个参数,当AudioFile需要用到文件的总大小时回调(调用Open和Read方式后同步回调);

第五个参数,当AudioFile需要设置文件的大小时回调(写音频文件功能时使用,暂不讨论);

第六、七个参数和返回值同AudioFileOpenURL方法;

这个方法的重点在于AudioFile_ReadProc这个回调。换一个角度理解,这个方法相比于第一个方法自由度更高,AudioFile需要的只是一个数据源,无论是磁盘上的文件、内存里的数据甚至是网络流只要能在AudioFile需要数据时(Open和Read时)通过AudioFile_ReadProc回调为AudioFile提供合适的数据就可以了,也就是说使用方法不仅仅可以读取本地文件也可以如AudioFileStream一样以流的形式读取数据。


下面来看一下AudioFile_GetSizeProcAudioFile_ReadProc这两个读取功能相关的回调

1234567
typedef SInt64 (*AudioFile_GetSizeProc)(void * inClientData);typedef OSStatus (*AudioFile_ReadProc)(void * inClientData,                                       SInt64 inPosition,                                       UInt32 requestCount,                                       void * buffer,                                       UInt32 * actualCount);

首先是AudioFile_GetSizeProc回调,这个回调很好理解,返回文件总长度即可,总长度的获取途径自然是文件系统或者httpResponse等等。

接下来是AudioFile_ReadProc回调:

第一个参数,上下文对象,不再赘述;

第二个参数,需要读取第几个字节开始的数据;

第三个参数,需要读取的数据长度;

第四个参数,返回参数,是一个数据指针并且其空间已经被分配,我们需要做的是把数据memcpy到buffer中;

第五个参数,实际提供的数据长度,即memcpy到buffer中的数据长度;

返回值,如果没有任何异常产生就返回noErr,如果有异常可以根据异常类型选择需要的error常量返回(一般用不到其他返回值,返回noErr就足够了);

这里需要解释一下这个回调方法的工作方式。AudioFile需要数据时会调用回调方法,需要数据的时间点有两个:

  1. Open方法调用时,由于AudioFile的Open方法调用过程中就会对音频格式信息进行解析,只有符合要求的音频格式才能被成功打开否则Open方法就会返回错误码(换句话说,Open方法一旦调用成功就相当于AudioStreamFile在Parse后返回ReadyToProducePackets一样,只要Open成功就可以开始读取音频数据,详见第三篇),所以在Open方法调用的过程中就需要提供一部分音频数据来进行解析;

  2. Read相关方法调用时,这个不需要多说很好理解;

通过回调提供数据时需要注意inPosition和requestCount参数,这两个参数指明了本次回调需要提供的数据范围是从inPosition开始requestCount个字节的数据。这里又可以分为两种情况:

  1. 有充足的数据:那么我们需要把这个范围内的数据拷贝到buffer中,并且给actualCount赋值requestCount,最后返回noError;

  2. 数据不足:没有充足数据的话就只能把手头有的数据拷贝到buffer中,需要注意的是这部分被拷贝的数据必须是从inPosition开始的连续数据,拷贝完成后给actualCount赋值实际拷贝进buffer中的数据长度后返回noErr,这个过程可以用下面的代码来表示:

1234567891011121314151617
static OSStatus MyAudioFileReadCallBack(void *inClientData,                                        SInt64 inPosition,                                        UInt32 requestCount,                                        void *buffer,                                        UInt32 *actualCount){    __unsafe_unretained MyContext *context = (__bridge MyContext *)inClientData;    *actualCount = [context availableDataLengthAtOffset:inPosition maxLength:requestCount];    if (*actualCount > 0)    {        NSData *data = [context dataAtOffset:inPosition length:*actualCount];        memcpy(buffer, [data bytes], [data length]);    }    return noErr;}

说到这里又需要分两种情况:

2.1. Open方法调用时的回调数据不足:AudioFile的Open方法会根据文件格式类型分几步进行数据读取以解析确定是否是一个合法的文件格式,其中每一步的inPosition和requestCount都不一样,如果某一步不成功就会直接进行下一步,如果几部下来都失败了,那么Open方法就会失败。简单的说就是在调用Open之前首先需要保证音频文件的格式信息完整,这就意味着AudioFile并不能独立用于音频流的读取,在流播放时首先需要使用AudioStreamFile来得到ReadyToProducePackets标志位来保证信息完整;

2.2. Read方法调用时的回调数据不足:这种情况下inPosition和requestCount的数值与Read方法调用时传入的参数有关,数据不足对于Read方法本身没有影响,只要回调返回noErr,Read就成功,只是实际交给Read方法的调用方的数据会不足,那么就把这个问题的处理交给了Read的调用方;


读取音频格式信息

成功打开音频文件后就可以读取其中的格式信息了,读取用到的方法如下:

123456789
extern OSStatus AudioFileGetPropertyInfo(AudioFileID inAudioFile,                                         AudioFilePropertyID inPropertyID,                                         UInt32 * outDataSize,                                         UInt32 * isWritable);                                      extern OSStatus AudioFileGetProperty(AudioFileID inAudioFile,                                     AudioFilePropertyID inPropertyID,                                     UInt32 * ioDataSize,                                     void * outPropertyData);    

AudioFileGetPropertyInfo方法用来获取某个属性对应的数据的大小(outDataSize)以及该属性是否可以被write(isWritable),而AudioFileGetProperty则用来获取属性对应的数据。对于一些大小可变的属性需要先使用AudioFileGetPropertyInfo获取数据大小才能取获取数据(例如formatList),而有些确定类型单个属性则不必先调用AudioFileGetPropertyInfo直接调用AudioFileGetProperty即可(比如BitRate),例子如下:

12345678910111213141516171819202122232425262728
AudioFileID fileID; //Open方法返回的AudioFileID//获取格式信息UInt32 formatListSize = 0;OSStatus status = AudioFileGetPropertyInfo(_fileID, kAudioFilePropertyFormatList, &formatListSize, NULL);if (status == noErr){    AudioFormatListItem *formatList = (AudioFormatListItem *)malloc(formatListSize);    status = AudioFileGetProperty(fileID, kAudioFilePropertyFormatList, &formatListSize, formatList);    if (status == noErr)    {        for (int i = 0; i * sizeof(AudioFormatListItem) < formatListSize; i += sizeof(AudioFormatListItem))        {            AudioStreamBasicDescription pasbd = formatList[i].mASBD;            //选择需要的格式。。                                     }    }    free(formatList);}//获取码率UInt32 bitRate;UInt32 bitRateSize = sizeof(bitRate);status = AudioFileGetProperty(fileID, kAudioFilePropertyBitRate, &size, &bitRate);if (status != noErr){    //错误处理}

可以获取的属性有下面这些,大家可以参考文档来获取自己需要的信息(注意到这里有EstimatedDuration,可以得到Duration了):

12345678910111213141516171819202122232425262728
enum{  kAudioFilePropertyFileFormat             =    'ffmt',  kAudioFilePropertyDataFormat             =    'dfmt',  kAudioFilePropertyIsOptimized            =    'optm',  kAudioFilePropertyMagicCookieData        =    'mgic',  kAudioFilePropertyAudioDataByteCount     =    'bcnt',  kAudioFilePropertyAudioDataPacketCount   =    'pcnt',  kAudioFilePropertyMaximumPacketSize      =    'psze',  kAudioFilePropertyDataOffset             =    'doff',  kAudioFilePropertyChannelLayout          =    'cmap',  kAudioFilePropertyDeferSizeUpdates       =    'dszu',  kAudioFilePropertyMarkerList             =    'mkls',  kAudioFilePropertyRegionList             =    'rgls',  kAudioFilePropertyChunkIDs               =    'chid',  kAudioFilePropertyInfoDictionary         =    'info',  kAudioFilePropertyPacketTableInfo        =    'pnfo',  kAudioFilePropertyFormatList             =    'flst',  kAudioFilePropertyPacketSizeUpperBound   =    'pkub',  kAudioFilePropertyReserveDuration        =    'rsrv',  kAudioFilePropertyEstimatedDuration      =    'edur',  kAudioFilePropertyBitRate                =    'brat',  kAudioFilePropertyID3Tag                 =    'id3t',  kAudioFilePropertySourceBitDepth         =    'sbtd',  kAudioFilePropertyAlbumArtwork           =    'aart',  kAudioFilePropertyAudioTrackCount        =    'atct',  kAudioFilePropertyUseAudioTrack          =    'uatk'}; 

读取音频数据

读取音频数据的方法分为两类:

1、直接读取音频数据:

12345
extern OSStatus AudioFileReadBytes (AudioFileID inAudioFile,                                    Boolean inUseCache,                                    SInt64 inStartingByte,                                    UInt32 * ioNumBytes,                                    void * outBuffer);

第一个参数,FileID;

第二个参数,是否需要cache,一般来说传false;

第三个参数,从第几个byte开始读取数据

第四个参数,这个参数在调用时作为输入参数表示需要读取读取多少数据,调用完成后作为输出参数表示实际读取了多少数据(即Read回调中的requestCount和actualCount);

第五个参数,buffer指针,需要事先分配好足够大的内存(ioNumBytes大,即Read回调中的buffer,所以Read回调中不需要再分配内存);

返回值表示是否读取成功,EOF时会返回kAudioFileEndOfFileError

使用这个方法得到的数据都是没有进行过帧分离的数据,如果想要用来播放或者解码还必须通过AudioFileStream进行帧分离;

2、按帧(Packet)读取音频数据:

12345678910111213141516
extern OSStatus AudioFileReadPacketData (AudioFileID inAudioFile,                                         Boolean inUseCache,                                         UInt32 * ioNumBytes,                                         AudioStreamPacketDescription * outPacketDescriptions,                                         SInt64 inStartingPacket,                                         UInt32 * ioNumPackets,                                         void * outBuffer);                                      extern OSStatus AudioFileReadPackets (AudioFileID inAudioFile,                                      Boolean inUseCache,                                      UInt32 * outNumBytes,                                      AudioStreamPacketDescription * outPacketDescriptions,                                      SInt64 inStartingPacket,                                      UInt32 * ioNumPackets,                                      void * outBuffer);

按帧读取的方法有两个,这两个方法看上去差不多,就连参数也几乎相同,但使用场景和效率上却有所不同,官方文档中如此描述这两个方法:

  • AudioFileReadPacketData is memory efficient when reading variable bit-rate (VBR) audio data;
  • AudioFileReadPacketData is more efficient than AudioFileReadPackets when reading compressed file formats that do not have packet tables, such as MP3 or ADTS. This function is a good choice for reading either CBR (constant bit-rate) or VBR data if you do not need to read a fixed duration of audio.
  • Use AudioFileReadPackets only when you need to read a fixed duration of audio data, or when you are reading only uncompressed audio.

只有当需要读取固定时长音频或者非压缩音频时才会用到AudioFileReadPackets,其余时候使用AudioFileReadPacketData会有更高的效率并且更省内存;

下面来看看这些参数:

第一、二个参数,同AudioFileReadBytes

第三个参数,对于AudioFileReadPacketData来说ioNumBytes这个参数在输入输出时都要用到,在输入时表示outBuffer的size,输出时表示实际读取了多少size的数据。而对AudioFileReadPackets来说outNumBytes只在输出时使用,表示实际读取了多少size的数据;

第四个参数,帧信息数组指针,在输入前需要分配内存,大小必须足够存在ioNumPackets个帧信息(ioNumPackets * sizeof(AudioStreamPacketDescription));

第五个参数,从第几帧开始读取数据;

第六个参数,在输入时表示需要读取多少个帧,在输出时表示实际读取了多少帧;

第七个参数,outBuffer数据指针,在输入前就需要分配好空间,这个参数看上去两个方法一样但其实并非如此。对于AudioFileReadPacketData来说只要分配近似帧大小 * 帧数的内存空间即可,方法本身会针对给定的内存空间大小来决定最后输出多少个帧,如果空间不够会适当减少出的帧数;而对于AudioFileReadPackets来说则需要分配最大帧大小(或帧大小上界) * 帧数的内存空间才行(最大帧大小和帧大小上界的区别等下会说);这也就是为何第三个参数一个是输入输出双向使用的,而另一个只是输出时使用的原因。就这点来说两个方法中前者在使用的过程中要比后者更省内存;

返回值,同AudioFileReadBytes

这两个方法读取后的数据为帧分离后的数据,可以直接用来播放或者解码。

下面给出两个方法的使用代码(以MP3为例):

123456789101112131415161718192021
AudioFileID fileID; //Open方法返回的AudioFileIDUInt32 ioNumPackets = ...; //要读取多少个packetSInt64 inStartingPacket = ...; //从第几个Packet开始读取UInt32 bitRate = ...; //AudioFileGetProperty读取kAudioFilePropertyBitRateUInt32 sampleRate = ...; //AudioFileGetProperty读取kAudioFilePropertyDataFormat或kAudioFilePropertyFormatListUInt32 byteCountPerPacket = 144 * bitRate / sampleRate; //MP3数据每个Packet的近似大小UInt32 descSize = sizeof(AudioStreamPacketDescription) * ioNumPackets;AudioStreamPacketDescription * outPacketDescriptions = (AudioStreamPacketDescription *)malloc(descSize);UInt32 ioNumBytes = byteCountPerPacket * ioNumPackets;void * outBuffer = (void *)malloc(ioNumBytes);OSStatus status = AudioFileReadPacketData(fileID,                                          false,                                          &ioNumBytes,                                          outPacketDescriptions,                                          inStartingPacket,                                          &ioNumPackets,                                          outBuffer);
12345678910111213141516171819202122
AudioFileID fileID; //Open方法返回的AudioFileIDUInt32 ioNumPackets = ...; //要读取多少个packetSInt64 inStartingPacket = ...; //从第几个Packet开始读取UInt32 maxByteCountPerPacket = ...; //AudioFileGetProperty读取kAudioFilePropertyMaximumPacketSize,最大的packet大小//也可以用://UInt32 byteCountUpperBoundPerPacket = ...; //AudioFileGetProperty读取kAudioFilePropertyPacketSizeUpperBound,当前packet大小上界(未扫描全文件的情况下)UInt32 descSize = sizeof(AudioStreamPacketDescription) * ioNumPackets;AudioStreamPacketDescription * outPacketDescriptions = (AudioStreamPacketDescription *)malloc(descSize);UInt32 outNumBytes = 0UInt32 ioNumBytes = maxByteCountPerPacket * ioNumPackets;void * outBuffer = (void *)malloc(ioNumBytes);OSStatus status = AudioFileReadPackets(fileID,                                       false,                                       &outNumBytes,                                       outPacketDescriptions,                                       inStartingPacket,                                       &ioNumPackets,                                       outBuffer);

Seek

seek的思路和之前讲AudioFileStream时讲到的是一样的,区别在于AudioFile没有方法来帮助修正seek的offset和seek的时间:

  • 使用AudioFileReadBytes时需要计算出approximateSeekOffset
  • 使用AudioFileReadPacketData或者AudioFileReadPackets时需要计算出seekToPacket

approximateSeekOffset和seekToPacket的计算方法参见第三篇。


关闭AudioFile

AudioFile使用完毕后需要调用AudioFileClose进行关闭,没啥特别需要注意的。

1
extern OSStatus AudioFileClose (AudioFileID inAudioFile);  

小结

本篇针对AudioFile的音频读取功能做了介绍,小结一下:

  • AudioFile有两个Open方法,需要针对自身的使用场景选择不同的方法;

  • AudioFileOpenURL用来读取本地文件

  • AudioFileOpenWithCallbacks的使用场景比前者要广泛,使用时需要注意AudioFile_ReadProc,这个回调方法在Open方法本身和Read方法被调用时会被同步调用

  • 必须保证音频文件格式信息可读时才能使用AudioFile的Open方法,AudioFile并不能独立用于音频流的读取,需要配合AudioStreamFile使用才能读取流(需要用AudioStreamFile来判断文件格式信息可读之后再调用Open方法);

  • 使用AudioFileGetProperty读取格式信息时需要判断所读取的信息是否需要先调用AudioFileGetPropertyInfo获得数据大小后再进行读取;

  • 读取音频数据应该根据使用的场景选择不同的音频读取方法,对于不同的读取方法seek时需要计算的变量也不相同;

  • AudioFile使用完毕后需要调用AudioFileClose进行关闭;


示例代码

对于本地文件用AudioFile读取比较简单就不在这里提供demo了,

简单的AudioFile封装。

对于流播放中的AudioFile使用推荐大家阅读豆瓣的开源播放器代码DOUAudioStreamer。

iOS音频播放 (五):AudioQueue

Audio Playback in iOS (Part 5) : AudioQueue


在第三篇和第四篇中介绍了如何用AudioStreamFileAudioFile解析音频数据格式、分离音频帧。下一步终于可以使用分离出来的音频帧进行播放了,本片中将来讲一讲如何使用AudioQueue播放音频数据。


AudioQueue介绍

AudioQueueAudioToolBox.framework中的一员,在官方文档中Apple这样描述AudioQueue的:

Audio Queue Services provides a straightforward, low overhead way to record and play audio in iOS and Mac OS X. It is the recommended technology to use for adding basic recording or playback features to your iOS or Mac OS X application.

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

对于支持的数据格式,Apple这样说:

Audio Queue Services lets you record and play audio in any of the following formats:* Linear PCM.* Any compressed format supported natively on the Apple platform you are developing for.* Any other format for which a user has an installed codec.

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


AudioQueue的工作模式

在使用AudioQueue之前首先必须理解其工作模式,它之所以这么命名是因为在其内部有一套缓冲队列(Buffer Queue)的机制。在AudioQueue启动之后需要通过AudioQueueAllocateBuffer生成若干个AudioQueueBufferRef结构,这些Buffer将用来存储即将要播放的音频数据,并且这些Buffer是受生成他们的AudioQueue实例管理的,内存空间也已经被分配(按照Allocate方法的参数),当AudioQueue被Dispose时这些Buffer也会随之被销毁。

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

官方文档给出了一副图来描述这一过程:

其中的callback按我的理解应该是指一个音频数据装填方法,该方法可以通过之前提到的数据使用后的回调来触发。

AudioQueue playbackAudioQueue playback

根据Apple提供的AudioQueue工作原理结合自己理解,可以得到其工作流程大致如下:

  1. 创建AudioQueue,创建一个自己的buffer数组BufferArray;
  2. 使用AudioQueueAllocateBuffer创建若干个AudioQueueBufferRef(一般2-3个即可),放入BufferArray;
  3. 有数据时从BufferArray取出一个buffer,memcpy数据后用AudioQueueEnqueueBuffer方法把buffer插入AudioQueue中;
  4. AudioQueue中存在Buffer后,调用AudioQueueStart播放。(具体等到填入多少buffer后再播放可以自己控制,只要能保证播放不间断即可);
  5. AudioQueue播放音乐后消耗了某个buffer,在另一个线程回调并送出该buffer,把buffer放回BufferArray供下一次使用;
  6. 返回步骤3继续循环直到播放结束

从以上步骤其实不难看出,AudioQueue播放的过程其实就是一个典型的生产者消费者问题。生产者是AudioFileStream或者AudioFile,它们生产处音频数据帧,放入到AudioQueue的buffer队列中,直到buffer填满后需要等待消费者消费;AudioQueue作为消费者,消费了buffer队列中的数据,并且在另一个线程回调通知数据已经被消费生产者可以继续生产。所以在实现AudioQueue播放音频的过程中必然会接触到一些多线程同步、信号量的使用、死锁的避免等等问题。

了解了工作流程之后再回头来看AudioQueue的方法,其中大部分方法都非常好理解,部分需要稍加解释。


创建AudioQueue

使用下列方法来生成AudioQueue的实例

12345678910111213
OSStatus AudioQueueNewOutput (const AudioStreamBasicDescription * inFormat,                              AudioQueueOutputCallback inCallbackProc,                              void * inUserData,                              CFRunLoopRef inCallbackRunLoop,                              CFStringRef inCallbackRunLoopMode,                              UInt32 inFlags,                              AudioQueueRef * outAQ);                              OSStatus AudioQueueNewOutputWithDispatchQueue(AudioQueueRef * outAQ,                                              const AudioStreamBasicDescription * inFormat,                                              UInt32 inFlags,                                              dispatch_queue_t inCallbackDispatchQueue,                                              AudioQueueOutputCallbackBlock inCallbackBlock);

先来看第一个方法:

第一个参数表示需要播放的音频数据格式类型,是一个AudioStreamBasicDescription对象,是使用AudioFileStream或者AudioFile解析出来的数据格式信息;

第二个参数AudioQueueOutputCallback是某块Buffer被使用之后的回调;

第三个参数为上下文对象;

第四个参数inCallbackRunLoop为AudioQueueOutputCallback需要在的哪个RunLoop上被回调,如果传入NULL的话就会再AudioQueue的内部RunLoop中被回调,所以一般传NULL就可以了;

第五个参数inCallbackRunLoopMode为RunLoop模式,如果传入NULL就相当于kCFRunLoopCommonModes,也传NULL就可以了;

第六个参数inFlags是保留字段,目前没作用,传0;

第七个参数,返回生成的AudioQueue实例;

返回值用来判断是否成功创建(OSStatus == noErr)。

第二个方法就是把RunLoop替换成了一个dispatch queue,其余参数同相同。


Buffer相关的方法

1. 创建Buffer

12345678
OSStatus AudioQueueAllocateBuffer(AudioQueueRef inAQ,                                  UInt32 inBufferByteSize,                                  AudioQueueBufferRef * outBuffer);                                  OSStatus AudioQueueAllocateBufferWithPacketDescriptions(AudioQueueRef inAQ,                                                        UInt32 inBufferByteSize,                                                        UInt32 inNumberPacketDescriptions,                                                        AudioQueueBufferRef * outBuffer);

第一个方法传入AudioQueue实例和Buffer大小,传出的Buffer实例;

第二个方法可以指定生成的Buffer中PacketDescriptions的个数;

2. 销毁Buffer

1
OSStatus AudioQueueFreeBuffer(AudioQueueRef inAQ,AudioQueueBufferRef inBuffer);

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

3. 插入Buffer

1234
OSStatus AudioQueueEnqueueBuffer(AudioQueueRef inAQ,                                 AudioQueueBufferRef inBuffer,                                 UInt32 inNumPacketDescs,                                 const AudioStreamPacketDescription * inPacketDescs);

Enqueue方法一共有两个,上面给出的是第一个方法,第二个方法AudioQueueEnqueueBufferWithParameters可以对Enqueue的buffer进行更多额外的操作,第二个方法我也没有细细研究,一般来说用第一个方法就能满足需求了,这里我也就只针对第一个方法进行说明:

这个Enqueue方法需要传入AudioQueue实例和需要Enqueue的Buffer,对于有inNumPacketDescs和inPacketDescs则需要根据需要选择传入,文档上说这两个参数主要是在播放VBR数据时使用,但之前我们提到过即便是CBR数据AudioFileStream或者AudioFile也会给出PacketDescription所以不能如此一概而论。简单的来说就是有就传PacketDescription没有就给NULL,不必管是不是VBR。


播放控制

1.开始播放

1
OSStatus AudioQueueStart(AudioQueueRef inAQ,const AudioTimeStamp * inStartTime);

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

2.解码数据

123
OSStatus AudioQueuePrime(AudioQueueRef inAQ,                          UInt32 inNumberOfFramesToPrepare,                          UInt32 * outNumberOfFramesPrepared);                                    

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

3.暂停播放

1
OSStatus AudioQueuePause(AudioQueueRef inAQ);

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

4.停止播放

1
OSStatus AudioQueueStop(AudioQueueRef inAQ, Boolean inImmediate);

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

5.Flush

12
OSStatusAudioQueueFlush(AudioQueueRef inAQ);

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

6.重置

1
OSStatus AudioQueueReset(AudioQueueRef inAQ);

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

7.获取播放时间

1234
OSStatus AudioQueueGetCurrentTime(AudioQueueRef inAQ,                                  AudioQueueTimelineRef inTimeline,                                  AudioTimeStamp * outTimeStamp,                                  Boolean * outTimelineDiscontinuity);

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

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

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

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

12345
AudioTimeStamp time = ...; //AudioQueueGetCurrentTime方法获取NSTimeInterval playedTime = time.mSampleTime / _format.mSampleRate; //seek时的播放时间NSTimeInterval seekTime = ...; //需要seek到哪个时间NSTimeInterval timingOffset = seekTime - playedTime;

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

1
NSTimeInterval progress = timingOffset + playedTime;

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


销毁AudioQueue

1
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时才能被调用。


属性和参数

和其他的AudioToolBox类一样,AudioToolBox有很多参数和属性可以设置、获取、监听。以下是相关的方法,这里就不再一一赘述:

123456789101112
//参数相关方法AudioQueueGetParameterAudioQueueSetParameter//属性相关方法AudioQueueGetPropertySizeAudioQueueGetPropertyAudioQueueSetProperty//监听属性变化相关方法AudioQueueAddPropertyListenerAudioQueueRemovePropertyListener

属性和参数的列表:

12345678910111213141516171819202122232425262728293031323334
//属性列表enum { // typedef UInt32 AudioQueuePropertyID    kAudioQueueProperty_IsRunning               = 'aqrn',       // value is UInt32    kAudioQueueDeviceProperty_SampleRate        = 'aqsr',       // value is Float64    kAudioQueueDeviceProperty_NumberChannels    = 'aqdc',       // value is UInt32    kAudioQueueProperty_CurrentDevice           = 'aqcd',       // value is CFStringRef    kAudioQueueProperty_MagicCookie             = 'aqmc',       // value is void*    kAudioQueueProperty_MaximumOutputPacketSize = 'xops',       // value is UInt32    kAudioQueueProperty_StreamDescription       = 'aqft',       // value is AudioStreamBasicDescription    kAudioQueueProperty_ChannelLayout           = 'aqcl',       // value is AudioChannelLayout    kAudioQueueProperty_EnableLevelMetering     = 'aqme',       // value is UInt32    kAudioQueueProperty_CurrentLevelMeter       = 'aqmv',       // value is array of AudioQueueLevelMeterState, 1 per channel    kAudioQueueProperty_CurrentLevelMeterDB     = 'aqmd',       // value is array of AudioQueueLevelMeterState, 1 per channel    kAudioQueueProperty_DecodeBufferSizeFrames  = 'dcbf',       // value is UInt32    kAudioQueueProperty_ConverterError          = 'qcve',       // value is UInt32    kAudioQueueProperty_EnableTimePitch         = 'q_tp',       // value is UInt32, 0/1    kAudioQueueProperty_TimePitchAlgorithm      = 'qtpa',       // value is UInt32. See values below.    kAudioQueueProperty_TimePitchBypass         = 'qtpb',       // value is UInt32, 1=bypassed};//参数列表enum    // typedef UInt32 AudioQueueParameterID;{    kAudioQueueParam_Volume         = 1,    kAudioQueueParam_PlayRate       = 2,    kAudioQueueParam_Pitch          = 3,    kAudioQueueParam_VolumeRampTime = 4,    kAudioQueueParam_Pan            = 13};

其中比较有价值的属性有:

  • kAudioQueueProperty_IsRunning监听它可以知道当前AudioQueue是否在运行,这个参数的作用在讲到AudioQueueDispose时已经提到过。
  • kAudioQueueProperty_MagicCookie部分音频格式需要设置magicCookie,这个cookie可以从AudioFileStreamAudioFile中获取。

比较有价值的参数有:

  • kAudioQueueParam_Volume,它可以用来调节AudioQueue的播放音量,注意这个音量是AudioQueue的内部播放音量和系统音量相互独立设置并且最后叠加生效。
  • kAudioQueueParam_VolumeRampTime参数和Volume参数配合使用可以实现音频播放淡入淡出的效果;
  • kAudioQueueParam_PlayRate参数可以调整播放速率;

后记

至此本片关于AudioQueue的话题接结束了。使用上面提到的方法已经可以满足大部分的播放需求,但AudioQueue的功能远不止如此,AudioQueueTimelineOffline RenderingAudioQueueProcessingTap等功能我目前也尚未涉及和研究,未来也许还会有更多新的功能加入,学无止境啊。

另外由于AudioQueue的相关内容无法单独做Demo进行展示,于是我提前把后一篇内容的Demo(一个简单的本地音频播放器)先在这里给出方便大家理解AudioQueue。如果觉得上面提到某一部分的很难以的话理解欢迎在下面留言或者在微博上和我交流,除此之外还可以阅读官方文档(我一直觉得官方文档是学习的最好途径);

iOS音频播放 (六):简单的音频播放器实现

Audio Playback in iOS (Part 6) : Create a Simple Audio Player


在前几篇中我分别讲到了AudioSessionAudioFileStreamAudioFileAudioQueue,这些类的功能已经涵盖了第一篇中所提到的音频播放所需要的步骤:

  1. 读取MP3文件 NSFileHandle
  2. 解析采样率、码率、时长等信息,分离MP3中的音频帧 AudioFileStream/AudioFile
  3. 对分离出来的音频帧解码得到PCM数据 AudioQueue
  4. 对PCM数据进行音效处理(均衡器、混响器等,非必须) 省略
  5. 把PCM数据解码成音频信号 AudioQueue
  6. 把音频信号交给硬件播放 AudioQueue
  7. 重复1-6步直到播放完成

下面我们就讲讲述如何用这些部件组成一个简单的本地音乐播放器,这里我会用到AudioSessionAudioFileStreamAudioFileAudioQueue

注意:在阅读本篇请实现阅读并理解前面1-5篇的内容以及2-5篇最后给出的封装类,本篇中的播放器实现将基于前面几篇中给出的MCAudioSession、MCAudioFileStream、MCAudioFile和MCAudioOutputQueue进行实现。


AudioFileStream vs AudioFile

解释一下为什么我要同时使用AudioFileStreamAudioFile

第一,对于网络流播必须有AudioFileStream的支持,这是因为我们在第四篇中提到过AudioFile在Open时会要求使用者提供数据,如果提供的数据不足会直接跳过并且返回错误码,而数据不足的情况在网络流中很常见,故无法使用AudioFile单独进行网络流数据的解析;

第二,对于本地音乐播放选用AudioFile更为合适,原因如下:

  1. AudioFileStream的主要是用在流播放中虽然不限于网络流和本地流,但流数据是按顺序提供的所以AudioFileStream也是顺序解析的,被解析的音频文件还是需要符合流播放的特性,对于不符合的本地文件AudioFileStream会在Parse时返回NotOptimized错误;
  2. AudioFile的解析过程并不是顺序的,它会在解析时通过回调向使用者索要某个位置的数据,即使数据在文件末尾也不要紧,所以AudioFile适用于所有类型的音频文件;

基于以上两点我们可以得出这样一个结论:一款完整功能的播放器应当同时使用AudioFileStream和AudioFile,用AudioFileStream来应对可以进行流播放的音频数据,以达到边播放边缓冲的最佳体验,用AudioFile来处理无法流播放的音频数据,让用户在下载完成之后仍然能够进行播放。

本来这个Demo应该做成基于网络流的音频播放,但由于最近比较忙一直过着公司和床两点一线的生活,来不及写网络流和文件缓存的模块,所以就用本地文件代替了,所以最终在Demo会先尝试用AudioFileStream解析数据,如果失败再尝试使用AudioFile以达到模拟网络流播放的效果。


准备工作

第一件事当然是要创建一个新工程,这里我选择了的模板是SingleView,工程名我把它命名为MCSimpleAudioPlayerDemo

创建完工程之后去到Target属性的Capabilities选项卡设置Background Modes,把Audio and Airplay勾选,这样我们的App就可以在进入后台之后继续播放音乐了:

接下来我们需要搭建一个简单的UI,在storyboard上创建两个UIButton和一个UISlider,Button用来做播放器的播放、暂停、停止等功能控制,Slider用来显示播放进度和seek。把这些UI组件和ViewController的属性/方法关联上之后简单的UI也就完成了。


接口定义

下面来创建播放器类MCSimpleAudioPlayer,首先是初始化方法(感谢@喵神的VVDocumenter):

123456789
/** *  初始化方法 * *  @param filePath 文件绝对路径 *  @param fileType 文件类型,作为后续创建AudioFileStream和AudioQueue的Hint使用 * *  @return player对象 */- (instancetype)initWithFilePath:(NSString *)filePath fileType:(AudioFileTypeID)fileType;

另外播放器作为一个典型的状态机,各种状态也是必不可少的,这里我只简单的定义了四种状态:

1234567
typedef NS_ENUM(NSUInteger, MCSAPStatus){    MCSAPStatusStopped = 0,    MCSAPStatusPlaying = 1,    MCSAPStatusWaiting = 2,    MCSAPStatusPaused = 3,};

再加上一些必不可少的属性和方法组成了MCSimpleAudioPlayer.h

123456789101112131415161718
@interface MCSimpleAudioPlayer : NSObject@property (nonatomic,copy,readonly) NSString *filePath;@property (nonatomic,assign,readonly) AudioFileTypeID fileType;@property (nonatomic,readonly) MCSAPStatus status;@property (nonatomic,readonly) BOOL isPlayingOrWaiting;@property (nonatomic,assign,readonly) BOOL failed;@property (nonatomic,assign) NSTimeInterval progress;@property (nonatomic,readonly) NSTimeInterval duration;- (instancetype)initWithFilePath:(NSString *)filePath fileType:(AudioFileTypeID)fileType;- (void)play;- (void)pause;- (void)stop;@end

初始化

在init方法中创建一个NSFileHandle的实例以用来读取数据并交给AudioFileStream解析,另外也可以根据生成的实例是否是nil来判断是否能够读取文件,如果返回的是nil就说明文件不存在或者没有权限那么播放也就无从谈起了。

1
_fileHandler = [NSFileHandle fileHandleForReadingAtPath:_filePath];

通过NSFileManager获取文件大小

1
_fileSize = [[[NSFileManager defaultManager] attributesOfItemAtPath:_filePath error:nil] fileSize];

初始化方法到这里就结束了,作为一个播放器我们自然不能在主线程进行播放,我们需要创建自己的播放线程。

创建一个成员变量_started来表示播放流程是否已经开始,在-play方法中如果_started为NO就创建线程_thread并以-threadMain方法作为main,否则说明线程已经创建并且在播放流程中:

12345678910111213
- (void)play{    if (!_started)    {        _started = YES;        _thread = [[NSThread alloc] initWithTarget:self selector:@selector(threadMain) object:nil];        [_thread start];    }    else    {        //如果是Pause状态就resume    }}

接下来就可以在-threadMain进行音频播放相关的操作了。


创建AudioSession

iOS音频播放的第一步,自然是要创建AudioSession,这里引入第二篇末尾给出的AudioSession封装MCAudioSession,当然各位也可以使用AVAudioSession

初始化的工作会在调用单例方法时进行,下一步是设置Category。

12
//初始化并且设置Category[[MCAudioSession sharedInstance] setCategory:kAudioSessionCategory_MediaPlayback error:NULL];

成功之后启用AudioSession,还有别忘了监听Interrupt通知。

123456789
if ([[MCAudioSession sharedInstance] setCategory:kAudioSessionCategory_MediaPlayback error:NULL]){    //active audiosession    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(interruptHandler:) name:MCAudioSessionInterruptionNotification object:nil];    if ([[MCAudioSession sharedInstance] setActive:YES error:NULL])    {        //go on    }}

读取、解析音频数据

成功创建并启用AudioSession之后就可以进入播放流程了,播放是一个无限循环的过程,所以我们需要一个while循环,在文件没有被播放完成之前需要反复的读取、解析、播放。那么第一步是需要读取并解析数据。按照之前说的我们会先使用AudioFileStream,引入第三篇末尾给出的AudioFileStream封装MCAudioFileStream。

创建AudioFileStream,MCAudioFileStream的init方法会完成这项工作,如果创建成功就设置delegate作为Parse数据的回调。

12345
_audioFileStream = [[MCAudioFileStream alloc] initWithFileType:_fileType fileSize:_fileSize error:&error];if (!error){    _audioFileStream.delegate = self;}

接下来要读取数据并且解析,用成员变量_offset表示_fileHandler已经读取文件位置,其主要作用是来判断Eof。调用MCAudioFileStream-parseData:error:方法来对数据进行解析。

1234567891011
NSData *data = [_fileHandler readDataOfLength:1000];_offset += [data length];if (_offset >= _fileSize){    isEof = YES;}[_audioFileStream parseData:data error:&error];if (error){    //解析失败,换用AudioFile}

解析完文件头之后MCAudioFileStreamreadyToProducePackets属性会被置为YES,此后所有的Parse方法都回触发-audioFileStream:audioDataParsed:方法并传递MCParsedAudioData的数组来保存解析完成的数据。这样就需要一个buffer来存储这些解析完成的音频数据。

于是创建了MCAudioBuffer类来管理所有解析完成的数据:

12345678910111213141516
@interface MCAudioBuffer : NSObject+ (instancetype)buffer;- (void)enqueueData:(MCParsedAudioData *)data;- (void)enqueueFromDataArray:(NSArray *)dataArray;- (BOOL)hasData;- (UInt32)bufferedSize;- (NSData *)dequeueDataWithSize:(UInt32)requestSize                    packetCount:(UInt32 *)packetCount                   descriptions:(AudioStreamPacketDescription **)descriptions;- (void)clean;@end

创建一个MCAudioBuffer的实例_buffer,解析完成的数据都会通过enqueue方法存储到_buffer中,在需要的使用可以通过dequeue取出来使用。

1234567
_buffer = [MCAudioBuffer buffer]; //初始化方法中创建//AudioFileStream解析完成的数据都被存储到了_buffer中- (void)audioFileStream:(MCAudioFileStream *)audioFileStream audioDataParsed:(NSArray *)audioData{    [_buffer enqueueFromDataArray:audioData];}

如果遇到AudioFileStream解析失败的话,转而使用AudioFile,引入第四篇末尾给出的AudioFile封装MCAudioFile(之前没有给出,最近补上的)。

1234567
_audioFileStream parseData:data error:&error];if (error){    //解析失败,换用AudioFile    _usingAudioFile = YES;    continue;}
12345678910111213
if (_usingAudioFile){    if (!_audioFile)    {        _audioFile = [[MCAudioFile alloc] initWithFilePath:_filePath fileType:_fileType];    }    if ([_buffer bufferedSize] < _bufferSize || !_audioQueue)    {        //AudioFile解析完成的数据都被存储到了_buffer中        NSArray *parsedData = [_audioFile parseData:&isEof];        [_buffer enqueueFromDataArray:parsedData];    }}

使用AudioFile时同样需要NSFileHandle来读取文件数据,但由于其回获取数据的特性我把FileHandle的相关操作都封装进去了,所以使用MCAudioFile解析数据时直接调用Parse方法即可。


播放

有了解析完成的数据,接下来就该AudioQueue出场了,引入第五篇末尾提到的AudioQueue的封装MCAudioOutputQueue。

首先创建AudioQueue,由于AudioQueue需要实现创建重用buffer所以需要事先确定bufferSize,这里我设置的bufferSize是近似0.1秒的数据量,计算bufferSize需要用到的duration和audioDataByteCount可以从MCAudioFileStream或者MCAudioFile中获取。有了bufferSize之后,加上数据格式format参数和magicCookie(部分音频格式需要)就可以生成AudioQueue了。

12345678910111213141516171819202122232425262728
- (BOOL)createAudioQueue{    if (_audioQueue)    {        return YES;    }    NSTimeInterval duration = _usingAudioFile ? _audioFile.duration : _audioFileStream.duration;    UInt64 audioDataByteCount = _usingAudioFile ? _audioFile.audioDataByteCount : _audioFileStream.audioDataByteCount;    _bufferSize = 0;    if (duration != 0)    {        _bufferSize = (0.1 / duration) * audioDataByteCount;    }    if (_bufferSize > 0)    {        AudioStreamBasicDescription format = _usingAudioFile ? _audioFile.format : _audioFileStream.format;        NSData *magicCookie = _usingAudioFile ? [_audioFile fetchMagicCookie] : [_audioFileStream fetchMagicCookie];        _audioQueue = [[MCAudioOutputQueue alloc] initWithFormat:format bufferSize:_bufferSize macgicCookie:magicCookie];        if (!_audioQueue.available)        {            _audioQueue = nil;            return NO;        }    }    return YES;}

接下来从_buffer中读出解析完成的数据,交给AudioQueue播放。如果全部播放完毕了就调用一下-flush让AudioQueue把剩余数据播放完毕。这里需要注意的是MCAudioOutputQueue-playData方法在调用时如果没有可以重用的buffer的话会阻塞当前线程直到AudioQueue回调方法送出可重用的buffer为止。

1234567891011121314
UInt32 packetCount;AudioStreamPacketDescription *desces = NULL;NSData *data = [_buffer dequeueDataWithSize:_bufferSize packetCount:&packetCount descriptions:&desces];if (packetCount != 0){    [_audioQueue playData:data packetCount:packetCount packetDescriptions:desces isEof:isEof];    free(desces);    if (![_buffer hasData] && isEof)    {        [_audioQueue flush];        break;    }}

暂停 & 恢复

暂停方法很简单,调用MCAudioOutputQueue-pause方法就可以了,但要注意的是需要和-playData:同步调用,否则可能引起一些问题(比如触发了pause实际由于并发操作没有真正pause住)。

同步的方法可以采用加锁的方式,也可以通过标志位在threadMain中进行Pause,Demo中我使用了后者。

123456789101112131415161718192021222324252627
//pause方法- (void)pause{    if (self.isPlayingOrWaiting)    {        _pauseRequired = YES;    }}//threadMain中- (void)threadMain{    ...      //pause    if (_pauseRequired)    {        [self setStatusInternal:MCSAPStatusPaused];        [_audioQueue pause];        [self _mutexWait];        _pauseRequired = NO;    }      //play    ...}

在暂停后还要记得阻塞线程。

恢复只要调用AudioQueue start方法就可以了,同时记得signal让线程继续跑

123456
- (void)_resume{    //AudioQueue的start方法被封装到了MCAudioOutputQueue的resume方法中    [_audioQueue resume];    [self _mutexSignal];}

播放进度 & Seek

对于播放进度我在第五篇讲AudioQueue时已经提到过了,使用AudioQueueGetCurrentTime方法可以获取实际播放的时间如果Seek之后需要根据计算timingOffset,然后根据timeOffset来计算最终的播放进度:

1234
- (NSTimeInterval)progress{    return _timingOffset + _audioQueue.playedTime;}

timingOffset的计算在Seek进行,Seek操作和暂停操作一样需要和其他AudioQueue的操作同步进行,否则可能造成一些并发问题。

123456
//seek方法- (void)setProgress:(NSTimeInterval)progress{    _seekRequired = YES;    _seekTime = progress;}

在seek时为了防止播放进度跳动,修改一下获取播放进度的方法:

12345678
- (NSTimeInterval)progress{    if (_seekRequired)    {        return _seekTime;    }    return _timingOffset + _audioQueue.playedTime;}

下面是threadMain中的Seek操作

123456789101112131415161718
if (_seekRequired){    [self setStatusInternal:MCSAPStatusWaiting];    _timingOffset = _seekTime - _audioQueue.playedTime;    [_buffer clean];    if (_usingAudioFile)    {        [_audioFile seekToTime:_seekTime];    }    else    {        _offset = [_audioFileStream seekToTime:&_seekTime];        [_fileHandler seekToFileOffset:_offset];    }    _seekRequired = NO;    [_audioQueue reset];}

Seek时需要做如下事情:

  1. 计算timingOffset
  2. 清除之前残余在_buffer中的数据
  3. 挪动NSFileHandle的游标
  4. 清除AudioQueue中已经Enqueue的数据
  5. 如果有用到音效器的还需要清除音效器里的残余数据

打断

在接到Interrupt通知时需要处理打断,下面是打断的处理方法:

1234567891011121314151617181920212223242526
- (void)interruptHandler:(NSNotification *)notification{    UInt32 interruptionState = [notification.userInfo[MCAudioSessionInterruptionStateKey] unsignedIntValue];    if (interruptionState == kAudioSessionBeginInterruption)    {        _pausedByInterrupt = YES;        [_audioQueue pause];        [self setStatusInternal:MCSAPStatusPaused];    }    else if (interruptionState == kAudioSessionEndInterruption)    {        AudioSessionInterruptionType interruptionType = [notification.userInfo[MCAudioSessionInterruptionTypeKey] unsignedIntValue];        if (interruptionType == kAudioSessionInterruptionType_ShouldResume)        {            if (self.status == MCSAPStatusPaused && _pausedByInterrupt)            {                if ([[MCAudioSession sharedInstance] setActive:YES error:NULL])                {                    [self play];                }            }        }    }}

这里需要注意,打断操作我放在了主线程进行而并非放到新开的线程中进行,原因如下:

  • 一旦打断开始AudioSession被抢占后音频立即被打断,此时AudioQueue的所有操作会暂停,这就意味着不会有任何数据消耗回调产生;

  • 我这个Demo的线程模型中在向AudioQueue Enqueue了足够多的数据之后会阻塞当前线程等待数据消耗的回调才会signal让线程继续跑;

于是就得到了这样的结论:一旦打断开始我创建的线程就会被阻塞,所以我需要在主线程来处理暂停和恢复播放。


停止 & 清理

停止操作也和其他操作一样会放到threadMain中执行

123456789101112131415
- (void)stop{    _stopRequired = YES;    [self _mutexSignal];}//treadMain中if (_stopRequired){    _stopRequired = NO;    _started = NO;    [_audioQueue stop:YES];    break;}

在播放被停止或者出错时会进入到清理流程,这里需要做一大堆操作,清理各种数据,关闭AudioSession,清除各种标记等等。

123456789101112131415161718192021222324252627282930313233343536
- (void)cleanup{    //reset file    _offset = 0;    [_fileHandler seekToFileOffset:0];    //deactive audiosession    [[MCAudioSession sharedInstance] setActive:NO error:NULL];    [[NSNotificationCenter defaultCenter] removeObserver:self name:MCAudioSessionInterruptionNotification object:nil];    //clean buffer    [_buffer clean];    _usingAudioFile = NO;    //close audioFileStream    [_audioFileStream close];    //close audiofile    [_audioFile close];    //stop audioQueue    [_audioQueue stop:YES];    //destory mutex & cond    [self _mutexDestory];    _started = NO;    _timingOffset = 0;    _seekTime = 0;    _seekRequired = NO;    _pauseRequired = NO;    _stopRequired = NO;    //reset status    [self setStatusInternal:MCSAPStatusStopped];}

连接播放器UI

播放器代码完成后就需要和UI连起来让播放器跑起来了。

在viewDidLoad时创建一个播放器:

12345678910111213
- (void)viewDidLoad{    [super viewDidLoad];    if (!_player)    {        NSString *path = [[NSBundle mainBundle] pathForResource:@"MP3Sample" ofType:@"mp3"];        _player = [[MCSimpleAudioPlayer alloc] initWithFilePath:path fileType:kAudioFileMP3Type];        [_player addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil];    }    [_player play];}

对播放器的status属性KVO用来操作播放和暂停按钮的状态以及播放进度timer的开启和暂停:

1234567891011121314151617181920212223242526
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context{    if (object == _player)    {        if ([keyPath isEqualToString:@"status"])        {            [self performSelectorOnMainThread:@selector(handleStatusChanged) withObject:nil waitUntilDone:NO];        }    }}- (void)handleStatusChanged{    if (_player.isPlayingOrWaiting)    {        [self.playOrPauseButton setTitle:@"Pause" forState:UIControlStateNormal];        [self startTimer];    }    else    {        [self.playOrPauseButton setTitle:@"Play" forState:UIControlStateNormal];        [self stopTimer];        [self progressMove:nil];    }}

播放进度交给timer来刷新:

123456789101112131415161718192021222324252627282930313233
- (void)startTimer{    if (!_timer)    {        _timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(progressMove:) userInfo:nil repeats:YES];        [_timer fire];    }}- (void)stopTimer{    if (_timer)    {        [_timer invalidate];        _timer = nil;    }}- (void)progressMove:(id)sender{    //在seek时不要刷新slider的thumb位置    if (!self.progressSlider.tracking)    {        if (_player.duration != 0)        {            self.progressSlider.value = _player.progress / _player.duration;        }        else        {            self.progressSlider.value = 0;        }    }}

监听slider的两个TouchUp时间来进行seek操作:

1234
- (IBAction)seek:(id)sender{    _player.progress = _player.duration * self.progressSlider.value;}

添加两个按钮的TouchUpInside事件进行播放控制:

12345678910111213141516
- (IBAction)playOrPause:(id)sender{    if (_player.isPlayingOrWaiting)    {        [_player pause];    }    else    {        [_player play];    }}- (IBAction)stop:(id)sender{    [_player stop];}

进阶的内容

关于简单播放器的构建就讲这么多,以下是一些音频播放相关的进阶内容,由于我自己也没有摸透它们所以暂时就不做详细介绍了以免误人子弟-_-,各位有兴趣可以研究一下,如果有疑问或者有新发现欢迎大家留言或者在微博上和我交流共同提高~

  1. AudioConverter可以实现音频数据的转换,在播放流程中它可以充当解码器的角色,可以把压缩的音频数据解码成为PCM数据;
  2. AudioUnit作为比AudioQueue更底层的音频播放类库,Apple赋予了它更强大的功能,除了一般的播放功能之外它还能使用iPhone自带的多种均衡器对音效进行调节;
  3. AUGraphAudioUnit提供音效处理功能(这个其实我一点也没接触过0_0)

iOS音频播放 (七):播放iPod Library中的歌曲

Audio Playback in iOS (Part 7) : Access iPod Library


由于最近工作量非常饱和,所以这第七篇来的有点晚(创建时间是9月7日。。说出来都是泪)。

现在市面上的音乐播放器都支持iPod Library歌曲(俗称iPod音乐或者本地音乐)的播放,用户对于iPod音乐播放的需求也一直十分强烈。这篇要讲的是如何来播放iPod Library的歌曲。


概述

根据官方文档描述Apple从iOS 3.0开始允许开发者访问用户的iPod library来获取用户放在其中的歌曲等多媒体内容。

为此Apple提供了多种方法来访问和播放iPod中的音乐,下面我们来分别列举一下这些方法。


访问MediaLibrary

官方文档访问iPod Library的方法有两种,分别是MediaPicker和MediaQuery。

MediaPicker

MediaPicker是一个高度封装的iPod Library访问方式,通过使用MPMediaPickerController类来访问iPod Library。这是一个UI控件,用户可以根据需要选择其中的音乐。这个类使用时非常方便,只需要生成一个“的实例,设置一下属性和delegate后present出来,接下来只要等待回调即可,在回调时需要手动dismiss picker。

123456789101112131415161718
MPMediaPickerController *picker = [[MPMediaPickerController alloc] initWithMediaTypes:MPMediaTypeAnyAudio];picker.prompt = @"请选择需要播放的歌曲";picker.showsCloudItems = NO;picker.allowsPickingMultipleItems = YES;picker.delegate = self;[self presentViewController:picker animated:YES completion:nil];- (void)mediaPickerDidCancel:(MPMediaPickerController *)mediaPicker{    [mediaPicker dismissViewControllerAnimated:YES completion:nil];}- (void)mediaPicker:(MPMediaPickerController *)mediaPicker didPickMediaItems:(MPMediaItemCollection *)mediaItemCollection{    [mediaPicker dismissViewControllerAnimated:YES completion:nil];    //do something}

上面的代码将会得到如下的效果:

通过MediaPicker最终可以得到MPMediaItemCollection,其中存放着所有在Picker中选中的歌曲,每一个歌曲使用一个MPMediaItem对象表示。对于MediaPicker的使用也可以参考官方文档。

MediaQuery

如果你觉得MeidaPicker的功能或者UI不能满足你的要求那么可以使用MediaQuery。MediaQuery可以直接访问iPod Library的DB,并根据需要获取数据。官方文档给出了MediaQuery的示意图。

MediaQuery功能十分强大,它可以根据一个或多个条件查询满足需要的MediaItem。

你可以使用MPMediaQuery的类方法来生成一些已经预置了条件的Query

1234567891011
// Base queries which can be used directly or as the basis for custom queries.// The groupingType for these queries is preset to the appropriate type for the query.+ (MPMediaQuery *)albumsQuery;+ (MPMediaQuery *)artistsQuery;+ (MPMediaQuery *)songsQuery;+ (MPMediaQuery *)playlistsQuery;+ (MPMediaQuery *)podcastsQuery;+ (MPMediaQuery *)audiobooksQuery;+ (MPMediaQuery *)compilationsQuery;+ (MPMediaQuery *)composersQuery;+ (MPMediaQuery *)genresQuery;

也可以自己生成MPMediaPredicate设置条件,并把它加到Query中,最后通过items和collections访问查询到的结果,例如:

1234567891011
MPMediaPropertyPredicate *artistNamePredicate =[MPMediaPropertyPredicate predicateWithValue:@"Happy the Clown"                                 forProperty:MPMediaItemPropertyArtist                              comparisonType:MPMediaPredicateComparisonEqualTo];MPMediaQuery *quert = [[MPMediaQuery alloc] init];[quert addFilterPredicate: artistNamePredicate];quert.groupingType = MPMediaGroupingArtist;NSArray *itemsFromArtistQuery = [quert items];NSArray *collectionsFromArtistQuery = [quert collections];

这一过程可以表示为(图来自官方文档):

这里对于MediaQuery的用法就不再继续展开,关于这块内容并没有什么晦涩难懂的地方需要解释,大家可以通过阅读官方文档来详细了解其用法。

MediaCollection

MPMediaCollection是MediaItem的合集,可以通过访问它的items属性来访问所有的MediaItem。

MPMediaPlaylist是一个特殊的MPMediaCollection代表用户创建的播放列表,它会比MediaCollection包含更多的信息,比如播放列表的名字等等。这些属性可以通过MPMediaEntity的方法访问(MPMediaCollection是MPMediaEntity的子类,MPMediaItem也是)。

1234567
// Returns the value for the given entity property.// MPMediaItem and MPMediaPlaylist have their own properties- (id)valueForProperty:(NSString *)property;// Executes a provided block with the fetched values for the given item properties, or nil if no value is available for a property.// In some cases, enumerating the values for multiple properties can be more efficient than fetching each individual property with -valueForProperty:.- (void)enumerateValuesForProperties:(NSSet *)properties usingBlock:(void (^)(NSString *property, id value, BOOL *stop))block NS_AVAILABLE_IOS(4_0);

MediaItem

通过MediaPicker和MediaQuery最终都会得到MPMediaItem,这个item中包含了许多信息。这些信息都可以通过MPMediaEntity的方法访问,其中参数非常多就不列举了具体可以参照MPMediaItem.h。


使用MPMusicPlayerController

拿到iPod Library中的歌曲后就可以开始播放了。播放的方式有很多种,先介绍一下MediaPlayer framework中的MPMusicPlayerController类。

通过MPMusicPlayerController的类方法可以生成两种播放器,生成方法如下:

12345
// Playing media items with the applicationMusicPlayer will restore the user's iPod state after the application quits.+ (MPMusicPlayerController *)applicationMusicPlayer;// Playing media items with the iPodMusicPlayer will replace the user's current iPod state.+ (MPMusicPlayerController *)iPodMusicPlayer;

这两个方法看似生成了一样的对象,但它们的行为却有很大不同。从Apple写的注释上我们可以很清楚的发现它们的区别。+applicationMusicPlayer不会继承来自iOS系统自带的iPod应用中的播放状态,同时也不会覆盖iPod的播放状态。而+iPodMusicPlayer完全继承iPod应用的播放状态(甚至是播放时间),对其实例的任何操作也会覆盖到iPod应用。对+iPodMusicPlayer方法command+点击后可以看到更详细的注释。

1234567
The iPod music player employs the iPod app on your behalf. On instantiation, it takes on the current iPod app state and controls that state as your app runs. Specifically, the shared state includes the following:Repeat mode (see Repeat Modes)Shuffle mode (see Shuffle ModesNow-playing item (see nowPlayingItem)Playback state (see playbackState)Other aspects of iPod state, such as the on-the-go playlist, are not shared. Music that is playing continues to play when your app moves to the background.

说白了,当在使用iPodMusicPlayerv其实并不是你的程序在播放音频,而是你的程序在操纵iPod应用播放音频,即使你的程序crash了或者被kill了,音乐也不会因此停止。

而对于+applicationMusicPlayer通过command+点击可以看到:

12
The application music player plays music locally within your app. It does not affect the iPod state.When your app moves to the background, the music player stops if it was playing.

从注释中可以知道这个方法返回的对象虽然不是调用iPod应用播放的也不会影响到iPod应用,但它有个很大的缺点:无法后台播放,即使你在active了audioSession并且在app的设置中设置了Background Audio同样不会奏效。

综上所述,一般在开发音乐软件时很少用到这两个接口来进行iPod Library的播放,大部分开发者都是用这个类中的volme来调整系统音量的(这个属性在SDK 7中也被deprecate掉了)。如果你想用到这个类进行播放的话,这里需要提个醒,给MPMusicPlayerController设置需要播放的音乐时要使用下面两个方法:

123
// Call -play to begin playback after setting an item queue source. Setting a query will implicitly use MPMediaGroupingTitle.- (void)setQueueWithQuery:(MPMediaQuery *)query;- (void)setQueueWithItemCollection:(MPMediaItemCollection *)itemCollection;

而不是这个属性:

123
// Returns the currently playing media item, or nil if none is playing.// Setting the nowPlayingItem to an item in the current queue will begin playback at that item.@property(nonatomic, copy) MPMediaItem *nowPlayingItem;

光看名字很容易被nowPlayingItem这个属性迷惑,它的意思其实是说在设置了MediaQuery或者MediaCollection之后再设置这个nowPlayingItem可以让播放器从这个item开始播放,前提是这个item需要在MediaQuery或者MediaCollection的.items集合内。


使用AVAudioPlayer和AVPlayer

除了使用MediaPlayer中的类还有很多其他方法来进行iPod播放,其中做的比较出色的是AVFoundation中的AVAudioPlayerAVPlayer

这两个类的都有通过NSURL生成实例的初始化方法:

123456
//AVAudioPlayer- (id)initWithContentsOfURL:(NSURL *)url error:(NSError **)outError;//AVPlayer+ (id)playerWithURL:(NSURL *)URL;- (id)initWithURL:(NSURL *)URL;

其中的NSURL正是来自于MPMediaItemMPMediaItemPropertyAssetURL属性。

1234
//A URL pointing to the media item,//from which an AVAsset object (or other URL-based AV Foundation object) can be created, with any options as desired. //Value is an NSURL object.MP_EXTERN NSString *const MPMediaItemPropertyAssetURL;

上面讲到MPMediaItem时已经提到了它是MPMediaEntity子类,可以通过-valueForProperty:方法访问其中的属性。通过传入MPMediaItemPropertyAssetURL就可以得到当前MediaItem对应的URL(ipod-library://xxxxx),生成Player进行播放。大致代码如下:

1234567891011121314151617
@interface MyClass : NSObject{  AVAudioPlayer *_player;  //AVPlayer *_player;}//设置AudioSession[[AVAudioSession sharedInstance] setActive:YES error:nil];[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:nil];//playNSError *error = nil;MPMediaItem *item = ...;NSURL *url = [item valueForProperty:MPMediaItemPropertyAssetURL];_player = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&error];//_player = [AVPlayer playerWithURL:url];[_player play];

注意:这里我需要更正一下,之前我在第二篇讲到AudioSession时写了这样一段话在使用AVAudioPlayer/AVPlayer时可以不用关心AudioSession的相关问题,Apple已经把AudioSession的处理过程封装了...。这段话不对,我把AVFoundation和Mediaplayer混淆了,在写的时候也没注意,应该是在使用MPMusicPlayerController播放时不需要关心AudioSession的问题。


读取和导出数据

前面说到使用MPMediaItemMPMediaItemPropertyAssetURL属性可以得到一个表示当前MediaItem的NSURL,有了这个NSURL我们使用AVFoundation中的类进行播放。播放只是最基本的需求,有了这个URL我们可以做更多更有趣的事情。

在AVFoundation中还有两个有趣的类:AVAssetReaderAVAssetExportSession。它们可以把iPod Library中的指定歌曲以指定的音频格式导出到内存中或者硬盘中,这个指定的格式包括PCM。这是一个激动人心的特性,有了PCM数据我们就可以做很多很多其他的事情了。

这部分如果要展开的话还会有相当多的内容,国外的先辈们早在2010年就已经发掘了这两个类的用法,详细参见这里和这里。这两篇讲的比较详细并且附有Sample(其中还涉及了一些Extended Audio File Services的内容),如果里面Sample无法下载可以从点击MediaLibraryExportThrowaway1.zip和VTM_AViPodReader.zip下载。

需要注意的是在使用AVAssetReader的过程中如果访问系统的相机或者照片可能会使AVAssetReader产生AVErrorOperationInterrupted错误,此时需要重新生成Reader后调用-startReading才可以继续读取数据。


小结

本篇介绍了一些与iPod Library相关的内容,小结一下:

  • Apple提供两种方法来访问iPod Library,它们分别是MPMediaPickerControllerMPMediaQuery

  • MPMediaPickerControllerMPMediaQuery最后输出给开发者的对象是MPMediaItemMPMediaItem的属性需要通过-valueForProperty:方法获取了;

  • MPMusicPlayerController可以用来播放MPMediaItem,但有很多局限性,使用时需要根据不同的使用场景来决定用哪个类方法生成实例;

  • AVAudioPlayerAVPlayer也可以用来播放MPMediaItem,这两个类的功能比较完善,推荐使用,在使用之前别忘记设置AudioSession;

  • MPMediaItem可以得到对应的URL,这个URL可以用来做很多事情,例如用AVAssetReaderAVAssetExportSession可以导出其中的数据;

iOS音频播放 (八):NowPlayingCenter和RemoteControl

Audio Playback in iOS (Part 8) : NowPlayingCenter & RemoteControl


距离上一篇博文发布已经有一个月多的时间了,在这其间我一直忙于筹办婚礼以至于这篇博文一直拖到了现在。

在之前一到六篇中我对iOS下的音频播放流程进行了阐述,在第七篇中介绍了如何播放iPod Lib中的歌曲,至此有关音频播放的话题就已经完结了,在这篇里我将会讲到的NowPlayingCenterRemoteControl这两个玩意本身和整个播放流程并没有什么关系,但它们可以让音频播放在iOS系统上获得更加好的用户体验。


NowPlayingCenter

NowPlayingCenter能够显示当前正在播放的歌曲信息,它可以控制的范围包括:

  • 锁频界面上所显示的歌曲播放信息和图片
  • iOS7之后控制中心上显示的歌曲播放信息
  • iOS7之前双击home键后出现的进程中向左滑动出现的歌曲播放信息
  • AppleTV,AirPlay中显示的播放信息
  • 车载系统中显示的播放信息

这些信息的显示都由MPNowPlayingInfoCenter类来控制,这个类的定义非常简单:

1234567891011
MP_EXTERN_CLASS_AVAILABLE(5_0) @interface MPNowPlayingInfoCenter : NSObject// Returns the default now playing info center.// The default center holds now playing info about the current application.+ (MPNowPlayingInfoCenter *)defaultCenter;// The current now playing info for the center.// Setting the info to nil will clear it.@property (copy) NSDictionary *nowPlayingInfo;@end

使用也同样简单,首先#import <MediaPlayer/MPNowPlayingInfoCenter.h>然后调用MPNowPlayingInfoCenter的单例方法获取实例,再把需要显示的信息组织成Dictionary并赋值给nowPlayingInfo属性就完成了。

nowPlayingInfo中一些常用属性被定义在<MediaPlayer/MPMediaItem.h>

123456789101112
MPMediaItemPropertyAlbumTitle              //NSStringMPMediaItemPropertyAlbumTrackCount         //NSNumber of NSUIntegerMPMediaItemPropertyAlbumTrackNumber        //NSNumber of NSUIntegerMPMediaItemPropertyArtist                  //NSStringMPMediaItemPropertyArtwork                 //MPMediaItemArtworkMPMediaItemPropertyComposer                //NSStringMPMediaItemPropertyDiscCount               //NSNumber of NSUIntegerMPMediaItemPropertyDiscNumber              //NSNumber of NSUIntegerMPMediaItemPropertyGenre                   //NSStringMPMediaItemPropertyPersistentID            //NSNumber of uint64_tMPMediaItemPropertyPlaybackDuration        //NSNumber of NSTimeIntervalMPMediaItemPropertyTitle                   //NSString

上面这些属性大多比较浅显易懂,基本上按照字面上的意思去理解就可以了,需要稍微解释以下的是MPMediaItemPropertyArtwork。这个属性表示的是锁屏界面或者AirPlay中显示的歌曲封面图,MPMediaItemArtwork类可以由UIImage类进行初始化。

1234567891011121314
MP_EXTERN_CLASS_AVAILABLE(3_0) @interface MPMediaItemArtwork : NSObject// Initializes an MPMediaItemArtwork instance with the given full-size image.// The crop rect of the image is assumed to be equal to the bounds of the // image as defined by the image's size in points, i.e. tightly cropped.- (instancetype)initWithImage:(UIImage *)image NS_DESIGNATED_INITIALIZER NS_AVAILABLE_IOS(5_0);// Returns the artwork image for an item at a given size (in points).- (UIImage *)imageWithSize:(CGSize)size;@property (nonatomic, readonly) CGRect bounds; // The bounds of the full size image (in points).@property (nonatomic, readonly) CGRect imageCropRect; // The actual content area of the artwork, in the bounds of the full size image (in points).@end

另外一些附加属性被定义在<MediaPlayer/MPNowPlayingInfoCenter.h>

123456789101112131415161718192021222324252627282930313233
// The elapsed time of the now playing item, in seconds.// Note the elapsed time will be automatically extrapolated from the previously // provided elapsed time and playback rate, so updating this property frequently// is not required (or recommended.)MP_EXTERN NSString *const MPNowPlayingInfoPropertyElapsedPlaybackTime NS_AVAILABLE_IOS(5_0); // NSNumber (double)// The playback rate of the now playing item, with 1.0 representing normal // playback. For example, 2.0 would represent playback at twice the normal rate.// If not specified, assumed to be 1.0.MP_EXTERN NSString *const MPNowPlayingInfoPropertyPlaybackRate NS_AVAILABLE_IOS(5_0); // NSNumber (double)// The "default" playback rate of the now playing item. You should set this// property if your app is playing a media item at a rate other than 1.0 in a// default playback state. e.g., if you are playing back content at a rate of// 2.0 and your playback state is not fast-forwarding, then the default// playback rate should also be 2.0. Conversely, if you are playing back content// at a normal rate (1.0) but the user is fast-forwarding your content at a rate// greater than 1.0, then the default playback rate should be set to 1.0.MP_EXTERN NSString *const MPNowPlayingInfoPropertyDefaultPlaybackRate NS_AVAILABLE_IOS(8_0); // NSNumber (double)// The index of the now playing item in the application's playback queue.// Note that the queue uses zero-based indexing, so the index of the first item // would be 0 if the item should be displayed as "item 1 of 10".MP_EXTERN NSString *const MPNowPlayingInfoPropertyPlaybackQueueIndex NS_AVAILABLE_IOS(5_0); // NSNumber (NSUInteger)// The total number of items in the application's playback queue.MP_EXTERN NSString *const MPNowPlayingInfoPropertyPlaybackQueueCount NS_AVAILABLE_IOS(5_0); // NSNumber (NSUInteger)// The chapter currently being played. Note that this is zero-based.MP_EXTERN NSString *const MPNowPlayingInfoPropertyChapterNumber NS_AVAILABLE_IOS(5_0); // NSNumber (NSUInteger)// The total number of chapters in the now playing item.MP_EXTERN NSString *const MPNowPlayingInfoPropertyChapterCount NS_AVAILABLE_IOS(5_0); // NSNumber (NSUInteger)

其中常用的是MPNowPlayingInfoPropertyElapsedPlaybackTimeMPNowPlayingInfoPropertyPlaybackRate

  • MPNowPlayingInfoPropertyElapsedPlaybackTime表示已经播放的时间,用这个属性可以让NowPlayingCenter显示播放进度;
  • MPNowPlayingInfoPropertyPlaybackRate表示播放速率。通常情况下播放速率为1.0,即真是时间的1秒对应播放时间中的1秒;

这里需要解释的是,NowPlayingCenter中的进度刷新并不是由app不停的更新nowPlayingInfo来做的,而是根据app传入的ElapsedPlaybackTimePlaybackRate进行自动刷新。例如传入ElapsedPlaybackTime=120s,PlaybackRate=1.0,那么NowPlayingCenter会显示2:00并且在接下来的时间中每一秒把进度加1秒并刷新显示。如果需要暂停进度,传入PlaybackRate=0.0即可。

所以每次播放暂停和继续都需要更新NowPlayingCenter并正确设置ElapsedPlaybackTimePlaybackRate否则NowPlayingCenter中的播放进度无法正常显示。

NowPlayingCenter的刷新时机

频繁的刷新NowPlayingCenter并不可取,特别是在有Artwork的情况下。所以需要在合适的时候进行刷新。

依照我自己的经验下面几个情况下刷新NowPlayingCenter比较合适:

  • 当前播放歌曲进度被拖动时
  • 当前播放的歌曲变化时
  • 播放暂停或者恢复时
  • 当前播放歌曲的信息发生变化时(例如Artwork,duration等)

在刷新时可以适当的通过判断app是否active来决定是否必须刷新以减少刷新次数。

MPMediaItemPropertyArtwork

这是一个非常有用的属性,我们可以利用歌曲的封面图来合成一些图片借此达到美化锁屏界面或者显示锁屏歌词。


RemoteControl

RemoteComtrol可以用来在不打开app的情况下控制app中的多媒体播放行为,涉及的内容主要包括:

  • 锁屏界面双击Home键后出现的播放操作区域
  • iOS7之后控制中心的播放操作区域
  • iOS7之前双击home键后出现的进程中向左滑动出现的播放操作区域
  • AppleTV,AirPlay中显示的播放操作区域
  • 耳机线控
  • 车载系统的设置

在何处处理RemoteComtrol

根据官方文档的描述:

If your app plays audio or video content, you might want it to respond to remote control events that originate from either transport controls or external accessories. (External accessories must conform to Apple-provided specifications.) iOS converts commands into UIEvent objects and delivers the events to an app. The app sends them to the first responder and, if the first responder doesn’t handle them, they travel up the responder chain.

RemoteComtrol事件产生时,iOS会以UIEvent的形式发送给app,app会首先转发到first responder,如果first responder不处理这个事件的话那么事件就会沿着responder chain继续转发。关于responder chain的相关内容可以查看这里。

从responder chain文档看来如果之前的所有responder全部不响应RemoteComtrol事件的话,最终事件会被转发给Application(如图)。所以我们知道作为responder chain的最末端,在UIApplication中实现RemoteComtrol的处理是最为合理的,而并非在UIWindow中或者AppDelegate中。

实现自己的UIApplication

首先新建一个UIApplication的子类

12345
#import <UIKit/UIKit.h>@interface MyApplication : UIApplication@end

然后找到工程中的main.m,可以看到代码如下:

123456
int main(int argc, char * argv[]){    @autoreleasepool {        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));    }}

在main中调用了UIApplicationMain方法

123
// If nil is specified for principalClassName, the value for NSPrincipalClass from the Info.plist is used. If there is no// NSPrincipalClass key specified, the UIApplication class is used. The delegate class will be instantiated using init.UIKIT_EXTERN int UIApplicationMain(int argc, char *argv[], NSString *principalClassName, NSString *delegateClassName);

我们需要做的就是给UIApplicationMain方法的第三个参数传入我们的application类名,如下:

12345678910
#import <UIKit/UIKit.h>#import "AppDelegate.h"#import "MyApplication.h"int main(int argc, char * argv[]){    @autoreleasepool {        return UIApplicationMain(argc, argv, NSStringFromClass([MyApplication class]), NSStringFromClass([AppDelegate class]));    }}

这样就成功实现了自己的UIApplication.

处理RemoteComtrol

了解了应该在何处处理RemoteComtrol事件之后,再来看下官方文档中描述的三个必要条件:

  • 接受者必须能够成为first responder
  • 必须显示地声明接收RemoteComtrol事件
  • 你的app必须是Now Playingapp

对于第一条就是要在自己的UIApplication中实现canBecomeFirstResponder方法:

12345678910
#import "MyApplication.h"@implementation MyApplication- (BOOL)canBecomeFirstResponder{    return YES;}@end

第二条是要求显示地调用[[UIApplication sharedApplication] beginReceivingRemoteControlEvents],调用的实际一般是在播放开始时;

第三条就是要求占据NowPlayingCenter,这个之前已经提到过了。

满足三个条件后可以在UIApplication中实现处理RemoteComtrol事件的方法,根据不同的事件实现不同的操作即可。

12345678910111213141516171819202122232425262728
#import "MyApplication.h"@implementation MyApplication- (BOOL)canBecomeFirstResponder{    return YES;}- (void)remoteControlReceivedWithEvent:(UIEvent *)event{    switch (event.subtype)    {        case UIEventSubtypeRemoteControlPlay:            //play            break;        case UIEventSubtypeRemoteControlPause:            //pause            break;        case UIEventSubtypeRemoteControlStop:            //stop            break;        default:            break;    }}@end

示例代码

git上有一个关于remotecontrol的小工程供大家参考ios-audio-remote-control


后记

到本篇为止iOS的音频播放话题基本上算是完结了。接下来我会在空余时间去研究一下iOS 8中新加入的AVAudioEngine,其功能涵盖播放、录音、混音、音效处理,看上去十分强大,从接口的定义上看像是对AudioUnit的高层封装,当研究有了一定的成果之后也会以博文的形式分享出来。



0 0
原创粉丝点击