阅读 347

AVFoundation 播放和录制音频

1 音频会话

1.1 分类 category

iOS 利用音频会话(audio session)实现可管理的音频环境,音频会话提供简单实用的方法使 OS 得知应用程序应该如何与 iOS 音频环境进行交互。AVFoundation 定义了 7 种分类来描述音频行为

分类 作用 是否允许混音 音频输入输出模式 是否支持后台 是否遵循静音切换
Ambient 游戏、效率应用程序 支持 O 不支持 不支持
Solo Ambient(default) 游戏、效率应用程序 不支持 O 不支持 遵循
Playback 音频和视频播放器 可选 O 支持 不遵循
Record 录音机、音频捕捉 不支持 I 支持 不遵循
Play and Record VoIP、语音聊天 可选 I/O 支持 不遵循
Audio Processing 离线会话和处理 F 不能播放和录制 不遵循
Multi-Route 使用外部硬件的高级 A/V 应用程序 F I/O 不遵循

同时可以用 options 和 modes 进一步自定义开发。

1.1.1 options

options 有以下选项

  • AVAudioSessionCategoryOptionMixWithOthers

支持 AVAudioSessionCategoryPlayAndRecord, AVAudioSessionCategoryPlayback, 和 AVAudioSessionCategoryMultiRoute,AVAudioSessionCategoryAmbient 自动设置了此选项,AVAudioSessionCategoryOptionDuckOthers 和AVAudioSessionCategoryOptionInterruptSpokenAudioAndMixWithOthers 也自动设置了此选项。如果使用这个选项激活会话,应用程序的音频不会中断从其他应用程序(如音乐应用程序)的音频,否则激活会话会打断其他音频会话。

  • AVAudioSessionCategoryOptionDuckOthers

支持 AVAudioSessionCategoryAmbient,AVAudioSessionCategoryPlayAndRecord, AVAudioSessionCategoryPlayback, 和 AVAudioSessionCategoryMultiRoute。设置此选项能够在播放音频时低音量听到后台播放的其他音频。整个选项周期与会话激活周期一致。

  • AVAudioSessionCategoryOptionAllowBluetooth

支持 AVAudioSessionCategoryRecord,AVAudioSessionCategoryPlayAndRecord;允许蓝牙免提设备启用。当应用使用 setPreferredInput:error: 方法选择了蓝牙无线设备作为输入时,也会自动选择相应的蓝牙设备作为输出,使用 MPVolumeView 对象将蓝牙设备作为输出时,输入也会相应改变。

  • AVAudioSessionCategoryOptionDefaultToSpeaker

支持 AVAudioSessionCategoryPlayAndRecord;在没有其他的音频路径(如耳机)可以使用的情况下设置这个选项,会议音频将通过设备的内置扬声器播放。当不设置此选项,并且没有其他的音频输出可用或选择时,音频将通过接收器播放。只有 iPhone 设备都配备有一个接收器; iPad 和 iPod touch 设备,此选项没有任何效果

当你的 iPhone 接有多个外接音频设备时(耳塞,蓝牙耳机等),AudioSession 将遵循 last-in wins 的原则来选择外接设备,即声音将被导向最后接入的设备。

当没有接入任何音频设备时,一般情况下声音会默认从扬声器出来,但有一个例外的情况:在 PlayAndRecord 这个 category 下,听筒会成为默认的输出设备。如果你想要改变这个行为,可以提供 MPVolumeView 来让用户切换到扬声器,也可通过 overrideOutputAudioPort 方法来 programmingly 切换到扬声器,也可以修改 category option 为AVAudioSessionCategoryOptionDefaultToSpeaker。

  • AVAudioSessionCategoryOptionInterruptSpokenAudioAndMixWithOthers

支持 AVAudioSessionCategoryPlayAndRecord, AVAudioSessionCategoryPlayback, and AVAudioSessionCategoryMultiRoute,设置此选项能使应用程序的音频会话与其他会话混合,但是会中断使用了 AVAudioSessionModeSpokenAudio 模式的会话。其他应用的音频会在此会话启动后暂停,并在此会话关闭后重新恢复。

在用到 AVAudioSessionCategoryOptionInterruptSpokenAudioAndMixWithOthers 选项时,中断了其他应用的音频后,自己的应用音频结束播放时,若想恢复其他应用的音频,需要在关闭音频会话的时候设置AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation 选项

[session setActive:NO
       withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation
             error:<#Your error object, or nil for testing#>];
复制代码
  • AVAudioSessionCategoryOptionAllowAirPlay

支持 AVAudioSessionCategoryPlayAndRecord,允许会话在 AirPlay 设备上执行。

1.1.2 mode

mode 用于定制化 audio sessions,如果将分类的 mode 设置不合理会执行默认的模式行为,如将 AVAudioSessionCategoryMultiRoute 类别设置 AVAudioSessionModeGameChat 模式。

  • AVAudioSessionModeDefault 默认音频会话模式

  • AVAudioSessionModeVoiceChat 如果应用需要执行例如 VoIP 类型的双向语音通信则选择此模式

  • AVAudioSessionModeVideoChat 如果应用正在进行在线视频会议,请指定此模式

  • AVAudioSessionModeGameChat 该模式由Game Kit 提供给使用 Game Kit 的语音聊天服务的应用程序设置

  • AVAudioSessionModeVideoRecording 如果应用正在录制电影,则选此模式

  • AVAudioSessionModeMeasurement 如果您的应用正在执行音频输入或输出的测量,请指定此模式

  • AVAudioSessionModeMoviePlayback 如果您的应用正在播放电影内容,请指定此模式

  • AVAudioSessionModeSpokenAudio 当需要持续播放语音,同时希望在其他程序播放短语音时暂停播放此应用语音,选取此模式

1.2 配置音频会话

首先获得指向 AVAudioSession 的单例指针,设置合适的分类,最后激活会话。

    AVAudioSession *session = [AVAudioSession sharedInstance];

    NSError *error;
    if (![session setCategory:AVAudioSessionCategoryPlayAndRecord error:&error]) {
        NSLog(@"Category Error: %@", [error localizedDescription]);
    }

    if (![session setActive:YES error:&error]) {
        NSLog(@"Activation Error: %@", [error localizedDescription]);
    }
复制代码

2. 播放音频

AVAudioPlayer 构建于 Core Audio 的 C-based Audio Queue Services 最顶层,局限性在于无法从网络流播放音频,不能访问原始音频样本,不能满足非常低的时延。

2.1 创建 AVAudioPlayer

可以通过 NSData 或本地音频文件的 NSURL 两种方式创建 AVAudioPlayer。

    NSURL *fileUrl = [[NSBundle mainBundle] URLForResource:@"rock" withExtension:@"mp3"];
    self.player = [[AVAudioPlayer alloc] initWithContentsOfURL:fileUrl error:nil];
    if (self.player) {
        [self.player prepareToPlay];
    }
复制代码

创建出 AVAudioPlayer 后建议调用 prepareToPlay 方法,这个方法会取得需要的音频硬件并预加载 Audio Queue 的缓冲区,当然如果不主动调用,执行 play 方法时也会默认调用,但是会造成轻微播放的延时。

2.2 对播放进行控制

AVAudioPlayer 的 play 可以播放音频,stop 和 pause 都可以暂停播放,但是 stop 会撤销调用 prepareToPlay 所做的设置。

  • 修改播放器的音量:播放器音量独立于系统音量,音量或播放增益定义为 0.0(静音)到 1.0(最大音量)之间的浮点值
  • 修改播放器的 pan 值:允许使用立体声播放声音,pan 值从 -1.0(极左)到 1.0(极右),默认值 0.0(居中)
  • 调整播放率:0.5(半速)到 2.0(2 倍速)
  • 设置 numberOfLoops 实现无缝循环:-1 表示无限循环(音频循环可以是未压缩的线性 PCM 音频,也可以是 AAC 之类的压缩格式音频,MP3 格式不推荐循环)
  • 音频计量:当播放发生时从播放器读取音量力度的平均值和峰值

2.3 实践

2.3.1 播放音频

        NSTimeInterval delayTime = [self.players[0] deviceCurrentTime] + 0.01;
        for (AVAudioPlayer *player in self.players) {
            [player playAtTime:delayTime];
        }
        self.playing = YES;
复制代码

对于多个需要播放的音频,如果希望同步播放效果,则需要捕捉当前设备时间并添加一个小延时,从而具有一个从开始播放时间计算的参照时间。deviveCurrentTime 是一个独立于系统事件的音频设备的时间值,当有多于 audioPlayer 处于 play 或者 pause 状态时 deviveCurrentTime 会单调增加,没有时置位为 0。playAtTime 的参数 time 要求必须是基于 deviveCurrentTime 且大于等于 deviveCurrentTime 的时间。

2.3.2 暂停播放

        for (AVAudioPlayer *player in self.players) {
            [player stop];
            player.currentTime = 0.0f;
        }
复制代码

暂停时需要将 audioPlayer 的 currentTime 值设置为 0.0,当音频正在播放时,这个值用于标识当前播放位置的偏移,不播放音频时标识重新播放音频的起始偏移。

2.3.4 修改音量、pan值、播放速率和循环

player.enableRate = YES;
player.rate = rate;
player.volume = volume;
player.pan = pan;
player.numberOfLoops = -1;
复制代码

2.4 配置音频会话

如果希望应用程序播放音频时屏蔽静音切换动作,需要设置会话分类为 AVAudioSessionCategoryPlayback,但是如果希望按下锁屏后还可以播放,就需要在 plist 里加入一个 Required background modes 类型的数组,在其中添加 App plays audio or streams audio/video using AirPlay。

2.5 处理中断事件

中断事件是指电话呼入、闹钟响起、弹出 FaceTime 等,中断事件发生时系统会调用 AVAudioPlayer 的 AVAudioPlayerDelegate 类型的 delegate 的下列方法

- (void)audioPlayerBeginInterruption:(AVAudioPlayer *)player NS_DEPRECATED_IOS(2_2, 8_0);
- (void)audioPlayerEndInterruption:(AVAudioPlayer *)player withOptions:(NSUInteger)flags NS_DEPRECATED_IOS(6_0, 8_0);
复制代码

中断结束调用的方法会带入一个 options 参数,如果是 AVAudioSessionInterruptionOptionShouldResume 则表明可以恢复播放音频了。

2.6 处理线路改变

在 iOS 设备上添加或移除音频输入、输出线路时会引发线路改变,最佳实践是,插入耳机时播放动作不改动,拔出耳机时应当暂停播放。

首先需要监听通知

        NSNotificationCenter *nsnc = [NSNotificationCenter defaultCenter];
        [nsnc addObserver:self
                 selector:@selector(handleRouteChange:)
                     name:AVAudioSessionRouteChangeNotification
                   object:[AVAudioSession sharedInstance]];
复制代码

然后判断是旧设备不可达事件,进一步取出旧设备的描述,判断旧设备是否是耳机,再做暂停播放处理。

- (void)handleRouteChange:(NSNotification *)notification {

    NSDictionary *info = notification.userInfo;

    AVAudioSessionRouteChangeReason reason =
        [info[AVAudioSessionRouteChangeReasonKey] unsignedIntValue];

    if (reason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable) {

        AVAudioSessionRouteDescription *previousRoute =
            info[AVAudioSessionRouteChangePreviousRouteKey];

        AVAudioSessionPortDescription *previousOutput = previousRoute.outputs[0];
        NSString *portType = previousOutput.portType;

        if ([portType isEqualToString:AVAudioSessionPortHeadphones]) {
            [self stop];
            [self.delegate playbackStopped];
        }
    }
}
复制代码

这里 AVAudioSessionPortHeadphones 只包含了有线耳机,无线蓝牙耳机需要判断 AVAudioSessionPortBluetoothA2DP 值。

3. 录制音频

AVAudioRecorder 用于负责录制音频。

3.1 创建 AVAudioRecorder

创建 AVAudioRecorder 需要以下信息

  • 用于写入音频的本地文件 URL
  • 用于配置录音会话键值信息的字典
  • 用于捕捉错误的 NSError
        NSString *tmpDir = NSTemporaryDirectory();
        NSString *filePath = [tmpDir stringByAppendingPathComponent:@"memo.caf"];
        NSURL *fileURL = [NSURL fileURLWithPath:filePath];

        NSDictionary *settings = @{
                                   AVFormatIDKey : @(kAudioFormatAppleIMA4),
                                   AVSampleRateKey : @44100.0f,
                                   AVNumberOfChannelsKey : @1,
                                   AVEncoderBitDepthHintKey : @16,
                                   AVEncoderAudioQualityKey : @(AVAudioQualityMedium)
                                   };

        NSError *error;
        self.recorder = [[AVAudioRecorder alloc] initWithURL:fileURL settings:settings error:&error];
        if (self.recorder) {
            self.recorder.delegate = self;
            self.recorder.meteringEnabled = YES;
            [self.recorder prepareToRecord];
        } else {
            NSLog(@"Error: %@", [error localizedDescription]);
        }
复制代码

prepareToRecord 方法执行底层 Audio Queue 初始化必要过程,并在指定位置创建文件。

3.2 通用设置参数

  • 音频格式

AVFormatIDKey 键对应写入内容的音频格式,它有以下可选值

kAudioFormatLinearPCM
kAudioFormatMPEG4AAC
kAudioFormatAppleLossless
kAudioFormatAppleIMA4
kAudioFormatiLBC
kAudioFormatULaw
复制代码

kAudioFormatLinearPCM 会将未压缩的音频流写入文件,文件体积大。kAudioFormatMPEG4AAC 和 kAudioFormatAppleIMA4 的压缩格式会显著缩小文件,并保证高质量音频内容。但是要注意,制定的音频格式与文件类型应该兼容,例如 wav 格式对应 kAudioFormatLinearPCM 值。

  • 采样率

AVSampleRateKey 指示采样率,即对输入的模拟音频信号每一秒内的采样数。常用值 8000,16000,22050,44100。

  • 通道数

AVNumberOfChannelsKey 指示定义记录音频内容的通道数,除非使用外部硬件录制,否则通常选择单声道。

  • 编码位元深度

AVEncoderBitDepthHintKey 指示编码位元深度,从 8 到 32。

  • 音频质量

AVEncoderAudioQualityKey 指示音频质量,可选值有 AVAudioQualityMin, AVAudioQualityLow, AVAudioQualityMedium, AVAudioQualityHigh, AVAudioQualityMax。

3.3 实践

3.3.1 配置音频会话

录音和播放应用应当使用 AVAudioSessionCategoryPlayAndRecord 分类来配置会话。

    AVAudioSession *session = [AVAudioSession sharedInstance];

    NSError *error;
    if (![session setCategory:AVAudioSessionCategoryPlayAndRecord error:&error]) {
        NSLog(@"Category Error: %@", [error localizedDescription]);
    }

    if (![session setActive:YES error:&error]) {
        NSLog(@"Activation Error: %@", [error localizedDescription]);
    }
复制代码

注意录音前需要申请麦克风权限。

3.3.2 录音控制

对录音过程的控制如下

[self.recorder record];
[self.recorder pause];
[self.recorder stop];
复制代码

其中选择了 stop 录音即停止,此时 AVAudioRecorder 会调用其遵循 AVAudioRecorderDelegate 协议的代理的 - (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag 方法。

3.3.3 录音保存

在初始化 AVAudioRecorder 时指定了临时文件目录作为存储音频的位置,音频录制结束时需要保存到 Document 目录下

    NSTimeInterval timestamp = [NSDate timeIntervalSinceReferenceDate];
    NSString *filename = [NSString stringWithFormat:@"%@-%f.m4a", name, timestamp];

    NSString *docsDir = [self documentsDirectory];
    NSString *destPath = [docsDir stringByAppendingPathComponent:filename];

    NSURL *srcURL = self.recorder.url;
    NSURL *destURL = [NSURL fileURLWithPath:destPath];

    NSError *error;
    BOOL success = [[NSFileManager defaultManager] copyItemAtURL:srcURL toURL:destURL error:&error];
    if (success) {
        handler(YES, [THMemo memoWithTitle:name url:destURL]);
        [self.recorder prepareToRecord];
    } else {
        handler(NO, error);
    }
复制代码

这里调用了 NSFileManager 的 copyItemAtURL 方法将文件内容拷贝到 Document 目录下。

3.3.4 展示时间

记录音频时需要展示时间提示用户当前录制时间,AVAudioRecorder 的 currentTime 属性可以获知当前时间,将其格式化后即可进行展示

- (NSString *)formattedCurrentTime {
    NSUInteger time = (NSUInteger)self.recorder.currentTime;
    NSInteger hours = (time / 3600);
    NSInteger minutes = (time / 60) % 60;
    NSInteger seconds = time % 60;

    NSString *format = @"%02i:%02i:%02i";
    return [NSString stringWithFormat:format, hours, minutes, seconds];
}
复制代码

但是需要实时展示时间的话,不能通过 KVO 来解决,只能加入到 NSTimer 中,每 0.5s 执行一次。

    [self.timer invalidate];
    self.timer = [NSTimer timerWithTimeInterval:0.5
                                         target:self
                                       selector:@selector(updateTimeDisplay)
                                       userInfo:nil
                                        repeats:YES];
    [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
复制代码

3.3.5 可视化音频信号

AVAudioRecorder 和 AVAudioPlayer 都有两个方法获取当前音频的平均分贝和峰值分贝数据。

- (float)averagePowerForChannel:(NSUInteger)channelNumber; /* returns average power in decibels for a given channel */
- (float)peakPowerForChannel:(NSUInteger)channelNumber; /* returns peak power in decibels for a given channel */
复制代码

返回值从 -160dB(静音) 到 0dB(最大分贝)。

获取值之前要在初始化播放器或记录器时设置 meteringEnabled 为 YES。

首先需要将 -160 到 0 的分贝值转为 0 到 1 范围内,需要用到下面这个类

@implementation THMeterTable {
    float _scaleFactor;
    NSMutableArray *_meterTable;
}

- (id)init {
    self = [super init];
    if (self) {
        float dbResolution = MIN_DB / (TABLE_SIZE - 1);

        _meterTable = [NSMutableArray arrayWithCapacity:TABLE_SIZE];
        _scaleFactor = 1.0f / dbResolution;

        float minAmp = dbToAmp(MIN_DB);
        float ampRange = 1.0 - minAmp;
        float invAmpRange = 1.0 / ampRange;

        for (int i = 0; i < TABLE_SIZE; i++) {
            float decibels = i * dbResolution;
            float amp = dbToAmp(decibels);
            float adjAmp = (amp - minAmp) * invAmpRange;
            _meterTable[i] = @(adjAmp);
        }
    }
    return self;
}

float dbToAmp(float dB) {
    return powf(10.0f, 0.05f * dB);
}

- (float)valueForPower:(float)power {
    if (power < MIN_DB) {
        return 0.0f;
    } else if (power >= 0.0f) {
        return 1.0f;
    } else {
        int index = (int) (power * _scaleFactor);
        return [_meterTable[index] floatValue];
    }
}

@end
复制代码

接下来可以实时获取到分贝平均值和峰值

- (THLevelPair *)levels {
    [self.recorder updateMeters];
    float avgPower = [self.recorder averagePowerForChannel:0];
    float peakPower = [self.recorder peakPowerForChannel:0];
    float linearLevel = [self.meterTable valueForPower:avgPower];
    float linearPeak = [self.meterTable valueForPower:peakPower];
    return [THLevelPair levelsWithLevel:linearLevel peakLevel:linearPeak];
}
复制代码

可以看到获取峰值和均值前必须调用 updateMeters 方法。