Correct way to recover from a Core Audio interruption

11th Jul, 2021 | coreaudio xcode swift

Core audio interruptions

Core Audio is an amazing framework but we cannot have it all to ourselves - our app must handover audio (unless we are mixing with others) if there are interruptions from phone calls, alarms and other audio notifications.

We want our app to handle such interruptions as gracefully as possible, pausing animations and any internal timers when the interruption begins and then resuming animations and audio when the interruption ends. Apple kindly gives us an interruption notification to perform this workflow:

    private func setupAudioInterruptionListener() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleAudioInterruption(notification:)),
            name: AVAudioSession.interruptionNotification,
            object: AVAudioSession.sharedInstance()
        )
    }

    @objc func handleAudioInterruption(notification: Notification) {
        print("Received handleAudioInterruption notification")

        guard let userInfo = notification.userInfo,
            let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
            let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
                return
        }

        switch type {
            case .began:
                print("Interruption began")

                // See note 1

            case .ended:
                guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
                let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
                if options.contains(.shouldResume) {
                    print("Interruption ended and we can resume")

                    // See note 2

                } else {
                    print("Interruption ended but we cannot resume")

                    // See note 3
                }

            default:
                break
        }
    }

Notes on this code snippet:

  1. Core Audio will already stop our AVAudioEngine session so we do not need to stop audio at this point. However we can use this state to stop animations or update the UI of our app to indicate audio has been suspended. This is particularly important if the interruption is a timer badge notification where most of your app is still visible.

  2. Here we can be sure the external process that interrupted us has completed so we can restart our app audio and update the UI.

  3. The interruption ended but core audio does not want us to restart. Maybe something really bad happened?

The problem

So we are done, right?

Unfortunately not. This was working for some AVAudioSession categories like playback but not working for .playAndRecord even though I could see the notifications were being called at the correct times.

The only way I could resume audio was restarting audio after a 500ms time delay which indicates I was missing something, so what was I missing?

Enter AVAudioEngineConfigurationChange

At first this does not look related to a simple audio interruption such as the timer app playing its alarm. However when we look in detail it is very relevant because it is completely possible that the app causing the interruption is using a different channel count or sample rate. In these cases this configuration notification is fired both when the interrupting application changes the configuration and then when core audio restores the configuration for our app when the interruption ends.

The important part is this interruption configuration change can occur after the AVAudioSession.interruptionNotification is fired. When this happens core audio must stop the application audio (eg if we restarted AVAudioEngine like in our example code) to update the configuration, leaving it ready but stopped.

A basic implmentation just needs to have a guard flag to store the interruption state and then restart audio in the configuration change notification handler.

Example code

    private var isSuspended = false

    private func setupAudioInterruptionListener() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleEngineConfigurationChange(notification:)),
            name: NSNotification.Name.AVAudioEngineConfigurationChange,
            object: engine
        )

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleAudioInterruption(notification:)),
            name: AVAudioSession.interruptionNotification,
            object: AVAudioSession.sharedInstance()
        )
    }

    @objc func handleEngineConfigurationChange(notification: Notification) {
        if !isSuspended {
            do {
                try self.engine.start()
            } catch {
                print("Error restarting audio: \(error)")
            }
        }
    }

    @objc func handleAudioInterruption(notification: Notification) {
        guard let userInfo = notification.userInfo,
            let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
            let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
                return
        }

        switch type {
            case .began:
                isSuspended = true

            case .ended:
                guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
                let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)

                isSuspended = false

                if options.contains(.shouldResume) {
                    do {
                        try self.engine.start()
                    } catch {
                        print("Error restarting audio: \(error)")
                    }
                } else {
                    // An interruption ended. Don't resume playback.
                    print("Resume but did not restart engine")
                }

            default:
                break
        }
    }

But wait

In hindsight the mistake was trying to implement core audio notifications one by one when actually they depend on each other, so they need to be implemented together.

A better implementation would be to handle the configuration notification properly, such as handling the sample rate changing. To handle such changes we must tear down our audio graph and rebuild it, so it makes sense to always do this when responding to this notification. Then we can be certain it will be working correctly with any type of change to the audio environment.