上一篇文章詳細說明瞭 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 的黏合劑程式碼後,模組例項化有兩種路徑:
- 透過
audioContext.audioWorklet.addModule()
將黏合程式碼載入 AudioWorkletGlobalScope,以便將 WebAssembly 模組例項化。 - 在主要範圍中例項化 WebAssembly 模組,然後透過 AudioWorkletNode 的建構函式選項傳輸模組。
這項決定主要取決於您的設計和偏好設定,但概念是 WebAssembly 模組可以在 AudioWorkletGlobalScope 中產生 WebAssembly 例項,而這會成為 AudioWorkletProcessor 例項中的音訊處理核心。
為了讓模式 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 上進行,而該執行緒會在主執行緒外執行。(詳情請參閱這篇文章)。
如果需要異步的大量處理作業,模式 B 就很實用。它會使用主執行緒,從伺服器擷取膠黏程式碼並編譯模組。接著,系統會透過 AudioWorkletNode 建構函式傳輸 WASM 模組。如果您必須在 AudioWorkletGlobalScope 開始轉譯音訊串流後,動態載入模組,這種模式就更有意義。視模組的大小而定,在轉譯期間編譯模組可能會導致串流發生異常。
WASM 堆積和音訊資料
WebAssembly 程式碼只能在專屬 WASM 堆積內分配的記憶體中運作。為了充分利用這個功能,您需要在 WASM 堆積和音訊資料陣列之間來回複製音訊資料。範例程式碼中的 HeapAudioBuffer 類別可妥善處理此作業。
我們正在討論早期提案,以便將 WASM 堆疊直接整合至 Audio Worklet 系統。在 JS 記憶體和 WASM 堆積之間移除這類多餘資料的複製作業似乎是自然的做法,但仍需進一步探討具體細節。
處理緩衝區大小不符
AudioWorkletNode 和 AudioWorkletProcessor 組合可像一般 AudioNode 一樣運作;AudioWorkletNode 會處理與其他程式碼的互動,而 AudioWorkletProcessor 則負責處理內部音訊處理作業。由於一般 AudioNode 一次處理 128 個影格,AudioWorkletProcessor 必須採取相同做法,才能成為核心功能。這是音訊工作區設計的其中一個優點,可確保 AudioWorkletProcessor 不會因內部緩衝而導致額外延遲,但如果處理函式需要的緩衝區大小與 128 個影格不同,就可能會發生問題。這種情況的常見解決方案是使用環狀緩衝區,也稱為循環緩衝區或 FIFO。
以下是 AudioWorkletProcessor 的示意圖,其中使用了兩個環狀緩衝區,以便支援使用 512 個影格輸入和輸出的 WASM 函式。(此處的 512 為任意選取的數字)。
圖表的演算法如下:
- AudioWorkletProcessor 會從其輸入內容,將 128 個影格推送至 Input RingBuffer。
- 只有在 Input RingBuffer 大於或等於 512 個影格時,才執行下列步驟。
- 從 Input RingBuffer 提取 512 個影格。
- 使用指定的 WASM 函式處理 512 個影格。
- 將 512 個影格推送至 Output RingBuffer。
- 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、SharedArrayBuffer、Atomics 和 Worker。有了這個不簡單的設定,以 C/C++ 編寫的現有音訊軟體就能在網路瀏覽器中執行,同時維持順暢的使用者體驗。
這項設計的最大優點,就是能夠將 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
States 陣列的每個欄位都代表共用緩衝區的重要資訊。其中最重要的是用於同步的欄位 (REQUEST_RENDER
)。這個概念是,Worker 會等待 AudioWorkletProcessor 觸碰這個欄位,並在喚醒時處理音訊。搭配使用 SharedArrayBuffer (SAB) 和 Atomics API,即可實現這項機制。
請注意,兩個執行緒的同步處理相當鬆散。Worker.process()
的開始時間會由 AudioWorkletProcessor.process()
方法觸發,但 AudioWorkletProcessor 不會等到 Worker.process()
完成後才執行。這是設計上的考量;AudioWorkletProcessor 是由音訊回呼驅動,因此不得同步封鎖。在最糟的情況下,音訊串流可能會出現重複或中斷的情形,但在轉譯效能穩定後,最終會恢復正常。
設定及執行
如上圖所示,此設計有幾個元件需要安排:DedicatedWorkerGlobalScope (DWGS)、AudioWorkletGlobalScope (AWGS)、SharedArrayBuffer 和主執行緒。下列步驟說明初始化階段應發生的情況。
初始化
- [Main] 會呼叫 AudioWorkletNode 建構函式。
- 建立 Worker。
- 系統會建立相關的 AudioWorkletProcessor。
- [DWGS] Worker 建立 2 個 SharedArrayBuffer。(一個用於共用狀態,另一個用於音訊資料)
- [DWGS] Worker 將 SharedArrayBuffer 參照傳送至 AudioWorkletNode。
- [Main] AudioWorkletNode 會將 SharedArrayBuffer 參照傳送至 AudioWorkletProcessor。
- [AWGS] AudioWorkletProcessor 會通知 AudioWorkletNode 設定已完成。
初始化完成後,系統就會開始呼叫 AudioWorkletProcessor.process()
。以下是每個轉譯迴圈迭代作業應發生的情況。
算繪迴圈
- [AWGS] 系統會為每個算繪量測單位呼叫
AudioWorkletProcessor.process(inputs, outputs)
。inputs
會推送至「Input SAB」。- Output SAB 會使用音訊資料填入
outputs
。 - 根據新的緩衝區索引,更新 States SAB。
- 如果 Output SAB 接近下溢量門檻,請喚醒 Worker 以轉譯更多音訊資料。
- [DWGS] 工作站會等待 (休眠)
AudioWorkletProcessor.process()
的喚醒信號。喚醒時:- 從 States SAB 擷取緩衝區索引。
- 使用 Input SAB 的資料執行處理函式,填入 Output SAB。
- 根據緩衝區索引更新 States SAB。
- 進入休眠狀態,等待下一個信號。
您可以在這裡找到程式碼範例,但請注意,您必須啟用 SharedArrayBuffer 實驗標記,這個示範才能運作。為了簡單起見,我們使用純 JS 程式碼編寫程式碼,但如有需要,也可以改用 WebAssembly 程式碼。這種情況應特別小心處理,使用 HeapAudioBuffer 類別包裝記憶體管理。
結論
Audio Worklet 的最終目標是讓 Web Audio API 真正「可擴充」。我們花了好幾年的時間設計這項功能,讓 Audio Worklet 能夠實作其他 Web Audio API。反過來說,現在設計的複雜度更高,這可能會帶來意料之外的挑戰。
幸運的是,這麼複雜的原因,純粹是為了讓開發人員更有效率。在 AudioWorkletGlobalScope 上執行 WebAssembly,可發揮網頁上高效能音訊處理的巨大潛力。如果是使用 C 或 C++ 編寫的大型音訊應用程式,建議您使用 Audio Worklet 搭配 SharedArrayBuffers 和 Worker,這可能是值得探索的實用選項。
抵免額
特別感謝 Chris Wilson、Jason Miller、Joshua Bell 和 Raymond Toy 審查本文草稿,並提供精闢的意見回饋。