Search code examples
audioswiftuiavaudioplayercombine

Using AVAudioPlayer in background


In my app, I've added the capabilities of background audio and background processing.

My code presently uses AVAudioPlayer to play audio. While playback is good when the app in the foreground, with a locked screen, the audio is has some static jitteriness to it.

My app is written using SwiftUI and Combine. Has anyone encountered this issue and what would you suggest as a workaround?

Here is the play method:

    /// Play an `AudioFile`
    /// - Parameters:
    ///   - audioFile: an `AudioFile` struct
    ///   - completion: optional completion, default is `nil`
    func play(_ audioFile: AudioFile,
              completion: (() -> Void)? = nil) {
        if audioFile != currentAudioFile {
            resetPublishedValues()
        }
        currentAudioFile = audioFile
        setupCurrentAudioFilePublisher()
        guard let path = Bundle.main.path(forResource: audioFile.filename, ofType: "mp3") else {
            return
        }
        
        let url = URL(fileURLWithPath: path)
        
        // everybody STFU
        stop()
        
        do {
            // make sure the sound is one
            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
            try AVAudioSession.sharedInstance().setActive(true)
            // instantiate instance of AVAudioPlayer
            audioPlayer = try AVAudioPlayer(contentsOf: url)
            audioPlayer.prepareToPlay()
            // play the sound
            let queue = DispatchQueue(label: "audioPlayer", qos: .userInitiated, attributes: .concurrent, autoreleaseFrequency: .inherit, target: nil)
            
            queue.async {
                self.audioPlayer.play()
            }
            audioPlayer.delegate = self
        } catch {
            // Not much to go wrong, so leaving alone for now, but need to make `throws` if we handle errors
            print(String(format: "play() error: %@", error.localizedDescription))
        }
    }

This is the class definition:

import AVFoundation
import Combine
import Foundation

/// A `Combine`-friendly wrapper for `AVAudioPlayer` which utilizes `Combine` `Publishers` instead of `AVAudioPlayerDelegate`
class CombineAudioPlayer: NSObject, AVAudioPlayerDelegate, ObservableObject {
    static let sharedInstance = CombineAudioPlayer()
    private var audioPlayer = AVAudioPlayer()
    /*
     FIXME: For now, gonna leave this timer on all the time, but need to refine
     down the road because it's going to generate a fuckload of data on the
     current interval.
     */
    // MARK: - Publishers
    private var timer = Timer.publish(every: 0.1,
                                      on: RunLoop.main,
                                      in: RunLoop.Mode.default).autoconnect()
    @Published public var currentAudioFile: AudioFile?
    public var isPlaying = CurrentValueSubject<Bool, Never>(false)
    public var currentTime = PassthroughSubject<TimeInterval, Never>()
    public var didFinishPlayingCurrentAudioFile = PassthroughSubject<AudioFile, Never>()
    
    private var cancellables: Set<AnyCancellable> = []
    
    // MARK: - Initializer
    private override init() {
        super.init()
        // set it up with a blank audio file
        setupPublishers()
        audioPlayer.setVolume(1.0, fadeDuration: 0)
    }
    
    // MARK: - Publisher Methods
    private func setupPublishers() {
        timer.sink(receiveCompletion: { completion in
            // TODO: figure out if I need anything here
            // Don't think so, as this will always be initialized
        },
        receiveValue: { value in
            self.isPlaying.send(self.audioPlayer.isPlaying)
            self.currentTime.send(self.currentTimeValue)
        })
        .store(in: &cancellables)
        
        didFinishPlayingCurrentAudioFile.sink(receiveCompletion: { _ in
            
        },
        receiveValue: { audioFile in
            self.resetPublishedValues()
        })
        .store(in: &cancellables)
    }
    
    private func setupCurrentAudioFilePublisher() {
        self.isPlaying.send(false)
        self.currentTime.send(0.0)
    }
    
    // MARK: - Playback Methods
    
    /// Play an `AudioFile`
    /// - Parameters:
    ///   - audioFile: an `AudioFile` struct
    ///   - completion: optional completion, default is `nil`
    func play(_ audioFile: AudioFile,
              completion: (() -> Void)? = nil) {
        if audioFile != currentAudioFile {
            resetPublishedValues()
        }
        currentAudioFile = audioFile
        setupCurrentAudioFilePublisher()
        guard let path = Bundle.main.path(forResource: audioFile.filename, ofType: "mp3") else {
            return
        }
        
        let url = URL(fileURLWithPath: path)
        
        // everybody STFU
        stop()
        
        do {
            // make sure the sound is one
            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
            try AVAudioSession.sharedInstance().setActive(true)
            // instantiate instance of AVAudioPlayer
            audioPlayer = try AVAudioPlayer(contentsOf: url)
            audioPlayer.prepareToPlay()
            // play the sound
            let queue = DispatchQueue(label: "audioPlayer", qos: .userInitiated, attributes: .concurrent, autoreleaseFrequency: .inherit, target: nil)
            
            queue.async {
                self.audioPlayer.play()
            }
            audioPlayer.delegate = self
        } catch {
            // Need to make `throws` if we handle errors
            print(String(format: "play error: %@", error.localizedDescription))
        }
    }
    
    func stop() {
        audioPlayer.stop()
        resetPublishedValues()
    }
    
    private func resetPublishedValues() {
        isPlaying.send(false)
        currentTime.send(0.0)
    }
    
    private var currentTimeValue: TimeInterval {
        audioPlayer.currentTime
    }
    
    /// Use the `Publisher` to determine when a sound is done playing.
    /// - Parameters:
    ///   - player: an `AVAudioPlayer` instance
    ///   - flag: a `Bool` indicating whether the sound was successfully played
    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        if let currentAudioFile = currentAudioFile {
            didFinishPlayingCurrentAudioFile.send(currentAudioFile)
        }
        resetPublishedValues()
    }
}

Solution

  • So I got it figured out. I had a few issues to contend with. Basically, I needed to play audio files at a specific time when the app was in the background. While this works fine if the sound is playing when the app is active, AVAudioPlayer won't let me start something after the app is in the background if audio playback is not already in progress.

    I won't go into the nitty gritty details, but I ended up making use of AVQueuePlayer, which I initialized as part of my CombineAudioPlayer class.

    1. Update AppDelegate.swift

    I added the following lines to AppDelegate's didFinishLaunchingWithOptions method.

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        do {
            try AVAudioSession.sharedInstance().setCategory(.playback,
                                                            mode: .default)
            try AVAudioSession.sharedInstance().setActive(true)
        } catch {
            print(String(format: "didFinishLaunchingWithOptions error: %@", error.localizedDescription))
        }
        
        return true
    }
    
    1. In my AudioPlayer class, I declared an AVQueuePlayer. It is critical this be initialized with the AudioPlayer class, not inside of a method.

    My ViewModel subscribes to a notification that listens for the app about to exit the foreground, it quickly generates a playlist and fires it just before the app exits.

    NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification).sink { _ in
        self.playBackground()
    }
    .store(in: &cancellables)
    
    private var bgAudioPlayer = AVQueuePlayer()
    

    Then, I created a method to generate a playlist for the AVQueuePlayer that looks something like this:

    func backgroundPlaylist(from audioFiles: [AudioFile]) -> [AVPlayerItem] {
        guard let firstFile = audioFiles.first else {
            // return empty array, don't wanna unwrap optionals
            return []
        }
        // declare a silence file
        let silence = AudioFile(displayName: "Silence",
                                filename: "1sec-silence")
        // start at zero
        var currentSeconds: TimeInterval = 0
        
        var playlist: [AVPlayerItem] = []
        
        // while currentSeconds is less than firstFile's fire time...
        while currentSeconds < firstFile.secondsInFuture {
            // add 1 second of silence to the playlist
            playlist.append(AVPlayerItem(url: silence.url!))
            // increment currentSeconds and we loop over again, adding more silence
            currentSeconds += 1
        }
        
        // once we're done, add the file we want to play
        playlist.append(AVPlayerItem(url: audioFiles.first!.url!))
                        
        return playlist
    }
    

    Lastly, the sound is played as follows:

    func playInBackground() {
        do {
            // make sure the sound is one
            try AVAudioSession.sharedInstance().setCategory(.playback,
                                                            mode: .default,
                                                            policy: .longFormAudio,
                                                            options: [])
            try AVAudioSession.sharedInstance().setActive(true)
            let playlist = backgroundPlaylist(from: backgroundPlaylist)
            bgAudioPlayer = AVQueuePlayer(items: playlist)
            bgAudioPlayer.play()
        } catch {
            // Not much to mess up, so leaving alone for now, but need to make
            // `throws` if we handle errors
            print(String(format: "playInBackground error: %@",
                            error.localizedDescription))
        }
    }