AVAudioSession services reset when capturing input from bluetooth device that disconnects

Viewed 807

TL;DR - AVAudioSession will fire AVAudioSessionMediaServicesWereLostNotification when a bluetooth port is specified with AVAudioSession.setPreferredInput and that device disconnects while actively reading input with either AVCaptureSession or AVAudioEngine. Example project here.

https://openradar.appspot.com/FB8921592

The Problem

Through what I consider a "happy path", or a path that should at least be supported by Apple, I can consistently get AVAudioSession to fire its AVAudioSessionMediaServicesWereLostNotification and AVAudioSessionMediaServicesWereResetNotification notifications.

In between these two notifications, your app can't do any audio or video I/O or processing (encoding or decoding). It's a total media services shutdown for 1 to 2 seconds.

The Trigger

This only happens when using AVAudioSession.setPreferredInput, where the port you set is a bluetooth device. While actively recording from that port, you turn the bluetooth device off.

When this happens, no other notifications from AVAudioSession, AVAudioEngine, or AVCaptureSession are fired before services are lost, so there is no way to preemptively handle the situation. The user can turn off their headset at any time, causing total services failure.

Some Code

First I set up the audio session

try! AVAudioSession.sharedInstance().setCategory(.record, mode: .videoRecording, options: .allowBluetooth)
try! AVAudioSession.sharedInstance().setActive(true)

Then specify the correct port

let bluetoothPort = AVAudioSession.sharedInstance().availableInputs?
    .first(where: { $0.portType == .bluetoothHFP })
guard let validPort = newPort else {
    print("Couldn't find a valid bluetooth port")
    return
}
print("Swapping \(AVAudioSession.sharedInstance().currentRoute.inputs.first!.portName) for \(validPort.portName)")
try! AVAudioSession.sharedInstance().setPreferredInput(newPort)

Finally set up the AVCaptureSession

let cap = AVCaptureSession()
cap.automaticallyConfiguresApplicationAudioSession = false
let device = AVCaptureDevice.default(for: .audio)!
let input = try! AVCaptureDeviceInput(device: device)
let output = AVCaptureAudioDataOutput()
output.setSampleBufferDelegate(self, queue: DispatchQueue(label: "test-queue"))
cap.beginConfiguration()
cap.addInput(input)
cap.addOutput(output)
cap.commitConfiguration()
cap.startRunning()

Or AVAudioEngine

let engine = AVAudioEngine()
let inputNode = engine.inputNode
let bus = 0
inputNode.installTap(onBus: bus, bufferSize: 1024, format: inputNode.inputFormat(forBus: 0)) { (buffer, time) in
    // anything really
}
engine.prepare()
try! engine.start()

I do have a repo that will demonstrate the problem using either AVAudioEngine or AVCaptureSession, you can find it here - https://github.com/bclymer/ios-preferredinput-crash. There are more explicit repro steps in the README.

The following devices of mine are impacted (it's all of them)

╔═════════════╦═════════════╗
║ Device Name ║ iOS Version ║
╠═════════════╬═════════════╣
║ iPhone 7    ║ iOS 14.2    ║
║ iPhone Xr   ║ iOS 14.0.1  ║
║ iPad Air 2  ║ iOS 13.7    ║
╚═════════════╩═════════════╝

What I'm Looking For

Is there a way to not have services reset while reading I/O from an external device that disconnects suddenly?

A solution that I'm aware of but is not acceptable is relying on Apple's "last in" approach to audio routing. When attaching a bluetooth device, it becomes the default route automatically if AVAudioSession.preferredInput == nil. However, I need to be able to explicitly choose between any number of external devices. Using implicit routing won't work for me.

0 Answers
Related