iosswiftaudioavfoundationavaudiosession

How to interrupt device audio to play a sound and then let the device audio continue on iOS


I have an app where the user can play voice messages received from other users. Playing the voice messages should interrupt device audio (music, podcast, etc playing from other apps), play the voice messages and then let the device audio continue.

Here is a use specific use case I am trying to achieve

With setting AVAudioSessions category to .ambient I can play the voice message "over" the playing Apple Music, but that is not what I need exactly.

If I use the .playback category that makes the Apple Music stop, plays the voice message in the app but Apple Music does not continue playing afterwards.


Solution

  • In theory, Apple has provided a "protocol" for interrupting and resuming background audio, and in a downloadable example, I show you what it is and prove that it works:

    https://github.com/mattneub/Programming-iOS-Book-Examples/tree/master/bk2ch14p653backgroundPlayerAndInterrupter

    In that example, there are two projects, representing two different apps. You run both of them simultaneously. BackgroundPlayer plays sound in the background; Interrupter interrupts it, pausing it, and when it is finished interrupting, BackgroundPlayer resumes.

    This, as you will see, is done by having Interrupter change its audio session category from ambient to playback while interrupting, and changing it back when finished, along with first deactivating itself entirely while sending the .notifyOthersOnDeactivation signal:

    func playFile(atPath path:String) {
        self.player?.delegate = nil
        self.player?.stop()
        let fileURL = URL(fileURLWithPath: path)
        guard let p = try? AVAudioPlayer(contentsOf: fileURL) else {return} // nicer
        self.player = p
        // error-checking omitted
        
        // switch to playback category while playing, interrupt background audio
        try? AVAudioSession.sharedInstance().setCategory(.playback, mode:.default)
        try? AVAudioSession.sharedInstance().setActive(true)
        
        self.player.prepareToPlay()
        self.player.delegate = self
        let ok = self.player.play()
        print("interrupter trying to play \(path): \(ok)")
    }
    
    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { // *
        let sess = AVAudioSession.sharedInstance()
        // this is the key move
        try? sess.setActive(false, options: .notifyOthersOnDeactivation)
        // now go back to ambient
        try? sess.setCategory(.ambient, mode:.default)
        try? sess.setActive(true)
        delegate?.soundFinished(self)
    }
    

    The trouble, however, is that response to .notifyOthersOnDeactivation is entirely dependent on the other app being well behaved. My other app, BackgroundPlayer, is well behaved. This is what it does:

    self.observer = NotificationCenter.default.addObserver(forName:
        AVAudioSession.interruptionNotification, object: nil, queue: nil) {
            [weak self] n in
            guard let self = self else { return } // legal in Swift 4.2
            let why = n.userInfo![AVAudioSessionInterruptionTypeKey] as! UInt
            let type = AVAudioSession.InterruptionType(rawValue: why)!
            switch type {
            case .began:
                print("interruption began:\n\(n.userInfo!)")
            case .ended:
                print("interruption ended:\n\(n.userInfo!)")
                guard let opt = n.userInfo![AVAudioSessionInterruptionOptionKey] as? UInt else {return}
                let opts = AVAudioSession.InterruptionOptions(rawValue: opt)
                if opts.contains(.shouldResume) {
                    print("should resume")
                    self.player.prepareToPlay()
                    let ok = self.player.play()
                    print("bp tried to resume play: did I? \(ok as Any)")
                } else {
                    print("not should resume")
                }
            @unknown default:
                fatalError()
            }
    }
    

    As you can see, we register for interruption notifications, and if we are interrupted, we look for the .shouldResume option — which is the result of the interrupter setting the notifyOthersOnDeactivation in the first place.

    So far, so good. But there's a snag. Some apps are not well behaved in this regard. And the most non-well-behaved is Apple's own Music app! Thus it is actually impossible to get the Music app to do what you want it to do. You are better off using ducking, where the system just adjusts the relative levels of the two apps for you, allowing the background app (Music) to continue playing but more quietly.