1

I'm trying to change the audio input (microphone) between all the available devices from AVAudioSession.sharedInstance().availableInputs. I'm using AVAudioSession.routeChangeNotification to get automatic route changes when devices get connected/disconnected and change the preferred input with setPreferredInput, then I restart my audioEngine and it works fine.

But when I try to change the preferred input programmatically It doesn't change the audio capture inputNode. But keeps the last connected device and capturing.

Even the AVAudioSession.sharedInstance().currentRoute.inputs changes but the audioEngine?.inputNode doesn't change to setPreferredInput call.

WhatsApp seems to have done that without any issues. enter image description here

Any suggestions or leads are highly appreciated. Thanks.

These are some related code segments. enableMic programmatically is the issue.

    private var audioEngine: AVAudioEngine?
    private var inputNode: AVAudioNode!

    private func setupAudioSession() {
        do {
            let session = AVAudioSession.sharedInstance()
            try session.setCategory(.playAndRecord, options: [.defaultToSpeaker, .allowBluetooth])
            try session.setActive(true)
        } catch {
            print(error)
        }
    }

    func setupAudioEngine() {
        audioEngine?.stop()
        audioEngine?.reset()

        setupAudioSession()

        audioEngine = AVAudioEngine()
        inputNode = audioEngine?.inputNode

        guard let hardwareSampleRate = audioEngine?.inputNode.inputFormat(forBus: 0).sampleRate else { return }

        let format = AVAudioFormat(standardFormatWithSampleRate: hardwareSampleRate, channels: 1)

        let requiredBufferSize: AVAudioFrameCount = 4800

        inputNode.installTap(onBus: 0,
                             bufferSize: requiredBufferSize,
                             format: format
        ) { [self] (buffer: AVAudioPCMBuffer, _: AVAudioTime) in
            // process the buffer
        }

        audioEngine?.prepare()
    }

    func startRecording() {
        do {
            try audioEngine?.start()
        } catch {
            print("Could not start audioEngine: \(error)")
        }
    }

    private func observeRouteChanges() {
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(handleRouteChange),
                                               name: AVAudioSession.routeChangeNotification,
                                               object: nil)
    }

    @objc func handleRouteChange(notification: Notification) {
        guard let userInfo = notification.userInfo,
              let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
              let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
            return
        }

        switch reason {

        case .newDeviceAvailable, .oldDeviceUnavailable:
            // enableMic with current route AVAudioSession.sharedInstance().currentRoute.inputs.first

        default: ()
        }

    }

    private func enableMic(mic: AVAudioSessionPortDescription?) {
        let session = AVAudioSession.sharedInstance()
        do {
            try session.setPreferredInput(mic)
            // re-setup the audio engine and start
        } catch {
            print(error)
        }
    }
ajw
  • 2,568
  • 23
  • 27
  • inside enableMic try doing ```audioEngine?.stop(), try session.setPreferredInput(mic), setupAudioEngine(), startRecording()``` – udi Jun 01 '23 at 06:03
  • @udi I did try that but no difference. – ajw Jun 01 '23 at 06:34

1 Answers1

1

Also new to AVFoundation. For your question

Change Audio input programmatically doesn't change Audio engine input AVFoundation? Yes, it does not change the inputNode property even if you programmatically change the input device.

Please take at Apple's documentation on this property: enter image description here

The inputNode does not change. That's expected. The inputNode property of AVAudioEngine is a computed property, and it is evaluated lazily(on demand) when it is first accessed.

When you access the inputNode property for the first time, the AVAudioEngine creates a singleton instance of the AVAudioInputNode class – using the AVAudioEngine's default AVAudioNode render format – to represent the hardware's audio input connection. This instance handles audio input in the audio engine's processing graph, and the audio input format is automatically determined by the properties of your audio hardware.

Once the node is created, subsequent calls to input node will return the same instance.

And to prove that I wrote a demo app, I think you have tried it. Take a look at this method, which is used to start tapping input node:

public func startMonitoring(){
    /* with out this code, installTap will crash due to 'required condition is false: format.sampleRate == hwFormat.sampleRate' install tap
    audioEngine = AVAudioEngine()
    */
    let inputNode = audioEngine.inputNode
    let inputFormat = inputNode.outputFormat(forBus: 0)
    // Install a tap on the audio engine with the buffer size and the input format.
    debugPrint("inputFormat:",inputFormat)
    audioEngine.inputNode.installTap(onBus: 0, bufferSize: AVAudioFrameCount(bufferSize), format: inputFormat) { buffer, _ in
      self.audioMetering(buffer: buffer)
    }
    audioEngine.prepare()
    do {
      try audioEngine.start()
    } catch {
      debugPrint("\(error.localizedDescription)")
    }
  }

If you try to call this method after the AVAudioEngineConfigurationChange notification, you will find the app crashes at audioEngine.inputNode.installTap(onBus:, bufferSize:,format:), especially when you are switching between AirPods and built-in microphone. That's because the inputNode does not update when the input device is physically changed. Just like the documentation says, 'it is created on demand when first accessed'.

I hope that clarifies how the lazy instantiation works for the inputNode property of AVAudioEngine. Let me know if you have any further questions.

BTW, here is the demo repo link. The demo both have automatic audio input switching and manual audio input switch.

enter image description here

kakaiikaka
  • 3,530
  • 13
  • 19
  • Please put the solution code into the text of your answer, not just at the far end of a link. – matt Jun 05 '23 at 12:28
  • Sure, but for this question, the code is not the key, looks Akila has some misunderstanding about the API’s behavior. – kakaiikaka Jun 05 '23 at 12:31
  • Could you test this with a Bluetooth device? This works fine for me with just headphones but not with AirPods. – ajw Jun 07 '23 at 01:10
  • testing with my AirPods Pro, looks like slightly different. – kakaiikaka Jun 07 '23 at 01:57
  • Yes. That's why I had to restart the session after setPreferredInput and then Airpods worked fine with notification but couldn't change programmatically. – ajw Jun 07 '23 at 05:41
  • 1
    I know your question now. Please check my repo again, see if that solved your question. We need to recreate a AVAudioEngine(), if not `inputNode.outputFormat(forBus: 0)` will not be updated with the new device. I'll update the answer later, but you can pull my code first. – kakaiikaka Jun 07 '23 at 09:08
  • 1
    One more thing: the install tap method will not stop if the format does not change. that's why earlier the code works when switching between wired earphone and build-in mic. But for AirPods, the hw format is not compatible with old format, therefore, the tapping stopped. – kakaiikaka Jun 07 '23 at 12:24
  • Yes, I missed AVAudioEngineConfigurationChange notification completely. Thank you very much for the answer and the code with explanation. – ajw Jun 08 '23 at 02:42
  • I also learn it, fixed a bug in our App by looking at your question. :D – kakaiikaka Jun 08 '23 at 03:08