本文将介绍如何创建一个音频工作线程处理器,并在 Web 音频应用程序中使用它。
在 Web Audio API 首次引入浏览器时,它就包含了使用 JavaScript
代码创建自定义音频处理器的能力,这些处理器会被调用以执行实时音频操作。ScriptProcessorNode
的缺点在于它在主线程上运行,因此会阻塞其他正在进行的操作,直至它执行完毕。这尤其是对于像音频处理这种可能会非常耗费计算资源的任务来说,非常不理想。
音频上下文的音频工作线程(AudioWorklet
)是一种脱离主线程运行的工作线程,它通过调用上下文的 audioWorklet.addModule()
方法来添加音频处理代码。调用 addModule()
方法会加载指定的 JavaScript
文件,该文件应包含音频处理器的实现代码。在处理器注册完成后,你可以创建一个新的 AudioWorkletNode
,当该节点与其他任何音频节点一同连接到音频节点链中时,音频就会通过该处理器的代码进行处理。
值得注意的是,由于音频处理通常会涉及大量计算,使用 WebAssembly
来构建处理器可能会受益匪浅,WebAssembly
能为网络应用程序带来近乎原生或者完全原生的性能。使用 WebAssembly
来实现音频处理算法可以使其性能显著提升。
高层次概述
在开始逐步探究 AudioWorklet
的使用方法之前,我们先来简要地从高层次概述一下所涉及的内容。
- 创建一个模块,该模块基于
AudioWorkletProcessor
(音频工作线程处理器) 定义一个音频工作线程处理器类,这个类从一个或多个传入源获取音频,对数据执行相应操作,然后输出处理后的音频数据; - 通过音频上下文的
audioWorklet
属性访问其音频工作线程(AudioWorklet
),然后调用音频工作线程的addModule()
方法来安装音频工作线程处理器模块; - 根据需要,通过将处理器的名称(由模块定义)传递给
AudioWorkletNode()
构造函数来创建音频处理节点; - 设置
AudioWorkletNode
所需的或者你想要配置的音频参数,这些参数是在音频工作线程处理器模块中定义的; - 将创建好的
AudioWorkletNode
像其他节点一样连接到音频处理链路中,然后像之前一样使用音频链路。
在本文的其余部分,我们将结合示例(包括你可以自行尝试的实际示例)更详细地探讨这些步骤。
本页面上的示例代码源自 这个实际示例,该示例是 webaudio examples 代码库的一部分。该示例创建了一个振荡器节点,并在播放生成的声音之前,使用 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
个样本),不过按计划,这个数值将来会发生变化,而且实际上可能会根据具体情况有所不同,所以你应该总是检查数组的长度,而不要假定其为特定的大小。然而,可以保证的是,输入和输出总会具有相同的长度。
每个输入可以有若干个声道。单声道输入只有一个声道;立体声输入有两个声道。环绕声可能会有六个或更多声道。因此,每个输入都有一个声道数组,即一个由 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()
方法的实现示例,它能够处理多个输入,并且每个输入都会被用于生成相应的输出。任何多余的输入都会被忽略。
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()
函数来确保我们处理的声道数量不会超出输出列表所能容纳的数量。在确定当前输入中要处理多少个声道时,也会进行同样的检查;我们处理的声道数量只会与目标输出所能容纳的数量相同。这样可以避免因超出这些数组的范围而导致错误。
混合输入
许多节点会执行混音操作,在这种操作中,输入会以某种方式合并成单个输出。如下示例对此进行了演示。
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
,并且该节点要么作为音频数据的源正在生成内容,要么正在从一个或多个输入接收数据,那么就会认为该节点处于活动状态。
将 process()
函数的返回结果指定为 true
,实质上是告知 Web Audio API,即便该接口认为已经没有什么需要处理的事情了,处理器仍需要持续被调用。换句话说,true
这个返回值会覆盖该接口的逻辑,并让你能够掌控处理器的生命周期策略,使得拥有该处理器的音频工作线程节点(AudioWorkletNode
)保持运行状态,即便在原本它会决定关闭该节点的情况下也不会关闭。
从 process()
方法中返回 false
则会告知应用程序接口,它应当遵循其常规逻辑,如果它认为合适的话,就关闭你的处理器节点。如果该应用程序接口判定不再需要该节点,那么 process()
方法将不会再被调用。
注意:
很遗憾,目前谷歌浏览器并没有按照现行标准来实现这一算法。相反,它会在你返回true
时让节点保持运行状态,在你返回false
时将其关闭。因此,出于兼容性方面的原因,你必须始终从process()
函数中返回true
,至少在谷歌浏览器上要这么做。不过,一旦谷歌浏览器的这个问题得到修复,若有可能的话,你会希望改变此行为,因为它可能会对性能产生些许负面影响。
创建一个音频处理器工作线程节点
要创建一个通过 AudioWorkletProcessor
传输音频数据块的音频节点,你需要遵循以下这些简单步骤:
- 加载并安装音频处理器模块;
- 创建一个音频工作线程节点(
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
不为空,那么我们就拥有了一个有效的音频上下文,其嘶嘶声(噪声)处理器节点已就绪,可供使用了。
支持音频参数
与任其他网络音频节点一样,AudioWorkletNode
也支持参数,这些参数会与执行实际工作的 AudioWorkletProcessor
共享。
为处理器添加参数支持
要给 AudioWorkletNode
添加参数,你需要在模块中基于 AudioWorkletProcessor
的处理器类中定义这些参数。这是通过给你的类添加静态取值函数(getter
)parameterDescriptors
来实现的。该函数应当返回一个由 AudioParam
对象组成的数组,处理器所支持的每个参数对应数组中的一个对象。
在下面 parameterDescriptors()
的实现中,返回的数组包含两个 AudioParam
对象。第一个将 gain
定义为介于 0
到 1
之间的值,默认值为 0.5
。第二个参数名为 frequency
,默认值为 440.0
,其取值范围从 27.5
到 4186.009
,包含两端端点值。
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
率参数。我们的节点只支持一个输入,所以它只会获取输入列表中的第一个输入,将增益应用到该输入上,然后把得到的结果数据写入到第一个输出的缓冲区中。
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;
评论0
暂时没有评论