AVFoundation框架解析(十九)—— AVAudioEngine之详细说明和一个简单示例(二)

版本记录

版本号 时间
V1.0 2018.08.19

前言

AVFoundation框架是ios中很重要的框架,所有与视频音频相关的软硬件控制都在这个框架里面,接下来这几篇就主要对这个框架进行介绍和讲解。感兴趣的可以看我上几篇。
1. AVFoundation框架解析(一)—— 基本概览
2. AVFoundation框架解析(二)—— 实现视频预览录制保存到相册
3. AVFoundation框架解析(三)—— 几个关键问题之关于框架的深度概括
4. AVFoundation框架解析(四)—— 几个关键问题之AVFoundation探索(一)
5. AVFoundation框架解析(五)—— 几个关键问题之AVFoundation探索(二)
6. AVFoundation框架解析(六)—— 视频音频的合成(一)
7. AVFoundation框架解析(七)—— 视频组合和音频混合调试
8. AVFoundation框架解析(八)—— 优化用户的播放体验
9. AVFoundation框架解析(九)—— AVFoundation的变化(一)
10. AVFoundation框架解析(十)—— AVFoundation的变化(二)
11. AVFoundation框架解析(十一)—— AVFoundation的变化(三)
12. AVFoundation框架解析(十二)—— AVFoundation的变化(四)
13. AVFoundation框架解析(十三)—— 构建基本播放应用程序
14. AVFoundation框架解析(十四)—— VAssetWriter和AVAssetReader的Timecode支持(一)
15. AVFoundation框架解析(十五)—— VAssetWriter和AVAssetReader的Timecode支持(二)
16. AVFoundation框架解析(十六)—— 一个简单示例之播放、录制以及混合视频(一)
17. AVFoundation框架解析(十七)—— 一个简单示例之播放、录制以及混合视频之源码及效果展示(二)
18. AVFoundation框架解析(十八)—— AVAudioEngine之基本概览(一)

开始

向大多数iOS开发人员提及音频处理,他们认为很困难甚至是恐惧。这是因为,在iOS 8之前,它意味着深入探讨低级Core Audio框架的深度 - 只有少数勇敢的灵魂才能做到这一点。值得庆幸的是,随着iOS 8和AVAudioEngine的发布,这一切都在2014年发生了变化。本文将向您展示如何使用Apple的新的更高级别的音频工具audio toolkit包来制作音频处理应用程序,而无需深入研究Core Audio

那就对了!您不再需要搜索模糊的基于指针的C / C ++结构和内存缓冲区来收集原始音频数据。

在这个AVAudioEngine教程中,您将使用AVAudioEngine构建下一个优秀的播客应用程序。更具体地说,您将添加由UI控制的音频功能:播放/暂停按钮,跳过前进/后退按钮,进度条和播放速率选择器。当你完成后,你会有一个很棒的应用程序。

注意:写作本文的环境Swift 4, iOS 11, Xcode 9。


iOS Audio Framework Introduction - iOS音频框架介绍

在进入项目之前,首先看一下iOS音频框架的概述:

  • CoreAudioAudioToolbox是低级C框架。
  • AVFoundation是一个Objective-C / Swift框架。
  • AVAudioEngineAVFoundation的一部分。
AVFoundation框架解析(十九)—— AVAudioEngine之详细说明和一个简单示例(二)_第1张图片
  • AVAudioEngine是一个定义一组连接的音频节点的类。 您将向项目添加两个节点:AVAudioPlayerNodeAVAudioUnitTimePitch
AVFoundation框架解析(十九)—— AVAudioEngine之详细说明和一个简单示例(二)_第2张图片

Setup Audio - 设置Audio

打开ViewController.swift并查看内部。 在顶部,您将看到所有连接的outlets和类变量。 actions还连接到sb中的相应outlets

将以下代码添加到setupAudio()

// 1
audioFileURL = Bundle.main.url(forResource: "Intro", withExtension: "mp4")

// 2
engine.attach(player)
engine.connect(player, to: engine.mainMixerNode, format: audioFormat)
engine.prepare()

do {
  // 3
  try engine.start()
} catch let error {
  print(error.localizedDescription)
}

仔细看看发生了什么:

  • 1)这将获取bundle音频文件的URL。 设置后,它将在上面变量声明部分的audioFileURLdidSet块中实例化audioFile
  • 2)将播放器节点附加到引擎,在连接其他节点之前必须执行此操作。 这些节点将生成,处理或输出音频。 音频引擎提供连接到播放器节点的主混音器节点。 默认情况下,主混音器连接到engine默认输出节点(iOS设备扬声器)。 prepare()预分配所需的资源。

接下来,将以下内容添加到scheduleAudioFile()

guard let audioFile = audioFile else { return }

skipFrame = 0
player.scheduleFile(audioFile, at: nil) { [weak self] in
  self?.needsFileScheduled = true
}

这会调度播放整个audioFileat:是您希望音频播放的未来时间(AVAudioTime)。 设置为nil会立即开始播放。 该文件仅调度播放一次。 再次点击Play按钮不会从头重新开始。 您需要重新调度再次播放。 播放完音频文件后,在完成块中设置标志needsFileScheduled

还有其他调度音频用于播放:

  • scheduleBuffer(AVAudioPCMBuffer,completionHandler:AVAudioNodeCompletionHandler?= nil):这提供了预先加载音频数据的缓冲区。
  • scheduleSegment(AVAudioFile,startingFrame:AVAudioFramePosition,frameCount:AVAudioFrameCount,at:AVAudioTime?,completionHandler:AVAudioNodeCompletionHandler?= nil):这就像scheduleFile,除了你指定开始播放的音频帧和播放的帧数。

然后,将以下内容添加到playTapped(_ :)

// 1
sender.isSelected = !sender.isSelected

// 2
if player.isPlaying {
  player.pause()
} else {
  if needsFileScheduled {
    needsFileScheduled = false
    scheduleAudioFile()
  }
  player.play()
}

下面细分一下:

  • 1)切换按钮的选择状态,这会更改sb中设置的按钮图像。
  • 2)使用player.isPlaying确定当前播放器正在播放。 如果是这样,暂停它,如果不是,请播放。 您还可以检查needsFileScheduled并根据需要重新调度文件。

Build并运行,然后点击playPauseButton。 你应该听到声音。 但是,没有UI反馈,你不知道文件有多长或者你现在播放到哪里。


Add Progress Feedback - 增加进度反馈

viewDidLoad()中添加如下代码:

updater = CADisplayLink(target: self, selector: #selector(updateUI))
updater?.add(to: .current, forMode: .defaultRunLoopMode)
updater?.isPaused = true

CADisplayLink是一个计时器对象,与显示器的刷新率同步。 您使用方法updateUI实例化它。 然后,将其添加到运行循环中 - 在本例中为默认运行循环default run loop。 最后,它不需要开始运行,因此将isPaused设置为true

用以下内容替换playTapped(_ :)的实现:

sender.isSelected = !sender.isSelected

if player.isPlaying {
  disconnectVolumeTap()
  updater?.isPaused = true
  player.pause()
} else {
  if needsFileScheduled {
    needsFileScheduled = false
    scheduleAudioFile()
  }
  connectVolumeTap()
  updater?.isPaused = false
  player.play()
}

这里的关键是当播放器暂停时使用updater.isPaused = true暂停UI。 您将在下面的VU Meter部分中了解connectVolumeTap()disconnectVolumeTap()

使用以下内容替换var currentFrame:AVAudioFramePosition = 0

var currentFrame: AVAudioFramePosition {
  // 1
  guard
    let lastRenderTime = player.lastRenderTime,
    // 2
    let playerTime = player.playerTime(forNodeTime: lastRenderTime)
    else {
      return 0
  }
  
  // 3
  return playerTime.sampleTime
}

currentFrame返回播放器呈现的最后一个音频样本。 下面一步步的看:

  • 1)player.lastRenderTime返回引擎启动时间的时间。 如果引擎未运行,则lastRenderTime返回nil。
  • 2)player.playerTime(forNodeTime :)lastRenderTime转换为相对于播放器开始时间的时间。 如果播放器没有播放,那么playerTime将返回nil。
  • 3)sampleTime是音频文件中的一些音频采样的时间。

现在进行UI更新。 将以下内容添加到updateUI()

// 1
currentPosition = currentFrame + skipFrame
currentPosition = max(currentPosition, 0)
currentPosition = min(currentPosition, audioLengthSamples)

// 2
progressBar.progress = Float(currentPosition) / Float(audioLengthSamples)
let time = Float(currentPosition) / audioSampleRate
countUpLabel.text = formatted(time: time)
countDownLabel.text = formatted(time: audioLengthSeconds - time)

// 3
if currentPosition >= audioLengthSamples {
  player.stop()
  updater?.isPaused = true
  playPauseButton.isSelected = false
  disconnectVolumeTap()
}

下面我们一步一步的看:

  • 1)属性skipFrame是添加到currentFrame或从currentFrame中减去的偏移量,最初设置为零。 确保currentPosition不超出文件范围。
  • 2)将progressBar.progress更新为audioFile中的currentPosition。 通过将currentPosition除以audioFilesampleRate来计算时间。 将countUpLabelcountDownLabel文本更新为audioFile中的当前时间。
  • 3)如果currentPosition位于文件末尾,则:
    • 停止播放器。
    • 暂停计时器。
    • 重置playPauseButton选择状态。
    • 断开音量tap。

Build并运行,然后点击playPauseButton。 再次,您将听到声音,但这次progressBar和计时器标签提供以前缺少的状态信息。


Implement the VU Meter - 实现VU Meter

现在是时候添加VU Meter功能了。 这是一个UIView定位在暂停图标的栏之间。 视图的高度由播放音频的平均功率决定。 这是您进行某些音频处理的第一次机会。

您将计算1k音频样本缓冲区的平均功率。 确定音频样本缓冲器的平均功率的常用方法是计算样本的均方根(RMS)。

平均功率是以分贝表示的一系列音频样本数据的平均值。 还有峰值功率,这是一系列样本数据中的最大值。

connectVolumeTap()下面添加以下helper方法:

func scaledPower(power: Float) -> Float {
  // 1
  guard power.isFinite else { return 0.0 }

  // 2
  if power < minDb {
    return 0.0
  } else if power >= 1.0 {
    return 1.0
  } else {
    // 3
    return (fabs(minDb) - fabs(power)) / fabs(minDb)
  }
}

scaledPower(power :)将负功率分贝值转换为正值,以适应调整上面的volumeMeterHeight.constant值。 这是它的作用:

  • 1)power.isFinite检查以确保功率是有效值 - 即,不是NaN - 如果不是则返回0.0。
  • 2)这将我们的vuMeter的dynamic range设置为80db。 对于低于-80.0的任何值,返回0.0。 iOS上的分贝值范围为-160db,接近静音,为0db,最大功率。 minDb设置为-80.0,动态范围为80db。 您可以更改此值以查看它如何影响vuMeter。
  • 3)计算0.0到1.0之间的缩放值。

现在,将以下内容添加到connectVolumeTap()

// 1
let format = engine.mainMixerNode.outputFormat(forBus: 0)
// 2
engine.mainMixerNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, when in
  // 3
  guard 
    let channelData = buffer.floatChannelData,
    let updater = self.updater 
    else {
      return
  }

  let channelDataValue = channelData.pointee
  // 4
  let channelDataValueArray = stride(from: 0, 
                                     to: Int(buffer.frameLength),
                                     by: buffer.stride).map{ channelDataValue[$0] }
  // 5
  let rms = sqrt(channelDataValueArray.map{ $0 * $0 }.reduce(0, +) / Float(buffer.frameLength))
  // 6
  let avgPower = 20 * log10(rms)
  // 7
  let meterLevel = self.scaledPower(power: avgPower)

  DispatchQueue.main.async {
    self.volumeMeterHeight.constant = !updater.isPaused ? 
           CGFloat(min((meterLevel * self.pauseImageHeight), self.pauseImageHeight)) : 0.0
  }
}

这里进行细分说明:

  • 1)获取mainMixerNode输出的数据格式。
  • 2)installTap(onBus:0,bufferSize:1024,format:format)使您可以访问mainMixerNode输出总线上的音频数据。您请求1024字节的缓冲区大小,但不保证请求的大小,特别是如果您请求的缓冲区太小或太大。 Apple的文档没有说明这些限制是什么。完成block接收AVAudioPCMBufferAVAudioTime作为参数。您可以检查buffer.frameLength以确定实际的缓冲区大小。 when提供缓冲区的捕获时间。
  • 3)buffer.floatChannelData为您提供了指向每个样本数据的指针数组。 channelDataValueUnsafeMutablePointer 的数组
  • 4)从UnsafeMutablePointer 数组转换为Float数组会使以后的计算更容易。为此,请使用stride(from:to:by :)channelDataValue中创建索引数组。然后map{channelDataValue [$ 0]}以访问和存储channelDataValueArray中的数据值。
  • 5)计算RMS涉及映射/缩减/除法操作。首先,映射操作对数组中的所有值进行平方,reduce操作求和。将平方和除以缓冲区大小,然后取平方根,生成缓冲区中音频样本数据的RMS。这应该是介于0.0和1.0之间的值,但可能存在一些边缘情况,它是负值。
  • 6)RMS转换为分贝(Acoustic Decibel reference)。这应该是-160和0之间的值,但如果rms为负,则该值为NaN
  • 7)将分贝缩放为适合您的vuMeter的值。

最后,将以下内容添加到disconnectVolumeTap()

engine.mainMixerNode.removeTap(onBus: 0)
volumeMeterHeight.constant = 0

AVAudioEngine每个总线只允许一次点击。 在不使用时将其删除是一种很好的做法。

Build并运行,然后点击playPauseButtonvuMeter现在处于活动状态,提供音频数据的平均功率反馈。


Implementing Skip - 实现Skip

是时候实现跳过前进和后退按钮了。skipForwardButton在音频文件中向前跳10秒,skipBackwardButton跳回10秒。

添加以下内容到seek(to:)

guard 
  let audioFile = audioFile,
  let updater = updater 
  else {
    return
}

// 1
skipFrame = currentPosition + AVAudioFramePosition(time * audioSampleRate)
skipFrame = max(skipFrame, 0)
skipFrame = min(skipFrame, audioLengthSamples)
currentPosition = skipFrame

// 2
player.stop()

if currentPosition < audioLengthSamples {
  updateUI()
  needsFileScheduled = false

  // 3
  player.scheduleSegment(audioFile, 
                         startingFrame: skipFrame, 
                         frameCount: AVAudioFrameCount(audioLengthSamples - skipFrame), 
                         at: nil) { [weak self] in
    self?.needsFileScheduled = true
  }

  // 4
  if !updater.isPaused {
    player.play()
  }
}

这是进行详细分解:

  • 1)通过乘以audioSampleRate将时间(以秒为单位)转换为帧位置,并将其添加到currentPosition。然后,确保skipFrame不在文件开头之前,也不超过文件末尾。
  • 2)player.stop()不仅停止播放,还清除所有先前调度的事件。调用updateUI()将UI设置为新的currentPosition值。
  • 3)player.scheduleSegment(_:startingFrame:frameCount:at :)调度从audioFileskipFrame位置开始播放。 frameCount是要播放的帧数。您想要播放到文件末尾,因此将其设置为audioLengthSamples - skipFrame。最后,at:nil指定立即开始播放,而不是在将来的某个时间开始播放。
  • 4)如果在调用skip之前播放器正在播放,则调用player.play()以恢复播放。 updater.isPaused可以方便地确定这一点,因为只有先前暂停了播放器才会生效。

Build并运行,然后点击playPauseButton。点击skipBackwardButton并使用skipForwardButton跳过前进和后退。观察progressBar和计数标签的变化。


Implementing Rate Change - 实现播放速率的改变

最后要实现的是改变播放速度。 如今,以超过1倍的速度收听播客是一项受欢迎的功能。

setupAudio()中,替换以下内容:

engine.attach(player)
engine.connect(player, to: engine.mainMixerNode, format: audioFormat)

以及:

engine.attach(player)
engine.attach(rateEffect)
engine.connect(player, to: rateEffect, format: audioFormat)
engine.connect(rateEffect, to: engine.mainMixerNode, format: audioFormat)

这会将rateEffectAVAudioUnitTimePitch节点)连接到音频图并将其连接起来。 此节点类型是效果节点,具体来说,它可以改变播放速率和音频音高。

didChangeRateValue() action处理对rateSlider的更改。 它计算rateSliderValues数组的索引并设置rateValue,它设置rateEffect.raterateSlider的值范围为0.5x到3.0x

Build并运行,然后点击playPauseButton。 调整rateSlider就可以听一下效果声音了。

参考文章

  • 1. WWDC 2014 Session 502: AVAudioEngine in Practice
  • 2. Apple's "Working with Audio"

后记

本篇主要讲述了AVAudioEngine之详细说明和一个简单示例,感兴趣的给个赞或者关注~~~

AVFoundation框架解析(十九)—— AVAudioEngine之详细说明和一个简单示例(二)_第3张图片

你可能感兴趣的:(AVFoundation框架解析(十九)—— AVAudioEngine之详细说明和一个简单示例(二))