在后台使用 AVAudioPlayer

Using AVAudioPlayer in background

在我的应用程序中,我添加了后台音频和后台处理功能。

我的代码目前使用 AVAudioPlayer 来播放音频。当应用程序在前台、锁定屏幕时播放效果很好,但音频有一些静态抖动。

我的应用程序是使用 SwiftUI 和 Combine 编写的。有没有人遇到过这个问题,您有什么解决方法建议?

这里是 play 方法:

    /// 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))
        }
    }

这是 class 定义:

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

所以我明白了。我有几个问题要解决。基本上,我需要在应用程序处于后台时的特定时间播放音频文件。虽然如果在应用程序处于活动状态时正在播放声音,这可以正常工作,但如果音频播放尚未在进行中,AVAudioPlayer 将不允许我在应用程序处于后台后开始某些操作。

我不会深入细节,但我最终使用了 AVQueuePlayer,我将其初始化为我的 CombineAudioPlayer class.

的一部分
  1. 更新AppDelegate.swift

我在 AppDelegatedidFinishLaunchingWithOptions 方法中添加了以下行。

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. 在我的 AudioPlayer class 中,我声明了一个 AVQueuePlayer。使用 AudioPlayer class 而非方法内部进行初始化非常重要。

我的 ViewModel 订阅了一个通知,该通知侦听即将退出前台的应用程序,它会快速生成一个播放列表并在应用程序退出之前触发它。

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

然后,我创建了一个方法来为 AVQueuePlayer 生成播放列表,如下所示:

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
}

最后播放的声音如下:

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