[iOS]電話がかかってきたとき、ヘッドホンジャックが抜かれたときの対応

(2021年10月更新。Xcode 13 + Swift 5 に書き改めました)

オーディオ系アプリで、電話がかかってきたとき、ヘッドフォンジャックが抜かれたときの対応。

電話がかかってきたとき、ヘッドホンが抜かれたときには、音楽を再生していた場合対処する必要があります。
電話がかかってきたとき、ヘッドホンが抜かれたときを検知して対応するには、Notification Centerを使った通知の仕組みを使います。

NotificationCenter – Foundation | Apple Developer Documentation
Notification centerにイベントを監視(Observe)するよう登録します。イベントからNotirication Centerに通知(Post Notification)があると、Notification Centerは、対応するメソッドを呼び出します。
今回は、割り込み(AVAudioSession.interruptionNotification)とルートチェンジ(AVAudioSession.routeChangeNotification)を監視するように記します。

こちらのドキュメントにそって、実装します。
Responding to Audio Session Route Changes | Apple Developer Documentation
Responding to Audio Session Interruptions | Apple Developer Documentation
このドキュメントによると、AVPlayerの場合は、適切なタイミングでポーズ・再開が行われます。
それ以外のオーディオ再生方法では、適切なポーズ・再開は自動的には行われません。
ですので、ポーズや再開は自前で実装する必要があります。またUIを適切なものに更新することも必要です。
(AVAudioPlayerに関しては、最初にこの記事を書いた2015年においては、ヘッドホンが抜かれたときに自動的に再生が止まった記憶があります。しかし、2019年6月現在、音楽が一旦途切れたあと再生されます。)
(実験したところ、電話などの割り込みの際には、現在のところ音楽はとまります。)

以下は、SpeechSynthesizerで音声を出力して、そのさいヘッドフォンをぬくと再生を止めるという実験です。ヘッドホンを抜き差しすると、メッセージラベルに状況が表示されます。

メッセージラベル、Speakボタン、Pauseボタンを貼り付けます。

メッセージラベルとViewControllerをOutletで結びます。
@IBOutlet weak var messageLabel: UILabel!

SpeakボタンとViewControllerをActionで結びます。
@IBAction func speakBtnTapped(_ sender: Any) 

PauseボタンとViewControllerをActionで結びます。
@IBAction func pauseBtnTapped(_ sender: Any)

ViewController.swift


import UIKit
import AVFoundation
import AVKit

class ViewController: UIViewController {

    @IBOutlet weak var messageLabel: UILabel!
    
    let syntherizer = AVSpeechSynthesizer()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        addAudioSessionObservers()
        
    }
    
    // 電話による割り込みと、オーディオルートの変化を監視します
    func addAudioSessionObservers() {
        
        AVAudioSession.sharedInstance()
        
        let center = NotificationCenter.default
        center.addObserver(self, selector: #selector(handleInterruption(_:)), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
        center.addObserver(self, selector: #selector(audioSessionRouteChanged(_:)), name: AVAudioSession.routeChangeNotification, object: nil)
        
    }
    
    /// Interruption : 電話による割り込み
    @objc func handleInterruption(_ notification: Notification) {
        guard let userInfo = notification.userInfo,
            let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
            let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
                return
        }
        
        if type == .began {
            // interruptionが開始した時(電話がかかってきたなど)
        }
        else if type == .ended {
            // interruptionが終了した時の処理
            if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
                let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
                if options.contains(.shouldResume) {
                    // Interruption Ended - playback should resume
                } else {
                    // Interruption Ended - playback should NOT resume
                }
            }
        }
    }
    
    /// Audio Session Route Change : ルートが変化した(ヘッドフォンが抜き差しされた)
    @objc func audioSessionRouteChanged(_ notification: Notification) {
        guard let userInfo = notification.userInfo,
            let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
            let reason = AVAudioSession.RouteChangeReason(rawValue:reasonValue) else {
                return
        }
        
        DispatchQueue.main.async {
            self.messageLabel.text = self.routeChangeReasonDescription(reason: reason)
        }

        switch reason {
        case .newDeviceAvailable:
            let session = AVAudioSession.sharedInstance()
            for output in session.currentRoute.outputs where output.portType == AVAudioSession.Port.headphones {
                // ヘッドフォンがつながった
                
                break
            }
        case .oldDeviceUnavailable:
            if let previousRoute =
                userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription {
                for output in previousRoute.outputs where output.portType == AVAudioSession.Port.headphones {
                    // ヘッドフォンが外れた
                    
                    // このサンプルでは音声をpauseしています
                    syntherizer.pauseSpeaking(at: .immediate)
                    
                    break
                }
            }
        default: ()
        }
        
    }
    
    private func speak() {
        let voice = AVSpeechSynthesisVoice(language: "ja-JP")
        let utterance = AVSpeechUtterance(string: "おはようございます。今日もいい天気ですね。音声合成で読み上げています。文章をうまくよめていますか?")
        utterance.voice = voice
        //utterance.preUtteranceDelay = 5
        if syntherizer.isPaused {
            syntherizer.continueSpeaking()
        } else {
            syntherizer.speak(utterance)
        }
    }
    

    /// Speakボタンが押された
    @IBAction func speakBtnTapped(_ sender: Any) {
        // 音声出力を行う
        speak()
    }
    
    /// pauseボタンが押された
    @IBAction func pauseBtnTapped(_ sender: Any) {
        // ポーズする
        syntherizer.pauseSpeaking(at: .immediate)
    }
    
    /// 監視する必要がなくなった段階で、Observerを取り外します
    private func removeAudioSessionObservers() {
        let center = NotificationCenter.default
        
        // AVAudio Session
        center.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
        center.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil)
        
    }
    
    /// route変更の理由表示用
    private func routeChangeReasonDescription(reason: AVAudioSession.RouteChangeReason) -> String {
        switch reason {
        case .unknown:
            return "unknown"
        case .newDeviceAvailable:
            return "newDeviceAvailable"
        case .oldDeviceUnavailable:
            return "oldDeviceUnailable"
        case .categoryChange:
            return "categoryChange"
        case .override:
            return "override"
        case .wakeFromSleep:
            return "wakeFromSleep"
        case .noSuitableRouteForCategory:
            return "noSuitableRouteForCategory"
        case .routeConfigurationChange:
            return "routeConfigurationChange"
        default:
            return "default"
        }
    }
    
}

「[iOS]電話がかかってきたとき、ヘッドホンジャックが抜かれたときの対応」への15件のフィードバック

  1. 上記を参考にイヤホンが抜けた際の処理を記述しましたが、なぜかAVAudioSessionRouteChangeNotificationが機能いたしません。
    center.addObserver(
    self,
    selector: #selector(self.audioSessionRouteChanged),
    name: NSNotification.Name.AVAudioSessionRouteChange,
    object: nil)
    のnameを別のものにすると機能いたしますので、AVAudioSessionRouteChangeNotificationの仕様変更などがあったのかな…と調べておりますが、それらしき情報は見当たりませんでした。原因などがお分かりでしたらお教え頂けるとありがたいです。

    返信
  2. nackpan fanさん、こんにちは。

    記事に記していたコードは、Xcode 7 + Swift 2時代のものでしたが、現在(2016/11)は、Xcode 8+Swift 3の環境で作業しています。
    記事に記していたものをもとにXcode 8の自動コンバートで生成されたコードをそのまま使っているのですが、手元で実験してみたところ、AVAudioSessionRouteChangeは機能しており、イヤホンの抜き差しに適切に反応しておりました。
    (iPhone 5s + iOS 10.1, iPhone 6 Plus + iOS 10.1, iPhone 4s + iOS 9.3.5, iPad Air + iOS 9.3.5で実験)

    記事に、Xcode 8の自動コンバートで、Swift 3に変換したサンプルコードを書き加えました。手がかりになるかもしれませんので、ご参照ください。

    返信
  3. nackpanさま、

    早速、調査の上ご返信頂きまして、ありがとうございました。
    Xcode8のサンプルコードまでお教え頂き、感動です。
    お教え頂いたコードに書き直してみましたが、なぜか引き続き私のアプリでは引き続きAVAudioSessionRouteChangeが機能いたしません…。
    また原因を調査し、もしかすると質問させて頂くことがあるかもしれません。
    この度は、迅速なご対応を頂き、本当にありがとうございました!!

    返信
  4. nackpanさま、

    昨日は大変お世話になりました。
    本件ですが、addAudioSessionObservers()とremoveAudioSessionObservers()の位置を調整したところ、問題解決いたしました。お騒がせしまして申し訳ございませんでした。
    ただ、nackpanさまの回答で色々と気づく点も多く大変助かりました。本当にありがとうございました☆また色々とこちらのブログを勉強して開発を楽しみたいと思います。今後ともよろしくお願い申し上げます。

    返信
  5. nackpan fanさん、こんにちは。

    問題が解決したそうでなによりです。

    ブログの記事が何かの役に立つことがあれば幸いです。

    返信
  6. nackpan様
    ヘッドフォン抜けの対応記事を参考にさせて頂きAVSpeechSynthesizerを使っての読み上げプログラムや、mp4ファイルの再生プログラムで、再生中のヘッドフォン抜けに対応できており、大変感謝しております。
    しかしながら、下記のように、
    ①AVSpeechSynthesizer読み上げ待ち時間設定した場合の待ち時間中
     utterance.preUtteranceDelay = preIntervalTimeDouble
     utterance.postUtteranceDelay = postIntervalTimeDouble
    や、
    ②5秒くらいの短いmp4動画ファイルの、複数連続再生時の一つずつの待ち時間を設定した時の待ち時間中
     DispatchQueue.main.asyncAfter(deadline: .now()+intervalAfterOnePhrase!) {
    に、ヘッドフォンを抜き差しすると、検知できておりません。
    このような、再生中でない時にも、検知する方法はあるのでしょうか。
    ご教示を頂きたく、どうかよろしくお願い致します。

    返信
  7. kkさん、こんにちは。
    コメントありがとうございます。

    あらためて、ヘッドフォンを抜きさしのさいのオーディオ再生について実験して、記事を書き改めました。
    元記事では、ヘッドフォンを抜くと音楽は自動的に止まると書いておりましたが、現在ではAVAudioPlayerやAVSpeechSynthesizerは一旦途切れた後、音が鳴り続けるようになっていました。ヘッドフォンを抜いた時には、UI更新の他に再生をポーズするなどの処理が必要になっています。

    kkさんのコメントにあった再生中でないと、ルートチェンジが検知できないということはないように思われます。notification centerでの監視を開始する箇所の問題かもしれません。

    返信
  8. nackpan様
    kkです。
    早速の返信ありがとうございます。
    これから内容を勉強させて頂き、結果がでれば返信させて頂きます。
    よろしくお願いします。

    返信
  9. nackpan様

    新しい記事内容に従って、実験した結果、
    ・utterance.preUtteranceDelay = 5
    を有効にして、時間待ち中に、ヘッドフォンを抜いたところ無事に動作し、感動致しました。ありがとうございます。
    ところが、色々と実験していたところ、
    //ヘッドフォンが外れた、の後の
    syntherizer.pauseSpeaking(at: .immediate)
    の代わりに
    syntherizer.stopSpeaking(at: .immediate)
    を実行するようにし、発声前の待ち時間中(preUtteranceDelay=5の間)にヘッドフォンを外すと、エラーにはなっていないのですが、speakボタンを押しても、動かなくなってしましました。
    一度は、発声をさせる必要があるのかを確かめるために、
    utterance.postUtteranceDelay = 5にして、speakボタンを数回
    押して、再生開始後、発声が終わって2〜3秒後にヘッドフォンを外しても同じ現象が起こり、動かなくなってしまいました。
    引き続き、実験をしておりますが、今の所、回避策がわかっておりません。
    可能であれば、アドバイスを頂ければ検討したいと思っております。
    お手数をお掛けして申し訳ありませんが、よろしくお願い致します。

    返信
  10. kkさん、こんにちは。
    コメントいただいた
    >syntherizer.stopSpeaking(at: .immediate)
    >を実行するようにし、発声前の待ち時間中(preUtteranceDelay=5の間)にヘッドフォンを外す
    を行うと、再生ができなくなる症状こちらでも確認しました。

    AppleのAVSpeechSynthesizer、stopSpeaking(at:)のドキュメントを見ても、この症状は不具合にも感じますが、実際のところ再生できなくなるので困りますね
    実験したところ、回避策としては、

    (1)
    let syntherizer = AVSpeechSynthesizer()

    var syntherizer = AVSpeechSynthesizer()
    にする。

    (2)
    if syntherizer.isPaused {
    syntherizer.continueSpeaking()
    } else {
    syntherizer.speak(utterance)
    }

    if syntherizer.isPaused {
    syntherizer.continueSpeaking()
    } else {
    syntherizer = AVSpeechSynthesizer()
    syntherizer.speak(utterance)
    }
    とする。
    で、動作しました。
    AVSpeechSynthesizerを再開以外の再生時にはあらためて作成し直す、ということで対処しています。

    返信
  11. nackpan様
    kk@nackpan神様です。
    回避策、実験した結果、動作確認できました。大変ありがたく、また、感動致しました。
    『AVSpeechSynthesizerを、再生時に、あらためて作成し直す』の意味については、『一旦、中身をリセットしている』ようなイメージを受けましたが、よくわかっておらず、もう少し勉強いたします。
    追加の質問となり、申し訳ありませんが、以下を実行すると、下記の現象が起こっているのですが、この回避策は、どのような事が考えられるのかをアドバイス頂ければ、ありがたいです。
    実行内容
    ①今回のプログラムに、リモコン対応の追加(nackpan様の記事を引用させて頂きました)
    //リモコン対応(1)
    func addRemoteCommandEvent() {
    let commandCenter = MPRemoteCommandCenter.shared()
    commandCenter.playCommand.addTarget(self, action: #selector(type(of: self).remotePlay(_:)))
    commandCenter.pauseCommand.addTarget(self, action: #selector(type(of: self).remotePause(_:)))
    }
    @objc func remotePlay(_ event: MPRemoteCommandEvent) {
    // プレイボタンが押された時の処理
    syntherizer.continueSpeaking()
    }
    @objc func remotePause(_ event: MPRemoteCommandEvent) {
    // ポーズボタンが押された時の処理
    syntherizer.pauseSpeaking(at: AVSpeechBoundary.immediate)
    }
    ②viewDidLoadに以下を追加
    //リモコン対応(2)
    addRemoteCommandEvent()
    ③viewDidLoadにバックグラウンドでも再生設定追加(これも、nackpan様の記事を引用させて頂きました)
    let session = AVAudioSession.sharedInstance()
    do {
    try session.setCategory(.playback, mode: .default)
    //try session.setCategory(.playback, mode: .default, options: .mixWithOthers)
    //try session.setCategory(AVAudioSession.Category.playback, mode: .default)
    //try session.setCategory(AVAudioSession.Category.playback)
    } catch {
    //エラー処理
    fatalError(“カテゴリー設定失敗”)
    }
    //sessionのアクティブ化
    do {
    try session.setActive(true)
    } catch {
    // audio session有効化失敗時の処理
    fatalError(“session有効化失敗”)
    }

    今回の質問の現象
     1.Speakボタンで、発声開始
     2.Pauseボタンで、一時停止
     3.画面消去(電源)ボタンで、一旦、画面消灯
     4.ホームボタン、または電源ボタンを押して、リモート画面表示
    ★5.再生ボタンを押すと、★最初の一回のみ★、『電話による割り込み』検出と同じ動作になる
     6.続けて、もう一度再生ボタンを押すと、再生を開始できるようになる

    上記の、『★5.』の現象の回避策がわからず、アドバイスを頂きたく
    どうか、よろしくお願い致します。

    返信
  12. kkさん、こんにちは。
    現在Macを修理に出しております。
    1週間ほどでかかる予定ですので、手元に戻ってきたのち、調べてみたいと思います。

    返信
  13. kkさん、こんにちは。
    コメントいただき、こちらでもAVSpeechSynthesizerの実験を行いました。
    コメントにあった問題が発生し、適切な解決の方法はまだ見つかっておりません。

    バックグラウンド再生
    [iOS][Swift]バックグラウンドに移行してもオーディオ再生を続ける – nackpan Blog
    Enabling Background Audio | Apple Developer Documentation

    リモートコマンドイベント
    [iOS][Swift]リモートコマンドイベントに対応する – nackpan Blog
    Controlling Background Audio | Apple Developer Documentation

    コメントでの状況は、これらが実装されているという前提で実験を行ったのですが、よろしいでしょうか?
    実験を進めていくと、そのほかにも奇妙な動作が生じました。生じた奇妙な動作というのは以下のようなものです。
    1. Speakボタンで、発声開始
    2. Pauseボタンで、一時停止
    3. ホームボタンでアプリを中断
    4. アプリを再開すると、音声がなってしまう!

    この症状に参ったので、いったん前提を確認したいと思い投稿しました。

    返信
  14. nackpan様
    kkです。投稿ありがとうございます。
    下記、2つを実装して実験をしております。
    ・バックグラウンド再生
    ・リモートコマンドイベント

    私の方でも、同じように、
    1. Speakボタンで、発声開始
    2. Pauseボタンで、一時停止
    3. ホームボタンでアプリを中断
    4. アプリを再開すると、音声がなってしまう!
    の現象が出ており、下記コードの、”else”のところに行っているようです。
    let options = AVAudioSession.InterruptionOptions(rawValue:optionsValue)
    if options.contains(.shouldResume) {
    // Interruption Ended – playback should resume
    } else {
    // Interruption Ended – playback should NOT resume
    }
    ここに
    ・syntherizer.pauseSpeaking(at: .immediate)
    を入れると一時停止が継続されています。

    取り急ぎ、報告を投稿させて頂きます。

    返信

nackpan へ返信する コメントをキャンセル