一篇對iOS音頻比較完善的文章


轉自:http://www.cnblogs.com/iOS-mt/p/4268532.html

感謝作者:夢想通

 

 

前言

從事音樂相關的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

 

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

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(來自StackOverflow):

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就維持打斷狀態;

官方文檔中有一張很形象的圖來闡述這個現象:

然而現在某些語音通訊軟件和某些音樂軟件卻無視了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回調方法

來看一下這個回調方法的定義

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回調方法。

回調的定義:

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 = 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的實例

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

創建完工程之后去到Target屬性的Capabilities選項卡設置Background Modes,把Audio and Airplay勾選,這樣我們的App就可以在進入后台之后繼續播放音樂了:

接下來我們需要搭建一個簡單的UI,在storyboard上創建兩個UIButton和一個UISlider,Button用來做播放器的播放、暫停、停止等功能控制,Slider用來顯示播放進度和seek。把這些UI組件和ViewController的屬性/方法關聯上之后簡單的UI也就完成了。


接口定義

下面來創建播放器類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

創建完工程之后去到Target屬性的Capabilities選項卡設置Background Modes,把Audio and Airplay勾選,這樣我們的App就可以在進入后台之后繼續播放音樂了:

接下來我們需要搭建一個簡單的UI,在storyboard上創建兩個UIButton和一個UISlider,Button用來做播放器的播放、暫停、停止等功能控制,Slider用來顯示播放進度和seek。把這些UI組件和ViewController的屬性/方法關聯上之后簡單的UI也就完成了。


接口定義

下面來創建播放器類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)

    前言

    從事音樂相關的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

     

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

    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(來自StackOverflow):

    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就維持打斷狀態;

    官方文檔中有一張很形象的圖來闡述這個現象:

    然而現在某些語音通訊軟件和某些音樂軟件卻無視了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回調方法

    來看一下這個回調方法的定義

    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回調方法。

    回調的定義:

    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 = 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的實例

    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

    創建完工程之后去到Target屬性的Capabilities選項卡設置Background Modes,把Audio and Airplay勾選,這樣我們的App就可以在進入后台之后繼續播放音樂了:

    接下來我們需要搭建一個簡單的UI,在storyboard上創建兩個UIButton和一個UISlider,Button用來做播放器的播放、暫停、停止等功能控制,Slider用來顯示播放進度和seek。把這些UI組件和ViewController的屬性/方法關聯上之后簡單的UI也就完成了。


    接口定義

    下面來創建播放器類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

    創建完工程之后去到Target屬性的Capabilities選項卡設置Background Modes,把Audio and Airplay勾選,這樣我們的App就可以在進入后台之后繼續播放音樂了:

    接下來我們需要搭建一個簡單的UI,在storyboard上創建兩個UIButton和一個UISlider,Button用來做播放器的播放、暫停、停止等功能控制,Slider用來顯示播放進度和seek。把這些UI組件和ViewController的屬性/方法關聯上之后簡單的UI也就完成了。


    接口定義

    下面來創建播放器類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)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM