Cannot listen to iOS app on Bluetooth if user has granted microphone access

242 Views Asked by At

I am developing an iOS 14 app that plays fragments of an audio file for the user to imitate. If the user wants to, the app can record the user's responses and play these back immediately. The user can also export an audio recording that comprises the original fragments, plus the user's responses.

I am using AudioKit 4.11

Because it is possible the user may never wish to take advantage of the app's recording abilities, the app initially adopts the audio session category of .playback. If the user wants to use the recording feature, the app triggers the standard Apple dialog for requesting microphone access, and if this is granted, switches the session category to .playAndRecord.

I have found that when the session category is .playback and the user has not yet granted microphone permission, I am able to listen to the app's output on a Bluetooth speaker, or on my Jabra Elite 65t Bluetooth earbuds when the app is running on a real iPhone. In the example below, this is the case when the app first runs and the user has only ever tapped "Play sound" or "Stop".

However, as soon as I tap "Play sound and record response" and grant microphone access, I am unable to listen to the app's output on a Bluetooth device, regardless of whether the session category applicable at the time is .playback (after tapping "Play sound and record response") or .playAndRecord (after tapping "Play sound") - unless I subsequently go to my phone's Privacy settings and toggle microphone access to off. Playback is available only through the phone's speaker, or through plugged in headphones.

When setting the session category of .playAndRecord I have tried invoking the .allowBluetoothA2DP option. Apple's advice implies this should allow me to listen to my app's sound over Bluetooth in the circumstances I have described above (see https://developer.apple.com/documentation/avfoundation/avaudiosession/categoryoptions/1771735-allowbluetootha2dp). However I've not found this to be the case.

The code below represents a runnable app (albeit one requiring the presence of AudioKit 4.11) that illustrates the problem in a simplified form. The only elements not shown here are an NSMicrophoneUsageDescription that I added to info.plist, and the file "blues.mp3" which I imported into the project.

ContentView:

import SwiftUI
import AudioKit
import AVFoundation

struct ContentView: View {

private var pr = PlayerRecorder()

var body: some View {
    VStack{
        Text("Play sound").onTapGesture{
            pr.setupforPlay()
            pr.playSound()
        }
        .padding()
        Text("Play sound and record response").onTapGesture{
            if recordingIsAllowed() {
                pr.activatePlayAndRecord()
                pr.startSoundAndResponseRecording()
            }
        }
        .padding()
        Text("Stop").onTapGesture{
            pr.stop()
        }
        .padding()
        
    }
}

func recordingIsAllowed() -> Bool {
    
    var retval = false
    AVAudioSession.sharedInstance().requestRecordPermission { granted in
        retval = granted
    }
    return retval
}

}

PlayerRecorder:

import Foundation
import AudioKit

class PlayerRecorder {

private var mic: AKMicrophone!
private var micBooster: AKBooster!
private var mixer: AKMixer!
private var outputBooster: AKBooster!
private var player: AKPlayer!
private var playerBooster: AKBooster!
private var recorder: AKNodeRecorder!
private var soundFile: AKAudioFile!
private var twentySecondTimer = Timer()

init() {
    AKSettings.defaultToSpeaker = true
    AKSettings.disableAudioSessionDeactivationOnStop = true
    AKSettings.notificationsEnabled = true
}

func activatePlayAndRecord() {
    do {
        try AKManager.shutdown()
    } catch {
        print("Shutdown failed")
    }
    setupForPlayAndRecord()
}


func playSound() {
    do {
        soundFile = try AKAudioFile(readFileName: "blues.mp3")
    } catch {
        print("Failed to open sound file")
    }
    do {
        try player.load(audioFile: soundFile!)
    } catch {
        print("Player failed to load sound file")
    }
    if micBooster != nil{
        micBooster.gain = 0.0
    }
    player.play()
}


func setupforPlay() {
    do {
        try AKSettings.setSession(category: .playback)
    } catch {
        print("Failed to set session category to .playback")
    }
    mixer = AKMixer()
    outputBooster = AKBooster(mixer)
    player = AKPlayer()
    playerBooster = AKBooster(player)
    playerBooster >>> mixer
    AKManager.output = outputBooster
    if !AKManager.engine.isRunning {
        try? AKManager.start()
    }
}


func setupForPlayAndRecord() {
    AKSettings.audioInputEnabled = true
    do {
        try AKSettings.setSession(category: .playAndRecord)
        /*  Have tried the following instead of the line above, but without success
         let options: AVAudioSession.CategoryOptions = [.allowBluetoothA2DP]
         try AKSettings.setSession(category: .playAndRecord, options: options.rawValue)
         
         Have also tried:
         try AKSettings.setSession(category: .multiRoute)
         */
    } catch {
        print("Failed to set session category to .playAndRecord")
    }
    
    mic = AKMicrophone()
    micBooster = AKBooster(mic)
    mixer = AKMixer()
    outputBooster = AKBooster(mixer)
    player = AKPlayer()
    playerBooster = AKBooster(player)
    mic >>> micBooster
    micBooster >>> mixer
    playerBooster >>> mixer
    AKManager.output = outputBooster
    micBooster.gain = 0.0
    outputBooster.gain = 1.0
    if !AKManager.engine.isRunning {
        try? AKManager.start()
    }
}

func startSoundAndResponseRecording() {
    // Start player and recorder. After 20 seconds, call a function that stops the player
    // (while allowing recording to continue until user taps Stop button).
    
    activatePlayAndRecord()
    playSound()
    
    // Force removal of any tap not previously removed with stop() call for recorder
    var mixerNode: AKNode?
    mixerNode = mixer
    for i in 0..<8 {
        mixerNode?.avAudioUnitOrNode.removeTap(onBus: i)
    }
    
    do {
        recorder = try? AKNodeRecorder(node: mixer)
        try recorder.record()
    } catch {
        print("Failed to start recorder")
    }
    twentySecondTimer = Timer.scheduledTimer(timeInterval: 20.0, target: self, selector: #selector(stopPlayerOnly), userInfo: nil, repeats: false)
}


func stop(){
    twentySecondTimer.invalidate()
    if player.isPlaying {
        player.stop()
    }
    if recorder != nil {
        if recorder.isRecording {
            recorder.stop()
        }
    }
    if AKManager.engine.isRunning {
        do {
            try AKManager.stop()
        } catch {
            print("Error occurred while stopping engine.")
        }
    }
    print("Stopped")
}

@objc func stopPlayerOnly () {
    player.stop()
    if !mic.isStarted {
        mic.start()
    }
    if !micBooster.isStarted {
        micBooster.start()
    }
    mic.volume = 1.0
    micBooster.gain = 1.0
    outputBooster.gain = 0.0
}
}
1

There are 1 best solutions below

0
On

Three additional lines of code near the beginning of setupForPlayAndRecord() solve the problem:

func setupForPlayAndRecord() {
    AKSettings.audioInputEnabled = true
    // Adding the following three lines solves the problem
    AKSettings.useBluetooth = true
    let categoryOptions: AVAudioSession.CategoryOptions = [.allowBluetoothA2DP]
    AKSettings.bluetoothOptions = categoryOptions
    do {
        try AKSettings.setSession(category: .playAndRecord)
    } catch {
        print("Failed to set session category to .playAndRecord")
    }
    mic = AKMicrophone()
    micBooster = AKBooster(mic)
    mixer = AKMixer()
    outputBooster = AKBooster(mixer)
    player = AKPlayer()
    playerBooster = AKBooster(player)
    mic >>> micBooster
    micBooster >>> mixer
    playerBooster >>> mixer
    AKManager.output = outputBooster
    micBooster.gain = 0.0
    outputBooster.gain = 1.0
    if !AKManager.engine.isRunning {
        try? AKManager.start()
    }
}