iOS AVAudioEngine使用教程
翻译: AK
声明:转发本文,请联系作者授权
原文地址
在这个AVAudioEngine教程中,您将学习如何使用Apple的更高级音频工具包添加高级音频功能.
向大多数iOS开发人员提及音频处理,它们会给你带来恐惧和恐惧。这是因为,在iOS 8之前,要深入了解非常低层的Core Audio frameworw - 只有少数勇敢才才能做到这一点。值得庆幸的是,随着iOS 8和AVAudioEngine的发布,这一切都在2014年发生了变化。这个AVAudioEngine教程将向您展示如何使用Apple的新的更高级别的音频工具包来制作音频处理应用程序,而无需深入研究Core Audio。
那就对了!您不再需要搜索模糊的基于指针的C / C ++结构和内存缓冲区来收集原始音频数据。
在这个AVAudioEngine教程中,您将使用AVAudioEngine构建下一个优秀的播客应用程序:Raycast。更具体地说,您将添加由UI控制的音频功能:播放/暂停按钮,跳过前进/后退按钮,进度条和播放速率选择器。当你完成后,你会有一个很棒的应用程序,可以听听Dru和Janie。
开始
在开始之前,下载这个教程的材料(在文章的最下面,你可以看到这个下载按钮),使用Xcode编译运行,你就可以看到基础界面了.
这些控制没有做任何事, 但是他们都联接到了IBOutlets和关联了IBActions 在view controllers中.
iOS Audio Framework 介绍
在进入工作之前,我们先快速浏览一下iOS Audio frameworks
- CoreAudio and AudioToolbox 都是c低级接口的 frameworks.
- AVFoundation 是 Objective-C/Swift framework.
-
AVAudioEngine 是 AVFoundation 的一部分.
- AVAudioEngine是一个定义一组连接的音频节点的类,你在项目添加两个节点 AVAudioPlayerNode 和 AVAudioUnitTimePitch
设置音频
打开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)
}
仔细看都发生哪些事情:
- 获取bundle中声音文件的URL, 把audioFileURL 传值给 audioFile
-
在连接其它节点之前先要把播放器连接到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 可以查看
- WWDC 2014 Session 502: AVAudioEngine in Practice
- Apple’s “Working with Audio”
- Beginning Audio with AVFoundation: Audio Effects
- Audio Tutorial for iOS: File and Data Formats
我们希望您喜欢AVAudioEngine上的这个教程。如果您有任何问题或意见,请加入以下论坛讨论!
上一篇: 008_swiftui_优化*应用
下一篇: 011_swiftui_卡牌战争。比点数