1. 程式人生 > >iOS音訊播放 (六):簡單的音訊播放器實現

iOS音訊播放 (六):簡單的音訊播放器實現

在前幾篇中我分別講到了AudioSessionAudioFileStreamAudioFileAudioQueue,這些類的功能已經涵蓋了第一篇中所提到的音訊播放所需要的步驟:

  1. 讀取MP3檔案 NSFileHandle
  2. 解析取樣率、位元速率、時長等資訊,分離MP3中的音訊幀 AudioFileStream/AudioFile
  3. 對分離出來的音訊幀解碼得到PCM資料 AudioQueue
  4. 對PCM資料進行音效處理(均衡器、混響器等,非必須) 省略
  5. 把PCM資料解碼成音訊訊號 AudioQueue
  6. 把音訊訊號交給硬體播放 AudioQueue
  7. 重複1-6步直到播放完成

下面我們就講講述如何用這些部件組成一個簡單的本地音樂播放器

,這裡我會用到AudioSessionAudioFileStreamAudioFileAudioQueue

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;