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.

4.5 (8) · 5 Reviews

Download materials
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

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:

  1. power.isFinite checks to make sure power is a valid value — i.e., not NaN — returning 0.0 if it isn’t.
  2. 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.
  3. 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:

  1. Get the data format for mainMixerNode‘s output.
  2. installTap(onBus: 0, bufferSize: 1024, format: format) gives you access to the audio data on the mainMixerNode‘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 check buffer.frameLength to determine the actual buffer size.
  3. buffer.floatChannelData gives you an array of pointers to each sample’s data. channelDataValue is an array of UnsafeMutablePointer<Float>.
  4. Converting from an array of UnsafeMutablePointer<Float> to an array of Float makes later calculations easier. To do that, use stride(from:to:by:) to create an array of indexes into channelDataValue. Then, map{ channelDataValue[$0] } to access and store the data values in channelDataValueArray.
  5. 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.
  6. 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.
  7. 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:

A small VU meter in the pause button.

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:

  1. Convert time, which is in seconds, to frame position by multiplying it by audioSampleRate, and add it to currentPosition. Then, make sure seekFrame is not before the start of the file nor past the end of the file.
  2. player.stop() not only stops playback, but also clears all previously scheduled events. Call updateDisplay() to set the UI to the new currentPosition value.
  3. player.scheduleSegment(_:startingFrame:frameCount:at:) schedules playback starting at seekFrame'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 to audioLengthSamples - seekFrame. Finally, at: nil specifies to start playback immediately instead of at some time in the future.
  4. 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.

The skip button pressed.