音訊工作小程式設計模式

Hongchan Choi

上一篇文章中, Audio Worklet 會詳細介紹基本概念和使用方式。自 Chrome 66 版推出以來,許多人要求我們提供更多範例,說明如何在實際應用程式中加以運用。Audio Worklet 可讓您充分發揮 WebAudio 的潛力,但要善加利用這項技術可能並不容易,因為這項工具需要瞭解與多個 JS API 包裝的並行程式設計。即便開發人員熟悉 WebAudio,整合 Audio Worklet 與其他 API (例如 WebAssembly) 也並不容易。

這篇文章將引導讀者在實際設定中使用 Audio Worklet 方法,並提供發揮最大功效的提示。也請務必查看程式碼範例和現場示範

重點回顧:音訊小程式

在深入探討之前,我們先快速回顧先前在這篇文章中介紹的音訊 Worklet 系統相關術語和事實。

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

設計模式

搭配 WebAssembly 使用 Audio Worklet

WebAssembly 是 AudioWorkletProcessor 的理想配套。搭配使用這兩項功能可為網路處理音訊帶來多種優勢,但兩者的兩個最大優點如下:a) 將現有的 C/C++ 音訊處理程式碼帶入 WebAudio 生態系統中,以及 b) 避免音訊處理程式碼中的 JS JIT 編譯和垃圾收集開銷。

如果您已投資音訊處理程式碼和程式庫,前者對開發人員來說非常重要,但後者對於幾乎所有 API 使用者而言都相當重要。在 WebAudio 的世界中,穩定音訊串流的時間預算相當耗費心力:取樣率為 44.1 Khz 時只有 3 毫秒。即使音訊處理程式碼有些中斷,也可能導致畫面故障。開發人員必須最佳化程式碼才能加快處理速度,同時盡量減少產生的 JS 垃圾。使用 WebAssembly 可以是可以同時解決這兩個問題的解決方案:速度較快,而且不會透過程式碼產生任何垃圾。

下一節將說明 WebAssembly 如何與 Audio Worklet 搭配使用,您可以在這裡找到隨附的程式碼範例。如需如何使用 Emscripten 和 WebAssembly (尤其是 Emscripten glue 程式碼) 的基本教學課程,請參閱這篇文章

設定

聽起來都很棒,但我們需要一些架構才能正確設定。 第一個需要思考的設計問題,是將 WebAssembly 模組例項化的方式與位置。擷取 Emscripten 的膠水程式碼後,模組例項化作業會有以下兩種路徑:

  1. 透過 audioContext.audioWorklet.addModule() 將膠合程式碼載入 AudioWorkletGlobalScope,以將 WebAssembly 模組例項化。
  2. 在主要範圍內對 WebAssembly 模組執行個體化,然後透過 AudioWorkletNode 的建構函式選項轉移模組。

要做出決定的主要因素主要取決於您的設計和偏好,但這麼做的概念是 WebAssembly 模組可以在 AudioWorkletGlobalScope 中產生 WebAssembly 執行個體,而該執行個體會成為 AudioWorkletProcessor 執行個體中的音訊處理核心。

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

為了讓 A 模式正常運作, Emscripten 需要下列幾種選項,為設定產生正確的 WebAssembly glue 程式碼:

-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 使用兩個環形緩衝區,可配合 WASM 函式,傳入及傳出 512 個影格。(此處的數字 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 個影格,來填入其輸出內容

如圖所示,輸入框架一律會在 Input RingBuffer 中累積,並覆寫緩衝區中最舊的影格區塊,藉此處理緩衝區溢位。這對即時音訊應用程式來說是合理的做法。同樣地,系統一律會提取 Output frame 區塊。輸出 RingBuffer 中的緩衝區反向溢位 (資料不足) 會導致系統不停串流,進而造成串流出現故障。

以 AudioWorkletNode 取代 ScriptProcessorNode (SPN) 時,這個模式很有用。由於 SPN 可讓開發人員選擇 256 至 16384 影格之間的緩衝區空間,因此使用 AudioWorkletNode 取代 SPN 可能難以替換,而使用環環緩衝區可提供更理想的解決方法。以此設計為基礎建構的音訊錄音工具會是絕佳的範例。

然而,請務必瞭解此設計只會協調緩衝區大小不相符的問題,而且不會有更多時間執行特定指令碼程式碼。如果程式碼無法在轉譯量子的時間預算內完成工作 (以 44.1 Khz 為約 3 毫秒),就會影響後續回呼函式的啟動時間,最終造成異常終止。

由於 WASM 堆積需要管理記憶體,因此將這個設計與 WebAssembly 搭配使用可能會變得複雜。在寫入時,必須複製傳入及傳出 WASM 堆積的資料,不過我們可以使用 HeapAudioBuffer 類別簡化記憶體管理流程。未來將討論使用使用者分配的記憶體來減少冗餘資料複製的概念。

RingBuffer 類別請見這裡

WebAudio Powerhouse:Audio Worklet 和 SharedArrayBuffer

本文的最後一項設計模式是將數個先進的 API 放在同一處:Audio Worklet、SharedArrayBufferAtomicsWorker。只要進行這項簡單的設定,就能讓使用 C/C++ 編寫的現有音訊軟體在網路瀏覽器中執行,同時維持順暢的使用者體驗。

上次設計模式總覽:音訊 Worklet、SharedArrayBuffer 和 Worker
上次設計模式的總覽:Audio Worklet、SharedArrayBuffer 和 Worker

此設計的最大優勢,就是只能單獨使用 DedicatedWorkerGlobalScope 進行音訊處理。在 Chrome 中,WorkerGlobalScope 的執行緒優先順序比 WebAudio 轉譯執行緒低,但與 AudioWorkletGlobalScope 有多項優點。專屬 WorkerGlobalScope 的 API 介面在範圍內也較不受限制。此外,由於 Worker API 已有多年的使用,因此 Emscripten 會提供更好的支援。

SharedArrayBuffer 是維持此設計效率的關鍵角色。雖然 Worker 和 AudioWorkletProcessor 都配備了非同步訊息傳遞功能 (MessagePort),但因為會重複記憶體配置和訊息傳遞延遲,這對即時音訊處理來說不太理想。因此,我們預先分配了一個記憶體區塊,並讓這兩個執行緒都能存取,以便快速雙向資料移轉。

從 Web Audio API 取得的觀點來看,這項設計可能不夠完善,因為這個設計使用音訊小程式做為簡單的「音訊接收器」,並在工作站中執行所有動作。但考量到使用 JavaScript 重寫 C/C++ 專案的成本是禁止的,甚至是不可能的,這種設計對這類專案來說可能是最有效率的實作路徑。

共用狀態與原子

使用共用記憶體處理音訊資料時,兩邊的存取都必須謹慎協調。共用能夠完整存取的狀態,就是解決這類問題的最佳解決方案。我們可以使用 SAB 支援的 Int32Array 來達到這個效果。

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

同步處理機制:SharedArrayBuffer 和 Atomics

狀態陣列的每個欄位都代表共用緩衝區的重要資訊。最重要的一個是同步處理作業 (REQUEST_RENDER) 的欄位。概念是,工作站會等待 AudioWorkletProcessor 觸碰此欄位,並在喚醒時處理音訊。除了 SharedArrayBuffer (SAB) 外,Atomics API 也實現了這個機制。

請注意,兩個執行緒的同步處理作業其實是鬆散的。AudioWorkletProcessor.process() 方法會觸發 Worker.process() 的啟動作業,但 AudioWorkletProcessor 不會等到 Worker.process() 完成後才執行。這是出自設計:AudioWorkletProcessor 是由音訊回呼所驅動,因此不能同步封鎖。最糟糕的情況下,音訊串流可能會發生重複或退出的問題,但當轉譯效能穩定時,音訊串流最終會復原。

設定與執行

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

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

初始化完成後,系統會開始呼叫 AudioWorkletProcessor.process()。每次疊代轉譯迴圈時,應執行以下動作。

轉譯迴圈
使用 SharedArrayBuffers 多執行緒算繪
使用 SharedArrayBuffers 進行多執行緒算繪
  1. [AWGS] 每個轉譯量子都會呼叫 AudioWorkletProcessor.process(inputs, outputs)
    1. 系統會將「inputs」推送至「Input SAB」
    2. 系統會在 Output SAB 中使用音訊資料填入 outputs
    3. 根據相應的新緩衝區索引更新狀態 SAB
    4. 如果「Output SAB」接近欠位門檻,Wake 工作站就會轉譯更多音訊資料。
  2. [DWGS] 工作站等待 (睡眠) AudioWorkletProcessor.process() 的 Wake 訊號。喚醒時:
    1. 狀態 SAB 擷取緩衝區索引。
    2. 使用「Input SAB」中的資料執行程序函式,以填入「Output SAB」
    3. 據此使用緩衝區索引更新狀態 SAB
    4. 會進入睡眠並等待下一個訊號。

您可以在此找到範例程式碼,但請注意,必須啟用 SharedArrayBuffer 實驗旗標,此示範才能正常運作。為了方便起見,編寫這類程式碼時使用的是純 JS 程式碼,但也可以視需要改用 WebAssembly 程式碼。您應該在處理這類情況時,使用 HeapAudioBuffer 類別納入記憶體管理。

結論

Audio Worklet 的最終目標是讓 Web Audio API 具有「可擴充」特性。我們多年來投入了大量心力,設計出其餘的 Web Audio API 搭配 Audio Worklet。但現在我們的設計變得更加複雜,這會帶來意想不到的挑戰。

幸運的是,這類複雜性僅為開發人員提供強大支援。能夠在 AudioWorkletGlobalScope 上執行 WebAssembly, 發揮網路高效能音訊處理能力的巨大潛力對於以 C 或 C++ 編寫的大規模音訊應用程式而言,將 Audio Worklet 與 SharedArrayBuffers 和 Worker 搭配使用,可能是很有吸引力的選項。

抵免額

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