使用 AudioWorklet 进行后台音频处理

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

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

混合输入

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

js
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 泵送音频数据块的音频节点,您需要遵循以下简单步骤

  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 非空,则我们有一个有效的音频上下文,其嘶嘶声处理器节点已就绪并可以使用。

支持音频参数

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

向处理器添加参数支持

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

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

js
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 速率参数。我们的节点只支持一个输入,因此它只获取列表中的第一个输入,对其应用增益,并将结果数据写入第一个输出的缓冲区。

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

从主线程脚本访问参数

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

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

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

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

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

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

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

js
let currentGain = gainParam.value;

另见