IOS 音频播放

iOS音频播放 (一):概述


前言

从事音乐相关的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论坛)。

IOS 音频播放上图为pcm数据IOS 音频播放上图为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 音频播放


iOS音频播放概述

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

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

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

IOS 音频播放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 音频播放

iOS音频播放 (二):AudioSession

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

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

IOS 音频播放AudioSession

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类首先需要调用初始化方法:

1
2
3
4
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事件:

1
2
3
4
5
6
7
8
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就可以用来做“拔掉耳机就把歌曲暂停”)。

1
2
3
4
5
6
7
8
9
10
11
//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
};
1
2
3
4
5
6
7
8
9
10
11
12
//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、判断是否插了耳机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
+ (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(来自*):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
+ (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时调用下面的接口

1
2
3
extern OSStatus AudioSessionSetProperty(AudioSessionPropertyID inID,
UInt32 inDataSize,
const void *inData);

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

1
2
3
4
UInt32 sessionCategory = kAudioSessionCategory_MediaPlayback;
AudioSessionSetProperty (kAudioSessionProperty_AudioCategory,
sizeof(sessionCategory),
&sessionCategory);

使用AVAudioSession时调用下面的接口

1
2
3
4
/* 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。

1
2
3
4
5
6
7
8
9
//AudioSession的AudioSessionCategory枚举
enum {
kAudioSessionCategory_AmbientSound = 'ambi',
kAudioSessionCategory_SoloAmbientSound = 'solo',
kAudioSessionCategory_MediaPlayback = 'medi',
kAudioSessionCategory_RecordAudio = 'reca',
kAudioSessionCategory_PlayAndRecord = 'plar',
kAudioSessionCategory_AudioProcessing = 'proc'
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//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了,启动方法:

1
2
3
4
5
6
7
8
//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就维持打断状态;

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

IOS 音频播放

然而现在某些语音通讯软件和某些音乐软件却无视了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的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#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,然后发送一个自定义的通知并带上对应的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
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参数):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
- (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的值。

示例代码

这里有我自己写的AudioSession的封装,如果各位需要支持iOS 5的话可以使用一下。


下篇预告

下一篇将讲述如何使用AudioFileStreamer分离音频帧,以及如何使用AudioQueue进行播放。

下一篇将讲述如何使用AudioFileStreamer提取音频文件格式信息和分离音频帧。


参考资料

AudioSession

原创文章,版权声明:*转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

Comments

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的实例:

1
2
3
4
5
extern OSStatus AudioFileStreamOpen (void * inClientData,
AudioFileStream_PropertyListenerProc inPropertyListenerProc,
AudioFileStream_PacketsProc inPacketsProc,
AudioFileTypeID inFileTypeHint,
AudioFileStreamID * outAudioFileStream);

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//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)。


解析数据

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

1
2
3
4
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则表示解析不成功,其中错误码包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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回调方法

IOS 音频播放

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

1
2
3
4
typedef void (*AudioFileStream_PropertyListenerProc)(void * inClientData,
AudioFileStreamID inAudioFileStream,
AudioFileStreamPropertyID inPropertyID,
UInt32 * ioFlags);

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

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

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

1
2
3
4
extern OSStatus AudioFileStreamGetProperty(AudioFileStreamID inAudioFileStream,
AudioFileStreamPropertyID inPropertyID,
UInt32 * ioPropertyDataSize,
void * outPropertyData);

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//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没有这样的接口。。)。

1
2
3
4
5
6
7
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。

1
2
3
4
5
6
7
SInt64 dataOffset;
UInt32 offsetSize = sizeof(dataOffset);
OSStatus status = AudioFileStreamGetProperty(inAudioFileStream, kAudioFileStreamProperty_DataOffset, &offsetSize, &dataOffset);
if (status != noErr)
{
//错误处理
}

3、kAudioFileStreamProperty_DataFormat

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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我们并不知晓,所以需要先获取一下总数据大小:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//获取数据大小
Boolean outWriteable;
UInt32 formatListSize;
OSStatus status = AudioFileStreamGetPropertyInfo(inAudioFileStream, kAudioFileStreamProperty_FormatList, &formatListSize, &outWriteable);
if (status != noErr)
{
//错误处理
} //获取formatlist
AudioFormatListItem *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。

1
2
3
4
5
6
7
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),那么计算方法如下:

1
2
3
UInt32 dataOffset = ...; //kAudioFileStreamProperty_DataOffset
UInt32 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回调方法。

IOS 音频播放

回调的定义:

1
2
3
4
5
typedef void (*AudioFileStream_PacketsProc)(void * inClientData,
UInt32 inNumberBytes,
UInt32 inNumberPackets,
const void * inInputData,
AudioStreamPacketDescription * inPacketDescriptions);

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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到哪个字节

1
2
3
4
5
6
7
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;

1
2
3
4
5
6
//首先需要计算每个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赋值,outDataByteOffset就是输入的seekToPacket对应的字节偏移量,我们可以根据outDataByteOffset来计算出精确的seekOffset和seekToTime;
  • 如果没找到那么还是应该用第1步计算出来的approximateSeekOffset来做seek;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
SInt64 seekByteOffset;
UInt32 ioFlags = 0;
SInt64 outDataByteOffset;
OSStatus status = AudioFileStreamSeek(audioFileStreamID, seekToPacket, &outDataByteOffset, &ioFlags);
if (status == noErr && !(ioFlags & kAudioFileStreamSeekFlag_OffsetIsEstimated))
{
//如果AudioFileStreamSeek方法找到了帧的字节偏移,需要修正一下时间
seekToTime -= ((seekByteOffset - 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进行关闭;


示例代码

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


下篇预告

下一篇将讲述如何使用AudioFile


参考资料

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

1
2
3
4
5
6
7
8
9
10
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

1
2
3
4
5
6
7
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这两个读取功能相关的回调

1
2
3
4
5
6
7
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,这个过程可以用下面的代码来表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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的调用方;


读取音频格式信息

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

1
2
3
4
5
6
7
8
9
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),例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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、直接读取音频数据:

1
2
3
4
5
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)读取音频数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
AudioFileID fileID; //Open方法返回的AudioFileID
UInt32 ioNumPackets = ...; //要读取多少个packet
SInt64 inStartingPacket = ...; //从第几个Packet开始读取 UInt32 bitRate = ...; //AudioFileGetProperty读取kAudioFilePropertyBitRate
UInt32 sampleRate = ...; //AudioFileGetProperty读取kAudioFilePropertyDataFormat或kAudioFilePropertyFormatList
UInt32 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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
AudioFileID fileID; //Open方法返回的AudioFileID
UInt32 ioNumPackets = ...; //要读取多少个packet
SInt64 inStartingPacket = ...; //从第几个Packet开始读取 UInt32 maxByteCountPerPacket = ...; //AudioFileGetProperty读取kAudioFilePropertyMaximumPacketSize,最大的packet大小
//也可以用:
//UInt32 byteCountUpperBoundPerPacket = ...; //AudioFileGetProperty读取kAudioFilePropertyPacketSizeUpperBound,当前packet大小上界(未扫描全文件的情况下) UInt32 descSize = sizeof(AudioStreamPacketDescription) * ioNumPackets;
AudioStreamPacketDescription * outPacketDescriptions = (AudioStreamPacketDescription *)malloc(descSize); UInt32 outNumBytes = 0;
UInt32 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按我的理解应该是指一个音频数据装填方法,该方法可以通过之前提到的数据使用后的回调来触发。

IOS 音频播放AudioQueue 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的实例

1
2
3
4
5
6
7
8
9
10
11
12
13
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

1
2
3
4
5
6
7
8
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

1
2
3
4
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.解码数据

1
2
3
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

1
2
OSStatus
AudioQueueFlush(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.获取播放时间

1
2
3
4
OSStatus AudioQueueGetCurrentTime(AudioQueueRef inAQ,
AudioQueueTimelineRef inTimeline,
AudioTimeStamp * outTimeStamp,
Boolean * outTimelineDiscontinuity);

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

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

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

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

1
2
3
4
5
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有很多参数和属性可以设置、获取、监听。以下是相关的方法,这里就不再一一赘述:

1
2
3
4
5
6
7
8
9
10
11
12
//参数相关方法
AudioQueueGetParameter
AudioQueueSetParameter //属性相关方法
AudioQueueGetPropertySize
AudioQueueGetProperty
AudioQueueSetProperty //监听属性变化相关方法
AudioQueueAddPropertyListener
AudioQueueRemovePropertyListener

属性和参数的列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//属性列表
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。如果觉得上面提到某一部分的很难以的话理解欢迎在下面留言或者在微博上和我交流,除此之外还可以阅读官方文档(我一直觉得官方文档是学习的最好途径);


示例代码

AudioStreamerFreeStreamer都用到了AudioQueue。在上面提到的Demo中也有我自己做的封装MCAudioOutputQueue


下篇预告

Streaming MP3/AAC audio again

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篇最后给出的封装类,本篇中的播放器实现将基于前面几篇中给出的MCAudioSessionMCAudioFileStreamMCAudioFileMCAudioOutputQueue进行实现。


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

IOS 音频播放

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

IOS 音频播放

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

IOS 音频播放


接口定义

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

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

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

1
2
3
4
5
6
7
typedef NS_ENUM(NSUInteger, MCSAPStatus)
{
MCSAPStatusStopped = 0,
MCSAPStatusPlaying = 1,
MCSAPStatusWaiting = 2,
MCSAPStatusPaused = 3,
};

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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,否则说明线程已经创建并且在播放流程中:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (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。

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

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

1
2
3
4
5
6
7
8
9
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数据的回调。

1
2
3
4
5
_audioFileStream = [[MCAudioFileStream alloc] initWithFileType:_fileType fileSize:_fileSize error:&error];
if (!error)
{
_audioFileStream.delegate = self;
}

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

1
2
3
4
5
6
7
8
9
10
11
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类来管理所有解析完成的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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取出来使用。

1
2
3
4
5
6
7
_buffer = [MCAudioBuffer buffer]; //初始化方法中创建

//AudioFileStream解析完成的数据都被存储到了_buffer中
- (void)audioFileStream:(MCAudioFileStream *)audioFileStream audioDataParsed:(NSArray *)audioData
{
[_buffer enqueueFromDataArray:audioData];
}

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

1
2
3
4
5
6
7
_audioFileStream parseData:data error:&error];
if (error)
{
//解析失败,换用AudioFile
_usingAudioFile = YES;
continue;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
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了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
- (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为止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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中我使用了后者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//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让线程继续跑

1
2
3
4
5
6
- (void)_resume
{
//AudioQueue的start方法被封装到了MCAudioOutputQueue的resume方法中
[_audioQueue resume];
[self _mutexSignal];
}

播放进度 & Seek

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

1
2
3
4
- (NSTimeInterval)progress
{
return _timingOffset + _audioQueue.playedTime;
}

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

1
2
3
4
5
6
//seek方法
- (void)setProgress:(NSTimeInterval)progress
{
_seekRequired = YES;
_seekTime = progress;
}

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

1
2
3
4
5
6
7
8
- (NSTimeInterval)progress
{
if (_seekRequired)
{
return _seekTime;
}
return _timingOffset + _audioQueue.playedTime;
}

下面是threadMain中的Seek操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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通知时需要处理打断,下面是打断的处理方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- (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中执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (void)stop
{
_stopRequired = YES;
[self _mutexSignal];
} //treadMain中
if (_stopRequired)
{
_stopRequired = NO;
_started = NO;
[_audioQueue stop:YES];
break;
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
- (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时创建一个播放器:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (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的开启和暂停:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- (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来刷新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
- (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操作:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (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)

示例代码

上面所讲述的内容对应的工程已经在github上了(MCSimpleAudioPlayer),有任何问题可以给我发issue~


下篇预告

下一篇会介绍一下如何播放iOS系统iPod Library中的歌曲(俗称iPod音乐或者本地音乐)

原创文章,版权声明:*转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

Comments

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篇最后给出的封装类,本篇中的播放器实现将基于前面几篇中给出的MCAudioSessionMCAudioFileStreamMCAudioFileMCAudioOutputQueue进行实现。


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

IOS 音频播放

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

IOS 音频播放

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

IOS 音频播放


接口定义

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

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

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

1
2
3
4
5
6
7
typedef NS_ENUM(NSUInteger, MCSAPStatus)
{
MCSAPStatusStopped = 0,
MCSAPStatusPlaying = 1,
MCSAPStatusWaiting = 2,
MCSAPStatusPaused = 3,
};

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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,否则说明线程已经创建并且在播放流程中:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (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。

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

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

1
2
3
4
5
6
7
8
9
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数据的回调。

1
2
3
4
5
_audioFileStream = [[MCAudioFileStream alloc] initWithFileType:_fileType fileSize:_fileSize error:&error];
if (!error)
{
_audioFileStream.delegate = self;
}

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

1
2
3
4
5
6
7
8
9
10
11
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类来管理所有解析完成的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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取出来使用。

1
2
3
4
5
6
7
_buffer = [MCAudioBuffer buffer]; //初始化方法中创建

//AudioFileStream解析完成的数据都被存储到了_buffer中
- (void)audioFileStream:(MCAudioFileStream *)audioFileStream audioDataParsed:(NSArray *)audioData
{
[_buffer enqueueFromDataArray:audioData];
}

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

1
2
3
4
5
6
7
_audioFileStream parseData:data error:&error];
if (error)
{
//解析失败,换用AudioFile
_usingAudioFile = YES;
continue;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
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了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
- (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为止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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中我使用了后者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//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让线程继续跑

1
2
3
4
5
6
- (void)_resume
{
//AudioQueue的start方法被封装到了MCAudioOutputQueue的resume方法中
[_audioQueue resume];
[self _mutexSignal];
}

播放进度 & Seek

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

1
2
3
4
- (NSTimeInterval)progress
{
return _timingOffset + _audioQueue.playedTime;
}

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

1
2
3
4
5
6
//seek方法
- (void)setProgress:(NSTimeInterval)progress
{
_seekRequired = YES;
_seekTime = progress;
}

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

1
2
3
4
5
6
7
8
- (NSTimeInterval)progress
{
if (_seekRequired)
{
return _seekTime;
}
return _timingOffset + _audioQueue.playedTime;
}

下面是threadMain中的Seek操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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通知时需要处理打断,下面是打断的处理方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- (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中执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (void)stop
{
_stopRequired = YES;
[self _mutexSignal];
} //treadMain中
if (_stopRequired)
{
_stopRequired = NO;
_started = NO;
[_audioQueue stop:YES];
break;
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
- (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时创建一个播放器:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (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的开启和暂停:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- (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来刷新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
- (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操作:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (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)

示例代码

上面所讲述的内容对应的工程已经在github上了(MCSimpleAudioPlayer),有任何问题可以给我发issue~


下篇预告

下一篇会介绍一下如何播放iOS系统iPod Library中的歌曲(俗称iPod音乐或者本地音乐)

原创文章,版权声明:*转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

Comments

原创文章,版权声明:*转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

 

上一篇:js 基础数据类型和引用类型 ,深浅拷贝问题,以及内存分配问题


下一篇:配置vCenter Server Appliance 6.7