avaudioengineavaudiounit

Dynamically reconnecting AVAudioEngine nodes without stopping sound


I'm trying to dynamically modify a graph of nodes in attached to an AVAudioEngine, but the graph outputs silence after every reconnection.

In the below sample, I'd like to dynamically connect player to engine.mainMixerNode, but whenever I call toggleBypass I get silence.

Is it possible to carry out this rewiring without pausing playback of the AVAudioPlayerNode?

class Sample: UIViewController {

    let engine = AVAudioEngine()
    let player = AVAudioPlayerNode()
    let effectNode = AVAudioUnitDelay()

    @objc func toggleBypass() {
        if effectNode.numberOfInputs == 0 {
            engine.connect(player, to: effectNode, format: file.processingFormat)
            engine.connect(effectNode, to: engine.mainMixerNode, format: file.processingFormat)
        } else {
            engine.connect(player, to: engine.mainMixerNode, format: file.processingFormat)
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .red

        view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(toggleBypass)))

        try! AVAudioSession.sharedInstance().setCategory(.playback)
        try! AVAudioSession.sharedInstance().setPreferredIOBufferDuration(0.005)
        try! AVAudioSession.sharedInstance().setActive(true, options: [])

        do {

            engine.attach(player)
            engine.attach(effectNode)

            engine.connect(player, to: effectNode, format: file.processingFormat)
            engine.connect(effectNode, to: engine.mainMixerNode, format: file.processingFormat)

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

            engine.prepare()

            try engine.start()

            player.play()

        } catch {
            assertionFailure(String(describing: error))
        }
    }

    lazy var file: AVAudioFile = {
        let fileURL = Bundle.main.url(forResource: "beber", withExtension: "mp3")!
        return try! AVAudioFile(forReading: fileURL)
    }()

    lazy var buffer: AVAudioPCMBuffer = {
        let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: UInt32(file.length))!
        try! file.read(into: buffer)
        return buffer
    }()

}

(Yes, I know I can update the effect's bypass property, but I'm interested knowing what the steps are to dynamically rewire a graph)


Solution

  • After further research, I realise that (as of iOS 13) it is not possible to carry out this rewiring without pausing playback of the AVAudioPlayerNode.

    Re-connecting a player node uses the connect(_:to:fromBus:toBus:format:) method, which first disconnects the node from the node it was previously connected to, thus stopping playback.

    However, you can use a different connect method (connect(_:to:fromBus:format:) to connect the player to several effects as well as to a mixer that plays through:

    let engine = AVAudioEngine()
    let player = AVAudioPlayerNode()
    let effectOne = AVAudioUnitTimePitch()
    let effectTwo = AVAudioUnitReverb()
    let mixerThru = AVAudioMixerNode()
    let mixerOne = AVAudioMixerNode()
    let mixerTwo = AVAudioMixerNode()
    
    engine.attach(player)
    engine.attach(effectOne)
    engine.attach(effectTwo)
    engine.attach(mixerThru)
    engine.attach(mixerOne)
    engine.attach(mixerTwo)
    
    player.scheduleBuffer(buffer, at: nil, options: .loops, completionHandler: nil)
    
    let effectThruConnectionPoint = AVAudioConnectionPoint(node: mixerThru, bus: 0)
    let effectOneConnectionPoint = AVAudioConnectionPoint(node: effectOne, bus: 0)
    let effectTwoConnectionPoint = AVAudioConnectionPoint(node: effectTwo, bus: 0)
    
    engine.connect(player, to: [effectThruConnectionPoint, effectOneConnectionPoint, effectTwoConnectionPoint], fromBus: 0, format: nil)
    
    engine.connect(effectOne, to: mixerOne, format: file.processingFormat)
    engine.connect(effectTwo, to: mixerTwo, format: file.processingFormat)
    engine.connect(mixerThru, to: engine.mainMixerNode, format: file.processingFormat)
    engine.connect(mixerOne, to: engine.mainMixerNode, format: file.processingFormat)
    engine.connect(mixerTwo, to: engine.mainMixerNode, format: file.processingFormat)
    
    mixerTwo.outputVolume = 0
    mixerThru.outputVolume = 0
    

    (Props to RH for the idea and code)