iPhone Navigation Type Audio Prompts

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.

  1.  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 call session.setActive(false)
  2. 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)

    }
 

 

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s