WebAssembly/Rust 教程:音高完美的音頻處理

已發表: 2022-03-11

在所有現代瀏覽器的支持下,WebAssembly(或“Wasm”)正在改變我們為 Web 開髮用戶體驗的方式。 它是一種簡單的二進制可執行格式,允許使用其他編程語言編寫的庫甚至整個程序在 Web 瀏覽器中運行。

開發人員經常尋找提高生產力的方法,例如:

  • 為多個目標平台使用單個應用程序代碼庫,但讓應用程序在所有目標平台上運行良好
  • 在桌面移動環境中創建流暢美觀的用戶體驗
  • 利用開源庫生態系統避免在應用程序開發過程中“重新發明輪子”

對於前端開發人員,WebAssembly 提供了所有這三個功能,可以滿足對真正可與原生移動或桌面體驗相媲美的 Web 應用程序 UI 的搜索。 它甚至允許使用以非 JavaScript 語言編寫的庫,例如 C++ 或 Go!

在這個 Wasm/Rust 教程中,我們將創建一個簡單的音高檢測器應用程序,例如吉他調音器。 它將使用瀏覽器的內置音頻功能,並以每秒 60 幀 (FPS) 的速度運行——即使在移動設備上也是如此。 您無需了解 Web Audio API,甚至無需熟悉 Rust 即可學習本教程; 但是,期望對 JavaScript 感到滿意。

注意:不幸的是,在撰寫本文時,本文中使用的技術(特定於 Web Audio API)在 Firefox 中尚不可用。 因此,儘管 Firefox 對 Wasm 和 Web Audio API 的支持非常出色,但本教程暫時推薦使用 Chrome、Chromium 或 Edge。

本 WebAssembly/Rust 教程涵蓋的內容

  • 在 Rust 中創建一個簡單的函數並從 JavaScript 調用它(通過 WebAssembly)
  • 使用瀏覽器的現代AudioWorklet API 在瀏覽器中進行高性能音頻處理
  • 用 JavaScript 在工作人員之間進行通信
  • 將它們組合成一個簡單的 React 應用程序

注意:如果您對本文的“如何”比“為什麼”更感興趣,請隨時直接進入教程。

為什麼是瓦斯姆?

使用 WebAssembly 可能有幾個原因:

  • 它允許在瀏覽器內執行以任何語言編寫的代碼。
    • 這包括使用以 JavaScript 以外的語言編寫的現有庫(數值、音頻處理、機器學習等)。
  • 根據所使用的語言選擇,Wasm 能夠以接近原生的速度運行。 這有可能使 Web 應用程序的性能特徵更接近移動和桌面的原生體驗

為什麼不總是使用 Wasm?

WebAssembly 的受歡迎程度肯定會繼續增長; 但是,它並不適合所有 Web 開發:

  • 對於簡單的項目,堅持使用 JavaScript、HTML 和 CSS 可能會在更短的時間內交付可用的產品。
  • 較舊的瀏覽器(例如​​ Internet Explorer)不直接支持 Wasm。
  • WebAssembly 的典型用途需要將工具(例如語言編譯器)添加到您的工具鏈中。 如果您的團隊優先考慮保持開發和持續集成工具盡可能簡單,那麼使用 Wasm 將與此背道而馳。

為什麼是 Wasm/Rust 教程?

雖然許多編程語言都編譯為 Wasm,但我在這個示例中選擇了 Rust。 Rust 由 Mozilla 於 2010 年創建,並且越來越受歡迎。 在 Stack Overflow 的 2020 年開發者調查中,Rust 佔據了“最受歡迎語言”的首位。 但是將 Rust 與 WebAssembly 結合使用的原因不僅僅是流行:

  • 首先,Rust 的運行時間很小,這意味著當用戶訪問該站點時,向瀏覽器發送的代碼更少,有助於保持網站佔用空間較小。
  • Rust 具有出色的 Wasm 支持,支持與 JavaScript的高級互操作性
  • Rust 提供接近C/C++ 級別的性能,但具有非常安全的內存模型。 與其他語言相比,Rust 在編譯代碼時會執行額外的安全檢查,從而大大降低了由空變量或未初始化變量引起的崩潰的可能性。 這可以導致更簡單的錯誤處理和更高的機會在出現意外問題時保持良好的用戶體驗。
  • Rust不是垃圾收集的。 這意味著 Rust 代碼可以完全控制何時分配和清理內存,從而實現一致的性能——這是實時系統的關鍵要求。

Rust 的許多好處也伴隨著陡峭的學習曲線,因此選擇正確的編程語言取決於多種因素,例如將開發和維護代碼的團隊的組成。

WebAssembly 性能:維護如絲般流暢的 Web 應用程序

由於我們使用 Rust 在 WebAssembly 中編程,我們如何使用 Rust 來獲得最初導致我們使用 Wasm 的性能優勢? 對於具有快速更新 GUI 的應用程序要讓用戶感覺“流暢”,它必須能夠像屏幕硬件一樣定期刷新顯示。 這通常是 60 FPS,因此我們的應用程序必須能夠在 ~16.7 ms (1,000 ms / 60 FPS) 內重繪其用戶界面。

我們的應用程序實時檢測並顯示當前音高,這意味著組合檢測計算和繪圖必須保持在每幀 16.7 毫秒內。 在下一節中,我們將利用瀏覽器支持來分析另一個線程上的音頻,同時主線程執行其工作。 這是性能的重大勝利,因為計算和繪圖各有16.7 毫秒可供使用。

網絡音頻基礎

在這個應用程序中,我們將使用高性能 WebAssembly 音頻模塊來執行音高檢測。 此外,我們將確保計算不在主線程上運行。

為什麼我們不能保持簡單並在主線程上執行音高檢測?

  • 音頻處理通常是計算密集型的。 這是由於每秒需要處理大量樣本。 例如,可靠地檢測音頻音高需要每秒分析 44,100 個樣本的頻譜。
  • JavaScript 的 JIT 編譯和垃圾回收發生在主線程上,我們希望在音頻處理代碼中避免這種情況,以獲得一致的性能。
  • 如果處理一幀音頻所花費的時間顯著佔用 16.7 毫秒的幀預算,則 UX 將受到動畫斷斷續續的影響。
  • 我們希望我們的應用程序即使在性能較低的移動設備上也能順利運行!

Web Audio worklet 允許應用程序繼續實現流暢的 60 FPS,因為音頻處理無法阻止主線程。 如果音頻處理太慢而落後,還會出現其他影響,例如音頻滯後。 但是,用戶體驗將保持對用戶的響應。

WebAssembly/Rust 教程:入門

本教程假設您安裝了 Node.js 以及npx 。 如果你還沒有npx ,你可以使用npm (Node.js 自帶)來安裝它:

 npm install -g npx

創建網絡應用

對於這個 Wasm/Rust 教程,我們將使用 React。

在終端中,我們將運行以下命令:

 npx create-react-app wasm-audio-app cd wasm-audio-app

這使用npx執行create-react-app命令(包含在 Facebook 維護的相應包中)以在目錄wasm-audio-app中創建一個新的 React 應用程序。

create-react-app是一個用於生成基於 React 的單頁應用程序 (SPA) 的 CLI。 它使使用 React 開始一個新項目變得異常容易。 但是,輸出項目包含需要替換的樣板代碼。

首先,雖然我強烈建議在整個開發過程中對您的應用程序進行單元測試,但測試超出了本教程的範圍。 所以我們將繼續刪除src/App.test.jssrc/setupTests.js

應用概述

我們的應用程序中有五個主要的 JavaScript 組件:

  • public/wasm-audio/wasm-audio.js包含與提供音高檢測算法的 Wasm 模塊的 JavaScript 綁定。
  • public/PitchProcessor.js是音頻處理髮生的地方。 它在 Web Audio 渲染線程中運行,並將使用 Wasm API。
  • src/PitchNode.js包含一個 Web Audio 節點的實現,它連接到 Web Audio 圖並在主線程中運行。
  • src/setupAudio.js使用 Web 瀏覽器 API 來訪問可用的錄音設備。
  • src/App.jssrc/App.css組成了應用程序用戶界面。

音高檢測應用程序的流程圖。塊 1 和 2 在 Web 音頻線程上運行。第 1 塊是 Wasm (Rust) Pitch Detector,在文件 wasm-audio/lib.rs 中。第 2 塊是 Web 音頻檢測 + 通信,在文件 PitchProcessor.js 中。它要求檢測器初始化,檢測器將檢測到的音高發送回 Web 音頻接口。塊 3、4 和 5 在主線程上運行。第 3 塊是網絡音頻控制器,在文件 PitchNode.js 中。它將 Wasm 模塊發送到 PitchProcessor.js,並從中接收檢測到的音高。第 4 塊是 setupAudio.js 中的 Web 音頻設置。它創建一個 PitchNode 對象。第 5 塊是 Web 應用程序 UI,由 App.js 和 App.css 組成。它在啟動時調用 setupAudio.js。它還通過向 PitchNode 發送消息來暫停或恢復音頻錄製,從中接收檢測到的音高以顯示給用戶。
Wasm 音頻應用概述。

讓我們直接深入到我們應用程序的核心,並為我們的 Wasm 模塊定義 Rust 代碼。 然後,我們將對與 Web 音頻相關的 JavaScript 的各個部分進行編碼,並以 UI 結束。

1. 使用 Rust 和 WebAssembly 進行音高檢測

我們的 Rust 代碼將從一組音頻樣本中計算音高。

生鏽

您可以按照這些說明構建用於開發的 Rust 鏈。

安裝用於在 Rust 中構建 WebAssembly 組件的工具

wasm-pack允許你構建、測試和發布 Rust 生成的 WebAssembly 組件。 如果您還沒有,請安裝 wasm-pack。

cargo-generate通過利用預先存在的 Git 存儲庫作為模板來幫助啟動和運行一個新的 Rust 項目。 我們將使用它在 Rust 中引導一個簡單的音頻分析器,可以使用 WebAssembly 從瀏覽器訪問它。

使用 Rust 鏈附帶的cargo工具,您可以安裝cargo-generate

 cargo install cargo-generate

一旦安裝(可能需要幾分鐘)完成,我們就可以創建我們的 Rust 項目了。

創建我們的 WebAssembly 模塊

從我們應用程序的根文件夾中,我們將克隆項目模板:

 $ cargo generate --git https://github.com/rustwasm/wasm-pack-template

當提示輸入新項目名稱時,我們將輸入wasm-audio

wasm-audio目錄中,現在將有一個Cargo.toml文件,其內容如下:

 [package] name = "wasm-audio" version = "0.1.0" authors = ["Your Name <[email protected]"] edition = "2018" [lib] crate-type = ["cdylib", "rlib"] [features] default = ["console_error_panic_hook"] [dependencies] wasm-bindgen = "0.2.63" ...

Cargo.toml用於定義 Rust 包(Rust 將其稱為“crate”),為 Rust 應用程序提供與package.json為 JavaScript 應用程序提供的類似功能。

[package]部分定義了將包發佈到 Rust 的官方包註冊表時使用的元數據。

[lib]部分描述了 Rust 編譯過程的輸出格式。 在這裡,“cdylib”告訴 Rust 生成一個可以從另一種語言(在我們的例子中為 JavaScript)加載的“動態系統庫”,並且包含“rlib”告訴 Rust 添加一個包含有關生成的庫的元數據的靜態庫。 第二個說明符對於我們的目的來說不是必需的——它有助於開發進一步的 Rust 模塊,這些模塊將這個 crate 作為依賴項使用——但可以安全地離開。

[features]中,我們要求 Rust 包含一個可選功能console_error_panic_hook以提供將 Rust 的未處理錯誤機制(稱為panic )轉換為顯示在開發工具中進行調試的控制台錯誤的功能。

最後, [dependencies]列出了這個依賴的所有 crate。 開箱即用的唯一依賴項是wasm-bindgen ,它為我們的 Wasm 模塊提供 JavaScript 綁定的自動生成。

在 Rust 中實現音高檢測器

這個應用程序的目的是能夠實時檢測音樂家的聲音或樂器的音高。 為了確保盡快執行,WebAssembly 模塊的任務是計算音高。 對於單音音高檢測,我們將使用在現有 Rust pitch-detection庫中實現的“McLeod”音高方法。

與 Node.js 包管理器 (npm) 非常相似,Rust 包含一個自己的包管理器,稱為 Cargo。 這允許輕鬆安裝已發佈到 Rust crate 註冊表的包。

要添加依賴項,請編輯Cargo.toml ,將pitch-detection行添加到依賴項部分:

 [dependencies] wasm-bindgen = "0.2.63" pitch-detection = "0.1"

這指示 Cargo 在下一次cargo build期間下載並安裝pitch-detection依賴項,或者,由於我們的目標是 WebAssembly,這將在下一次wasm-pack中執行。

在 Rust 中創建一個 JavaScript 可調用的音高檢測器

首先,我們將添加一個文件,定義一個有用的實用程序,我們稍後將討論其用途:

創建wasm-audio/src/utils.rs並將該文件的內容粘貼到其中。

我們將使用以下代碼替換wasm-audio/lib.rs中生成的代碼,該代碼通過快速傅里葉變換 (FFT) 算法執行音高檢測:

 use pitch_detection::{McLeodDetector, PitchDetector}; use wasm_bindgen::prelude::*; mod utils; #[wasm_bindgen] pub struct WasmPitchDetector { sample_rate: usize, fft_size: usize, detector: McLeodDetector<f32>, } #[wasm_bindgen] impl WasmPitchDetector { pub fn new(sample_rate: usize, fft_size: usize) -> WasmPitchDetector { utils::set_panic_hook(); let fft_pad = fft_size / 2; WasmPitchDetector { sample_rate, fft_size, detector: McLeodDetector::<f32>::new(fft_size, fft_pad), } } pub fn detect_pitch(&mut self, audio_samples: Vec<f32>) -> f32 { if audio_samples.len() < self.fft_size { panic!("Insufficient samples passed to detect_pitch(). Expected an array containing {} elements but got {}", self.fft_size, audio_samples.len()); } // Include only notes that exceed a power threshold which relates to the // amplitude of frequencies in the signal. Use the suggested default // value of 5.0 from the library. const POWER_THRESHOLD: f32 = 5.0; // The clarity measure describes how coherent the sound of a note is. For // example, the background sound in a crowded room would typically be would // have low clarity and a ringing tuning fork would have high clarity. // This threshold is used to accept detect notes that are clear enough // (valid values are in the range 0-1). const CLARITY_THRESHOLD: f32 = 0.6; let optional_pitch = self.detector.get_pitch( &audio_samples, self.sample_rate, POWER_THRESHOLD, CLARITY_THRESHOLD, ); match optional_pitch { Some(pitch) => pitch.frequency, None => 0.0, } } }

讓我們更詳細地研究一下:

 #[wasm_bindgen]

wasm_bindgen是一個 Rust 宏,有助於實現 JavaScript 和 Rust 之間的綁定。 當編譯為 WebAssembly 時,此宏指示編譯器創建一個與類的 JavaScript 綁定。 上面的 Rust 代碼將轉換為 JavaScript 綁定,這些綁定只是用於傳入和傳出 Wasm 模塊的瘦包裝器。 輕抽象層與 JavaScript 之間的直接共享內存相結合,幫助 Wasm 提供出色的性能。

 #[wasm_bindgen] pub struct WasmPitchDetector { sample_rate: usize, fft_size: usize, detector: McLeodDetector<f32>, } #[wasm_bindgen] impl WasmPitchDetector { ... }

Rust 沒有類的概念。 相反,對象的數據由struct及其行為通過impltrait來描述。

為什麼要通過對象而不是普通函數來公開音高檢測功能? 因為那樣,我們只在創建WasmPitchDetector期間初始化內部 McLeodDetector 使用的數據結構一次。 這通過避免在操作期間進行昂貴的內存分配來保持detect_pitch函數的快速運行。

 pub fn new(sample_rate: usize, fft_size: usize) -> WasmPitchDetector { utils::set_panic_hook(); let fft_pad = fft_size / 2; WasmPitchDetector { sample_rate, fft_size, detector: McLeodDetector::<f32>::new(fft_size, fft_pad), } }

當 Rust 應用程序遇到無法輕鬆恢復的錯誤時,調用panic! 宏。 這指示 Rust 報告錯誤並立即終止應用程序。 在錯誤處理策略到位之前,使用恐慌對於早期開發特別有用,因為它可以讓您快速捕捉錯誤假設。

在設置過程中調用一次utils::set_panic_hook()將確保恐慌消息出現在瀏覽器開發工具中。

接下來,我們定義fft_pad ,即應用於每個分析 FFT 的零填充量。 當分析在傳入的採樣音頻數據中移動時,填充與算法使用的窗口函數相結合,有助於“平滑”結果。 使用 FFT 長度一半的焊盤適用於許多儀器。

最後,Rust 會自動返回最後一條語句的結果,因此WasmPitchDetector struct 語句就是new()的返回值。

我們的impl WasmPitchDetector Rust 代碼的其餘部分定義了用於檢測音高的 API:

 pub fn detect_pitch(&mut self, audio_samples: Vec<f32>) -> f32 { ... }

這就是 Rust 中成員函數定義的樣子。 公共成員detect_pitch添加到WasmPitchDetector 。 它的第一個參數是對包含structimpl字段的相同類型的實例化對象的可變引用 ( &mut ),但在調用時會自動傳遞,如下所示。

此外,我們的成員函數接受一個任意大小的 32 位浮點數數組並返回一個數字。 在這裡,這將是跨這些樣本計算的結果音高(以赫茲為單位)。

 if audio_samples.len() < self.fft_size { panic!("Insufficient samples passed to detect_pitch(). Expected an array containing {} elements but got {}", self.fft_size, audio_samples.len()); }

上面的代碼檢測是否為函數提供了足夠的樣本以執行有效的音高分析。 如果沒有,Rust panic! 調用宏會立即退出 Wasm,並將錯誤消息打印到瀏覽器開發工具控制台。

 let optional_pitch = self.detector.get_pitch( &audio_samples, self.sample_rate, POWER_THRESHOLD, CLARITY_THRESHOLD, );

這會調用第三方庫從最新的音頻樣本中計算音高。 可以調整POWER_THRESHOLDCLARITY_THRESHOLD以調整算法的靈敏度。

我們以通過match關鍵字隱式返回浮點值結束,其工作方式類似於其他語言中的switch語句。 Some()None讓我們可以適當地處理案例,而不會遇到空指針異常。

構建 WebAssembly 應用程序

在開發 Rust 應用程序時,通常的構建過程是使用cargo build調用構建。 但是,我們正在生成一個 Wasm 模塊,因此我們將使用wasm-pack ,它在針對 Wasm 時提供更簡單的語法。 (它還允許將生成的 JavaScript 綁定發佈到 npm 註冊表,但這超出了本教程的範圍。)

wasm-pack支持多種構建目標。 因為我們將直接從 Web Audio 工作集中使用模塊,所以我們將針對web選項。 其他目標包括為 webpack 等捆綁器構建或從 Node.js 消費。 我們將從wasm-audio/子目錄運行它:

 wasm-pack build --target web

如果成功,則會在./pkg下創建一個 npm 模塊。

這是一個 JavaScript 模塊,具有自己的自動生成的package.json 。 如果需要,可以將其發佈到 npm 註冊表。 現在為了簡單起見,我們可以簡單地將這個pkg複製並粘貼到我們的文件夾public/wasm-audio下:

 cp -R ./wasm-audio/pkg ./public/wasm-audio

有了這個,我們創建了一個 Rust Wasm 模塊,可供 Web 應用程序使用,或者更具體地說,由PitchProcessor

2.我們的PitchProcessor類(基於 Native AudioWorkletProcessor

對於這個應用程序,我們將使用最近獲得廣泛瀏覽器兼容性的音頻處理標準。 具體來說,我們將使用 Web Audio API 並在自定義AudioWorkletProcessor中運行昂貴的計算。 之後,我們將創建相應的自定義AudioWorkletNode類(我們將其稱為PitchNode )作為返回主線程的橋樑。

創建一個新文件public/PitchProcessor.js並將以下代碼粘貼到其中:

 import init, { WasmPitchDetector } from "./wasm-audio/wasm_audio.js"; class PitchProcessor extends AudioWorkletProcessor { constructor() { super(); // Initialized to an array holding a buffer of samples for analysis later - // once we know how many samples need to be stored. Meanwhile, an empty // array is used, so that early calls to process() with empty channels // do not break initialization. this.samples = []; this.totalSamples = 0; // Listen to events from the PitchNode running on the main thread. this.port.onmessage = (event) => this.onmessage(event.data); this.detector = null; } onmessage(event) { if (event.type === "send-wasm-module") { // PitchNode has sent us a message containing the Wasm library to load into // our context as well as information about the audio device used for // recording. init(WebAssembly.compile(event.wasmBytes)).then(() => { this.port.postMessage({ type: 'wasm-module-loaded' }); }); } else if (event.type === 'init-detector') { const { sampleRate, numAudioSamplesPerAnalysis } = event; // Store this because we use it later to detect when we have enough recorded // audio samples for our first analysis. this.numAudioSamplesPerAnalysis = numAudioSamplesPerAnalysis; this.detector = WasmPitchDetector.new(sampleRate, numAudioSamplesPerAnalysis); // Holds a buffer of audio sample values that we'll send to the Wasm module // for analysis at regular intervals. this.samples = new Array(numAudioSamplesPerAnalysis).fill(0); this.totalSamples = 0; } }; process(inputs, outputs) { // inputs contains incoming audio samples for further processing. outputs // contains the audio samples resulting from any processing performed by us. // Here, we are performing analysis only to detect pitches so do not modify // outputs. // inputs holds one or more "channels" of samples. For example, a microphone // that records "in stereo" would provide two channels. For this simple app, // we use assume either "mono" input or the "left" channel if microphone is // stereo. const inputChannels = inputs[0]; // inputSamples holds an array of new samples to process. const inputSamples = inputChannels[0]; // In the AudioWorklet spec, process() is called whenever exactly 128 new // audio samples have arrived. We simplify the logic for filling up the // buffer by making an assumption that the analysis size is 128 samples or // larger and is a power of 2. if (this.totalSamples < this.numAudioSamplesPerAnalysis) { for (const sampleValue of inputSamples) { this.samples[this.totalSamples++] = sampleValue; } } else { // Buffer is already full. We do not want the buffer to grow continually, // so instead will "cycle" the samples through it so that it always // holds the latest ordered samples of length equal to // numAudioSamplesPerAnalysis. // Shift the existing samples left by the length of new samples (128). const numNewSamples = inputSamples.length; const numExistingSamples = this.samples.length - numNewSamples; for (let i = 0; i < numExistingSamples; i++) { this.samples[i] = this.samples[i + numNewSamples]; } // Add the new samples onto the end, into the 128-wide slot vacated by // the previous copy. for (let i = 0; i < numNewSamples; i++) { this.samples[numExistingSamples + i] = inputSamples[i]; } this.totalSamples += inputSamples.length; } // Once our buffer has enough samples, pass them to the Wasm pitch detector. if (this.totalSamples >= this.numAudioSamplesPerAnalysis && this.detector) { const result = this.detector.detect_pitch(this.samples); if (result !== 0) { this.port.postMessage({ type: "pitch", pitch: result }); } } // Returning true tells the Audio system to keep going. return true; } } registerProcessor("PitchProcessor", PitchProcessor);

PitchProcessorPitchNode的伴侶,但在單獨的線程中運行,因此可以在不阻塞主線程上完成的工作的情況下執行音頻處理計算。

主要是PitchProcessor

  • 通過將 Wasm 模塊編譯並加載到工作集中來處理從PitchNode發送的"send-wasm-module"事件。 完成後,它通過發送"wasm-module-loaded"事件讓PitchNode知道。 需要這種回調方法是因為PitchNodePitchProcessor之間的所有通信都跨越了線程邊界並且不能同步執行。
  • 還通過配置WasmPitchDetector來響應來自PitchNode"init-detector"事件。
  • 處理從瀏覽器音頻圖接收到的音頻樣本,將音高檢測計算委託給 Wasm 模塊,然後將任何檢測到的音高發送回PitchNode (它通過其onPitchDetectedCallback將音高發送到 React 層)。
  • 以特定的唯一名稱註冊自己。 通過這種方式,瀏覽器知道——通過PitchNode的基類,即本機AudioWorkletNode稍後在構造PitchNode時如何實例化我們的PitchProcessor 。 請參閱setupAudio.js

下圖可視化了PitchNodePitchProcessor之間的事件流:

比較 PitchNode 和 PitchProcess 對像在運行時的交互的更詳細的流程圖。在初始設置期間,PitchNode 將 Wasm 模塊作為字節數組發送到 PitchProcessor,PitchProcessor 編譯它們並將它們發送回 PitchNode,最終以請求 PitchProcessor 初始化自身的事件消息進行響應。在錄製音頻時,PitchNode 不發送任何內容,並從 PitchProcessor 接收兩種類型的事件消息:檢測到的音高或錯誤,如果發生來自 Wasm 或工作集。
運行時事件消息。

3.添加Web Audio Worklet代碼

PitchNode.js為我們的自定義音高檢測音頻處理提供了接口。 PitchNode對像是一種機制,使用在AudioWorklet線程中工作的 WebAssembly 模塊檢測到的音高將進入主線程並通過 React 進行渲染。

src/PitchNode.js中,我們將繼承 Web Audio API 的內置AudioWorkletNode

 export default class PitchNode extends AudioWorkletNode { /** * Initialize the Audio processor by sending the fetched WebAssembly module to * the processor worklet. * * @param {ArrayBuffer} wasmBytes Sequence of bytes representing the entire * WASM module that will handle pitch detection. * @param {number} numAudioSamplesPerAnalysis Number of audio samples used * for each analysis. Must be a power of 2. */ init(wasmBytes, onPitchDetectedCallback, numAudioSamplesPerAnalysis) { this.onPitchDetectedCallback = onPitchDetectedCallback; this.numAudioSamplesPerAnalysis = numAudioSamplesPerAnalysis; // Listen to messages sent from the audio processor. this.port.onmessage = (event) => this.onmessage(event.data); this.port.postMessage({ type: "send-wasm-module", wasmBytes, }); } // Handle an uncaught exception thrown in the PitchProcessor. onprocessorerror(err) { console.log( `An error from AudioWorkletProcessor.process() occurred: ${err}` ); }; onmessage(event) { if (event.type === 'wasm-module-loaded') { // The Wasm module was successfully sent to the PitchProcessor running on the // AudioWorklet thread and compiled. This is our cue to configure the pitch // detector. this.port.postMessage({ type: "init-detector", sampleRate: this.context.sampleRate, numAudioSamplesPerAnalysis: this.numAudioSamplesPerAnalysis }); } else if (event.type === "pitch") { // A pitch was detected. Invoke our callback which will result in the UI updating. this.onPitchDetectedCallback(event.pitch); } } }

PitchNode執行的關鍵任務是:

  • 將 WebAssembly 模塊作為原始字節序列(從setupAudio.js傳入的字節序列)發送到PitchProcessor ,後者在AudioWorklet線程上運行。 這就是PitchProcessor加載音高檢測 Wasm 模塊的方式。
  • 處理成功編譯PitchProcessor時 PitchProcessor 發送的事件,並向其發送另一個傳遞音高檢測配置信息的事件。
  • 處理從PitchProcessor到達的檢測到的音高,並通過 onPitchDetectedCallback() 將它們轉發給 UI 函數setLatestPitch() onPitchDetectedCallback()

注意:此對象代碼在主線程上運行,因此應避免對檢測到的音高執行進一步處理,以防代價高昂並導致幀速率下降。

4. 添加代碼以設置網絡音頻

為了讓 Web 應用程序能夠訪問和處理來自客戶端機器麥克風的實時輸入,它必須:

  1. 獲得用戶對瀏覽器訪問任何已連接麥克風的權限
  2. 將麥克風的輸出作為音頻流對象訪問
  3. 附加代碼以處理傳入的音頻流樣本並產生一系列檢測到的音高

src/setupAudio.js中,我們將這樣做,並異步加載 Wasm 模塊,以便在附加 PitchNode 之前使用它初始化 PitchNode:

 import PitchNode from "./PitchNode"; async function getWebAudioMediaStream() { if (!window.navigator.mediaDevices) { throw new Error( "This browser does not support web audio or it is not enabled." ); } try { const result = await window.navigator.mediaDevices.getUserMedia({ audio: true, video: false, }); return result; } catch (e) { switch (e.name) { case "NotAllowedError": throw new Error( "A recording device was found but has been disallowed for this application. Enable the device in the browser settings." ); case "NotFoundError": throw new Error( "No recording device was found. Please attach a microphone and click Retry." ); default: throw e; } } } export async function setupAudio(onPitchDetectedCallback) { // Get the browser audio. Awaits user "allowing" it for the current tab. const mediaStream = await getWebAudioMediaStream(); const context = new window.AudioContext(); const audioSource = context.createMediaStreamSource(mediaStream); let node; try { // Fetch the WebAssembly module that performs pitch detection. const response = await window.fetch("wasm-audio/wasm_audio_bg.wasm"); const wasmBytes = await response.arrayBuffer(); // Add our audio processor worklet to the context. const processorUrl = "PitchProcessor.js"; try { await context.audioWorklet.addModule(processorUrl); } catch (e) { throw new Error( `Failed to load audio analyzer worklet at url: ${processorUrl}. Further info: ${e.message}` ); } // Create the AudioWorkletNode which enables the main JavaScript thread to // communicate with the audio processor (which runs in a Worklet). node = new PitchNode(context, "PitchProcessor"); // numAudioSamplesPerAnalysis specifies the number of consecutive audio samples that // the pitch detection algorithm calculates for each unit of work. Larger values tend // to produce slightly more accurate results but are more expensive to compute and // can lead to notes being missed in faster passages ie where the music note is // changing rapidly. 1024 is usually a good balance between efficiency and accuracy // for music analysis. const numAudioSamplesPerAnalysis = 1024; // Send the Wasm module to the audio node which in turn passes it to the // processor running in the Worklet thread. Also, pass any configuration // parameters for the Wasm detection algorithm. node.init(wasmBytes, onPitchDetectedCallback, numAudioSamplesPerAnalysis); // Connect the audio source (microphone output) to our analysis node. audioSource.connect(node); // Connect our analysis node to the output. Required even though we do not // output any audio. Allows further downstream audio processing or output to // occur. node.connect(context.destination); } catch (err) { throw new Error( `Failed to load audio analyzer WASM module. Further info: ${err.message}` ); } return { context, node }; }

This assumes a WebAssembly module is available to be loaded at public/wasm-audio , which we accomplished in the earlier Rust section.

5. Define the Application UI

Let's define a basic user interface for the pitch detector. We'll replace the contents of src/App.js with the following code:

 import React from "react"; import "./App.css"; import { setupAudio } from "./setupAudio"; function PitchReadout({ running, latestPitch }) { return ( <div className="Pitch-readout"> {latestPitch ? `Latest pitch: ${latestPitch.toFixed(1)} Hz` : running ? "Listening..." : "Paused"} </div> ); } function AudioRecorderControl() { // Ensure the latest state of the audio module is reflected in the UI // by defining some variables (and a setter function for updating them) // that are managed by React, passing their initial values to useState. // 1. audio is the object returned from the initial audio setup that // will be used to start/stop the audio based on user input. While // this is initialized once in our simple application, it is good // practice to let React know about any state that _could_ change // again. const [audio, setAudio] = React.useState(undefined); // 2. running holds whether the application is currently recording and // processing audio and is used to provide button text (Start vs Stop). const [running, setRunning] = React.useState(false); // 3. latestPitch holds the latest detected pitch to be displayed in // the UI. const [latestPitch, setLatestPitch] = React.useState(undefined); // Initial state. Initialize the web audio once a user gesture on the page // has been registered. if (!audio) { return ( <button onClick={async () => { setAudio(await setupAudio(setLatestPitch)); setRunning(true); }} > Start listening </button> ); } // Audio already initialized. Suspend / resume based on its current state. const { context } = audio; return ( <div> <button onClick={async () => { if (running) { await context.suspend(); setRunning(context.state === "running"); } else { await context.resume(); setRunning(context.state === "running"); } }} disabled={context.state !== "running" && context.state !== "suspended"} > {running ? "Pause" : "Resume"} </button> <PitchReadout running={running} latestPitch={latestPitch} /> </div> ); } function App() { return ( <div className="App"> <header className="App-header"> Wasm Audio Tutorial </header> <div className="App-content"> <AudioRecorderControl /> </div> </div> ); } export default App;

And we'll replace App.css with some basic styles:

 .App { display: flex; flex-direction: column; align-items: center; text-align: center; background-color: #282c34; min-height: 100vh; color: white; justify-content: center; } .App-header { font-size: 1.5rem; margin: 10%; } .App-content { margin-top: 15vh; height: 85vh; } .Pitch-readout { margin-top: 5vh; font-size: 3rem; } button { background-color: rgb(26, 115, 232); border: none; outline: none; color: white; margin: 1em; padding: 10px 14px; border-radius: 4px; width: 190px; text-transform: capitalize; cursor: pointer; font-size: 1.5rem; } button:hover { background-color: rgb(45, 125, 252); }

With that, we should be ready to run our app—but there's a pitfall to address first.

WebAssembly/Rust Tutorial: So Close!

Now when we run yarn and yarn start , switch to the browser, and attempt to record audio (using Chrome or Chromium, with developer tools open), we're met with some errors:

At wasm_audio.js line 24 there's the error, "Uncaught ReferenceError: TextDecoder is not defined," followed by one at setupAudio.js line 84 triggered by the async onClick from App.js line 43, which reads, "Uncaught (in promise) Error: Failed to load audio analyzer WASM module. Further info: Failed to construct 'AudioWorkletNode': AudioWorkletNode cannot be created: The node name 'PitchProcessor' is not defined in AudioWorkletGlobalScope."
Wasm requirements have wide support—just not yet in the Worklet spec.

The first error, TextDecoder is not defined , occurs when the browser attempts to execute the contents of wasm_audio.js . This in turn results in the failure to load the Wasm JavaScript wrapper, which produces the second error we see in the console.

The underlying cause of the issue is that modules produced by the Wasm package generator of Rust assume that TextDecoder (and TextEncoder ) will be provided by the browser. This assumption holds for modern browsers when the Wasm module is being run from the main thread or even a worker thread. However, for worklets (such as the AudioWorklet context needed in this tutorial), TextDecoder and TextEncoder are not yet part of the spec and so are not available.

TextDecoder is needed by the Rust Wasm code generator to convert from the flat, packed, shared-memory representation of Rust to the string format that JavaScript uses. Put another way, in order to see strings produced by the Wasm code generator, TextEncoder and TextDecoder must be defined.

This issue is a symptom of the relative newness of WebAssembly. As browser support improves to support common WebAssembly patterns out of the box, these issues will likely disappear.

For now, we are able to work around it by defining a polyfill for TextDecoder .

Create a new file public/TextEncoder.js and import it from public/PitchProcessor.js :

 import "./TextEncoder.js";

Make sure that this import statement comes before the wasm_audio import.

Finally, paste this implementation into TextEncoder.js (courtesy of @Yaffle on GitHub).

The Firefox Question

As mentioned earlier, the way we combine Wasm with Web Audio worklets in our app will not work in Firefox. Even with the above shim, clicking the “Start Listening” button will result in this:

 Unhandled Rejection (Error): Failed to load audio analyzer WASM module. Further info: Failed to load audio analyzer worklet at url: PitchProcessor.js. Further info: The operation was aborted.

That's because Firefox doesn't yet support importing modules from AudioWorklets —for us, that's PitchProcessor.js running in the AudioWorklet thread.

The Completed Application

Once done, we simply reload the page. The app should load without error. Click “Start Listening” and allow your browser to access your microphone. You'll see a very basic pitch detector written in JavaScript using Wasm:

A screenshot of the app showing its title,
Real-time pitch detection.

Programming in WebAssembly with Rust: A Real-time Web Audio Solution

In this tutorial, we have built a web application from scratch that performs computationally expensive audio processing using WebAssembly. WebAssembly allowed us to take advantage of near-native performance of Rust to perform the pitch detection. Further, this work could be performed on another thread, allowing the main JavaScript thread to focus on rendering to support silky-smooth frame rates even on mobile devices.

Wasm/Rust and Web Audio Takeaways

  • Modern browsers provide performant audio (and video) capture and processing inside web apps.
  • Rust has great tooling for Wasm, which helps recommend it as the language of choice for projects incorporating WebAssembly.
  • Compute-intensive work can be performed efficiently in the browser using Wasm.

Despite the many WebAssembly advantages, there are a couple Wasm pitfalls to watch out for:

  • Tooling for Wasm within worklets is still evolving. For example, we needed to implement our own versions of TextEncoder and TextDecoder functionality required for passing strings between JavaScript and Wasm because they were missing from the AudioWorklet context. That, and importing Javascript bindings for our Wasm support from an AudioWorklet is not yet available in Firefox.
  • Although the application we developed was very simple, building the WebAssembly module and loading it from the AudioWorklet required significant setup. Introducing Wasm to projects does introduce an increase in tooling complexity, which is important to keep in mind.

For your convenience, this GitHub repo contains the final, completed project. If you also do back-end development, you may also be interested in using Rust via WebAssembly within Node.js.


進一步閱讀 Toptal 工程博客:

  • Web Audio API:為什麼要編寫代碼?
  • WebVR 第 3 部分:釋放 WebAssembly 和 AssemblyScript 的潛力