使用 AudioWorklet 进行后台音频处理
本文解释了如何创建音频工作线程处理器并在 Web 音频应用程序中使用它。
当 Web Audio API 首次引入浏览器时,它包含了使用 JavaScript 代码创建自定义音频处理器的功能,这些处理器将被调用以执行实时音频操作。ScriptProcessorNode 的缺点是它在主线程上运行,因此会阻塞所有其他正在进行的操作,直到它完成执行。这远非理想,特别是对于像音频处理这样计算密集型的任务。
引入 AudioWorklet。音频上下文的音频工作线程是一个 Worklet,它在主线程之外运行,通过调用上下文的 audioWorklet.addModule() 方法来执行添加到其中的音频处理代码。调用 addModule() 会加载指定的 JavaScript 文件,该文件应包含音频处理器的实现。注册处理器后,您可以创建一个新的 AudioWorkletNode,当该节点与任何其他音频节点链接到音频节点链中时,它会通过处理器的代码传递音频。
值得注意的是,由于音频处理通常涉及大量计算,因此您的处理器可能会通过使用 WebAssembly 进行构建而大受裨益,WebAssembly 为 Web 应用程序带来了接近原生或完全原生的性能。使用 WebAssembly 实现您的音频处理算法可以使其表现出显著更好的性能。
高级概述
在我们开始逐步研究 AudioWorklet 的使用之前,让我们先对所涉及的内容进行简要的高级概述。
- 创建定义音频工作线程处理器类的模块,该类基于
AudioWorkletProcessor,它从一个或多个传入源获取音频,对数据执行操作,并输出生成的音频数据。 - 通过音频上下文的
audioWorklet属性访问其AudioWorklet,并调用音频工作线程的addModule()方法来安装音频工作线程处理器模块。 - 根据需要,通过将处理器的名称(由模块定义)传递给
AudioWorkletNode()构造函数来创建音频处理节点。 - 设置
AudioWorkletNode所需的任何音频参数,或您希望配置的参数。这些参数在音频工作线程处理器模块中定义。 - 将创建的
AudioWorkletNode连接到您的音频处理管道中,就像连接任何其他节点一样,然后像往常一样使用您的音频管道。
在本文的其余部分,我们将更详细地研究这些步骤,并提供示例(包括您可以自己尝试的运行示例)。
此页面上的示例代码源自 此工作示例,该示例是 MDN Web 音频示例 GitHub 存储库 的一部分。该示例创建一个振荡器节点,并使用 AudioWorkletNode 向其中添加白噪声,然后播放生成的声音。提供了滑块控件,允许控制振荡器和音频工作线程输出的增益。
创建音频工作线程处理器
从根本上说,音频工作线程处理器(我们通常将其称为“音频处理器”或“处理器”,否则本文的长度将是现在的两倍)是使用 JavaScript 模块实现的,该模块定义并安装了自定义音频处理器类。
音频工作线程处理器的结构
音频工作线程处理器是一个 JavaScript 模块,它包含以下内容
- 一个 JavaScript 类,定义了音频处理器。该类扩展了
AudioWorkletProcessor类。 - 音频处理器类必须实现一个
process()方法,该方法接收传入的音频数据并将经过处理器处理的数据写回。 - 该模块通过调用
registerProcessor()来安装新的音频工作线程处理器类,指定音频处理器的名称和定义该处理器的类。
一个音频工作线程处理器模块可以定义多个处理器类,通过对 registerProcessor() 的单独调用来注册每个处理器类。只要每个处理器类都有自己唯一的名称,这将运行良好。这也比从网络甚至用户本地磁盘加载多个模块更高效。
基本代码框架
音频处理器类的最基本框架如下
class MyAudioProcessor extends AudioWorkletProcessor {
constructor() {
super();
}
process(inputList, outputList, parameters) {
// Using the inputs (or not, as needed),
// write the output into each of the outputs
// …
return true;
}
}
registerProcessor("my-audio-processor", MyAudioProcessor);
处理器实现之后是调用全局函数 registerProcessor(),该函数仅在音频上下文的 AudioWorklet 范围内可用,该 audioWorklet.addModule() 调用是处理器脚本的调用者。此 registerProcessor() 调用将您的类注册为在设置 AudioWorkletNode 时创建的任何 AudioWorkletProcessor 的基础。
这是最基本的框架,在将代码添加到 process() 中以处理这些输入和输出之前,实际上没有任何效果。这引出了我们讨论这些输入和输出。
输入和输出列表
输入和输出列表一开始可能有点令人困惑,即使一旦你了解了情况,它们实际上非常简单。
让我们从内部开始向外扩展。从根本上说,单个音频通道的音频(例如左扬声器或低音炮)表示为 Float32Array,其值是单个音频样本。根据规范,您的 process() 函数接收的每个音频块包含 128 帧(即每个通道 128 个样本),但计划是 此值将来会改变,并且实际上可能因情况而异,因此您应该 始终 检查数组的 length,而不是假设特定大小。但是,保证输入和输出将具有相同的块长度。
每个输入可以有多个通道。单声道输入有一个通道;立体声输入有两个通道。环绕声可能有六个或更多通道。因此,每个输入又是一个通道数组。也就是说,一个 Float32Array 对象数组。
然后,可以有多个输入,因此 inputList 是一个 Float32Array 对象数组的数组。每个输入可以有不同数量的通道,每个通道都有自己的样本数组。
因此,给定输入列表 inputList
const numberOfInputs = inputList.length;
const firstInput = inputList[0];
const firstInputChannelCount = firstInput.length;
const firstInputFirstChannel = firstInput[0]; // (or inputList[0][0])
const firstChannelByteCount = firstInputFirstChannel.length;
const firstByteOfFirstChannel = firstInputFirstChannel[0]; // (or inputList[0][0][0])
输出列表的结构完全相同;它是一个输出数组,每个输出又是一个通道数组,每个通道又是一个 Float32Array 对象,其中包含该通道的样本。
您如何使用输入以及如何生成输出在很大程度上取决于您的处理器。如果您的处理器只是一个生成器,它可以忽略输入,只需用生成的数据替换输出的内容。或者您可以独立处理每个输入,对每个输入中每个通道的传入数据应用算法,并将结果写入相应的输出通道(请记住,输入和输出的数量可能不同,并且这些输入和输出上的通道计数也可能不同)。或者您可以获取所有输入并执行混合或其他计算,从而使单个输出充满数据(或所有输出都充满相同的数据)。
这完全取决于您。这是您的音频编程工具包中一个非常强大的工具。
处理多个输入
让我们看一下 process() 的实现,它可以处理多个输入,每个输入用于生成相应的输出。任何多余的输入都将被忽略。
class MyAudioProcessor extends AudioWorkletProcessor {
// …
process(inputList, outputList, parameters) {
const sourceLimit = Math.min(inputList.length, outputList.length);
for (let inputNum = 0; inputNum < sourceLimit; inputNum++) {
const input = inputList[inputNum];
const output = outputList[inputNum];
const channelCount = Math.min(input.length, output.length);
for (let channelNum = 0; channelNum < channelCount; channelNum++) {
input[channelNum].forEach((sample, i) => {
// Manipulate the sample
output[channelNum][i] = sample;
});
}
}
return true;
}
}
请注意,在确定要处理并发送到相应输出的源数量时,我们使用 Math.min() 来确保我们只处理输出列表中有足够空间容纳的通道数量。在确定当前输入中要处理的通道数量时也执行相同的检查;我们只处理目标输出中有足够空间容纳的通道数量。这可以避免由于超出这些数组而导致的错误。
混合输入
许多节点执行 混合 操作,其中输入以某种方式组合成单个输出。这在以下示例中进行了演示。
class MyAudioProcessor extends AudioWorkletProcessor {
// …
process(inputList, outputList, parameters) {
const sourceLimit = Math.min(inputList.length, outputList.length);
for (let inputNum = 0; inputNum < sourceLimit; inputNum++) {
let input = inputList[inputNum];
let output = outputList[0];
let channelCount = Math.min(input.length, output.length);
for (let channelNum = 0; channelNum < channelCount; channelNum++) {
for (let i = 0; i < input[channelNum].length; i++) {
let sample = output[channelNum][i] + input[channelNum][i];
if (sample > 1.0) {
sample = 1.0;
} else if (sample < -1.0) {
sample = -1.0;
}
output[channelNum][i] = sample;
}
}
}
return true;
}
}
这与前面的示例在许多方面相似,但只有第一个输出——outputList[0]——被修改。每个样本都被添加到输出缓冲区中的相应样本中,并带有一个简单的代码片段,通过限制值来防止样本超过 -1.0 到 1.0 的合法范围;还有其他方法可以避免削波,这些方法可能更不容易失真,但这是一个简单的示例,总比没有好。
音频工作线程处理器的生命周期
您可以影响音频工作线程处理器生命周期的唯一方法是通过 process() 返回的值,该值应该是一个布尔值,指示是否覆盖 用户代理 关于您的节点是否仍在使用的决策。
通常,任何音频节点的生命周期策略都很简单:如果节点仍然被认为是活跃地处理音频,它将继续被使用。对于 AudioWorkletNode,如果其 process() 函数返回 true 并且 节点正在生成内容作为音频数据的源,或者正在从一个或多个输入接收数据,则该节点被认为是活跃的。
将 true 指定为 process() 函数的结果,实际上是在告诉 Web Audio API,您的处理器需要继续被调用,即使 API 认为您没有什么可做的了。换句话说,true 覆盖了 API 的逻辑,并让您控制处理器的生命周期策略,即使它可能决定关闭节点,也能使处理器的所属 AudioWorkletNode 保持运行。
从 process() 方法返回 false 会告诉 API 应该遵循其正常逻辑,并在认为合适时关闭您的处理器节点。如果 API 确定不再需要您的节点,则不会再次调用 process()。
注意: 目前,不幸的是,Chrome 没有以与当前标准匹配的方式实现此算法。相反,如果您返回 true,它会使节点保持活动状态,如果您返回 false,它会将其关闭。因此,出于兼容性原因,您必须始终从 process() 返回 true,至少在 Chrome 上是这样。但是,一旦 此 Chrome 问题 得到解决,如果可能的话,您将希望更改此行为,因为它可能会对性能产生轻微的负面影响。
创建音频处理器工作线程节点
要创建一个通过 AudioWorkletProcessor 泵送音频数据块的音频节点,您需要遵循以下简单步骤
- 加载并安装音频处理器模块
- 创建一个
AudioWorkletNode,通过其名称指定要使用的音频处理器模块 - 将输入连接到
AudioWorkletNode,并将其输出连接到适当的目标(可以是其他节点,也可以是AudioContext对象的destination属性)。
要使用音频工作线程处理器,您可以使用类似于以下的代码
let audioContext = null;
async function createMyAudioProcessor() {
if (!audioContext) {
try {
audioContext = new AudioContext();
await audioContext.resume();
await audioContext.audioWorklet.addModule("module-url/module.js");
} catch (e) {
return null;
}
}
return new AudioWorkletNode(audioContext, "processor-name");
}
此 createMyAudioProcessor() 函数创建并返回一个新的 AudioWorkletNode 实例,该实例配置为使用您的音频处理器。如果尚未完成,它还会处理音频上下文的创建。
为了确保上下文可用,它首先创建上下文(如果尚未可用),然后将包含处理器的模块添加到工作线程。完成此操作后,它会实例化并返回一个新的 AudioWorkletNode。一旦您掌握了它,就可以将其连接到其他节点,并像使用任何其他节点一样使用它。
然后,您可以按以下方式创建一个新的音频处理器节点
let newProcessorNode = await createMyAudioProcessor();
如果返回的值 newProcessorNode 非空,则我们有一个有效的音频上下文,其嘶嘶声处理器节点已就绪并可以使用。
支持音频参数
就像任何其他 Web Audio 节点一样,AudioWorkletNode 支持参数,这些参数与执行实际工作的 AudioWorkletProcessor 共享。
向处理器添加参数支持
要向 AudioWorkletNode 添加参数,您需要在模块中基于 AudioWorkletProcessor 的处理器类中定义它们。这是通过向类添加静态 getter parameterDescriptors 来完成的。此函数应返回一个 AudioParam 对象数组,每个处理器支持的参数对应一个。
在 parameterDescriptors() 的以下实现中,返回的数组有两个 AudioParam 对象。第一个将 gain 定义为 0 到 1 之间的值,默认值为 0.5。第二个参数名为 frequency,默认值为 440.0,范围为 27.5 到 4186.009(包含)。
class MyAudioProcessor extends AudioWorkletProcessor {
// …
static get parameterDescriptors() {
return [
{
name: "gain",
defaultValue: 0.5,
minValue: 0,
maxValue: 1,
},
{
name: "frequency",
defaultValue: 440.0,
minValue: 27.5,
maxValue: 4186.009,
},
];
}
}
访问处理器节点的参数就像在传递给 process() 实现的 parameters 对象中查找它们一样简单。在 parameters 对象中是数组,每个参数一个,并且与您的参数共享相同的名称。
- A 速率参数
-
对于 a 速率参数(其值随时间自动变化的参数),参数在
parameters对象中的条目是一个AudioParam对象数组,其中每个正在处理的块中的帧对应一个。这些值将应用于相应的帧。 - K 速率参数
-
另一方面,K 速率参数每个块只能更改一次,因此参数数组只有一个条目。将该值用于块中的每个帧。
在下面的代码中,我们看到一个 process() 函数,它处理一个 gain 参数,该参数可以用作 a 速率或 k 速率参数。我们的节点只支持一个输入,因此它只获取列表中的第一个输入,对其应用增益,并将结果数据写入第一个输出的缓冲区。
class MyAudioProcessor extends AudioWorkletProcessor {
// …
process(inputList, outputList, parameters) {
const input = inputList[0];
const output = outputList[0];
const gain = parameters.gain;
for (let channelNum = 0; channelNum < input.length; channelNum++) {
const inputChannel = input[channelNum];
const outputChannel = output[channelNum];
// If gain.length is 1, it's a k-rate parameter, so apply
// the first entry to every frame. Otherwise, apply each
// entry to the corresponding frame.
if (gain.length === 1) {
for (let i = 0; i < inputChannel.length; i++) {
outputChannel[i] = inputChannel[i] * gain[0];
}
} else {
for (let i = 0; i < inputChannel.length; i++) {
outputChannel[i] = inputChannel[i] * gain[i];
}
}
}
return true;
}
}
在这里,如果 gain.length 指示 gain 参数值数组中只有一个值,则数组中的第一个条目将应用于块中的每个帧。否则,对于块中的每个帧,将应用 gain[] 中的相应条目。
从主线程脚本访问参数
您的主线程脚本可以像访问任何其他节点一样访问参数。为此,您首先需要通过调用 AudioWorkletNode 的 parameters 属性的 get() 方法来获取参数的引用
let gainParam = myAudioWorkletNode.parameters.get("gain");
返回并存储在 gainParam 中的值是用于存储 gain 参数的 AudioParam。然后,您可以使用 AudioParam 方法 setValueAtTime() 在给定时间有效地更改其值。
例如,在这里,我们将值设置为 newValue,立即生效。
gainParam.setValueAtTime(newValue, audioContext.currentTime);
您可以类似地使用 AudioParam 接口中的任何其他方法来随时间应用更改、取消计划的更改等。
读取参数的值就像查看其 value 属性一样简单
let currentGain = gainParam.value;
另见
- Web Audio API
- 进入 Audio Worklet (Chrome 开发者博客)