音訊工作小程式設計模式

Hongchan Choi

上一篇文章詳細說明瞭 Audio Worklet 的基本概念和用法。自 Chrome 66 推出以來,我們收到許多要求,希望能提供更多實際應用程式使用方式的範例。Audio Worklet 可發揮 WebAudio 的全部潛力,但要充分利用這項工具,您必須瞭解包裝多個 JS API 的並行程式設計。即使是熟悉 WebAudio 的開發人員,要將 Audio Worklet 與其他 API (例如 WebAssembly) 整合也相當困難。

本文將讓讀者進一步瞭解如何在實際設定中使用 Audio Worklet,並提供充分發揮 Audio Worklet 效能的訣竅。別忘了查看程式碼範例和直播演示

重點摘要:音訊工作區

在深入瞭解前,讓我們先快速複習一下「Audio Worklet」系統的相關術語和事實,這些內容先前已在這篇文章中介紹過。

  • BaseAudioContext:Web Audio API 的主要物件。
  • Audio Worklet:Audio Worklet 作業的特殊指令碼檔案載入器。屬於 BaseAudioContext。BaseAudioContext 可包含一個 Audio Worklet。載入的劇本檔案會在 AudioWorkletGlobalScope 中評估,並用於建立 AudioWorkletProcessor 例項。
  • AudioWorkletGlobalScope:Audio Worklet 作業的特殊 JS 全域範圍。在 WebAudio 專用的轉譯執行緒上執行。BaseAudioContext 可以有一個 AudioWorkletGlobalScope。
  • AudioWorkletNode:專為 Audio Worklet 作業設計的 AudioNode。從 BaseAudioContext 例項化。BaseAudioContext 可包含多個 AudioWorkletNode,類似於原生 AudioNode。
  • AudioWorkletProcessor:AudioWorkletNode 的對應項目。AudioWorkletNode 的實際內容,會根據使用者提供的程式碼處理音訊串流。在 AudioWorkletNode 建構時,會在 AudioWorkletGlobalScope 中將其例項化。AudioWorkletNode 可以有一個相符的 AudioWorkletProcessor。

設計模式

搭配 WebAssembly 使用 Audio Worklet

WebAssembly 是 AudioWorkletProcessor 的完美搭配元件。這兩項功能的結合可為網路上的音訊處理帶來多項優勢,但兩大優勢是:a) 將現有的 C/C++ 音訊處理程式碼帶入 WebAudio 生態系統,以及 b) 避免在音訊處理程式碼中執行 JS 即時編譯和垃圾收集的額外負擔。

對於已投資音訊處理程式碼和程式庫的開發人員而言,前者相當重要,但後者對幾乎所有 API 使用者都至關重要。在 WebAudio 中,穩定音訊串流的時間預算相當嚴格:取樣率為 44.1Khz 時,時間預算只有 3 毫秒。即使音訊處理程式碼出現輕微問題,也可能導致故障。開發人員必須將程式碼最佳化,以便加快處理速度,同時盡量減少產生的 JavaScript 垃圾量。使用 WebAssembly 可以同時解決這兩個問題:速度更快,且不會產生程式碼垃圾。

下一節將說明如何將 WebAssembly 與 Audio Worklet 搭配使用,您也可以參考這裡的程式碼示例。如需 Emscripten 和 WebAssembly 的使用方式基本教學 (特別是 Emscripten 黏合程式碼),請參閱這篇文章

設定

這聽起來很棒,但我們需要一些結構來妥善設定。首先要問的問題是如何在何處實例化 WebAssembly 模組。擷取 Emscripten 的黏合劑程式碼後,模組例項化有兩種路徑:

  1. 透過 audioContext.audioWorklet.addModule() 將黏合程式碼載入 AudioWorkletGlobalScope,以便將 WebAssembly 模組例項化。
  2. 在主要範圍中例項化 WebAssembly 模組,然後透過 AudioWorkletNode 的建構函式選項傳輸模組。

這項決定主要取決於您的設計和偏好設定,但概念是 WebAssembly 模組可以在 AudioWorkletGlobalScope 中產生 WebAssembly 例項,而這會成為 AudioWorkletProcessor 例項中的音訊處理核心。

WebAssembly 模組例化模式 A:使用 .addModule() 呼叫
WebAssembly 模組例項化模式 A:使用 .addModule() 呼叫

為了讓模式 A 正常運作,Emscripten 需要幾個選項,才能為我們的設定產生正確的 WebAssembly 黏合程式碼:

-s BINARYEN_ASYNC_COMPILATION=0 -s SINGLE_FILE=1 --post-js mycode.js

這些選項可確保在 AudioWorkletGlobalScope 中同步編譯 WebAssembly 模組。它也會在 mycode.js 中附加 AudioWorkletProcessor 的類別定義,以便在模組初始化後載入。使用同步編譯的主要原因是 audioWorklet.addModule() 的承諾解析結果不會等待 AudioWorkletGlobalScope 中的承諾解析結果。一般來說,我們不建議在主執行緒中進行同步載入或編譯作業,因為這會封鎖同一個執行緒中的其他工作,但在本例中,我們可以略過這項規則,因為編譯作業會在 AudioWorkletGlobalScope 上進行,而該執行緒會在主執行緒外執行。(詳情請參閱這篇文章)。

WASM 模組例項化模式 B:使用 AudioWorkletNode 建構函式的跨執行緒轉移
WASM 模組例項模式 B:使用 AudioWorkletNode 建構函式的跨執行緒轉移

如果需要異步的大量處理作業,模式 B 就很實用。它會使用主執行緒,從伺服器擷取膠黏程式碼並編譯模組。接著,系統會透過 AudioWorkletNode 建構函式傳輸 WASM 模組。如果您必須在 AudioWorkletGlobalScope 開始轉譯音訊串流後,動態載入模組,這種模式就更有意義。視模組的大小而定,在轉譯期間編譯模組可能會導致串流發生異常。

WASM 堆積和音訊資料

WebAssembly 程式碼只能在專屬 WASM 堆積內分配的記憶體中運作。為了充分利用這個功能,您需要在 WASM 堆積和音訊資料陣列之間來回複製音訊資料。範例程式碼中的 HeapAudioBuffer 類別可妥善處理此作業。

HeapAudioBuffer 類別,可讓您更輕鬆地使用 WASM 堆積
HeapAudioBuffer 類別,可讓您更輕鬆地使用 WASM 堆積區

我們正在討論早期提案,以便將 WASM 堆疊直接整合至 Audio Worklet 系統。在 JS 記憶體和 WASM 堆積之間移除這類多餘的資料複製作業似乎是自然的做法,但仍需進一步探討具體細節。

處理緩衝區大小不符

AudioWorkletNode 和 AudioWorkletProcessor 組合可像一般 AudioNode 一樣運作;AudioWorkletNode 會處理與其他程式碼的互動,而 AudioWorkletProcessor 則負責處理內部音訊處理作業。由於一般 AudioNode 一次處理 128 個影格,AudioWorkletProcessor 必須採取相同做法,才能成為核心功能。這是 Audio Worklet 設計的其中一個優點,可確保 AudioWorkletProcessor 不會因內部緩衝而導致額外延遲,但如果處理函式需要的緩衝區大小與 128 個影格不同,就可能會發生問題。這種情況的常見解決方案是使用環狀緩衝區,也稱為循環緩衝區或 FIFO。

以下是 AudioWorkletProcessor 的示意圖,其中使用兩個環形緩衝區,以便支援使用 512 個影格輸入和輸出的 WASM 函式。(這裡的 512 是任意選取的數字)。

在 AudioWorkletProcessor 的 `process()` 方法中使用 RingBuffer
在 AudioWorkletProcessor 的 `process()` 方法中使用 RingBuffer

圖表的演算法如下:

  1. AudioWorkletProcessor 會從其輸入內容,將 128 個影格推送至 Input RingBuffer
  2. 只有在 Input RingBuffer 大於或等於 512 個影格時,才執行下列步驟。
    1. Input RingBuffer 提取 512 個影格。
    2. 使用指定的 WASM 函式處理 512 個影格。
    3. 將 512 個影格推送至 Output RingBuffer
  3. AudioWorkletProcessor 會從 Output RingBuffer 提取 128 個影格,以填滿其 Output

如圖所示,輸入影格一律會累積至輸入環狀緩衝區,並透過覆寫緩衝區中最舊的影格區塊來處理緩衝區溢位。這對於即時音訊應用程式來說是合理的做法。同樣地,系統一律會提取輸出影格區塊。Output RingBuffer 中的緩衝區不足 (資料不足) 會導致靜音,進而導致串流中出現故障。

這個模式非常適合用於將 ScriptProcessorNode (SPN) 替換為 AudioWorkletNode。由於 SPN 允許開發人員選擇 256 到 16384 個影格之間的緩衝區大小,因此將 SPN 與 AudioWorkletNode 直接替換可能會很困難,使用環狀緩衝區則是個不錯的解決方法。音訊錄製器就是一個很好的例子,可在這個設計上進行建構。

不過,請務必瞭解,這項設計只會協調緩衝區大小不相符的問題,不會提供更多時間執行指定的腳本程式碼。如果程式碼無法在算繪量子 (在 44.1Khz 時約為 3 毫秒) 的時間預算內完成工作,就會影響後續回呼函式的開始時間,並最終導致錯誤。

由於 WASM 堆積區的記憶體管理,因此將這項設計與 WebAssembly 混合可能會變得複雜。在撰寫本文時,WASM 堆積區的資料進出必須複製,但我們可以利用 HeapAudioBuffer 類別,讓記憶體管理稍微簡單一些。使用者分配記憶體的概念將在日後討論,以減少不必要的資料複製。

您可以參閱這篇文章,瞭解 RingBuffer 類別。

WebAudio 強大功能:音訊工作區和 SharedArrayBuffer

本文最後一個設計模式是將幾個尖端 API 放在同一個位置:Audio Worklet、SharedArrayBufferAtomicsWorker。有了這項不簡單的設定,以 C/C++ 編寫的現有音訊軟體就能在網路瀏覽器中執行,同時維持順暢的使用者體驗。

最後一個設計模式的簡介:Audio Worklet、SharedArrayBuffer 和 Worker
最後一個設計模式的概觀:Audio Worklet、SharedArrayBuffer 和 Worker

這項設計的最大優點,就是能夠將 DedicatedWorkerGlobalScope 專門用於音訊處理。在 Chrome 中,WorkerGlobalScope 會在 WebAudio 轉譯執行緒的較低優先順序執行緒上執行,但相較於 AudioWorkletGlobalScope,它有幾項優點。在範圍內可用的 API 途徑方面,DedicatedWorkerGlobalScope 的限制較少。此外,由於 Worker API 已存在多年,因此 Emscripten 可提供更完善的支援。

要讓這項設計有效運作,SharedArrayBuffer 扮演了至關重要的角色。雖然 Worker 和 AudioWorkletProcessor 都配備非同步訊息傳遞功能 (MessagePort),但由於重複的記憶體配置和訊息傳遞延遲,因此不適合用於即時音訊處理。因此,我們會預先分配記憶體區塊,以便從兩個執行緒存取,加快雙向資料傳輸速度。

從 Web Audio API 純粹主義者的角度來看,這個設計可能不太理想,因為它使用 Audio Worklet 做為簡單的「音訊接收器」,並在 Worker 中執行所有操作。不過,考量到以 JavaScript 重寫 C/C++ 專案的成本可能會過高,甚至無法實現,因此這項設計可能是這類專案最有效率的實作途徑。

共用狀態和原子

使用共用記憶體儲存音訊資料時,必須謹慎協調兩端的存取權。共用可原子存取的狀態,就是解決這類問題的解決方案。我們可以利用 SAB 支援的 Int32Array 來達成這個目的。

同步機制:SharedArrayBuffer 和 Atomics
同步處理機制:SharedArrayBuffer 和 Atomics

同步機制:SharedArrayBuffer 和 Atomics

States 陣列的每個欄位都代表共用緩衝區的重要資訊。其中最重要的是用於同步的欄位 (REQUEST_RENDER)。這個概念是,Worker 會等待 AudioWorkletProcessor 觸碰這個欄位,並在喚醒時處理音訊。搭配使用 SharedArrayBuffer (SAB) 和 Atomics API,即可實現這項機制。

請注意,兩個執行緒的同步處理相當鬆散。Worker.process() 的開始時間會由 AudioWorkletProcessor.process() 方法觸發,但 AudioWorkletProcessor 不會等到 Worker.process() 完成後才執行。這是設計上的考量;AudioWorkletProcessor 是由音訊回呼驅動,因此不得同步封鎖。在最糟的情況下,音訊串流可能會出現重複或中斷的情形,但在算繪效能穩定後,最終會恢復正常。

設定及執行

如上圖所示,此設計有幾個元件需要安排:DedicatedWorkerGlobalScope (DWGS)、AudioWorkletGlobalScope (AWGS)、SharedArrayBuffer 和主執行緒。下列步驟說明初始化階段應發生的情況。

初始化
  1. [Main] 會呼叫 AudioWorkletNode 建構函式。
    1. 建立 Worker。
    2. 系統會建立相關的 AudioWorkletProcessor。
  2. [DWGS] Worker 建立 2 個 SharedArrayBuffer。(一個用於共用狀態,另一個用於音訊資料)
  3. [DWGS] Worker 會將 SharedArrayBuffer 參照傳送至 AudioWorkletNode。
  4. [Main] AudioWorkletNode 會將 SharedArrayBuffer 參照傳送至 AudioWorkletProcessor。
  5. [AWGS] AudioWorkletProcessor 會通知 AudioWorkletNode 設定已完成。

初始化完成後,系統就會開始呼叫 AudioWorkletProcessor.process()。以下是每個轉譯迴圈迭代作業應發生的情況。

算繪迴圈
使用 SharedArrayBuffer 進行多執行緒轉譯
使用 SharedArrayBuffer 進行多執行緒轉譯
  1. [AWGS] 系統會為每個算繪量測單位呼叫 AudioWorkletProcessor.process(inputs, outputs)
    1. inputs 會推送至「Input SAB」
    2. Output SAB 會使用音訊資料填入 outputs
    3. 根據新的緩衝區索引,更新 States SAB
    4. 如果 Output SAB 接近下溢量門檻,請喚醒 Worker 以轉譯更多音訊資料。
  2. [DWGS] 工作站會等待 (休眠) AudioWorkletProcessor.process() 的喚醒信號。喚醒時:
    1. States SAB 擷取緩衝區索引。
    2. 使用 Input SAB 的資料,執行處理函式,以填入 Output SAB
    3. 根據緩衝區索引更新 States SAB
    4. 進入休眠狀態,等待下一個信號。

您可以在這裡找到程式碼範例,但請注意,您必須啟用 SharedArrayBuffer 實驗標記,這個示範才能運作。為簡化起見,程式碼是使用純 JS 程式碼編寫,但可視需要替換為 WebAssembly 程式碼。這種情況應特別小心處理,請使用 HeapAudioBuffer 類別包裝記憶體管理。

結論

音訊工作區的最終目標是讓 Web Audio API 真正「可擴充」。我們花了好幾年的時間設計這項功能,讓 Audio Worklet 能夠實作其他 Web Audio API。反過來說,現在設計的複雜度更高,這可能會帶來意料之外的挑戰。

幸運的是,這類複雜性只是為了讓開發人員更有效率。在 AudioWorkletGlobalScope 上執行 WebAssembly,可發揮網頁上高效能音訊處理的巨大潛力。對於以 C 或 C++ 編寫的大型音訊應用程式,使用 Audio Worklet 搭配 SharedArrayBuffers 和 Worker 可能是值得探索的實用選項。

抵免額

特別感謝 Chris Wilson、Jason Miller、Joshua Bell 和 Raymond Toy 審查本文草稿,並提供精闢的意見回饋。