音频

概览

随着移动互联网的发展,如今的手机早已不是打电话、发短信那么简单了,播放音乐、视频、录音、拍照等都是很常用的功能。在iOS中对于多媒体的支持是非常强大的,无论是音视频播放、录制,还是对麦克风、摄像头的操作都提供了多套API。在今天的文章中将会对这些内容进行一一介绍:

音频

在iOS中音频播放从形式上可以分为音效播放和音乐播放。前者主要指的是一些短音频播放,通常作为点缀音频,对于这类音频不需要进行进度、循环等控制。后者指的是一些较长的音频,通常是主音频,对于这些音频的播放通常需要进行精确的控制。在iOS中播放两类音频分别使用AudioToolbox.framework和AVFoundation.framework来完成音效和音乐播放。

音效

AudioToolbox.framework是一套基于C语言的框架,使用它来播放音效其本质是将短音频注册到系统声音服务(System Sound Service)。System Sound Service是一种简单、底层的声音播放服务,但是它本身也存在着一些限制:

  • 音频播放时间不能超过30s
  • 数据必须是PCM或者IMA4格式
  • 音频文件必须打包成.caf、.aif、.wav中的一种(注意这是官方文档的说法,实际测试发现一些.mp3也可以播放)

使用System Sound Service 播放音效的步骤如下:

  1. 调用AudioServicesCreateSystemSoundID( CFURLRef inFileURL, SystemSoundID* outSystemSoundID)函数获得系统声音ID。
  2. 如果需要监听播放完成操作,则使用AudioServicesAddSystemSoundCompletion( SystemSoundID inSystemSoundID,
    CFRunLoopRef inRunLoop, CFStringRef inRunLoopMode, AudioServicesSystemSoundCompletionProc inCompletionRoutine, void* inClientData)方法注册回调函数。
    3.调用AudioServicesPlaySystemSound(SystemSoundID inSystemSoundID) 或者AudioServicesPlayAlertSound(SystemSoundID inSystemSoundID) 方法播放音效(后者带有震动效果)。

下面是一个简单的示例程序:

//
//  ViewController.m
//  01-音效
//
//  Created by Andy on 2020/2/13.
//  Copyright © 2020 李正林. All rights reserved.
//

#import "ViewController.h"
#import <AudioToolbox/AudioToolbox.h>

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    
    [self playSoundEffect:@"videoRing.caf"];
}

/// 播放完成回调函数
/// @param soundID 系统声音ID
/// @param clientData 回调时传递的数据
void soundCompleteCallback(SystemSoundID soundID, void * clientData) {
    
    NSLog(@"播放完成...");
}

/// 播放音频文件
/// @param name 音频文件名称
- (void)playSoundEffect:(NSString *)name {
    
    NSString *audioFile = [[NSBundle mainBundle] pathForResource:name ofType:nil];
    NSURL *fileUrl = [NSURL fileURLWithPath:audioFile];
    // 1.获得系统声音ID
    SystemSoundID soundID = 0;
    /**
     * inFileUrl:音频文件url
     * outSystemSoundID:声音id(此函数会将音频文件加入到系统音频服务器中并返回一个长整型ID)
     */
    AudioServicesCreateSystemSoundID((__bridge CFURLRef)(fileUrl), &soundID);
    // 如果需要在播放完之后执行某些操作,可以调用如下方法注册一个播放完成回调函数
    AudioServicesAddSystemSoundCompletion(soundID, NULL, NULL, soundCompleteCallback, NULL);
    //2.播放音频
    AudioServicesPlaySystemSound(soundID); // 播放音效
//    AudioServicesPlayAlertSound(soundID); // 播放音效并震动
}

@end

音乐

如果播放较大的音频或者要对音频有精确的控制则System Sound Service可能就很难满足实际需求了,通常这种情况会选择使用AVFoundation.framework中的AVAudioPlayer来实现。AVAudioPlayer可以看成一个播放器,它支持多种音频格式,而且能够进行进度、音量、播放速度等控制。首先简单看一下AVAudioPlayer常用的属性和方法:

属性 说明
@property(readonly, getter=isPlaying) BOOL playing 是否正在播放,只读
@property(readonly) NSUInteger numberOfChannels 音频声道数,只读
@property(readonly) NSTimeInterval duration 音频时长
@property(readonly) NSURL *url 音频文件路径,只读
@property(readonly) NSData *data 音频数据,只读
@property float pan 立体声平衡,如果为-1.0则完全左声道,如果0.0则左右声道平衡,如果为1.0则完全为右声道
@property float volume 音量大小,范围0-1.0
@property BOOL enableRate 是否允许改变播放速率
@property float rate 播放速率,范围0.5-2.0,如果为1.0则正常播放,如果要修改播放速率则必须设置enableRate为YES
@property NSTimeInterval currentTime 当前播放时长
@property(readonly) NSTimeInterval deviceCurrentTime 输出设备播放音频的时间,注意如果播放中被暂停此时间也会继续累加
@property NSInteger numberOfLoops 循环播放次数,如果为0则不循环,如果小于0则无限循环,大于0则表示循环次数
@property(readonly) NSDictionary *settings 音频播放设置信息,只读
@property(getter=isMeteringEnabled) BOOL meteringEnabled 是否启用音频测量,默认为NO,一旦启用音频测量可以通过updateMeters方法更新测量值
对象方法 说明
- (instancetype)initWithContentsOfURL:(NSURL *)url error:(NSError **)outError 使用文件URL初始化播放器,注意这个URL不能是HTTP URL,AVAudioPlayer不支持加载网络媒体流,只能播放本地文件
- (instancetype)initWithData:(NSData *)data error:(NSError **)outError 使用NSData初始化播放器,注意使用此方法时必须文件格式和文件后缀一致,否则出错,所以相比此方法更推荐使用上述方法或- (instancetype)initWithData:(NSData *)data fileTypeHint:(NSString *)utiString error:(NSError **)outError方法进行初始化
- (BOOL)prepareToPlay; 加载音频文件到缓冲区,注意即使在播放之前音频文件没有加载到缓冲区程序也会隐式调用此方法。
- (BOOL)play; 播放音频文件
- (BOOL)playAtTime:(NSTimeInterval)time 在指定的时间开始播放音频
- (void)pause; 暂停播放
- (void)stop; 停止播放
- (void)updateMeters 更新音频测量值,注意如果要更新音频测量值必须设置meteringEnabled为YES,通过音频测量值可以即时获得音频分贝等信息
- (float)peakPowerForChannel:(NSUInteger)channelNumber; 获得指定声道的分贝峰值,注意如果要获得分贝峰值必须在此之前调用updateMeters方法
- (float)averagePowerForChannel:(NSUInteger)channelNumber 获得指定声道的分贝平均值,注意如果要获得分贝平均值必须在此之前调用updateMeters方法
@property(nonatomic, copy) NSArray *channelAssignments 获得或设置播放声道
代理方法 说明
- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag 音频播放完成
- (void)audioPlayerDecodeErrorDidOccur:(AVAudioPlayer *)player error:(NSError *)error 音频解码发生错误

AVAudioPlayer的使用比较简单:

1.初始化AVAudioPlayer对象,此时通常指定本地文件路径。
2.设置播放器属性,例如重复次数、音量大小等。
3.调用play方法播放。

下面就使用AVAudioPlayer实现一个简单播放器,在这个播放器中实现了播放、暂停、显示播放进度功能,当然例如调节音量、设置循环模式、甚至是声波图像(通过分析音频分贝值)等功能都可以实现,这里就不再一一演示。界面效果如下:

Simulator Screen Shot - iPhone 11 Pro Max - 2020-02-15 at 22.10.28.png

当然由于AVAudioPlayer一次只能播放一个音频文件,所有上一曲、下一曲其实可以通过创建多个播放器对象来完成,这里暂不实现。播放进度的实现主要依靠一个定时器实时计算当前播放时长和音频总时长的比例,另外为了演示委托方法,下面的代码中也实现了播放完成委托方法,通常如果有下一曲功能的话播放完可以触发下一曲音乐播放。下面是主要代码:

//
//  ViewController.m
//  02-音乐
//
//  Created by Andy on 2020/2/13.
//  Copyright © 2020 李正林. All rights reserved.
//

#define kMusicFile @"KGETOSKBDLQMOD02.mp3"
#define kMusicSinger @"影视剧乐队"
#define kMusicTitle @"喜剧之王背景音乐"

#import "ViewController.h"
#import <AVFoundation/AVFoundation.h>

@interface ViewController () <AVAudioPlayerDelegate>

@property (nonatomic, strong) AVAudioPlayer * audioPlayer; //播放器
@property (weak, nonatomic) IBOutlet UILabel *controlPanel; //控制面板
@property (weak, nonatomic) IBOutlet UIProgressView *playProgress;//播放进度
@property (weak, nonatomic) IBOutlet UILabel *musicSinger; //演唱者
@property (weak, nonatomic) IBOutlet UIButton *playOrPause; //播放/暂停按钮(如果tag为0认为是暂停状态,1是播放状态)

@property (weak ,nonatomic) NSTimer *timer;//进度更新定时器

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    
    [self setupUI];
}

- (void)setupUI {
    
    self.controlPanel.text = kMusicTitle;
    self.musicSinger.text = kMusicSinger;
    self.playOrPause.selected = YES;
    self.playProgress.progress = 0.0;
}

- (NSTimer *)timer {
    
    if (!_timer) {
        _timer=[NSTimer scheduledTimerWithTimeInterval:0.5 target:self selector:@selector(updateProgress) userInfo:nil repeats:true];
    }
    return _timer;
}

/// 创建播放器
- (AVAudioPlayer *)audioPlayer {
    
    if (!_audioPlayer) {
        
        NSString *urlStr=[[NSBundle mainBundle]pathForResource:kMusicFile ofType:nil];
        NSURL *url=[NSURL fileURLWithPath:urlStr];
        NSError *error=nil;
        // 初始化播放器,注意这里的Url参数只能时文件路径,不支持HTTP Url
        _audioPlayer=[[AVAudioPlayer alloc]initWithContentsOfURL:url error:&error];
        // 设置播放器属性
        _audioPlayer.numberOfLoops = 0; // 设置为0不循环
        _audioPlayer.delegate = self;
        [_audioPlayer prepareToPlay]; // 加载音频文件到缓存
        
        if(error) {
            
            NSLog(@"初始化播放器过程发生错误,错误信息:%@",error.localizedDescription);
            return nil;
        }
    }
    return _audioPlayer;
}

/// 播放音频
- (void)play {
    
    if (![self.audioPlayer isPlaying]) {
        [self.audioPlayer play];
        self.timer.fireDate=[NSDate distantPast];//恢复定时器
    }
}

/// 暂停播放
- (void)pause {
    
    if ([self.audioPlayer isPlaying]) {
        [self.audioPlayer pause];
        self.timer.fireDate=[NSDate distantFuture]; // 暂停定时器,注意不能调用invalidate方法,此方法会取消,之后无法恢复
    }
}

/// 点击播放/暂停按钮
/// @param sender 播放/暂停按钮
- (IBAction)playClick:(UIButton *)sender {
    
    sender.selected = !sender.selected;
    if(sender.selected) {
        sender.selected = YES;
        [self pause];
    } else {
        sender.selected = NO;
        [self play];
    }
}

/// 更新播放进度
- (void)updateProgress {
    
    float progress= self.audioPlayer.currentTime /self.audioPlayer.duration;
    [self.playProgress setProgress:progress animated:true];
}

#pragma mark - 播放器代理方法
- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag {
    
    NSLog(@"音乐播放完成...");
    self.playOrPause.selected = YES;
}

@end

音频会话

事实上上面的播放器还存在一些问题,例如通常我们看到的播放器即使退出到后台也是可以播放的,而这个播放器如果退出到后台它会自动暂停。如果要支持后台播放需要做下面几件事情:

1.设置后台运行模式:在plist文件中添加Required background modes,并且设置item 0=App plays audio or streams audio/video using AirPlay(其实可以直接通过Xcode在Project Targets-Capabilities-Background Modes中设置)

WeChat35d58ef472a48c086cfc1ca8bfb2f7da.png

2.设置AVAudioSession的类型为AVAudioSessionCategoryPlayback并且调用setActive::方法启动会话。

AVAudioSession *audioSession = [AVAudioSession sharedInstance];
[audioSession setCategory:AVAudioSessionCategoryPlayback error:nil];
[audioSession setActive:YES error:nil];

3.为了能够让应用退到后台之后支持耳机控制,建议添加远程控制事件(这一步不是后台播放必须的)

前两步是后台播放所必须设置的,第三步主要用于接收远程事件,这部分内容之前的文章中有详细介绍,如果这一步不设置虽然也能够在后台播放,但是无法获得音频控制权(如果在使用当前应用之前使用其他播放器播放音乐的话,此时如果按耳机播放键或者控制中心的播放按钮则会播放前一个应用的音频),并且不能使用耳机进行音频控制。第一步操作相信大家都很容易理解,如果应用程序要允许运行到后台必须设置,正常情况下应用如果进入后台会被挂起,通过该设置可以让应用程序继续在后台运行。但是第二步使用的AVAudioSession有必要进行一下详细的说明。

在iOS中每个应用都有一个音频会话,这个会话就通过AVAudioSession来表示。AVAudioSession同样存在于AVFoundation框架中,它是单例模式设计,通过sharedInstance进行访问。在使用Apple设备时大家会发现有些应用只要打开其他音频播放就会终止,而有些应用却可以和其他应用同时播放,在多种音频环境中如何去控制播放的方式就是通过音频会话来完成的。下面是音频会话的几种会话模式:

会话类型 说明 是否要求输入 是否要求输出 是否遵从静音键
AVAudioSessionCategoryAmbient 混音播放,可以与其他音频应用同时播放
AVAudioSessionCategorySoloAmbient 独占播放
AVAudioSessionCategoryPlayback 后台播放,也是独占的
AVAudioSessionCategoryRecord 录音模式,用于录音时使用
AVAudioSessionCategoryPlayAndRecord 播放和录音,此时可以录音也可以播放
AVAudioSessionCategoryAudioProcessing 硬件解码音频,此时不能播放和录制
AVAudioSessionCategoryMultiRoute 多种输入输出,例如可以耳机、USB设备同时播放

注意:是否遵循静音键表示在播放过程中如果用户通过硬件设置为静音是否能关闭声音。

根据前面对音频会话的理解,相信大家开发出能够在后台播放的音频播放器并不难,但是注意一下,在前面的代码中也提到设置完音频会话类型之后需要调用setActive::方法将会话激活才能起作用。类似的,如果一个应用已经在播放音频,打开我们的应用之后设置了在后台播放的会话类型,此时其他应用的音频会停止而播放我们的音频,如果希望我们的程序音频播放完之后(关闭或退出到后台之后)能够继续播放其他应用的音频的话则可以调用setActive::方法关闭会话。代码如下:

//
//  ViewController.m
//  03-音频会话
//
//  Created by Andy on 2020/2/13.
//  Copyright © 2020 李正林. All rights reserved.
//

#define kMusicFile @"KGETOSKBDLQMOD03.mp3"
#define kMusicSinger @"陈瑞"
#define kMusicTitle @"天生一对"

#import "ViewController.h"
#import <AVFoundation/AVFoundation.h>

@interface ViewController () <AVAudioPlayerDelegate>

@property (nonatomic, strong) AVAudioPlayer * audioPlayer; //播放器
@property (weak, nonatomic) IBOutlet UILabel *controlPanel; //控制面板
@property (weak, nonatomic) IBOutlet UIProgressView *playProgress;//播放进度
@property (weak, nonatomic) IBOutlet UILabel *musicSinger; //演唱者
@property (weak, nonatomic) IBOutlet UIButton *playOrPause; //播放/暂停按钮(如果tag为0认为是暂停状态,1是播放状态)

@property (weak ,nonatomic) NSTimer *timer;//进度更新定时器

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    
    [self setupUI];
}

/// 显示当面视图控制器时注册远程事件
/// @param animated 是否以动画的形式显示
- (void)viewWillAppear:(BOOL)animated {
    
    [super viewWillAppear:animated];
    
    // 开启远程控制
    [[UIApplication sharedApplication] beginReceivingRemoteControlEvents];
    // 作为第一响应者
//    [self becomeFirstResponder];
}

/// 当前控制器视图不显示时取消远程控制
/// @param animated 是否以动画的形式消失
- (void)viewWillDisappear:(BOOL)animated {
    
    [super viewWillDisappear:animated];
    [[UIApplication sharedApplication] endReceivingRemoteControlEvents];
//    [self resignFirstResponder];
}

- (void)setupUI {
    
    self.controlPanel.text = kMusicTitle;
    self.musicSinger.text = kMusicSinger;
    self.playOrPause.selected = YES;
    self.playProgress.progress = 0.0;
}

- (NSTimer *)timer {
    
    if (!_timer) {
        _timer=[NSTimer scheduledTimerWithTimeInterval:0.5 target:self selector:@selector(updateProgress) userInfo:nil repeats:true];
    }
    return _timer;
}

/// 创建播放器
- (AVAudioPlayer *)audioPlayer {
    
    if (!_audioPlayer) {
        
        NSString *urlStr=[[NSBundle mainBundle]pathForResource:kMusicFile ofType:nil];
        NSURL *url=[NSURL fileURLWithPath:urlStr];
        NSError *error=nil;
        // 初始化播放器,注意这里的Url参数只能时文件路径,不支持HTTP Url
        _audioPlayer=[[AVAudioPlayer alloc]initWithContentsOfURL:url error:&error];
        // 设置播放器属性
        _audioPlayer.numberOfLoops = 0; // 设置为0不循环
        _audioPlayer.delegate = self;
        [_audioPlayer prepareToPlay]; // 加载音频文件到缓存
        
        if(error) {
            
            NSLog(@"初始化播放器过程发生错误,错误信息:%@",error.localizedDescription);
            return nil;
        }
        
        // 设置后台播放模式
        AVAudioSession *audioSession = [AVAudioSession sharedInstance];
        [audioSession setCategory:AVAudioSessionCategoryPlayback error:nil];
//        [audioSession setCategory:AVAudioSessionCategoryPlayback withOptions:AVAudioSessionCategoryOptionAllowBluetooth error:nil];
        [audioSession setActive:YES error:nil];
        // 添加通知,拨出耳机后暂停播放
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(routeChange:) name:AVAudioSessionRouteChangeNotification object:nil];
    }
    return _audioPlayer;
}

/// 播放音频
- (void)play {
    
    if (![self.audioPlayer isPlaying]) {
        [self.audioPlayer play];
        self.timer.fireDate=[NSDate distantPast];//恢复定时器
    }
}

/// 暂停播放
- (void)pause {
    
    if ([self.audioPlayer isPlaying]) {
        [self.audioPlayer pause];
        self.timer.fireDate=[NSDate distantFuture]; // 暂停定时器,注意不能调用invalidate方法,此方法会取消,之后无法恢复
    }
}

/// 点击播放/暂停按钮
/// @param sender 播放/暂停按钮
- (IBAction)playClick:(UIButton *)sender {
    
    sender.selected = !sender.selected;
    if(sender.selected) {
        sender.selected = YES;
        [self pause];
    } else {
        sender.selected = NO;
        [self play];
    }
}

/// 更新播放进度
- (void)updateProgress {
    
    float progress= self.audioPlayer.currentTime /self.audioPlayer.duration;
    [self.playProgress setProgress:progress animated:true];
}

/// 一旦输出改变则执行此方法
/// @param notification 输出改变通知对象
- (void)routeChange:(NSNotification *)notification {
    
    NSDictionary *dic = notification.userInfo;
    int changeReason = [dic[AVAudioSessionRouteChangeReasonKey] intValue];
    // 等于AVAudioSessionRouteChangeReasonOldDeviceUnavailable表示旧输出不可用
    if (changeReason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable) {
        
        AVAudioSessionRouteDescription *routeDescription = dic[AVAudioSessionRouteChangePreviousRouteKey];
        AVAudioSessionPortDescription *portDescription = [routeDescription.outputs firstObject];
        // 原设备为耳机则暂停
        if ([portDescription.portType isEqualToString:@"Headphones"]) {
            [self pause];
        }
    }
    
//    [dic enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
//
//        NSLog(@"key:%@ - obj:%@",key,obj);
//    }];
}

- (void)dealloc {
    
    [[NSNotificationCenter defaultCenter] removeObserver:self name:AVAudioSessionRouteChangeNotification object:nil];
}

#pragma mark - 播放器代理方法
- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag {
    
    NSLog(@"音乐播放完成...");
    self.playOrPause.selected = YES;
    
    // 根据实际情况播放完成可以将会话关闭,其他音频应用继续播放
    [[AVAudioSession sharedInstance] setActive:NO error:nil];
}

@end

在上面的代码中还实现了拔出耳机暂停音乐播放的功能,这也是一个比较常见的功能。在iOS7及以后的版本中可以通过通知获得输出改变的通知,然后拿到通知对象后根据userInfo获得是何种改变类型,进而根据情况对音乐进行暂停操作。


运行效果如下:

Simulator Screen Shot - iPhone 11 Pro Max - 2020-02-15 at 22.11.25.png

扩展--播放音乐库中的音乐

众所周知音乐是iOS的重要组成播放,无论是iPod、iTouch、iPhone还是iPad都可以在iTunes购买音乐或添加本地音乐到音乐库中同步到你的iOS设备。在MediaPlayer.frameowork中有一个MPMusicPlayerController用于播放音乐库中的音乐。

下面先来看一下MPMusicPlayerController的常用属性和方法:

属性 说明
@property (nonatomic, readonly) MPMusicPlaybackState playbackState 播放器状态,枚举类型:MPMusicPlaybackStateStopped:停止播放 MPMusicPlaybackStatePlaying:正在播放 MPMusicPlaybackStatePaused:暂停播放 MPMusicPlaybackStateInterrupted:播放中断 MPMusicPlaybackStateSeekingForward:向前查找 MPMusicPlaybackStateSeekingBackward:向后查找
@property (nonatomic) MPMusicRepeatMode repeatMode 重复模式,枚举类型: MPMusicRepeatModeDefault:默认模式,使用用户的首选项(系统音乐程序设置) MPMusicRepeatModeNone:不重复 MPMusicRepeatModeOne:单曲循环 MPMusicRepeatModeAll:在当前列表内循环
@property (nonatomic) MPMusicShuffleMode shuffleMode 随机播放模式,枚举类型: MPMusicShuffleModeDefault:默认模式,使用用户首选项(系统音乐程序设置) MPMusicShuffleModeOff:不随机播放 MPMusicShuffleModeSongs:按歌曲随机播放 MPMusicShuffleModeAlbums:按专辑随机播放
@property (nonatomic, copy) MPMediaItem *nowPlayingItem 正在播放的音乐项
@property (nonatomic, readonly) NSUInteger indexOfNowPlayingItem 当前正在播放的音乐在播放队列中的索引
@property(nonatomic, readonly) BOOL isPreparedToPlay 是否准好播放准备
@property(nonatomic) NSTimeInterval currentPlaybackTime 当前已播放时间,单位:秒
@property(nonatomic) float currentPlaybackRate 当前播放速度,是一个播放速度倍率,0表示暂停播放,1代表正常速度
类方法 说明
+ (MPMusicPlayerController *)applicationMusicPlayer; 获取应用播放器,注意此类播放器无法在后台播放
+ (MPMusicPlayerController *)systemMusicPlayer 获取系统播放器,支持后台播放
对象方法 说明
- (void)setQueueWithQuery:(MPMediaQuery *)query 使用媒体队列设置播放源媒体队列
- (void)setQueueWithItemCollection:(MPMediaItemCollection *)itemCollection 使用媒体项集合设置播放源媒体队列
- (void)skipToNextItem 下一曲
- (void)skipToBeginning 从起始位置播放
- (void)skipToPreviousItem 上一曲
- (void)beginGeneratingPlaybackNotifications 开启播放通知,注意不同于其他播放器,MPMusicPlayerController要想获得通知必须首先开启,默认情况无法获得通知
- (void)endGeneratingPlaybackNotifications 关闭播放通知
- (void)prepareToPlay 做好播放准备(加载音频到缓冲区),在使用play方法播放时如果没有做好准备回自动调用该方法
- (void)play 开始播放
- (void)pause 暂停播放
- (void)stop 停止播放
- (void)beginSeekingForward 开始向前查找(快进)
- (void)beginSeekingBackward 开始向后查找(快退)
- (void)endSeeking 结束查找
通知 说明(注意:要想获得MPMusicPlayerController通知必须首先调用beginGeneratingPlaybackNotifications开启通知)
MPMusicPlayerControllerPlaybackStateDidChangeNotification 播放状态改变
MPMusicPlayerControllerNowPlayingItemDidChangeNotification 当前播放音频改变
MPMusicPlayerControllerVolumeDidChangeNotification 声音大小改变
MPMediaPlaybackIsPreparedToPlayDidChangeNotification 准备好播放
  • MPMusicPlayerController有两种播放器:applicationMusicPlayer和systemMusicPlayer,前者在应用退出后音乐播放会自动停止,后者在应用停止后不会退出播放状态。
  • MPMusicPlayerController加载音乐不同于前面的AVAudioPlayer是通过一个文件路径来加载,而是需要一个播放队列。在MPMusicPlayerController中提供了两个方法来加载播放队列:- (void)setQueueWithQuery:(MPMediaQuery *)query和- (void)setQueueWithItemCollection:(MPMediaItemCollection *)itemCollection,正是由于它的播放音频来源是一个队列,因此MPMusicPlayerController支持上一曲、下一曲等操作。

那么接下来的问题就是如何获取MPMediaQueue或者MPMediaItemCollection?MPMediaQueue对象有一系列的类方法来获得媒体队列:

+ (MPMediaQuery *)albumsQuery;
+ (MPMediaQuery *)artistsQuery;
+ (MPMediaQuery *)songsQuery;
+ (MPMediaQuery *)playlistsQuery;
+ (MPMediaQuery *)podcastsQuery;
+ (MPMediaQuery *)audiobooksQuery;
+ (MPMediaQuery *)compilationsQuery;
+ (MPMediaQuery *)composersQuery;
+ (MPMediaQuery *)genresQuery;

有了这些方法,就可以很容易获到歌曲、播放列表、专辑媒体等媒体队列了,这样就可以通过:- (void)setQueueWithQuery:(MPMediaQuery *)query方法设置音乐来源了。又或者得到MPMediaQueue之后创建MPMediaItemCollection,使用- (void)setQueueWithItemCollection:(MPMediaItemCollection *)itemCollection设置音乐来源。

有时候可能希望用户自己来选择要播放的音乐,这时可以使用MPMediaPickerController,它是一个视图控制器,类似于UIImagePickerController,选择完播放来源后可以在其代理方法中获得MPMediaItemCollection对象。

无论是通过哪种方式获得MPMusicPlayerController的媒体源,可能都希望将每个媒体的信息显示出来,这时候可以通过MPMediaItem对象获得。一个MPMediaItem代表一个媒体文件,通过它可以访问媒体标题、专辑名称、专辑封面、音乐时长等等。无论是MPMediaQueue还是MPMediaItemCollection都有一个items属性,它是MPMediaItem数组,通过这个属性可以获得MPMediaItem对象。

下面就简单看一下MPMusicPlayerController的使用,在下面的例子中简单演示了音乐的选择、播放、暂停、通知、下一曲、上一曲功能,相信有了上面的概念,代码读起来并不复杂(示例中是直接通过MPMeidaPicker进行音乐选择的,但是仍然提供了两个方法getLocalMediaQuery和getLocalMediaItemCollection来演示如何直接通过MPMediaQueue获得媒体队列或媒体集合):

//
//  ViewController.m
//  04-扩展--播放音乐库中的音乐
//
//  Created by Andy on 2020/2/15.
//  Copyright © 2020 李正林. All rights reserved.
//

#import "ViewController.h"
#import <MediaPlayer/MediaPlayer.h>

@interface ViewController () <MPMediaPickerControllerDelegate>

@property (nonatomic,strong) MPMediaPickerController *mediaPicker;//媒体选择控制器
@property (nonatomic,strong) MPMusicPlayerController *musicPlayer; //音乐播放器

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
}

-(void)dealloc {
    
    [self.musicPlayer endGeneratingPlaybackNotifications];
}

/// 获得音乐播放器
- (MPMusicPlayerController *)musicPlayer {
    
    if (!_musicPlayer) {
        _musicPlayer = [MPMusicPlayerController systemMusicPlayer];
        [_musicPlayer beginGeneratingPlaybackNotifications];//开启通知,否则监控不到MPMusicPlayerController的通知
        [self addNotification];//添加通知
        // 如果不使用MPMediaPickerController可以使用如下方法获得音乐库媒体队列
        // [_musicPlayer setQueueWithItemCollection:[self getLocalMediaItemCollection]];
    }
    return _musicPlayer;
}

/// 创建媒体选择器
- (MPMediaPickerController *)mediaPicker {
    
    if (!_mediaPicker) {
        // 初始化媒体选择器,这里设置媒体类型为音乐,其实这里也可以选择视频、广播等
//        _mediaPicker = [[MPMediaPickerController alloc] initWithMediaTypes:MPMediaTypeMusic];
        _mediaPicker = [[MPMediaPickerController alloc] initWithMediaTypes:MPMediaTypeAny];
        _mediaPicker.allowsPickingMultipleItems = YES; // 允许多选
//        _mediaPicker.showsCloudItems = YES; // 显示icloud选项
        _mediaPicker.prompt = @"请选择要播放的音乐";
        _mediaPicker.delegate = self; // 设置选择器代理
    }
    return _mediaPicker;
}

/// 取得媒体队列
- (MPMediaQuery *)getLocalMediaQuery {
    
    MPMediaQuery *mediaQueue = [MPMediaQuery songsQuery];
    for (MPMediaItem *item in mediaQueue.items) {
        NSLog(@"标题:%@,%@",item.title,item.albumTitle);
    }
    return mediaQueue;
}

/// 取得媒体集合
- (MPMediaItemCollection *)getLocalMediaItemCollection {
    
    MPMediaQuery *mediaQueue = [MPMediaQuery songsQuery];
    NSMutableArray *array = [NSMutableArray array];
    for (MPMediaItem *item in mediaQueue.items) {
        
        [array addObject:item];
        NSLog(@"标题:%@,%@",item.title,item.albumTitle);
    }
    MPMediaItemCollection *mediaItemCollection = [[MPMediaItemCollection alloc]initWithItems:[array copy]];
    
    return mediaItemCollection;
}

#pragma mark - MPMediaPickerController代理方法
//选择完成
- (void)mediaPicker:(MPMediaPickerController *)mediaPicker didPickMediaItems:(MPMediaItemCollection *)mediaItemCollection {
    
    MPMediaItem *mediaItem=[mediaItemCollection.items firstObject];//第一个播放音乐
    //注意很多音乐信息如标题、专辑、表演者、封面、时长等信息都可以通过MPMediaItem的valueForKey:方法得到,但是从iOS7开始都有对应的属性可以直接访问
//    NSString *title = [mediaItem valueForKey:MPMediaItemPropertyAlbumTitle];
//    NSString *artist = [mediaItem valueForKey:MPMediaItemPropertyAlbumArtist];
//    MPMediaItemArtwork *artwork = [mediaItem valueForKey:MPMediaItemPropertyArtwork];
    //UIImage *image = [artwork imageWithSize:CGSizeMake(100, 100)]; // 专辑图片
    NSLog(@"标题:%@,表演者:%@,专辑:%@",mediaItem.title ,mediaItem.artist,mediaItem.albumTitle);
    [self.musicPlayer setQueueWithItemCollection:mediaItemCollection];
    [self dismissViewControllerAnimated:YES completion:nil];
}

// 取消选择
- (void)mediaPickerDidCancel:(MPMediaPickerController *)mediaPicker {
    [self dismissViewControllerAnimated:YES completion:nil];
}

#pragma mark - 通知

/// 添加通知
- (void)addNotification {
    
    NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];
    [notificationCenter addObserver:self selector:@selector(playbackStateChange:) name:MPMusicPlayerControllerPlaybackStateDidChangeNotification object:self.musicPlayer];
}

/// 播放状态改变通知
/// @param notification 通知对象
- (void)playbackStateChange:(NSNotification *)notification {
    
    switch (self.musicPlayer.playbackState) {
            
        case MPMusicPlaybackStatePlaying:
            NSLog(@"正在播放...");
            break;
            
        case MPMusicPlaybackStatePaused:
            NSLog(@"播放暂停.");
            break;
            
        case MPMusicPlaybackStateStopped:
            NSLog(@"播放停止.");
            break;
            
        default:
            break;
    }
}

#pragma mark - UI事件
- (IBAction)selectClick:(UIButton *)sender {
    
    [self presentViewController:self.mediaPicker animated:YES completion:nil];
}

- (IBAction)playClick:(UIButton *)sender {
    
    [self.musicPlayer play];
}

- (IBAction)puaseClick:(UIButton *)sender {
    
    [self.musicPlayer pause];
}

- (IBAction)stopClick:(UIButton *)sender {
    
    [self.musicPlayer stop];
}

- (IBAction)nextClick:(UIButton *)sender {
    
    [self.musicPlayer skipToNextItem];
}

- (IBAction)prevClick:(UIButton *)sender {
    
    [self.musicPlayer skipToPreviousItem];
}


@end

运行效果如下:


Simulator Screen Shot - iPhone 11 Pro Max - 2020-02-15 at 19.16.04.png

录音

除了上面说的,在AVFoundation框架中还要一个AVAudioRecorder类专门处理录音操作,它同样支持多种音频格式。与AVAudioPlayer类似,你完全可以将它看成是一个录音机控制类,下面是常用的属性和方法:

属性 说明
@property(readonly, getter=isRecording) BOOL recording; 是否正在录音,只读
@property(readonly) NSURL *url 录音文件地址,只读
@property(readonly) NSDictionary *settings 录音文件设置,只读
@property(readonly) NSTimeInterval currentTime 录音时长,只读,注意仅仅在录音状态可用
@property(readonly) NSTimeInterval deviceCurrentTime 输入设置的时间长度,只读,注意此属性一直可访问
@property(getter=isMeteringEnabled) BOOL meteringEnabled; 是否启用录音测量,如果启用录音测量可以获得录音分贝等数据信息
@property(nonatomic, copy) NSArray *channelAssignments 当前录音的通道
对象方法 说明
- (instancetype)initWithURL:(NSURL *)url settings:(NSDictionary *)settings error:(NSError **)outError 录音机对象初始化方法,注意其中的url必须是本地文件url,settings是录音格式、编码等设置
- (BOOL)prepareToRecord 准备录音,主要用于创建缓冲区,如果不手动调用,在调用record录音时也会自动调用
- (BOOL)record 开始录音
- (BOOL)recordAtTime:(NSTimeInterval)time 在指定的时间开始录音,一般用于录音暂停再恢复录音
- (BOOL)recordForDuration:(NSTimeInterval) duration 按指定的时长开始录音
- (BOOL)recordAtTime:(NSTimeInterval)time forDuration:(NSTimeInterval) duration 在指定的时间开始录音,并指定录音时长
- (void)pause; 暂停录音
- (void)stop; 停止录音
- (BOOL)deleteRecording; 删除录音,注意要删除录音此时录音机必须处于停止状态
- (void)updateMeters; 更新测量数据,注意只有meteringEnabled为YES此方法才可用
- (float)peakPowerForChannel:(NSUInteger)channelNumber; 指定通道的测量峰值,注意只有调用完updateMeters才有值
- (float)averagePowerForChannel:(NSUInteger)channelNumber 指定通道的测量平均值,注意只有调用完updateMeters才有值
代理方法 说明
- (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag 完成录音
- (void)audioRecorderEncodeErrorDidOccur:(AVAudioRecorder *)recorder error:(NSError *)error 录音编码发生错误

AVAudioRecorder很多属性和方法跟AVAudioPlayer都是类似的,但是它的创建有所不同,在创建录音机时除了指定路径外还必须指定录音设置信息,因为录音机必须知道录音文件的格式、采样率、通道数、每个采样点的位数等信息,但是也并不是所有的信息都必须设置,通常只需要几个常用设置。关于录音设置详见帮助文档中的“AV Foundation Audio Settings Constants”。

下面就使用AVAudioRecorder创建一个录音机,实现了录音、暂停、停止、播放等功能,实现效果大致如下:


Simulator Screen Shot - iPhone 11 Pro Max - 2020-02-15 at 21.51.07.png

在这个示例中将实行一个完整的录音控制,包括录音、暂停、恢复、停止,同时还会实时展示用户录音的声音波动,当用户点击完停止按钮还会自动播放录音文件。程序的构建主要分为以下几步:

  1. 设置音频会话类型为AVAudioSessionCategoryPlayAndRecord,因为程序中牵扯到录音和播放操作。
  2. 创建录音机AVAudioRecorder,指定录音保存的路径并且设置录音属性,注意对于一般的录音文件要求的采样率、位数并不高,需要适当设置以保证录音文件的大小和效果。
  3. 设置录音机代理以便在录音完成后播放录音,打开录音测量保证能够实时获得录音时的声音强度。(注意声音强度范围-160到0,0代表最大输入)
  4. 创建音频播放器AVAudioPlayer,用于在录音完成之后播放录音。
  5. 创建一个定时器以便实时刷新录音测量值并更新录音强度到UIProgressView中显示。
  6. 添加录音、暂停、恢复、停止操作,需要注意录音的恢复操作其实是有音频会话管理的,恢复时只要再次调用record方法即可,无需手动管理恢复时间等。

下面是主要代码:

//
//  ViewController.m
//  05-录音
//
//  Created by Andy on 2020/2/15.
//  Copyright © 2020 李正林. All rights reserved.
//

#import "ViewController.h"
#import <AVFoundation/AVFoundation.h>

#define kRecordAudioFile @"myRecord.caf"

@interface ViewController () <AVAudioRecorderDelegate>

@property (nonatomic,strong) AVAudioRecorder *audioRecorder; // 音频录音机
@property (nonatomic,strong) AVAudioPlayer *audioPlayer; // 音频播放器,用于播放录音文件
@property (nonatomic,strong) NSTimer *timer; // 录音声波监控(注意这里暂时不对播放进行监控)

@property (weak, nonatomic) IBOutlet UIButton *record; // 开始录音
@property (weak, nonatomic) IBOutlet UIButton *pause; // 暂停录音
@property (weak, nonatomic) IBOutlet UIButton *resume; // 恢复录音
@property (weak, nonatomic) IBOutlet UIButton *stop; // 停止录音
@property (weak, nonatomic) IBOutlet UIProgressView *audioPower; // 音频波动

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    
    [self setAudioSession];
}

#pragma mark - 私有方法

/// 设置音频会话
- (void)setAudioSession {
    
    AVAudioSession *audioSession=[AVAudioSession sharedInstance];
    // 设置为播放和录音状态,以便可以在录制完之后播放录音
    [audioSession setCategory:AVAudioSessionCategoryPlayAndRecord error:nil];
    [audioSession setActive:YES error:nil];
}

/// 取得录音文件保存路径
- (NSURL *)getSavePath {
    
    NSString *urlStr = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject];
    urlStr = [urlStr stringByAppendingPathComponent:kRecordAudioFile];
    NSLog(@"file path:%@",urlStr);
    NSURL *url=[NSURL fileURLWithPath:urlStr];
    return url;
}

/// 取得录音文件设置
- (NSDictionary *)getAudioSetting {
    
    NSMutableDictionary *dicM=[NSMutableDictionary dictionary];
    // 设置录音格式
    [dicM setObject:@(kAudioFormatLinearPCM) forKey:AVFormatIDKey];
    // 设置录音采样率,8000是电话采样率,对于一般录音已经够了
    [dicM setObject:@(8000) forKey:AVSampleRateKey];
    // 设置通道,这里采用单声道
    [dicM setObject:@(1) forKey:AVNumberOfChannelsKey];
    // 每个采样点位数,分为8、16、24、32
    [dicM setObject:@(8) forKey:AVLinearPCMBitDepthKey];
    // 是否使用浮点数采样
    [dicM setObject:@(YES) forKey:AVLinearPCMIsFloatKey];
    // ....其他设置等
    return dicM;
}

/// 获得录音机对象
- (AVAudioRecorder *)audioRecorder {
    
    if (!_audioRecorder) {
        // 创建录音文件保存路径
        NSURL *url = [self getSavePath];
        // 创建录音格式设置
        NSDictionary *setting = [self getAudioSetting];
        // 创建录音机
        NSError *error = nil;
        _audioRecorder = [[AVAudioRecorder alloc] initWithURL:url settings:setting error:&error];
        _audioRecorder.delegate = self;
        _audioRecorder.meteringEnabled = YES; // 如果要监控声波则必须设置为YES
        if (error) {
            NSLog(@"创建录音机对象时发生错误,错误信息:%@",error.localizedDescription);
            return nil;
        }
    }
    return _audioRecorder;
}

/// 创建播放器
- (AVAudioPlayer *)audioPlayer {
    
    if (!_audioPlayer) {
        NSURL *url = [self getSavePath];
        NSError *error = nil;
        _audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&error];
        _audioPlayer.numberOfLoops = 0;
        [_audioPlayer prepareToPlay];
        if (error) {
            NSLog(@"创建播放器过程中发生错误,错误信息:%@",error.localizedDescription);
            return nil;
        }
    }
    return _audioPlayer;
}

/// 录音声波监控定时器
- (NSTimer *)timer {
    
    if (!_timer) {
        _timer = [NSTimer scheduledTimerWithTimeInterval:0.1f target:self selector:@selector(audioPowerChange) userInfo:nil repeats:YES];
    }
    return _timer;
}

/// 录音声波状态设置
- (void)audioPowerChange {
    
    [self.audioRecorder updateMeters]; // 更新测量值
    float power = [self.audioRecorder averagePowerForChannel:0]; // 取得第一个通道的音频,注意音频强度范围时-160到0
    CGFloat progress = (1.0/160.0) * (power+160.0);
    [self.audioPower setProgress:progress];
}

#pragma mark - UI事件

/// 点击录音按钮
/// @param sender 录音按钮
- (IBAction)recordClick:(UIButton *)sender {
    
    if (![self.audioRecorder isRecording]) {
        [self.audioRecorder record]; // 首次使用应用时如果调用record方法会询问用户是否允许使用麦克风
        self.timer.fireDate = [NSDate distantPast];
    }
}

/// 点击暂定按钮
/// @param sender  暂停按钮
- (IBAction)pauseClick:(UIButton *)sender {
    
    if ([self.audioRecorder isRecording]) {
        [self.audioRecorder pause];
        self.timer.fireDate = [NSDate distantFuture];
    }
}

/// 点击恢复按钮  - 恢复录音只需要再次调用record,AVAudioSession会帮助你记录上次录音位置并追加录音
/// @param sender 恢复按钮
- (IBAction)resumeClick:(UIButton *)sender {
    
    [self recordClick:sender];
}

/// 点击停止按钮
/// @param sender 停止按钮
- (IBAction)stopClick:(UIButton *)sender {
    
    [self.audioRecorder stop];
    self.timer.fireDate = [NSDate distantFuture];
    self.audioPower.progress = 0.0;
}

#pragma mark - 录音机代理方法

/// 录音完成,录音完成后播放录音
/// @param recorder 录音机对象
/// @param flag 是否成功
- (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag {
    if (![self.audioPlayer isPlaying]) {
        [self.audioPlayer play];
    }
    NSLog(@"录音完成!");
}

@end

音频队列服务

大家应该已经注意到了,无论是前面的录音还是音频播放均不支持网络流媒体播放,当然对于录音来说这种需求可能不大,但是对于音频播放来说有时候就很有必要了。AVAudioPlayer只能播放本地文件,并且是一次性加载所以音频数据,初始化AVAudioPlayer时指定的URL也只能是File URL而不能是HTTP URL。当然,将音频文件下载到本地然后再调用AVAudioPlayer来播放也是一种播放网络音频的办法,但是这种方式最大的弊端就是必须等到整个音频播放完成才能播放,而不能使用流式播放,这往往在实际开发中是不切实际的。那么在iOS中如何播放网络流媒体呢?就是使用AudioToolbox框架中的音频队列服务Audio Queue Services。

使用音频队列服务完全可以做到音频播放和录制,首先看一下录音音频服务队列:

一个音频服务队列Audio Queue有三部分组成:

三个缓冲器Buffers:每个缓冲器都是一个存储音频数据的临时仓库。

一个缓冲队列Buffer Queue:一个包含音频缓冲器的有序队列。

一个回调Callback:一个自定义的队列回调函数。

声音通过输入设备进入缓冲队列中,首先填充第一个缓冲器;当第一个缓冲器填充满之后自动填充下一个缓冲器,同时会调用回调函数;在回调函数中需要将缓冲器中的音频数据写入磁盘,同时将缓冲器放回到缓冲队列中以便重用。下面是Apple官方关于音频队列服务的流程示意图:

类似的,看一下音频播放缓冲队列,其组成部分和录音缓冲队列类似。

但是在音频播放缓冲队列中,回调函数调用的时机不同于音频录制缓冲队列,流程刚好相反。将音频读取到缓冲器中,一旦一个缓冲器填充满之后就放到缓冲队列中,然后继续填充其他缓冲器;当开始播放时,则从第一个缓冲器中读取音频进行播放;一旦播放完之后就会触发回调函数,开始播放下一个缓冲器中的音频,同时填充第一个缓冲器放;填充满之后再次放回到缓冲队列。下面是详细的流程:

当然,要明白音频队列服务的原理并不难,问题是如何实现这个自定义的回调函数,这其中我们有大量的工作要做,控制播放状态、处理异常中断、进行音频编码等等。由于牵扯内容过多,而且不是本文目的,如果以后有时间将另开一篇文章重点介绍,目前有很多第三方优秀框架可以直接使用,例如AudioStreamerFreeStreamer。由于前者当前只有非ARC版本,所以下面不妨使用FreeStreamer来简单演示在线音频播放的过程,当然在使用之前要做如下准备工作:

  1. 拷贝FreeStreamer中的Reachability.h、Reachability.m和Common、astreamer两个文件夹中的内容到项目中。
  2. 添加FreeStreamer使用的类库:CFNetwork.framework、AudioToolbox.framework、AVFoundation.framework
    、libxml2.dylib、MediaPlayer.framework。
  3. 如果引用libxml2.dylib编译不通过,需要在Xcode的Targets-Build Settings-Header Build Path中添加$(SDKROOT)/usr/include/libxml2。
  4. 将FreeStreamer中的FreeStreamerMobile-Prefix.pch文件添加到项目中并将Targets-Build Settings-Precompile Prefix Header设置为YES,在Targets-Build Settings-Prefix Header设置为$(SRCROOT)/项目名称/FreeStreamerMobile-Prefix.pch(因为Xcode6默认没有pch文件)

然后就可以编写代码播放网络音频了:

//
//  FreeStreamerManager.h
//  06-音频队列服务
//
//  Created by Andy on 2020/2/16.
//  Copyright © 2020 李正林. All rights reserved.
//

#import <Foundation/Foundation.h>
#import <FSAudioStream.h>

@protocol FreeStreamerPlayFinishedManagerDelegate <NSObject>

- (void)freeStreamerManagerPlayFinished;

@end

NS_ASSUME_NONNULL_BEGIN

@interface FreeStreamerManager : NSObject

@property (nonatomic, weak) id<FreeStreamerPlayFinishedManagerDelegate> delegate;

@property (nonatomic, strong) FSAudioStream * audioStream;

/**
 播放,可以播放网络音频资源或本地fileurl音频资源

 @param url 网络或本地URL资源
 */
- (void)freeStreamerManagerPlayWithUrl:(NSURL *)url;

/**
 暂停
 */
- (void)freeStreamerManagerPause;

/**
 恢复播放
 */
- (void)freeStreamerManagerResume;

/**
 停止
 */
- (void)freeStreamerManagerStop;

/**
 上一首

 @param prevUrl URL音频文件资源
 */
- (void)freeStreamerManagerPrevWithUrl:(NSURL *)prevUrl;

/**
 下一首

 @param nextUrl URL音频文件资源
 */
- (void)freeStreamerManagerNextWithUrl:(NSURL *)nextUrl;

/// 上一首 或下一首
/// @param prevOrNextUrl URL音频文件资源
- (void)freeStreamerManagerPrevOrNextWithUrl:(NSURL *)prevOrNextUrl;

@end

NS_ASSUME_NONNULL_END

//
//  FreeStreamerManager.m
//  06-音频队列服务
//
//  Created by Andy on 2020/2/16.
//  Copyright © 2020 李正林. All rights reserved.
//

#import "FreeStreamerManager.h"

@interface FreeStreamerManager ()

@property (nonatomic, strong) NSTimer * audioTimer;

@end

@implementation FreeStreamerManager

- (void)freeStreamerManagerPlayWithUrl:(NSURL *)url {
    
    if (!_audioStream) {
        _audioStream = [[FSAudioStream alloc] init];
        // 播放失败的回调
        _audioStream.onFailure = ^(FSAudioStreamError error, NSString *errorDescription) {
            NSLog(@"播放过程中发生错误,错误信息:%@",errorDescription);
        };
        // 播放完成的回调
        __weak typeof(self) weakSelf = self;
        _audioStream.onCompletion=^(){
            NSLog(@"播放完成!");
            if ([weakSelf.delegate respondsToSelector:@selector(freeStreamerManagerPlayFinished)]) {
                
                [weakSelf.delegate freeStreamerManagerPlayFinished];
            }
        };
        // 设置音量
        [_audioStream setVolume:0.5];
        // 使用音频链接URL播放音频
        [_audioStream playFromURL:url];
    }
    // 不进行检测格式 <开启检测之后,有些网络音频链接无法播放>
    _audioStream.strictContentTypeChecking = NO;
    _audioStream.defaultContentType = @"audio/mpeg";
    
    if (!self.audioStream.isPlaying) {
        [self.audioStream play];
    }
}

- (void)freeStreamerManagerPause {
    
    if (self.audioStream.isPlaying) {
        [self.audioStream pause];
    }
}

- (void)freeStreamerManagerResume {
    
    if (!self.audioStream.isPlaying) {
        [self.audioStream pause];
    }
}

- (void)freeStreamerManagerStop {
    
    [self.audioStream stop];
}

- (void)freeStreamerManagerPrevWithUrl:(NSURL *)prevUrl {
    
    [self.audioStream playFromURL:prevUrl];
}

- (void)freeStreamerManagerNextWithUrl:(NSURL *)nextUrl {
    
    [self.audioStream playFromURL:nextUrl];
}

- (void)freeStreamerManagerPrevOrNextWithUrl:(NSURL *)prevOrNextUrl {
    
    [self.audioStream playFromURL:prevOrNextUrl];
}

@end


//
//  ViewController.m
//  06-音频队列服务 - AudioQueueServices
//
//  Created by Andy on 2020/2/15.
//  Copyright © 2020 李正林. All rights reserved.
// 使用FreeStreamer实现网络音频播放


#import "ViewController.h"
#import "FreeStreamerManager.h"

@interface ViewController () <FreeStreamerPlayFinishedManagerDelegate>

/// 播放序号
@property (weak, nonatomic) IBOutlet UILabel *musicIndexLabel;

/// 背景图
@property (weak, nonatomic) IBOutlet UIImageView *backgroundIconImgView;

/// FreeStreamerManager
@property (nonatomic, strong) FreeStreamerManager * streamerManager;

/// 进度
@property (weak, nonatomic) IBOutlet UISlider *progress;

/// 音量
@property (weak, nonatomic) IBOutlet UISlider *valumSlider;

/// 当前播放时间
@property (weak, nonatomic) IBOutlet UILabel *currentTimelabel;

/// 总时长
@property (weak, nonatomic) IBOutlet UILabel *totalTimeLabel;

@property (nonatomic, assign) NSInteger totalTime;

@property (nonatomic, strong) NSTimer * audioTimer;

/// 当前播放序号
@property (nonatomic, assign) NSInteger currentIndex;

/// 总列表序号
@property (nonatomic, assign) NSInteger totalIndex;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    
    [self setData];
}

- (void)setData {
    
    self.currentIndex = 1;
    self.totalIndex = 51;
    
    self.musicIndexLabel.text = [NSString stringWithFormat:@"%ld / %ld", self.currentIndex, self.totalIndex];
}

- (NSURL *)getFileUrl {
    
    NSString *urlString = [[NSBundle mainBundle] pathForResource:@"KGETOSKBDLQMOD01.mp3" ofType:nil];
    NSURL *url = [NSURL fileURLWithPath:urlString];
    return url;
}

- (NSURL *)getNetworkUrl {
    
    NSString *urlString = @"http://up.mcyt.net/down/45957.mp3";
    NSURL *url = [NSURL URLWithString:urlString];
    return url;
}

- (FreeStreamerManager *)streamerManager{
    if (!_streamerManager) {
        _streamerManager = [[FreeStreamerManager alloc] init];
        _streamerManager.delegate = self;
    }
    return _streamerManager;
}


- (IBAction)play:(id)sender {
    
    if (!_streamerManager) {
        _streamerManager = [[FreeStreamerManager alloc] init];
    }
    [_streamerManager freeStreamerManagerPlayWithUrl:[self getMusicUrlWithCurrentIndex:self.currentIndex]];
    [self addTimer];
}

- (IBAction)pause:(id)sender {
    
    [_streamerManager freeStreamerManagerPause];
}

- (IBAction)resume:(id)sender {
    
    [_streamerManager freeStreamerManagerResume];
}

- (IBAction)stop:(id)sender {
    
    [_streamerManager freeStreamerManagerStop];
    [self removeTimer];
}

// 上一曲
- (IBAction)prev:(id)sender {
    
    [self.streamerManager freeStreamerManagerStop];
    self.currentIndex--;
    
}

// 下一曲
- (IBAction)next:(id)sender {
    
    [self.streamerManager freeStreamerManagerStop];
    self.currentIndex++;
    
}

- (void)setCurrentIndex:(NSInteger)currentIndex {
    
    _currentIndex = currentIndex;
    
    if (_currentIndex == 0) {
        _currentIndex = self.totalIndex;
    } else if (_currentIndex == self.totalIndex + 1) {
        _currentIndex = 1;
    }
    
    self.musicIndexLabel.text = [NSString stringWithFormat:@"%ld / %ld", self.currentIndex, self.totalIndex];
    
    [self.streamerManager freeStreamerManagerPrevOrNextWithUrl:[self getMusicUrlWithCurrentIndex:_currentIndex]];
    
    self.backgroundIconImgView.image = [UIImage imageNamed:[self getMusicBackgroundImageViewFileNameWithCurrentIndex:_currentIndex]];
}

- (NSURL *)getMusicUrlWithCurrentIndex:(NSInteger)currentIndex {
    
    NSString *musicName = [NSString stringWithFormat:@"%@%02ld.mp3", @"KGETOSKBDLQMOD", currentIndex];
    
    NSString *urlString = [[NSBundle mainBundle] pathForResource:musicName ofType:nil];
    NSURL *url = [NSURL fileURLWithPath:urlString];
    return url;
}

- (NSString *)getMusicBackgroundImageViewFileNameWithCurrentIndex:(NSInteger)currentIndex {
    
    NSString *backgroundImageViewName = [NSString stringWithFormat:@"0C55353F-%ld", currentIndex];
    return backgroundImageViewName;
}

// 快进 / 快退
// 使用[self.audioStream seekToPosition:position]进行播放进度的切换
//根据不同的状态给UISlider添加不同的addTarget方法:

- (IBAction)changeValueForProgress:(UISlider *)sender {
    
    [self.progress addTarget:self action:@selector(progressChangeAction:) forControlEvents:(UIControlEventValueChanged)];
//    [self.progress addTarget:self action:@selector(progressTouchBeginAction:) forControlEvents:(UIControlEventTouchDown)];
//    [self.progress addTarget:self action:@selector(progressTouchEndAction:) forControlEvents:(UIControlEventTouchUpInside)];
}

- (IBAction)changeValueForProgressTouchDown:(UISlider *)sender {
    
    [self.progress addTarget:self action:@selector(progressTouchBeginAction:) forControlEvents:(UIControlEventTouchDown)];
}

- (IBAction)changeValueForProgressTouchUpInside:(UISlider *)sender {
    
    [self.progress addTarget:self action:@selector(progressTouchEndAction:) forControlEvents:(UIControlEventTouchUpInside)];
}

- (void)addTimer {
    
    self.audioTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(playProgressAction) userInfo:nil repeats:YES];
    [self.audioTimer fire];
    NSLog(@"addTimer");
}

- (void)removeTimer {
    
    [self.audioTimer invalidate];
    self.audioTimer = nil;
    NSLog(@"removeTimer");
}

// 进度正在改变
- (void)progressChangeAction:(UISlider *)slider {
    float value = slider.value;
    // 进度 * 总时间 获取当前时间
    float current = value * _totalTime;
    // 当前分钟数
    double minutesElapsed =floor(fmod(current/60.0,60.0));
    // 当前秒数
    double secondsElapsed =floor(fmod(current,60.0));
    // 格式化当前时间
    NSString *currentTime = [NSString stringWithFormat:@"%02.0f:%02.0f", minutesElapsed, secondsElapsed];
    // 改变显示当前时间的标签文字
    self.currentTimelabel.text = currentTime;
}
// 开始改变进度
- (void)progressTouchBeginAction:(UISlider *)sender {
    NSLog(@"开始触摸");
    [self removeTimer];
    // 暂停
    [self pause:nil];
}
// 结束改变进度
- (void)progressTouchEndAction:(UISlider *)sender {
    NSLog(@"结束触摸");
    [self addTimer];
    // 播放
    [self play:nil];
    // 获取进度 0 ~ 1
    float value = sender.value == 0 ? 0.001 : sender.value;
    // 创建播放进度对象
    FSStreamPosition position;
    // 赋值
    position.position = value;
    // 跳转进度
    [self.streamerManager.audioStream seekToPosition:position];
}

- (void)playProgressAction {
    
    FSStreamPosition cur = self.streamerManager.audioStream.currentTimePlayed;
    float playbackTime = cur.playbackTimeInSeconds/1;
    double minutesElapsed = floor(fmod(playbackTime/60.0,60.0));
    double secondsElapsed = floor(fmod(playbackTime,60.0));
    NSString *currentTime = [NSString stringWithFormat:@"%02.0f:%02.0f", minutesElapsed, secondsElapsed];
    NSLog(@"当前播放时间:%f", playbackTime);//播放进度
    NSLog(@"格式化当前播放时间:%@", currentTime);
    // 获取视频的总时长
    float totalTime = playbackTime / cur.position;
    // 记录音频总时间
    _totalTime = totalTime;
    NSLog(@"总时间:%f", totalTime);
    if ([[NSString stringWithFormat:@"%f",totalTime] isEqualToString:@"nan"]) {
        NSLog(@"格式化总时间:00:00");
    }else{
        double minutesElapsed1 =floor(fmod(totalTime/60.0,60.0));
        double secondsElapsed1 =floor(fmod(totalTime,60.0));
        NSString *total = [NSString stringWithFormat:@"/ %02.0f:%02.0f",minutesElapsed1, secondsElapsed1];
        NSLog(@"格式化总时间:%@", total);
        // 改变当前播放时间和音频总时间的显示
        self.currentTimelabel.text = currentTime;
        self.totalTimeLabel.text = total;
    }
    float  prebuffer = (float)self.streamerManager.audioStream.prebufferedByteCount;
    float contentlength = (float)self.streamerManager.audioStream.contentLength;
    if (contentlength>0) {
        NSLog(@"缓存进度:%f", prebuffer / contentlength);
        // 改变播放进度
        self.progress.value = cur.position;
    }
}

- (IBAction)valumChangeAction:(UISlider *)sender {
    
    [self.valumSlider addTarget:self action:@selector(valumSliderChangeAction:) forControlEvents:(UIControlEventValueChanged)];
}

- (void)valumSliderChangeAction:(UISlider *)slider {
    
    self.streamerManager.audioStream.volume = slider.value;
}




#pragma mark- 接收远程控制信息

// 配置第一响应者
// 让播放控制类成为第一响应者,后台的控制在该类中响应:
- (void)viewWillAppear:(BOOL)animated {
    
    [super viewWillAppear:animated];
    
    //以及设置app支持接受远程控制事件代码。设置app支持接受远程控制事件,
    
    //其实就是在dock中可以显示应用程序图标,同时点击该图片时,打开app。
    
    //或者锁屏时,双击home键,屏幕上方出现应用程序播放控制按钮。
    
    [[UIApplication sharedApplication] beginReceivingRemoteControlEvents];
    
    [self becomeFirstResponder]; //成为FristResponder
}

- (void)viewWillDisappear:(BOOL)animated {
    
    [super viewWillDisappear:animated];
    
    [[UIApplication sharedApplication] endReceivingRemoteControlEvents];
    
    [self resignFirstResponder];
}

// 接收远程控制信息
// 实现远程控制接收事件,进行区分事件的类别,响应不同的操作:
- (void)remoteControlReceivedWithEvent:(UIEvent *)event {
    if (event.type == UIEventTypeRemoteControl) {
        switch (event.subtype) {
                // 播放
            case UIEventSubtypeRemoteControlPlay:
            {
                [self play:nil];
            }
                break;
                // 暂停
            case UIEventSubtypeRemoteControlPause:
            {
                [self pause:nil];
            }
                break;
                // 停止播放
            case UIEventSubtypeRemoteControlStop:
            {
                [self.streamerManager.audioStream stop];
            }
                break;
                // 播放下一曲按钮
            case UIEventSubtypeRemoteControlNextTrack:
            {
                [self next:nil];
            }
                break;
                // 播放上一曲按钮
            case UIEventSubtypeRemoteControlPreviousTrack:
            {
                [self prev:nil];
            }
                break;
            case UIEventSubtypeRemoteControlTogglePlayPause:
            {
                if (self.streamerManager.audioStream.isPlaying) {
                    [self pause:nil];
                } else {
                    [self play:nil];
                }
            }
                break;
            default:
                break;
        }
    }
}

#pragma mark-  FreeStreamerPlayFinishedManagerDelegate
- (void)freeStreamerManagerPlayFinished {
    
    NSLog(@"---FreeStreamerPlayFinishedManagerDelegate---play finished-----");
    self.currentIndex++;
}

/**
// 修改锁屏界面音频信息
// 当前音频开始播放及时修改信息。
// 改变锁屏歌曲信息
- (void)setLockScreenNowPlayingInfo {
    
    //更新锁屏时的歌曲信息
    if (NSClassFromString(@"MPNowPlayingInfoCenter")) {
        
        NSMutableDictionary *dict = [[NSMutableDictionary alloc] init];
        // 歌曲名
        [dict setObject:@"体面" forKey:MPMediaItemPropertyTitle];
        // 演唱者
        [dict setObject:@"于文文" forKey:MPMediaItemPropertyArtist];
        // 专辑名
        [dict setObject:@"专辑《体面》" forKey:MPMediaItemPropertyAlbumTitle];
        
        //专辑缩略图
        UIImage *newImage = [UIImage imageNamed:@"音乐"];
        [dict setObject:[[MPMediaItemArtwork alloc] initWithImage:newImage] forKey:MPMediaItemPropertyArtwork];
        
        //设置锁屏状态下屏幕显示播放音乐信息
        [[MPNowPlayingInfoCenter defaultCenter] setNowPlayingInfo:dict];
        
    }
    
}
 */


// 修改锁屏界面音频的播放进度
/**
 定时器修改进度
 
 @param duration 总时间
 @param current 当前时间
 */
//- (void)changeLockProgress:(NSInteger)duration current:(NSInteger)current {
//    if(self.audioStream.isPlaying) {
//
//        //当前播放时间
//        NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithDictionary:[[MPNowPlayingInfoCenter defaultCenter] nowPlayingInfo]];
//        // 歌曲总时长
//        [dict setObject:@(duration) forKey:MPMediaItemPropertyPlaybackDuration];
//        // 当前播放时间
//        [dict setObject:@(current) forKey:MPNowPlayingInfoPropertyElapsedPlaybackTime];
//        [[MPNowPlayingInfoCenter defaultCenter] setNowPlayingInfo:dict];
//
//    }
//}


@end


运行效果如下:


Simulator Screen Shot - iPhone 11 Pro Max - 2020-02-16 at 19.17.04.png

其实FreeStreamer的功能很强大,不仅仅是播放本地、网络音频那么简单,它还支持播放列表、检查包内容、RSS订阅、播放中断等很多强大的功能,甚至还包含了一个音频分析器,有兴趣的朋友可以访问官网查看详细用法。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容