iOS - AVAudioPlayerNode.play() 执行很慢

iOS - AVAudioPlayerNode.play() execution is very slow

我在 iOS 游戏应用程序中使用 AVAudioEngine 处理音频。我遇到的一个问题是 AVAudioPlayerNode.play() 需要很长时间才能执行,这在游戏等实时应用程序中可能是个问题。

play() 只是激活播放器节点——您不必在每次播放声音时都调用它。因此,不必经常调用它,但必须偶尔调用它,例如在最初激活播放器时,或在它被停用后(在某些情况下会发生)。即使只是偶尔调用,执行时间长也是个问题,尤其是当您需要同时对多个玩家调用 play() 时。

play() 的执行时间似乎与 AVAudioSession.ioBufferDuration 的值成正比,您可以使用 AVAudioSession.setPreferredIOBufferDuration() 请求更改。这是我用来测试的一些代码:

import AVFoundation
import UIKit

class ViewController: UIViewController {
    private let engine = AVAudioEngine()
    private let player = AVAudioPlayerNode()
    private let ioBufferSize = 1024.0 // Or 256.0

    override func viewDidLoad() {
        super.viewDidLoad()

        let audioSession = AVAudioSession.sharedInstance()

        try! audioSession.setPreferredIOBufferDuration(ioBufferSize / 44100.0)
        try! audioSession.setActive(true)

        engine.attach(player)
        engine.connect(player, to: engine.mainMixerNode, format: nil)

        try! engine.start()

        print("IO buffer duration: \(audioSession.ioBufferDuration)")
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        if player.isPlaying {
            player.stop()
        } else {
            let startTime = CACurrentMediaTime()
            player.play()
            let endTime = CACurrentMediaTime()

            print("\(endTime - startTime)")
        }
    }
}

以下是我使用缓冲区大小 1024(我相信这是默认值)获得的 play() 的一些示例计时:

0.0218
0.0147
0.0211
0.0160
0.0184
0.0194
0.0129
0.0160

以下是使用 256 缓冲区大小的一些示例计时:

0.0014
0.0029
0.0033
0.0023
0.0030
0.0039
0.0031
0.0032

正如您在上面看到的,对于 1024 的缓冲区大小,执行时间往往在 15-20 毫秒范围内(大约 60 FPS 的完整帧)。缓冲区大小为 256,约为 3 毫秒 - 还不错,但当你每帧只有约 17 毫秒的时间可以使用时,它仍然很昂贵。

这是 iPad Mini 2 运行 iOS 12.4.2。这显然是一台旧设备,但我在模拟器上看到的结果似乎成正比,因此它似乎与缓冲区大小和函数本身的行为有关,而不是与所使用的硬件有关。我不知道引擎盖下发生了什么,但 play() 似乎有可能阻塞到下一个音频周期开始或类似的事情。

请求较小的缓冲区大小似乎是部分解决方案,但存在一些潜在的缺点。根据文档 here, lower buffer sizes can mean more disk access when streaming from a file, and irrespective of that, the request may not be honored at all. Also, ,有人报告了与低缓冲区大小相关的播放问题。考虑到所有这些,我不愿意将其作为解决方案。

这让我的 play() 执行时间在 15-20 毫秒范围内,这通常意味着在 60 FPS 时会丢失帧。如果我安排一次只调用 play() 一次,而且很少调用,也许它不会引人注意,但这并不理想。

我在其他地方搜索过相关信息并询问过这个问题,但似乎在实践中遇到这种行为的人并不多,或者这对他们来说不是问题。

AVAudioEngine 旨在用于实时应用程序,因此如果我是对的 AVAudioPlayerNode.play() 阻塞与缓冲区大小成比例的大量时间,这似乎是一个设计问题。我意识到这可能不是很多人正在处理的问题,但我在这里发帖询问是否有人遇到过 AVAudioEngine 的这个特定问题,如果是,如果有任何见解、建议或解决方法,任何人都可以提供。

我对此进行了相当彻底的调查。这是我的发现。

现在已经在各种设备和 iOS 版本(包括撰写本文时的最新版本 13.2)上测试了行为,并且让其他人也对其进行了测试,我目前的结论是AVAudioPlayerNode.play() 的执行时间长是固有的,并且没有明显的解决方法。正如我最初提到的 post,可以通过请求更短的缓冲持续时间来减少执行时间,但如前所述,这似乎不是一个可行的解决方案。

我从可靠消息来源那里听说,在后台线程上调用 play()(例如使用 Grand Central Dispatch)应该是安全的,而且这确实是解决问题的一种方法。然而,尽管在不同线程上调用 play()(或其他 AVAudioEngine 相关函数)在技术上可能是安全的,但我怀疑这是否是一个好主意(下面有进一步的解释)。

据我所知,文档没有说明这一点,但 AVAudioEngine 会在各种情况下抛出 NSException,如果不进行特殊处理,将导致应用程序终止Swift.

导致抛出 NSException 的其中一个原因是如果您调用 AVAudioPlayerNode.play() 而引擎未处于 运行。显然,如果您只需要担心自己的代码,您可以采取措施确保这种情况不会发生。

但是,iOS 本身有时会自行停止引擎,例如当发生音频中断时。如果您在此之后调用 play() 并在重新启动引擎之前调用,则会抛出 NSException。如果对 play() 的所有调用都在主线程上,则很容易避免此错误,但多线程使问题复杂化,并且似乎可能会引入在引擎停止后意外调用 play() 的风险.尽管可能有解决此问题的方法,但多线程似乎引入了不希望的复杂性和脆弱性,因此我选择不追求它。

我目前的策略如下。由于前面讨论的原因,我没有使用多线程。相反,我正在尽我所能减少对 play() 的调用次数,包括整体和每帧。这包括仅支持立体声音频(出于各种原因,同时支持单声道和立体声会导致对 play() 的更多调用,这是不希望的)。

最后,我还研究了 AVAudioEngine 的替代方案。 iOS 仍支持 OpenAL,但已弃用。使用音频队列服务或音频单元等低级 API 的自定义实现是可能的,但并非易事。我也看过一些开源解决方案,但我看过的选项在幕后使用 AVAudioEngine,因此遇到同样的问题,and/or 有其他缺点或局限性。当然也有商业的选择,可以为部分开发者提供解决方案。