AVSpeechSynthesizerをバックグラウンド再生ありで使う

(2019/08/09更新。バックグラウンド移行時に音が止まっているならAudioSessionを無効化する方法に書き換えました。)

以前[iOS]電話がかかってきたとき、ヘッドホンジャックが抜かれたときの対応 – nackpan Blogという記事を書きました。

そこでのkkさんのコメントをきっかけにAVSpeechSynthesizerの使い方について調べました。

AVSpeechSynthesizerを用いてテキストを読み上げるアプリ。フォアグラウンドで再生している場合は問題ありません。
バックグラウンド再生を絡めた場合にトラブル。
AVSpeechSynthesizerでspeak中にpauseしたあとアプリを中断、再びアプリを開くと勝手に音が鳴るなど、アプリ再開時の挙動が妙でした。

ログを見るとアプリ再開時にinterruptionの通知が届いていたので、interruptionNotificationについて調査。

InterruptionNotificationについて

Interruptionの通知は、電話がかかってきた時などでAudioSessionに割り込みが発生した時に届くものと考えていました。
今回、アプリの再開時に、interruptionの通知が届きました。

Appleのドキュメントを見直しました。

interruptionNotification – AVAudioSession | Apple Developer Documentation

Noteとして以下の記述があります。

Starting in iOS 10, the system will deactivate the audio session of most apps in response to the app process being suspended. When the app starts running again, it will receive an interruption notification that its audio session has been deactivated by the system. This notification is necessarily delayed in time because it can only be delivered once the app is running again. If your app’s audio session was suspended for this reason, the userInfo dictionary will contain the AVAudioSessionInterruptionWasSuspendedKey key with a value of true.

If your audio session is configured to be non-mixable (the default behavior for the playback, playAndRecord, soloAmbient, and multiRoute categories), it’s recommended that you deactivate your audio session if you’re not actively using audio when you go into the background. Doing so will avoid having your audio session deactivated by the system (and receiving this somewhat confusing notification).

interruptionNotification – AVAudioSession | Apple Developer Documentation

 * iOS 10以降では、システムは、アプリプロセスが中断されると、ほとんどのアプリの AudioSessionを無効にする。
*  アプリの再開時に、AudioSessionがシステムによって無効にされたことを示す割り込み(interruption)通知を受け取る。
* この通知でAVAudioSessionInterruptionWasSuspendedKeyの値はtrue
* AudioSessionがミックス不可(playback、playAndRecord、soloAmbient、multiRouteのデフォルトの動作)設定なら、バックグラウンド移行時に、アクティブに使用していないAudioSessionを無効にすることをお勧め
 * そうすることで、この通知を受け取らなくて済む

とあるように、システムによってAudioSessionが無効にされたことを示すinterruptionがアプリの再開時に届く、とのことでした。

InterruptionNotificationの実験

バックグラウンド再生ありのAVSpeechSynthesizerを使ったアプリで、interruptionの通知について実験しました。
当初、実験した際(07/29)には、iOS 12.4で
* AVSpeechSynthesizerでspeak中に、pause。電源ボタンを押してアプリを中断。すると音声が勝手に鳴ってしまう。電源ボタンを押した直後にinterruptionの通知が届きました。(willResignActiveの前に発生)
という症状が発生しました。
しかし、その後あらためて実験すると、まったくこの症状は出ず、interruptionNotification – AVAudioSession のドキュメントにあるように再開後にinterruptionの通知が届きました。

対処法

アプリ再開時に音が勝手になるなど挙動が変になる問題は、システムによるAudioSessionの無効化が関係しているのだろう。AVSpeechSynthesizerでは再開時に上手いことやってくれない。Noteにあるように、明示的にAudioSessionを無効化すれば、通知を受け取らずに済むし挙動も適切なものになると考えました。

interruptionNotificationのNoteでは、アプリのAudioSessionが無効にされたことを示す通知がアプリ再開後に届くとあり、それを避けるには、バックグラウンド移行時にAudioSessionを無効化するとよい、とありました。


当初の実験(07/29)では、iOS 12.4での電源ボタンによるアプリの中断で、電源ボタンを押した直後に通知が届き、バックグラウンド移行時にAudioSessionを無効化する処理を書いても、音声が短いながらも勝手に鳴ってしまう症状が出ました。そのため、AVSpeechSynthesizerでの音声再生が終了したら、その段階でAudioSessionを無効化にするということで対処することにしていました。これにはAVSpeechSynthesizerDelegateを使いAVSpeechSynthesizerのpause, stop, finishの完了を知り、そこでAudioSessionを無効化にしていました。

しかし、その後の実験では「電源ボタンによるアプリの中断で、電源ボタンを押した直後に通知が届き、音声が短いながらも勝手に鳴ってしまう」症状は再現しませんでした。


そこで、interruptionNotificationのNoteが勧める方法に従いました。
バックグラウンド移行時に音が止まっている場合、AudioSessionを無効化。
speakのさいにAudioSessionを有効化しました。

バックグラウンド移行を検知する方法はこちらの記事が参考になります。
NotificationCenterを用いたライフサイクルイベントの検知

実装

今回はViewController.swiftにすべて記述することとしました。
メッセージラベル、Speakボタン、Pauseボタン、Stopボタンを貼り付けます。

アプリ画面

メッセージラベルとViewControllerをOutletで結びます。
@IBOutlet weak var messageLabel: UILabel!
SpeakボタンとViewControllerをActionで結びます。
@IBAction func speakBtnTapped(_ sender: Any) 
PauseボタンとViewControllerをActionで結びます。
@IBAction func pauseBtnTapped(_ sender: Any)
StopボタンとViewControllerをActionで結びます。
@IBAction func stopBtnTapped(_ sender: Any)

バックグラウンド再生用。
Capabilities > Background Modes をON > Audio, AirPlay, and Picture in Pictureにチェック

ViewController.swift

import UIKit
import AVFoundation
import AVKit
import MediaPlayer

class ViewController: UIViewController, AVSpeechSynthesizerDelegate {

    @IBOutlet weak var messageLabel: UILabel!
    var syntherizer = AVSpeechSynthesizer()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        // AudioSessionカテゴリをbackground再生ができるものに設定
        let audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setCategory(AVAudioSession.Category.playback)
        } catch {
            print("Setting category to AVAudioSessionCategoryPlayback failed.")
        }

        addAudioSessionObservers()
        addRemoteCommandEvent()
        addLifeCycleObserver()
    }
    
    // 電話による割り込みと、オーディオルートの変化を監視します
    func addAudioSessionObservers() {
        let center = NotificationCenter.default
        center.addObserver(self, selector: #selector(handleInterruption(_:)), name: AVAudioSession.interruptionNotification, object: nil)
        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が開始した時(電話がかかってきたなど)
            if let wasSuspendedKeyValue = userInfo[AVAudioSessionInterruptionWasSuspendedKey] as? NSNumber {
                let wasSuspendedKey = wasSuspendedKeyValue.boolValue
                if wasSuspendedKey {
                    // suspeended key : true
                } else {
                    // suspended key : false
                }
            } else {
                // suspended key : nil
            }
        }
        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しています
                    pause()
                    break
                }
            }
        default: ()
        }
        
    }
    
    public func speak() {
        let audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setActive(true)
        } catch {
            
        }
        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)
        }
    }
    
    public func pause() {
        syntherizer.pauseSpeaking(at: .immediate)
    }
    
    /// Speakボタンが押された
    @IBAction func speakBtnTapped(_ sender: Any) {
        // 音声出力を行う
        speak()
    }
    
    /// pauseボタンが押された
    @IBAction func pauseBtnTapped(_ sender: Any) {
        pause()
    }
    
    @IBAction func stopBtnTapped(_ sender: Any) {
        syntherizer.stopSpeaking(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"
        }
    }
    
    // MARK: Remote Command Event
    func addRemoteCommandEvent() {
        let commandCenter = MPRemoteCommandCenter.shared()
        commandCenter.togglePlayPauseCommand.addTarget(self, action: #selector(type(of: self).remoteTogglePlayPause(_:)))
        commandCenter.playCommand.addTarget(self, action: #selector(type(of: self).remotePlay(_:)))
        commandCenter.pauseCommand.addTarget(self, action: #selector(type(of: self).remotePause(_:)))
        commandCenter.nextTrackCommand.addTarget(self, action: #selector(type(of: self).remoteNextTrack(_:)))
        commandCenter.previousTrackCommand.addTarget(self, action: #selector(type(of: self).remotePrevTrack(_:)))
    }
    
    @objc func remoteTogglePlayPause(_ event: MPRemoteCommandEvent) {
        // イヤホンのセンターボタンを押した時の処理
        // 略

    }
    
    @objc func remotePlay(_ event: MPRemoteCommandEvent) {
        // プレイボタンが押された時の処理
        speak()

    }
    
    @objc func remotePause(_ event: MPRemoteCommandEvent) {
        // ポーズボタンが押された時の処理
        pause()
        
    }
    
    @objc func remoteNextTrack(_ event: MPRemoteCommandEvent) {
        // 「次へ」ボタンが押された時の処理
        // (略)
    }
    
    @objc func remotePrevTrack(_ event: MPRemoteCommandEvent) {
        // 「前へ」ボタンが押された時の処理
        // (略)
        
    }
    
    // MARK: - Life Cycle
    func addLifeCycleObserver() {
        let center = NotificationCenter.default
        // 今回はbackground移行を検知
        center.addObserver(self, selector: #selector(didEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil)
    }
    
    @objc func didEnterBackground(_ notification: Notification) {
        // 音が止まっていたらAudioSessionを無効化
        if !syntherizer.isSpeaking || syntherizer.isPaused {
            deactivateAudioSession()
        }

        let app = UIApplication.shared
        app.beginBackgroundTask(expirationHandler: {
            // このblockはバックグラウンド処理に入って、所定時間後(180秒程度後)に実行される
            // (バックグラウンド再生が継続している間は実行されない)
            // ここでAudioSessionを無効化しておく
            // (この処理がないとバックグラウンド再生中にpauseしたあと、3分すぎにアプリを再開すると
            // いきなり音が鳴る症状が出てしまう)
            self.deactivateAudioSession()
            
        })
    }
    
    func deactivateAudioSession() {
        let audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setActive(false)
        } catch {
            
        }
    }
}

投稿者:

nackpan

nackpan

iOSアプリを作っています。 リピーティングに便利な「語学学習支援プレイヤー」つくりました。

「AVSpeechSynthesizerをバックグラウンド再生ありで使う」への2件のフィードバック

  1. nackpan様

    kkです。

    新しい記事、調査、ありがとうございます。

    interruptionNotificationに関するnote、大変勉強になります。
    また、appleへのアクセスの重要さを真に実感させて頂き、感謝
    いたします。今後、継続して勉強いたします。

    私の方での実機確認結果ですが、
    新しい記事で頂いた内容(記事のコード)をそのまま、コピーし
    下記の1行のみ追加して、以下の手順で実験を行ったところ、
    utterance.preUtteranceDelay = 5

    手順:
    ①Speakボタンを2〜3回(1回以上)押す。
    ②1回目の発声が終わって、待ち時間に入った時点でpauseを押す。
    ③ホームボタンを押す。
    ④アイコンを押して、アプリを立ち上げる。
    ⑤発声が、再び開始される。

    ④のアプリ立ち上げの時に、『電話の割り込み』が働いているようです。
    utterance.preUtteranceDelayを”=0”にすると、上記現象は起きません。

    素人なりに色々と検討してみましたが、現象変わらずでしたので、
    コメント投稿をさせて頂きます。

    何か間違っているとこ、不十分なところがあれば、ご指摘頂きたく、お手数をおかけして大変申し訳ありませんが、どうかよろしくお願い致します。

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

    まず、ごめんなさい。
    元記事(07/29)に記した方法でなくとも対応できることがわかりました。

    元記事(07/29)で、AudioSessionの無効化を行わずにバックグラウンドに移行したさいのinterruptionについて検証していました。
    そこで、
    “`
    IOS 12.4
    * AVSpeechSynthesizerでspeak中に、pause。電源ボタンを押してアプリを中断。すると音声が勝手に鳴ってしまう。電源ボタンを押した直後にinterruptionの通知が届きました。(willResignActiveの前に発生)
    “`
    と記しました。
    今回あらためて検証するとまったく再現しませんでした。
    (デバイスを再起動するとトラブルが解消するというケースがありましたが、今回もそのたぐいでしょうか…)
    AppleのinterruptionNotificationのドキュメントにあるように、再開後にinterruptionの通知が届きました。

    元記事(07/29)では、「電源ボタンを押した直後、バックグラウンドへ移行する前にinterruptionの通知が届き、音声が鳴ってしまう」という症状がでていたので、バックグラウンド移行時にAudioSessionを無効化するという対処法ではなく、音声が止まった段階(pause, stop, finish)でAudioSessionを明示的に無効化するという方法を使っていました。

    今回、その症状は発生しないとなったので、AppleのinterruptionNotificationのドキュメントが勧めている「バックグラウンド移行時に、音が止まっているならAudioSessionを明示的に無効化する」という方法に書き換えました。

    それに合わせて、記事も更新しました。

    元記事(07/29)の「音声が止まった段階(pause, stop, finish)でAudioSessionを明示的に無効化する」という方法では、preUtteranceDelayを使用した際にうまくいかない問題について。

    検証したところ、preUtteranceDelayでの待ち時間にpauseした場合は、delegateメソッドspeechSynthesizer(_:didPause:)
    が呼び出されませんでした。(音声再生中にpauseした場合は、呼び出されます)
    そのため、待ち時間にpauseした場合は、speechSynthesizer(_:didPause:)内で行なっているAudioSessionの無効化が行われません。
    これにより、バックグランド移行からのアプリ再開で音声が鳴ってしまう症状が出ています。

    対処法としては、「バックグラウンド移行時に、音が止まっているならAudioSessionを明示的に無効化する」処理を追加することになります。
    これで、カバーできるので、「音声が止まった段階(pause, stop, finish)でAudioSessionを明示的に無効化する」処理を用いる必要はなくなります。

    AVSpeechSynthesizerは癖が強いと申しますかなんというか…
    AVAudioPlayerでは再開時に思わぬ音が鳴るなどの症状は出なかったので、参りますね…

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください