AVAudioEngineConfigurationChange Clearing AVPlayerNode

Hi all,

I am working on an app where I have live prompts playing, in addition to a voice channel that sometimes becomes active. Right now I am using two different AVAudioSession Configurations so what we only switch to a mic enabled mode when we actually need input from the mic. These are defined below.

When just using the device hardware, everything works as expected and the modes change and the playback continues as needed. However when using bluetooth devices such as AirPods where the switch from AD2P to HFP is needed, I am getting a AVAudioEngineConfigurationChange notification. In response I am tearing down the engine and creating a new one with the same 2 player nodes. This does work fine and there are no crashes, except all the audio I have scheduled on a player node has now been cleared. All the completion blocks marked with ".dataPlayedBack" return the second this event happens, and leaves me in a state where I now have a valid engine setup again but have no idea what actually played, or was errantly marked as such.

Is this the expected behavior when getting a configuration change notification?

Adding some information below to my audio graph for context:

All my parts of the graph, I disconnect when getting this event and do the same to the new engine

    private var inputEngine: AVAudioEngine
    private var audioEngine: AVAudioEngine
    private let voicePlayerNode: AVAudioPlayerNode
    private let promptPlayerNode: AVAudioPlayerNode

        audioEngine.attach(voicePlayerNode)
        audioEngine.attach(promptPlayerNode)

        audioEngine.connect(
            voicePlayerNode,
            to: audioEngine.mainMixerNode,
            format: voiceNodeFormat
        )

        audioEngine.connect(
            promptPlayerNode,
            to: audioEngine.mainMixerNode,
            format: nil
        )

An example of how I am scheduling playback, and where that completion is firing even if it didn't actually play.

      private func scheduleVoicePlayback(_ id: AudioPlaybackSample.Id, buffer: AVAudioPCMBuffer) async throws {
        guard !voicePlayerQueue.samples.contains(where: { $0 == id }) else {
            return
        }

        seprateQueue.append(buffer)

        if !isVoicePlaying {
            activateAudioSession()
        }

        voicePlayerQueue.samples.append(id)

        if !voicePlayerNode.isPlaying {
            voicePlayerNode.play()
        }

        if let convertedBuffer = buffer.convert(to: voiceNodeFormat) {
            await voicePlayerNode.scheduleBuffer(convertedBuffer, completionCallbackType: .dataPlayedBack)
        } else {
            throw AudioPlaybackError.failedToConvert
        }

        voiceSampleHasBeenPlayed(id)
    }

And lastly my audio session configuration if its useful.

extension AVAudioSession {
    static func setDefaultCategory() {
        do {
            try sharedInstance().setCategory(
                .playback,
                options: [
                    .duckOthers, .interruptSpokenAudioAndMixWithOthers
                ]
            )
        } catch {
            print("Failed to set default category? \(error.localizedDescription)")
        }
    }

    static func setVoiceChatCategory() {
        do {
            try sharedInstance().setCategory(
                .playAndRecord,
                options: [
                    .defaultToSpeaker,
                    .allowBluetooth,
                    .allowBluetoothA2DP,
                    .duckOthers,
                    .interruptSpokenAudioAndMixWithOthers
                ]
            )
        } catch {
            print("Failed to set category? \(error.localizedDescription)")
        }
    }
}

Hello @draff27, thank you for your post. Stopping an AVAudioPlayerNode unschedules all previously scheduled buffers and file segments, and returns the player timeline to sample time 0.

Right now I am using two different AVAudioSession Configurations so what we only switch to a mic enabled mode when we actually need input from the mic.

One idea would be to keep the session configured with the playAndRecord category. When setting both allowBluetooth and allowBluetoothA2DP, the system gives hands-free ports a higher priority for routing. Please note that the system automatically routes to A2DP ports if you configure an audio session to use the playback category. But the allowBluetooth option, can only be set if the audio session category is playAndRecord or record. When a port supports HFP but doesn't support AD2P, switching to playback might therefore not produce the desired behavior.

AVAudioEngineConfigurationChange Clearing AVPlayerNode
 
 
Q