欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

iOS AVAudioEngine使用教程

程序员文章站 2022-05-30 13:49:26
...

翻译: AK
声明:转发本文,请联系作者授权
原文地址

在这个AVAudioEngine教程中,您将学习如何使用Apple的更高级音频工具包添加高级音频功能.

向大多数iOS开发人员提及音频处理,它们会给你带来恐惧和恐惧。这是因为,在iOS 8之前,要深入了解非常低层的Core Audio frameworw - 只有少数勇敢才才能做到这一点。值得庆幸的是,随着iOS 8和AVAudioEngine的发布,这一切都在2014年发生了变化。这个AVAudioEngine教程将向您展示如何使用Apple的新的更高级别的音频工具包来制作音频处理应用程序,而无需深入研究Core Audio。

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

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

开始

在开始之前,下载这个教程的材料(在文章的最下面,你可以看到这个下载按钮),使用Xcode编译运行,你就可以看到基础界面了.

这些控制没有做任何事, 但是他们都联接到了IBOutlets和关联了IBActions 在view controllers中.

iOS Audio Framework 介绍

在进入工作之前,我们先快速浏览一下iOS Audio frameworks

  • CoreAudio and AudioToolbox 都是c低级接口的 frameworks.
  • AVFoundation 是 Objective-C/Swift framework.
  • AVAudioEngine 是 AVFoundation 的一部分.

    iOS AVAudioEngine使用教程

  • AVAudioEngine是一个定义一组连接的音频节点的类,你在项目添加两个节点 AVAudioPlayerNode 和 AVAudioUnitTimePitch

iOS AVAudioEngine使用教程

设置音频

打开ViewController.swift并查看类内容。在顶部,您将看到所有连接的接口和类变量。这些事件已经连接到了 storyboard中.
在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, 把audioFileURL 传值给 audioFile
  2. 在连接其它节点之前先要把播放器连接到engine,这些节点将处理和输出音频.这些节点将生成,处音频引擎提供连接到播放器节点的主混音器节点。默认情况下,主混音器连接到engine默认输出节点(iOS设备扬声器)。 prepare()预分配所需的资源.

    接下来,将下面的代码scheduleAudioFile()方法中

 guard let audioFile = audioFile else { return }

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

这会播放整个audioFile。 at:是您希望音频播放的指定时间(AVAudioTime).设置为nil会立即开始播放。该文件仅会播放一次。再次点击“播放”按钮不会从头重新开始。您需要重新安排再次播放。播放完音频文件后,在完成回调中设置needsFileScheduled

其它的播放方法

//提供了预加载音频数据的缓冲区
 - scheduleBuffer(AVAudioPCMBuffer, completionHandler: AVAudioNodeCompletionHandler? = nil): 

//这就像scheduleFile,可以指定开始播放的音频帧和播放的帧数
- scheduleSegment(AVAudioFile, startingFrame: AVAudioFramePosition, frameCount: AVAudioFrameCount, at: AVAudioTime?, completionHandler: AVAudioNodeCompletionHandler? = nil): 

下面代码添加到 playTapped 方法中

// 1
sender.isSelected = !sender.isSelected

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

标记
- 1 切换按钮的选择状态,这会更改storyboard中设置的按钮图像
- 2 使用player.isPlaying来判断当前播放器的状态,如果正常可以暂停 如果没有播放,你要检查一下needsFileScheduled 和音频文件

编译并运行 然后点 playPauseButton 你应该可以听到Ray’s的什么歌, 但没有UI反馈 你不知道文件一共多长, 现在播放到哪了.

添加进度回调

添加下面代码 到 viewDidLoad()方法中

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

CADisplayLink是一个计时器对象,与显示器的刷新率同步。您使用selector 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除以audioFile的sampleRate来计算时间。将countUpLabel和countDownLabel文本更新为audioFile中的当前时间

3,如果 currentPosition已经到了文件的结尾 然后:

  • 停止播放器
  • 暂停timer
  • 重新设置playPauseButton的选中状
  • 断开音量的事件

    编译运行 然后再一次点击 playPauseButton,你可以听到Ray’s intro,同时可以看到进度条的时间信息

实现VU Meter

现在是时候添加VU Meter功能了。这是一个UIView定位在两栏之间。视图的高度由播放音频的平均功率决定。这是您进行某些音频处理的第一次机会。
您将计算1k音频样本缓冲区的平均功率。确定音频样本缓冲器的平均功率的常用方法是计算样本的均方根(RMS)。
平均功率是以分贝表示的一系列音频样本数据的平均值。还有峰值功率,这是一系列样本数据中的最大值

在connectVolumeTap方法下面 添加一个工具方法

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的动态范围设置为80db。对于低于-80.0的任何值,返回0.0。 iOS上的分贝值范围为-160db,接近静音,为0db,最大功率。 minDb设置为-80.0,动态范围为80db。您可以更改此值以查看它如何影响vuMeter
  • 3,完成 scaled的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‘s中取到数据格式
  • 2,installTap(onBus:0,bufferSize:1024,format:format)使您可以访问mainMixerNode输出总线上的音频数据。您请求1024字节的缓冲区大小,但不保证请求的大小,特别是如果您请求的缓冲区太小或太大。 Apple的文档没有说明这些限制是什么。完成块接收AVAudioPCMBuffer和AVAudioTime作为参数。您可以检查buffer.frameLength以确定实际的缓冲区大小。
  • 3,buffer.floatChannelData为您提供了指向每个样本数据的指针数组。 channelDataValue是UnsafeMutablePointer 的数组
  • 4,从UnsafeMutablePointer 数组转换为Float数组会使以后的计算更容易。为此,请使用stride(from:to:by :)在channelDataValue中创建索引数组。然后映射{channelDataValue [$ 0]}以访问和存储channelDataValueArray中的数据值
  • 5,计算RMS涉及映射/缩减/除法操作。首先,映射操作对数组中的所有值进行平方,reduce操作求和。将平方和除以缓冲区大小,然后取平方根,生成缓冲区中音频样本数据的RMS。这应该是介于0.0和1.0之间的值,但可能存在一些边缘情况,它是负值。
  • 6,将RMS转换为分贝(声学分贝参考)。这应该是-160和0之间的值,但如果rms为负,则该值为NaN。

最后添加下面的代码到disconnectVolumeTap():

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

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

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

实现快进

是时候实现跳过前进和后退按钮了。 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 :)安排从audioFile的skipFrame位置开始播放。 frameCount是要播放的帧数。您想要播放到文件末尾,因此将其设置为audioLengthSamples - skipFrame。最后,at:nil指定立即开始播放,而不是在将来的某个时间开始播放。
  • 4,如果在调用skip之前播放器正在播放,则调用player.play()以恢复播放。 updater.isPaused可以方便地确定这一点,因为只有先前暂停了播放器才会生效

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

实现码率变化

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

把setupAudio()方法替换成下面代码

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

这会将rateEffect(AVAudioUnitTimePitch节点)连接到音频图并将其连接起来。此节点类型是效果节点,具体来说,它可以改变播放速率和音频音高。
didChangeRateValue()动作处理对rateSlider的更改。它计算rateSliderValues数组的索引并设置rateValue,它设置rateEffect.rate。 rateSlider的值范围为0.5x到3.0x

构建并运行,然后点击playPauseButton。调整rateSlider,听听Ray新的效果

接下来要做什么?
你可以下载工程代码在这个教程的最上面和最下面下载连接 进行下载
查看其它的效果 你可以添加到 audioSetup()方法中,一个方法是可以使用滑动条控制rateEffect.pitch
学习更多的关于ios AVAudioEngine 可以查看

我们希望您喜欢AVAudioEngine上的这个教程。如果您有任何问题或意见,请加入以下论坛讨论!