使用 AudioWorklet 进行后台音频处理

本文介绍了如何创建音频工作线程处理器并在 Web 音频应用程序中使用它。

当 Web 音频 API 首次引入浏览器时,它包含了使用 JavaScript 代码创建自定义音频处理器的能力,这些处理器将被调用以执行实时音频操作。ScriptProcessorNode 的缺点是它在主线程上运行,因此会阻塞所有其他正在进行的操作,直到它完成执行。这远非理想,尤其是对于音频处理这样可能非常消耗计算资源的操作而言。

现在有 AudioWorklet。音频上下文的音频工作线程是一个 Worklet,它在主线程之外运行,执行通过调用上下文的 audioWorklet.addModule() 方法添加到它的音频处理代码。调用 addModule() 会加载指定的 JavaScript 文件,该文件应包含音频处理器的实现。注册处理器后,您可以创建一个新的 AudioWorkletNode,当该节点与其他音频节点一起连接到音频节点链中时,该节点会将音频通过处理器的代码。

值得注意的是,由于音频处理通常会涉及大量的计算,因此您的处理器可能会从使用 WebAssembly 构建中获益匪浅,WebAssembly 可为 Web 应用程序提供接近本机或完全本机的性能。使用 WebAssembly 实现您的音频处理算法可以显著提高其性能。

高级概述

在我们开始逐步查看 AudioWorklet 的使用之前,让我们先简要概述一下其中涉及的内容。

  1. 创建定义音频工作线程处理器类的模块,该类基于 AudioWorkletProcessor,它接收来自一个或多个传入源的音频,对数据执行其操作,然后输出结果音频数据。
  2. 通过其 audioWorklet 属性访问音频上下文的 AudioWorklet,并调用音频工作线程的 addModule() 方法来安装音频工作线程处理器模块。
  3. 根据需要,通过将处理器的名称(由模块定义)传递给 AudioWorkletNode() 构造函数来创建音频处理节点。
  4. 设置 AudioWorkletNode 需要或您希望配置的任何音频参数。这些参数在音频工作线程处理器模块中定义。
  5. 将创建的 AudioWorkletNode 连接到您的音频处理管道,就像您处理任何其他节点一样,然后像往常一样使用您的音频管道。

在本文的剩余部分,我们将更详细地介绍这些步骤,并提供示例(包括您可以自己尝试的示例)。

此页面上的示例代码源自 此工作示例,该示例是 MDN Web 音频示例的 GitHub 存储库 的一部分。该示例创建了一个振荡器节点,并使用 AudioWorkletNode 向其添加白噪声,然后播放结果声音。滑块控件可用于控制振荡器和音频工作线程输出的增益。

查看代码

实时尝试

创建音频工作线程处理器

从根本上说,音频工作线程处理器(我们通常将其称为“音频处理器”或“处理器”,否则本文将是现在的两倍长)是使用定义和安装自定义音频处理器类的 JavaScript 模块实现的。

音频工作线程处理器的结构

音频工作线程处理器是一个 JavaScript 模块,它包含以下内容

  • 定义音频处理器的 JavaScript 类。此类扩展了 AudioWorkletProcessor 类。
  • 音频处理器类必须实现一个 process() 方法,该方法接收传入的音频数据并写回由处理器操作的数据。
  • 该模块通过调用 registerProcessor() 安装新的音频工作线程处理器类,为音频处理器指定一个名称和定义处理器的类。

单个音频工作线程处理器模块可以定义多个处理器类,并使用对 registerProcessor() 的单独调用注册每个类。只要每个类都有其唯一的名称,这样就可以正常工作。与从网络或用户的本地磁盘加载多个模块相比,这更有效。

基本代码框架

音频处理器类的最基本框架如下所示

js
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

js
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() 的实现,它可以处理多个输入,每个输入用于生成相应的输出。任何多余的输入都会被忽略。

js
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() 来确保我们只处理输出列表中可容纳的通道数量。在确定当前输入中要处理的通道数量时,也会执行相同的检查;我们只处理目标输出中可容纳的通道数量。这避免了由于超出这些数组范围而导致的错误。

混合输入

许多节点执行 **混合** 操作,其中输入以某种方式组合成单个输出。以下示例演示了这一点。

js
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,您的处理器需要继续被调用,即使 API 认为您没有其他事情要做了。换句话说,true 覆盖了 API 的逻辑,并让您控制处理器的生命周期策略,使处理器的拥有者 AudioWorkletNode 即使在它原本会决定关闭节点时也能继续运行。

process() 方法返回 false 会告诉 API 应该遵循其正常逻辑,如果它认为合适,应该关闭您的处理器节点。如果 API 确定不再需要您的节点,则 process() 将不再被调用。

注意:目前,不幸的是,Chrome 没有以与当前标准相匹配的方式实现此算法。相反,如果返回 true,它会让节点保持活动状态,如果返回 false,它会关闭节点。因此,出于兼容性原因,您必须始终从 process() 返回 true,至少在 Chrome 上如此。但是,一旦 修复了此 Chrome 问题,您可能需要更改此行为,因为它可能会对性能造成轻微负面影响。

创建音频处理器工作线程节点

要创建通过 AudioWorkletProcessor 传输音频数据块的音频节点,您需要按照以下简单步骤操作

  1. 加载并安装音频处理器模块
  2. 创建一个 AudioWorkletNode,指定要使用其名称的音频处理器模块
  3. 将输入连接到 AudioWorkletNode,并将它的输出连接到适当的目标(其他节点或 AudioContext 对象的 destination 属性)。

要使用音频工作区处理器,您可以使用类似以下代码的代码

js
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。一旦您拥有它,您就可以将其连接到其他节点,并像使用任何其他节点一样使用它。

然后,您可以通过以下操作创建新的音频处理器节点

js
let newProcessorNode = await createMyAudioProcessor();

如果返回的值 newProcessorNode 不是 null,则我们有一个有效的音频上下文,其嘶嘶声处理器节点已就位,并可以随时使用。

支持音频参数

就像任何其他 Web Audio 节点一样,AudioWorkletNode 支持参数,这些参数与执行实际工作的 AudioWorkletProcessor 共享。

将参数支持添加到处理器

要将参数添加到 AudioWorkletNode,您需要在模块中基于 AudioWorkletProcessor 的处理器类的定义中定义它们。这是通过将静态 getter parameterDescriptors 添加到您的类来完成的。此函数应返回一个 AudioParam 对象数组,每个对象对应于处理器支持的一个参数。

在以下 parameterDescriptors() 实现中,返回的数组具有两个 AudioParam 对象。第一个将 gain 定义为 0 到 1 之间的值,默认值为 0.5。第二个参数名为 frequency,默认值为 440.0,范围为 27.5 到 4186.009,包含在内。

js
static get parameterDescriptors() {
  return [
   {
      name: "gain",
      defaultValue: 0.5,
      minValue: 0,
      maxValue: 1
    },
    {
      name: "frequency",
      defaultValue: 440.0,
      minValue: 27.5,
      maxValue: 4186.009
    }
  ];
}

访问处理器节点的参数与在 parameters 对象(传递到您的 process() 实现中)中查找它们一样简单。在 parameters 对象中是数组,每个数组对应于您的一个参数,并共享与您的参数相同的名称。

A 速率参数

对于 A 速率参数(其值会随时间自动变化的参数),参数在 parameters 对象中的条目是一个 AudioParam 对象数组,每个对象对应于正在处理的块中的每一帧。这些值将应用于相应的帧。

K 速率参数

另一方面,K 速率参数每块只能更改一次,因此参数数组只有一个条目。将该值用于块中的每一帧。

在下面的代码中,我们看到了一个 process() 函数,它处理一个 gain 参数,该参数可以作为 A 速率参数或 K 速率参数使用。我们的节点只支持一个输入,因此它只获取列表中的第一个输入,将增益应用于它,并将结果数据写入第一个输出的缓冲区。

js
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[] 中的对应条目。

从主线程脚本访问参数

您的主线程脚本可以像访问任何其他节点一样访问参数。为此,首先您需要通过调用 AudioWorkletNodeparameters 属性的 get() 方法来获取对参数的引用

js
let gainParam = myAudioWorkletNode.parameters.get("gain");

返回并存储在 gainParam 中的值是 AudioParam,用于存储 gain 参数。然后,您可以使用 AudioParam 方法 setValueAtTime() 在给定时间更改其值。

例如,在这里,我们将值设置为 newValue,立即生效。

js
gainParam.setValueAtTime(newValue, audioContext.currentTime);

您也可以类似地使用 AudioParam 接口中的任何其他方法来应用随时间推移的更改、取消计划的更改等。

读取参数的值就像查看其 value 属性一样简单

js
let currentGain = gainParam.value;

另请参阅