音频 Worklet 设计模式

Hongchan Choi

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

本文将帮助读者更好地了解如何在实际环境中使用音频 Worklet,并提供一些提示,帮助他们充分发挥音频 Worklet 的强大功能。请务必查看代码示例和实时演示

小结:音频 Worklet

在深入了解之前,我们先来快速回顾一下这篇文章中之前介绍过的有关 Audio Worklet 系统的术语和事实。

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

设计模式

将音频 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 正常运行,Emscripten 需要一些选项来为我们的配置生成正确的 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 堆
HeapAudioBuffer 类,可更轻松地使用 WASM 堆

我们正在讨论一项早期提案,旨在将 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 帧从其输入推送到输入环形缓冲区
  2. 仅当输入环形缓冲区大于或等于 512 帧时,才执行以下步骤。
    1. 输入环形缓冲区中提取 512 帧。
    2. 使用给定的 WASM 函数处理 512 帧。
    3. 将 512 帧推送到输出环形缓冲区
  3. AudioWorkletProcessor 会从输出环形缓冲区中提取 128 帧,以填充其输出

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

当您要将 ScriptProcessorNode (SPN) 替换为 AudioWorkletNode 时,此模式非常有用。由于 SPN 允许开发者选择介于 256 帧和 16384 帧之间的缓冲区大小,因此将 SPN 直接替换为 AudioWorkletNode 可能很困难,而使用环形缓冲区提供了一个很好的解决方法。音频录制器就是一个很好的示例,您可以基于此设计构建而成。

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

由于 WASM 堆存在内存管理,将这种设计与 WebAssembly 混合可能很复杂。在撰写本文时,必须克隆进出 WASM 堆的数据,但我们可以利用 HeapAudioBuffer 类来稍微简化内存管理。我们将在日后讨论使用用户分配的内存来减少多余数据克隆的想法。

您可以在此处找到 RingBuffer 类。

WebAudio 强大组合:Audio Worklet 和 SharedArrayBuffer

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

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

这种设计的最大优势在于,能够将 DedicatedWorkerGlobalScope 专用于音频处理。在 Chrome 中,WorkerGlobalScope 在优先级低于 WebAudio 呈现线程的线程上运行,但它比 AudioWorkletGlobalScope 具有多项优势。在可在该作用域中使用的 API Surface 方面,DedicatedWorkerGlobalScope 的限制较少。此外,由于 Worker API 已经存在多年,因此您可以期待 Emscripten 提供更好的支持。

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

从 Web Audio API 纯粹主义者的角度来看,此设计可能不太理想,因为它将 Audio Worklet 用作简单的“音频接收器”,并在 Worker 中执行所有操作。不过,考虑到使用 JavaScript 重写 C/C++ 项目的成本可能非常高昂甚至是不可能的,因此这种设计可能是此类项目最有效的实现路径。

共享状态和原子操作

将共享内存用于音频数据时,必须仔细协调两端的访问。共享可原子访问的状态是解决此类问题的一种方法。为此,我们可以利用由 SAB 支持的 Int32Array

同步机制:SharedArrayBuffer 和原子操作
同步机制:SharedArrayBuffer 和原子操作

同步机制:SharedArrayBuffer 和原子操作

States 数组的每个字段都代表与共享缓冲区相关的重要信息。其中最重要的是用于同步的字段 (REQUEST_RENDER)。其基本思想是,Worker 会等待 AudioWorkletProcessor 触摸此字段,并在其唤醒时处理音频。除了 SharedArrayBuffer (SAB) 之外,Atomics API 也使此机制成为可能。

请注意,两个线程的同步非常松散。Worker.process() 的开始将由 AudioWorkletProcessor.process() 方法触发,但 AudioWorkletProcessor 不会等待 Worker.process() 完成。这是有意为之;AudioWorkletProcessor 由音频回调驱动,因此不得同步阻塞。在最糟糕的情况下,音频串流可能会出现重复或掉落,但在渲染性能稳定后,最终会恢复正常。

设置和运行

如上图所示,此设计有多个组件需要整理:DedicatedWorkerGlobalScope (DWGS)、AudioWorkletGlobalScope (AWGS)、SharedArrayBuffer 和主线程。以下步骤介绍了初始化阶段应发生的情况。

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

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

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

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

总结

音频 Worklet 的最终目标是让 Web Audio API 真正“可扩展”。为了能够使用 Audio Worklet 实现 Web Audio API 的其余部分,我们花费了多年的心力进行设计。反过来,现在它的设计更为复杂,这可能会带来意想不到的挑战。

幸运的是,这种复杂性完全是为了赋予开发者强大的能力。能够在 AudioWorkletGlobalScope 上运行 WebAssembly,为在 Web 上进行高性能音频处理释放了巨大潜力。对于使用 C 或 C++ 编写的大规模音频应用,结合使用音频 Worklet 和 SharedArrayBuffers 和工作器会是个不错的选择。

赠金

特别感谢 Chris Wilson、Jason Miller、Joshua Bell 和 Raymond Toy,感谢他们审阅本文章的草稿并给出见解深刻的反馈。