音频 Worklet 设计模式

Hongchan Choi

上一篇文章中介绍了关于音频 Worklet 的文章,其中详细介绍了基本概念和用法。自它在 Chrome 66 中推出以来,人们越来越多地寻求有关如何在实际应用中使用的更多示例。音频 Worklet 充分发挥 WebAudio 的潜力,但要充分发挥 WebAudio 的潜能,可能极具挑战性,因为它需要了解封装于多个 JS API 的并发编程。即使对于熟悉 WebAudio 的开发者来说,将 Audio Worklet 与其他 API(例如 WebAssembly)集成也并非易事。

本文将帮助读者更好地了解如何在实际环境中使用音频 Worklet,并提供一些充分利用音频 Worklet 的提示。另外,请务必查看代码示例和现场演示

要点回顾:音频 Worklet

在深入了解之前,我们先快速回顾一下之前在这篇博文中介绍的音频 Worklet 系统的相关术语和事实。

  • BaseAudioContext:Web Audio API 的主要对象。
  • Audio Worklet:用于音频 Worklet 操作的特殊脚本文件加载器。属于 BaseAudioContext。一个 BaseAudioContext 可以有一个 Audio Worklet。加载的脚本文件在 AudioWorkletGlobalScope 中进行评估,并且用于创建 AudioWorkletProcessor 实例。
  • AudioWorkletGlobalScope:音频 Worklet 操作的特殊 JS 全局范围。在 WebAudio 的专用渲染线程上运行。一个 BaseAudioContext 可以有一个 AudioWorkletGlobalScope。
  • AudioWorkletNode:专为音频 Worklet 操作设计的 AudioNode。从 BaseAudioContext 进行实例化。与原生 AudioNode 类似,BaseAudioContext 可以有多个 AudioWorkletNodes。
  • AudioWorkletProcessor:AudioWorkletNode 的对应项。由用户提供的代码处理音频流的 AudioWorkletNode 的实际内容。构建 AudioWorkletNode 时,它会在 AudioWorkletGlobalScope 中实例化。AudioWorkletNode 可以有一个匹配的 AudioWorkletProcessor。

设计模式

将 Audio Worklet 与 WebAssembly 搭配使用

WebAssembly 是 AudioWorkletProcessor 的完美配套应用。这两项功能相结合可以为 Web 上的音频处理带来各种优势,但其中两个最大的优势包括:a) 将现有 C/C++ 音频处理代码引入 WebAudio 生态系统;b) 避免音频处理代码中 JS JIT 编译和垃圾回收的开销。

前者对于已有在音频处理代码和库方面进行投资的开发者来说非常重要,而后者对于 API 的几乎所有用户来说则至关重要。在 WebAudio 中,稳定音频流的用时预算要求非常高:在采样率为 44.1Khz 时,它只有 3 毫秒的时间。音频处理代码即使发生轻微的故障也可能会导致故障。开发者必须优化代码以加快处理速度,但同时最大限度地减少生成的 JS 垃圾回收量。使用 WebAssembly 可以同时解决这两个问题:这种方法速度更快,且不会产生任何代码。

下一部分介绍了如何将 WebAssembly 与音频 Worklet 搭配使用,可在此处查看随附的代码示例。 有关如何使用 Emscripten 和 WebAssembly(尤其是 Emscripten 粘合代码)的基本教程,请查看这篇文章

设置

听起来不错,但我们需要一定的结构才能正确设置。要问的第一个设计问题是如何以及在何处实例化 WebAssembly 模块。提取 Emscripten 的粘合代码后,模块实例化有两种路径:

  1. 通过 audioContext.audioWorklet.addModule() 将粘合代码加载到 AudioWorkletGlobalScope 中,从而实例化 WebAssembly 模块。
  2. 在主作用域中实例化 WebAssembly 模块,然后通过 AudioWorkletNode 的构造函数选项传输该模块。

具体决定在很大程度上取决于您的设计和偏好,但思路是,WebAssembly 模块可以在 AudioWorkletGlobalScope 中生成 WebAssembly 实例,而该实例会成为 AudioWorkletProcessor 实例中的音频处理内核。

WebAssembly 模块实例化模式 A:使用 .addModule() 调用
WebAssembly 模块实例化模式 A:使用 .addModule() 调用

为了使模式 A 正常运行,Escripten 需要几个选项来为我们的配置生成正确的 WebAssembly 粘合代码:

-s BINARYEN_ASYNC_COMPILATION=0 -s SINGLE_FILE=1 --post-js mycode.js

这些选项可确保在 AudioWorkletGlobalScope 中同步编译 WebAssembly 模块。此外,它还会在 mycode.js 中附加 AudioWorkletProcessor 的类定义,以便在初始化模块后加载它。使用同步编译的主要原因是,audioWorklet.addModule() 的 promise 解析不会等待 AudioWorkletGlobalScope 中的 promise 解析。通常不建议在主线程中进行同步加载或编译,因为它会阻塞同一线程中的其他任务,但在这里,我们可以绕过该规则,因为编译发生在在主线程之外运行的 AudioWorkletGlobalScope 上。(如需了解详情,请参阅此处)。

WASM 模块实例化模式 B:使用 AudioWorkletNode 构造函数的跨线程传输
WASM 模块实例化模式 B:使用 AudioWorkletNode 构造函数的跨线程传输

如果需要执行繁重的异步操作,模式 B 可能很有用。它利用主线程从服务器提取粘合代码并编译模块。然后,它会通过 AudioWorkletNode 的构造函数传输 WASM 模块。如果您在 AudioWorkletGlobalScope 开始呈现音频流之后必须动态加载模块,此模式会更合理。在渲染过程中对其进行编译可能会导致数据流故障,具体取决于模块的大小。

WASM 堆和音频数据

WebAssembly 代码仅适用于在专用 WASM 堆中分配的内存。为充分利用此功能,您需要在 WASM 堆和音频数据数组之间来回克隆音频数据。示例代码中的 HeapAudioBuffer 类可以很好地处理此操作。

HeapAudioBuffer 类,用于更轻松地使用 WASM 堆
用于简化 WASM 堆的 HeapAudioBuffer 类

有一个早期方案正在讨论中,即将 WASM 堆直接集成到音频 Worklet 系统中。摆脱 JS 内存和 WASM 堆之间的这种冗余数据克隆似乎是很自然的,但具体细节需要确定。

处理缓冲区空间不匹配问题

AudioWorkletNode 和 AudioWorkletProcessor 对被设计为像常规 AudioNode 一样工作;AudioWorkletNode 负责处理与其他代码的交互,而 AudioWorkletProcessor 负责内部音频处理。由于常规 AudioNode 一次处理 128 个,因此 AudioWorkletProcessor 必须执行同样的操作才能成为核心功能。这是音频 Worklet 设计的一个优点,可确保不会因在 AudioWorkletProcessor 中引入内部缓冲而引起额外的延迟,但如果处理功能需要的缓冲区大小不同于 128 帧,则可能会出现问题。对于这种情况,常见的解决方案是使用环形缓冲区(也称为环形缓冲区或 FIFO)。

下图显示了 AudioWorkletProcessor 使用两个环形缓冲区来容纳进出 512 帧的 WASM 函数。(这里的数字 512 是任意挑选的。)

在 AudioWorkletProcessor 的 `process()` 方法中使用 RingBuffer
在 AudioWorkletProcessor 的“process()”方法中使用 RingBuffer

示意图的算法为:

  1. AudioWorkletProcessor 将 128 帧从其输入推送到 Input RingBuffer
  2. 仅当 Input RingBuffer 大于或等于 512 帧时,才执行以下步骤。
    1. Input RingBuffer 拉取 512 帧。
    2. 使用给定 WASM 函数处理 512 帧。
    3. 将 512 帧推送到输出 RingBuffer
  3. AudioWorkletProcessor 从输出 RingBuffer 提取 128 帧来填充其输出

如图所示,输入帧始终会累积到 Input RingBuffer 中,并通过覆盖缓冲区中最早的帧块来处理缓冲区溢出。对于实时音频应用而言,这是合理的做法。同样,系统始终会拉取输出帧块。输出 RingBuffer 中的缓冲区下溢(数据不足)将导致静音,从而导致流中出现故障。

在将 ScriptProcessorNode (SPN) 替换为 AudioWorkletNode 时,此模式很有用。由于 SPN 允许开发者选择 256 到 16384 帧之间的缓冲区大小,因此用 AudioWorkletNode 直接替换 SPN 可能会很困难,而使用环形缓冲区是一种不错的权宜解决方法。基于这种设计构建的录音器就是一个很好的例子。

但请务必注意,此设计仅协调缓冲区空间大小不匹配,而不会有更多时间运行给定脚本代码。如果代码无法在渲染量子的时序预算内(在 44.1Khz 下约 3 毫秒)完成任务,则会影响后续回调函数的起始时间,最终导致故障。

由于围绕 WASM 堆进行内存管理,将此设计与 WebAssembly 混用可能会很复杂。在写入时,必须克隆进出 WASM 堆的数据,但我们可以利用 HeapAudioBuffer 类来稍微简化内存管理。 日后我们会讨论使用用户分配的内存来减少冗余数据克隆的思路。

您可以在此处找到 RingBuffer 类。

WebAudio Powerhouse:Audio Worklet 和 SharedArrayBuffer

本文的最后一种设计模式是将几个最先进的 API 放在一处:音频 Worklet、SharedArrayBufferAtomicsWorker。通过这种重要的设置,它可以让使用 C/C++ 编写的现有音频软件在网络浏览器中运行,同时保持流畅的用户体验。

最后一个设计模式概览:音频 Worklet、SharedArrayBuffer 和 Worker
最后一个设计模式概览:音频 Worklet、SharedArrayBuffer 和 Worker

此设计的最大优势是能够仅使用 DedicatedWorkerGlobalScope 处理音频。在 Chrome 中,WorkerGlobalScope 在优先级较低的线程上运行,其优先级低于 WebAudio 渲染线程,但与 AudioWorkletGlobalScope 相比,它具有多项优势。就范围内的可用 API Surface 而言,专用互连全球范围的限制较少。此外,Emscripten 可以提供更好的支持,因为 Worker API 已存在多年。

SharedArrayBuffer 对此设计的高效工作起着至关重要的作用。虽然 Worker 和 AudioWorkletProcessor 都配备了异步消息传递 (MessagePort),但由于存在重复的内存分配和消息传递延迟,它不适合实时音频处理。因此,我们预先分配了一个可从两个线程访问的内存块,以实现快速双向数据传输。

从 Web Audio API 纯粹的角度来看,此设计可能看起来不太理想,因为它将音频 Worklet 用作简单的“音频接收器”,并在 worker 中执行所有操作。但是,考虑到在 JavaScript 中重写 C/C++ 项目的成本可能令人望而却步,甚至无法实现,这种设计可能是此类项目最高效的实现途径。

共享状态和原子

针对音频数据使用共享内存时,必须谨慎协调双方的访问。共享以原子方式可访问的状态就是解决此问题的解决方案。为此,我们可以利用由 SAB 支持的 Int32Array

同步机制:SharedArrayBuffer 和 Atomics
同步机制:SharedArrayBuffer 和 Atomics

同步机制:SharedArrayBuffer 和 Atomics

状态数组的每个字段表示有关共享缓冲区的重要信息。其中最重要的一个字段是同步字段 (REQUEST_RENDER)。具体思路是,Worker 会等待 AudioWorkletProcessor 触及此字段,并在唤醒时处理音频。通过与 SharedArrayBuffer (SAB) 一起,Atomics API 实现了这种机制。

请注意,两个线程的同步比较松散。Worker.process() 的启用将由 AudioWorkletProcessor.process() 方法触发,但 AudioWorkletProcessor 不会等待 Worker.process() 完成。这是设计目的;AudioWorkletProcessor 由音频回调驱动,因此不得被同步阻塞。在最糟糕的情况下,音频流可能会出现重复或丢失的情况,但最终会在呈现性能稳定后恢复。

设置和运行

如上图所示,此设计需要排列多个组件:专用互连 (DWGS)、AudioWorkletGlobalScope (AWGS)、SharedArrayBuffer 和主线程。以下步骤介绍了初始化阶段应执行的操作。

初始化
  1. [Main] AudioWorkletNode 构造函数被调用。
    1. 创建工作器。
    2. 系统将会创建关联的 AudioWorkletProcessor。
  2. [DWGS] 工作器创建了 2 个 SharedArrayBuffer。(一个用于共享状态,另一个用于音频数据)
  3. [DWGS] 工作器向 AudioWorkletNode 发送 SharedArrayBuffer 引用。
  4. [Main] AudioWorkletNode 向 AudioWorkletProcessor 发送 SharedArrayBuffer 引用。
  5. [AWGS] AudioWorkletProcessor 会通知 AudioWorkletNode 设置已完成。

初始化完成后,系统会开始调用 AudioWorkletProcessor.process()。以下是在渲染循环的每次迭代中应发生的情况。

渲染循环
使用 SharedArrayBuffers 进行多线程渲染
使用 SharedArrayBuffers 进行多线程渲染
  1. [AWGS] 针对每个渲染量子调用 AudioWorkletProcessor.process(inputs, outputs)
    1. inputs 将被推送到输入 SAB
    2. outputs 将通过在输出 SAB 中使用音频数据来填充。
    3. 相应地使用新的缓冲区索引更新状态 SAB
    4. 如果输出 SAB 接近下溢阈值,则唤醒工作器以渲染更多音频数据。
  2. [DWGS] 工作器等待(休眠)来自 AudioWorkletProcessor.process() 的唤醒信号。唤醒后:
    1. States SAB 中提取缓冲区索引。
    2. 使用来自输入 SAB 的数据运行处理函数,以填充输出 SAB
    3. 相应地使用缓冲区索引更新状态 SAB
    4. 进入休眠状态并等待下一个信号。

示例代码可在此处找到,但请注意,必须启用 SharedArrayBuffer 实验性标记,本演示才能正常运行。为简单起见,代码使用纯 JS 代码编写,但如果需要,可以将其替换为 WebAssembly 代码。通过使用 HeapAudioBuffer 类封装内存管理,应格外小心处理此类情况。

总结

Audio Worklet 的最终目标是让 Web Audio API 真正实现“可扩展”。我们投入了多年的努力,使 Web Audio API 的其余功能能够与 Audio Worklet 一起实现。反过来,现在其设计的复杂性更高,这可能是一个意想不到的挑战。

幸运的是,如此复杂的原因纯粹是为了给开发者提供支持。能够在 AudioWorkletGlobalScope 上运行 WebAssembly,从而在网络上实现高性能音频处理的巨大潜力。对于使用 C 或 C++ 编写的大规模音频应用,将音频 Worklet 与 SharedArrayBuffers 和 worker 搭配使用可能会是一个很有吸引力的选择。

赠金

特别感谢 Chris Wilson、Jason Miller、Joshua Bell 和 Raymond Toy 审阅本文的草稿并提供见解深刻的反馈。