使用 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 開發者部落格)