使用 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;

另見