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()
}
}
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.
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
}
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))
}
}