This page was inspired by the purchase of the TomTom GO mobile app for navigation as a result of a mobile network outage that left me stranded because neither Google Maps nor Apple Maps worked when the network was down.
I had owned the earlier version of TomTom Navigator and that we great but since discontinued. At $25/year I figured the TomTom GO app was worth it if it meant I was not going to be stranded without a network.
One of the most annoying things about TomTom GO is the fact that you cannot hear any voice prompts through the cars stereo UNLESS the stereo input is set to Bluetooth. Both Google Maps and Apple Maps provide turn by turn prompts over the car stereo even if the car radio is playing.
To make matters worse it seems TomTom with don’t want to fix it or simply don’t know how to fix it.
Here is what I discovered…
The Default Output Device is the ear speaker (the one used when talking on the phone without a headset
By default when you create an AVAudioSession and then create an AVAudioPlayer to play back a sound clip e.g. voice prompted navigation instruction the sound will play back through the phones ear speaker and not the Speaker Phone. Kind of makes sense because when you get a phone call thats how things work too.
I wasn’t able to find any obvious method for setting the output device for the app, however there is an API for selecting the SpeakerPhone
session.overrideOutputAudioPort(AVAudioSessionPortOverride.speaker)
session.overrideOutputAudioPort(AVAudioSessionPortOverride.none)
So – and this is the tricky part – you really need to figure out what the use case is and which output device to use for that use case.
Here is my solution
1. We always want the voice prompt or sound to use the speaker phone is the user is not currently using any other listening device.
2. Any time the user has another output device connected and in use we should use that.
So how do we determine when we need to override things and use the speaker phone ?
Strangely if you query the current output ports you don’t seem to get anything that identified the ear speaker on the phone
let outputs = try session.currentRoute.outputs if self.checkOutputs(outputs) { try session.overrideOutputAudioPort(AVAudioSessionPortOverride.speaker) }
func checkOutputs(_ outputports: [AVAudioSessionPortDescription])->Bool{ for output in outputports { if output.portType == AVAudioSessionPortBluetoothA2DP || output.portType == AVAudioSessionPortBluetoothLE || output.portType == AVAudioSessionPortLineOut || output.portType == AVAudioSessionPortAirPlay || output.portType == AVAudioSessionPortCarAudio || output.portType == AVAudioSessionPortUSBAudio || output.portType == AVAudioSessionPortBluetoothHFP { return false } } return true }
So now if the output is any of those listed above then the output will not be redirected to use the phones speaker phone.
NOTE:
According to the Apple documents navigation style background prompts should start and activate a session and close it on completion so this requires a few additional things to be implemented to ensure it works properly.
- To close the session once the audio has finished playing you need to implement
AVAudioPlayerDelegate
functions so you get notified when the sound has finished playing then you callsession.setActive(false)
- To avoid playing more than one prompt simultaneously you need to set a flag when you start playing the prompt and clear it when the
// MARK: AVAudioPlayerDelegate func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { self.isPlayingPrompt = false let session = AVAudioSession.sharedInstance() do { try session.setActive(false) } catch let error as NSError { DebugLog("Failed to deactivate the audio session: \(error.localizedDescription)") return } }
// This code should be included in your Singleton controller class class PromptController: NSObject, AVAudioPlayerDelegate { // MARK: - Singleton static let sharedController = PromptController() // this must be an instance variable and not a let var in a function var player: AVAudioPlayer? // Singleton instance variable var isPlayingPrompt = false // MARK: Audio functions func playPrompt(){ // Only let one play at a time guard !self.isPlayingPrompt else { DebugLog("Already playing Prompt") return } self.isPlayingPrompt = true guard let url = Bundle.main.url(forResource: "Prompt", withExtension: "m4a") else { DebugLog("Error no Prompt sound file found") self.isPlayingPrompt = false return } // Set audio session mode let session = AVAudioSession.sharedInstance() do { // Configure the audio session for playback and record // Not sure this is supported in background mode so // might need some more work to figure out how to ensure // it works in background mode try session.setCategory(AVAudioSessionCategoryPlayAndRecord , mode: AVAudioSessionModeDefault, options: [.duckOthers, .interruptSpokenAudioAndMixWithOthers, .allowBluetooth]) let outputs = try session.currentRoute.outputs if self.checkOutputs(outputs) { try session.overrideOutputAudioPort(AVAudioSessionPortOverride.speaker) } try session.setActive(true) /* The following line is required for the player to work on iOS 11. Change the file type accordingly*/ player = try AVAudioPlayer(contentsOf: url, fileTypeHint: AVFileType.m4a.rawValue) player?.delegate = self /* iOS 10 and earlier require the following line: player = try AVAudioPlayer(contentsOf: url, fileTypeHint: AVFileTypeMPEGLayer3) */ guard let player = player else { return } player.play() } catch let error as NSError { self.isPlayingPrompt = false DebugLog("Failed to set the audio session category and mode: \(error.localizedDescription)") return } AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) }