AVAudioEngine Tutorial for iOS: Getting Started
Learn how to use AVAudioEngine to build the next greatest podcasting app! Implement audio features to pause, skip, speed up, slow down and change the pitch of audio in your app. By Ryan Ackermann.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
AVAudioEngine Tutorial for iOS: Getting Started
20 mins
Implementing the VU Meter
Now it’s time to add the VU Meter functionality. VU Meters indicate live audio by depicting a bouncing graphic according to the volume of the audio.
You’ll use a View positioned to fit between the pause icon’s bars. The average power of the playing audio determines the height of the view. This is your first opportunity for some audio processing.
You’ll compute the average power on a 1k buffer of audio samples. A common way to determine the average power of a buffer of audio samples is to calculate the Root Mean Square (RMS) of the samples.
Average power is the representation, in decibels, of the average value of a range of audio sample data. You should also be aware of peak power, which is the max value in a range of sample data.
Replace the code in scaledPower(power:)
with the following:
// 1
guard power.isFinite else {
return 0.0
}
let minDb: Float = -80
// 2
if power < minDb {
return 0.0
} else if power >= 1.0 {
return 1.0
} else {
// 3
return (abs(minDb) - abs(power)) / abs(minDb)
}
scaledPower(power:)
converts the negative power decibel value to a positive value that adjusts the meterLevel
value. Here’s what it does:
-
power.isFinite
checks to make sure power is a valid value — i.e., not NaN — returning 0.0 if it isn’t. - This sets the dynamic range of the VU meter to 80db. For any value below -80.0, return 0.0. Decibel values on iOS have a range of -160db, near silent, to 0db, maximum power.
minDb
is set to -80.0, which provides a dynamic range of 80db. 80 provides sufficient resolution to draw the interface in pixels. Alter this value to see how it affects the VU meter. - Compute the scaled value between 0.0 and 1.0.
Now, add the following to connectVolumeTap()
:
// 1
let format = engine.mainMixerNode.outputFormat(forBus: 0)
// 2
engine.mainMixerNode.installTap(
onBus: 0,
bufferSize: 1024,
format: format
) { buffer, _ in
// 3
guard let channelData = buffer.floatChannelData 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 {
return $0 * $0
}
.reduce(0, +) / Float(buffer.frameLength))
// 6
let avgPower = 20 * log10(rms)
// 7
let meterLevel = self.scaledPower(power: avgPower)
DispatchQueue.main.async {
self.meterLevel = self.isPlaying ? meterLevel : 0
}
}
There’s a lot going on here, so here’s the breakdown:
- Get the data format for
mainMixerNode
‘s output. -
installTap(onBus: 0, bufferSize: 1024, format: format)
gives you access to the audio data on themainMixerNode
‘s output bus. You request a buffer size of 1024 bytes, but the requested size isn’t guaranteed, especially if you request a buffer that’s too small or large. Apple’s documentation doesn’t specify what those limits are. The completion block receives an AVAudioPCMBuffer and AVAudioTime as parameters. You can checkbuffer.frameLength
to determine the actual buffer size. -
buffer.floatChannelData
gives you an array of pointers to each sample’s data.channelDataValue
is an array ofUnsafeMutablePointer<Float>
. - Converting from an array of
UnsafeMutablePointer<Float>
to an array ofFloat
makes later calculations easier. To do that, usestride(from:to:by:)
to create an array of indexes intochannelDataValue
. Then,map{ channelDataValue[$0] }
to access and store the data values inchannelDataValueArray
. - Computing the power with Root Mean Square involves a map/reduce/divide operation. First, the map operation squares all the values in the array, which the reduce operation sums. Divide the sum of the squares by the buffer size, then take the square root, producing the RMS of the audio sample data in the buffer. This should be a value between 0.0 and 1.0, but there could be some edge cases where it’s a negative value.
- Convert the RMS to decibels. Here’s an acoustic decibel reference, if you need it. The decibel value should be between -160 and 0, but if RMS is negative, this decibel value would be
NaN
. - Scale the decibels into a value suitable for your VU meter.
Finally, add the following to disconnectVolumeTap()
:
engine.mainMixerNode.removeTap(onBus: 0)
meterLevel = 0
AVAudioEngine allows only a single tap per bus. It’s a good practice to remove it when not in use.
Build and run, then tap play/pause:
The VU meter is now active, providing average power feedback of the audio data. Your app’s users will be able to easily discern visually when audio is playing.
Implementing Skip
Time to implement the skip forward and back buttons. In this app, each button seeks forward or backward by 10 seconds.
Add the following to seek(to:)
:
guard let audioFile = audioFile else {
return
}
// 1
let offset = AVAudioFramePosition(time * audioSampleRate)
seekFrame = currentPosition + offset
seekFrame = max(seekFrame, 0)
seekFrame = min(seekFrame, audioLengthSamples)
currentPosition = seekFrame
// 2
let wasPlaying = player.isPlaying
player.stop()
if currentPosition < audioLengthSamples {
updateDisplay()
needsFileScheduled = false
let frameCount = AVAudioFrameCount(audioLengthSamples - seekFrame)
// 3
player.scheduleSegment(
audioFile,
startingFrame: seekFrame,
frameCount: frameCount,
at: nil
) {
self.needsFileScheduled = true
}
// 4
if wasPlaying {
player.play()
}
}
Here's the play-by-play:
- Convert time, which is in seconds, to frame position by multiplying it by
audioSampleRate
, and add it tocurrentPosition
. Then, make sureseekFrame
is not before the start of the file nor past the end of the file. -
player.stop()
not only stops playback, but also clears all previously scheduled events. CallupdateDisplay()
to set the UI to the newcurrentPosition
value. -
player.scheduleSegment(_:startingFrame:frameCount:at:)
schedules playback starting atseekFrame
's position of the audio file.frameCount
is the number of frames to play. You want to play to the end of file, so set it toaudioLengthSamples - seekFrame
. Finally,at: nil
specifies to start playback immediately instead of at some time in the future. - If the audio was playing before skip was called, then call
player.play()
to resume playback.
Time to use this method to seek. Add the following to skip(forwards:)
:
let timeToSeek: Double
if forwards {
timeToSeek = 10
} else {
timeToSeek = -10
}
seek(to: timeToSeek)
Both of the skip buttons in the view call this method. The audio skips ahead by 10 seconds if the forwards
parameter is true
. In contrast, the audio jumps backward if the parameter is false
.
Build and run, then tap play/pause. Tap the skip forward and skip backward buttons to skip forward and back. Watch as the progressBar
and count labels change.