AVAudioEngine Real-Time Effects

May 31 2020

Thanks to AVPlayer, playing back audio files isn't complicated on iOS and macOS. But what if you want to add real-time effects to audio? AVAudioEngine makes that very easy as well.

Let's see how to do so:

Audio Playback

Before adding any effects, we'll need to set up a basic audio player. First, we'll create the AudioPlayer class:

import AVFoundation

class AudioPlayer {
    var engine: AVAudioEngine!
    var player: AVAudioPlayerNode!

    func setup(url: URL) {
        // TODO: Setup
    }
}

These variables will contain the audio engine and the player node later on.


AVAudioEngine is based on the concept of nodes. A node can provide, modify, or output audio. You can connect these nodes to create custom audio players, for example. In this case, an AVAudioPlayerNode will provide the audio.


Let's start creating the setup function. First, we'll need to initialize the audio engine and player:

engine = AVAudioEngine()
player = AVAudioPlayerNode()

Next, we'll set the app's audio session category to "playback":

try? AVAudioSession.sharedInstance().setCategory(.playback)

Let's open the audio file as an AVAudioFile with the url passed into the setup function:

guard let file = try? AVAudioFile(forReading: url) else {
    fatalError("Couldn't read file")
}

After having opened the file, we can create an audio buffer with the file's length and format…

guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: AVAudioFrameCount(file.length)) {
    fatalError("Couldn't create buffer")
}

…and read the audio file into the buffer:

try? file.read(into: buffer)
// TODO: Add effects

For this example, we'll let the player loop the audio file:

player.scheduleBuffer(buffer, at: nil, options: AVAudioPlayerNodeBufferOptions.loops, completionHandler: nil)

In the (very basic) explanation of the node-based architecture of AVAudioEngine, I mentioned that different nodes have to be connected. We'll now attach the player node to the audio engine, and connect the player to the engine's mixer node:

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

Finally, we'll start the audio engine and begin playing the audio file:

engine.prepare()
try? engine.start()
player.play()

Adding Effects

Since we've now set up the player, we're ready to add effects. The following code will need to be added to where we previously inserted the todo (// TODO: Add effects). I'll demonstrate how to add an effect node with the AVAudioUnitDistortion audio unit node.

Let's create the distortion effect and load a factory preset:

let distortion = AVAudioUnitDistortion()
distortion.loadFactoryPreset(.drumsBitBrush)

That's basically it! Now we can attach and connect the effect node. To do so, replace the previously added engine.connect(…) with these new connections:

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

Here we connect the player to the distortion effect and the distortion effect to the main mixer node. This way the player audio will be routed through the effect audio unit node before being played back.

More Effects

Here are some other effects, you can try out:

You can even chain different effects together by connecting them (example: player → eq → distortion → main mixer).

Usage

Our finished player class can be used like this:

let player = AudioPlayer()
player.setup(url: /* some URL */)

All you need to do is replace /* some URL */ with a URL from an audio file.

Conclusion

The node-based architecture of AVAudioEngine allows you to create custom audio players with very little effort. If you just need to play audio, AVPlayer is the API to use. But for more complicated use cases AVAudioEngine is awesome! You can even build synthesizers with it.