WebAssembly / Rustチュートリアル:ピッチパーフェクトなオーディオ処理
公開: 2022-03-11最新のすべてのブラウザーでサポートされているWebAssembly(または「Wasm」)は、Webのユーザーエクスペリエンスを開発する方法を変革しています。 これは、他のプログラミング言語で記述されたライブラリまたはプログラム全体をWebブラウザで実行できるようにする単純なバイナリ実行可能形式です。
開発者は、次のような生産性を高める方法を探すことがよくあります。
- 複数のターゲットプラットフォームに単一のアプリコードベースを使用しますが、すべてのプラットフォームでアプリを適切に実行します
- デスクトップおよびモバイル環境でスムーズで美しいUXを作成する
- オープンソースライブラリエコシステムを利用して、アプリ開発中の「車輪の再発明」を回避します
フロントエンド開発者向けに、WebAssemblyは3つすべてを提供し、ネイティブのモバイルまたはデスクトップエクスペリエンスに真に匹敵するWebアプリUIの検索に答えます。 また、C++やGo!などのJavaScript以外の言語で記述されたライブラリを使用することもできます。
この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
の使用 - JavaScriptでのワーカー間の通信
- 必要最低限のReactアプリケーションにすべてを結び付ける
注:この記事の「理由」よりも「方法」に関心がある場合は、チュートリアルに直接進んでください。
なぜWasm?
WebAssemblyを使用することが理にかなっている理由はいくつかあります。
- これにより、おそらく任意の言語で記述されたコードをブラウザ内で実行できます。
- これには、JavaScript以外の言語で記述された既存のライブラリ(数値、音声処理、機械学習など)の利用が含まれます。
- 使用する言語の選択に応じて、Wasmはネイティブに近い速度で動作できます。 これにより、Webアプリケーションのパフォーマンス特性がモバイルとデスクトップの両方のネイティブエクスペリエンスにはるかに近づく可能性があります。
いつもWasmを使わないのはなぜですか?
WebAssemblyの人気は確実に高まり続けるでしょう。 ただし、すべてのWeb開発に適しているわけではありません。
- 単純なプロジェクトの場合、JavaScript、HTML、およびCSSに固執することで、動作する製品をより短時間で提供できる可能性があります。
- Internet Explorerなどの古いブラウザは、Wasmを直接サポートしていません。
- WebAssemblyの一般的な使用法では、言語コンパイラなどのツールをツールチェーンに追加する必要があります。 チームが開発と継続的インテグレーションツールを可能な限りシンプルに保つことを優先する場合、Wasmを使用するとこれに逆行します。
なぜWasm/Rustチュートリアルなのか、具体的には?
多くのプログラミング言語はWasmにコンパイルされますが、この例ではRustを選択しました。 Rustは2010年にMozillaによって作成され、人気が高まっています。 Stack Overflowによる2020年の開発者調査では、Rustが「最も愛されている言語」のトップの座を占めています。 しかし、WebAssemblyでRustを使用する理由は、単なる流行を超えています。
- 何よりもまず、Rustのランタイムは小さいため、ユーザーがサイトにアクセスしたときにブラウザーに送信されるコードが少なくなり、Webサイトのフットプリントを低く抑えることができます。
- Rustは優れたWasmサポートを備えており、JavaScriptとの高レベルの相互運用性をサポートしています。
- Rustは、ほぼC / C ++レベルのパフォーマンスを提供しますが、非常に安全なメモリモデルを備えています。 他の言語と比較すると、Rustはコードのコンパイル中に追加の安全性チェックを実行し、空の変数または初期化されていない変数によって引き起こされるクラッシュの可能性を大幅に減らします。 これにより、エラー処理が簡単になり、予期しない問題が発生したときに優れたUXを維持できる可能性が高くなります。
- さびはガベージコレクションされません。 これは、Rustコードがメモリの割り当てとクリーンアップのタイミングを完全に制御し、リアルタイムシステムの重要な要件である一貫したパフォーマンスを可能にすることを意味します。
Rustの多くの利点には、学習曲線も急勾配であるため、適切なプログラミング言語の選択は、コードの開発と保守を行うチームの構成など、さまざまな要因によって異なります。
WebAssemblyのパフォーマンス:シルキーでスムーズなWebアプリの維持
Rustを使用してWebAssemblyでプログラミングしているので、そもそもWasmにつながったパフォーマンス上の利点を得るために、Rustをどのように使用できるでしょうか。 急速に更新されるGUIを備えたアプリケーションがユーザーにとって「スムーズ」であると感じるには、画面のハードウェアと同じくらい定期的に表示を更新できる必要があります。 これは通常60FPSであるため、アプリケーションは約16.7ミリ秒(1,000ミリ秒/ 60 FPS)以内にユーザーインターフェイスを再描画できる必要があります。
このアプリケーションは、現在のピッチをリアルタイムで検出して表示します。つまり、検出の計算と描画を組み合わせて、フレームあたり16.7ミリ秒以内に収める必要があります。 次のセクションでは、メインスレッドが機能している間に、ブラウザのサポートを利用して別のスレッドのオーディオを分析します。 計算と描画はそれぞれ16.7ミリ秒であるため、これはパフォーマンスの大きなメリットです。
Webオーディオの基本
このアプリケーションでは、高性能のWebAssemblyオーディオモジュールを使用してピッチ検出を実行します。 さらに、計算がメインスレッドで実行されないようにします。
物事をシンプルに保ち、メインスレッドでピッチ検出を実行できないのはなぜですか?
- 多くの場合、オーディオ処理は計算量が多くなります。 これは、毎秒処理する必要のあるサンプルの数が多いためです。 たとえば、オーディオピッチを確実に検出するには、毎秒44,100サンプルのスペクトルを分析する必要があります。
- JavaScriptのJITコンパイルとガベージコレクションはメインスレッドで実行されます。一貫したパフォーマンスを実現するために、オーディオ処理コードではこれを回避したいと考えています。
- オーディオのフレームを処理するのにかかる時間が16.7ミリ秒のフレームバジェットを大幅に消費すると、UXはアニメーションの途切れに悩まされます。
- パフォーマンスの低いモバイルデバイスでもアプリをスムーズに実行できるようにしたいと考えています。
Webオーディオワークレットを使用すると、オーディオ処理でメインスレッドを維持できないため、アプリはスムーズな60FPSを達成し続けることができます。 オーディオ処理が遅すぎて遅れると、オーディオの遅れなどの他の影響があります。 ただし、UXはユーザーに応答し続けます。
WebAssembly / Rustチュートリアル:はじめに
このチュートリアルでは、Node.jsとnpx
がインストールされていることを前提としています。 npx
をまだお持ちでない場合は、 npm
(Node.jsに付属)を使用してインストールできます。
npm install -g npx
Webアプリを作成する
この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.js
とsrc/setupTests.js
setupTests.jsを削除します。
アプリケーションの概要
このアプリケーションには、5つの主要なJavaScriptコンポーネントがあります。
-
public/wasm-audio/wasm-audio.js
には、ピッチ検出アルゴリズムを提供するWasmモジュールへのJavaScriptバインディングが含まれています。 -
public/PitchProcessor.js
は、オーディオ処理が行われる場所です。 Web Audioレンダリングスレッドで実行され、WasmAPIを使用します。 -
src/PitchNode.js
には、Web Audioグラフに接続され、メインスレッドで実行されるWebAudioノードの実装が含まれています。 -
src/setupAudio.js
は、WebブラウザAPIを使用して、使用可能なオーディオ録音デバイスにアクセスします。 -
src/App.js
とsrc/App.css
は、アプリケーションのユーザーインターフェイスを構成します。
アプリケーションの核心を直接掘り下げて、WasmモジュールのRustコードを定義しましょう。 次に、Webオーディオ関連のJavaScriptのさまざまな部分をコーディングし、UIで終了します。
1.RustとWebAssemblyを使用したピッチ検出
Rustコードは、オーディオサンプルの配列から音楽のピッチを計算します。
錆びる
これらの手順に従って、開発用のRustチェーンを構築できます。
RustでWebAssemblyコンポーネントを構築するためのツールをインストールする
wasm-pack
を使用すると、Rustで生成されたWebAssemblyコンポーネントを構築、テスト、公開できます。 まだインストールしていない場合は、wasm-packをインストールします。
cargo-generate
のに役立ちます。 これを使用して、ブラウザーからWebAssemblyを使用してアクセスできるRustの単純なオーディオアナライザーをブートストラップします。
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は「クレート」と呼びます)を定義するために使用され、 package.json
がJavaScriptアプリケーションに対して行うのと同様の関数をRustアプリに対して提供します。
[package]
セクションは、Rustの公式パッケージレジストリにパッケージを公開するときに使用されるメタデータを定義します。
[lib]
セクションでは、Rustコンパイルプロセスからの出力形式について説明します。 ここで、「cdylib」はRustに、別の言語(この場合はJavaScript)からロードできる「動的システムライブラリ」を生成するように指示し、「rlib」を含めると、生成されたライブラリに関するメタデータを含む静的ライブラリを追加するようにRustに指示します。 この2番目の指定子は、私たちの目的には必要ありません。これは、このクレートを依存関係として消費するRustモジュールの開発を支援しますが、そのままにしておくのは安全です。
[features]
では、Rustのunhandled-errorsメカニズム( panic
と呼ばれる)をデバッグ用の開発ツールに表示されるコンソールエラーに変換する機能を提供するオプション機能console_error_panic_hook
を含めるようにRustに依頼します。
最後に、 [dependencies]
には、これが依存するすべてのクレートが一覧表示されます。 すぐに使用できる唯一の依存関係はwasm-bindgen
です。これは、WasmモジュールへのJavaScriptバインディングの自動生成を提供します。
Rustにピッチ検出器を実装する
このアプリの目的は、ミュージシャンの声や楽器のピッチをリアルタイムで検出できるようにすることです。 これを可能な限り迅速に実行するために、WebAssemblyモジュールはピッチの計算を担当します。 単一音声のピッチ検出には、既存のRust pitch-detection
ライブラリに実装されている「McLeod」ピッチメソッドを使用します。
Node.jsパッケージマネージャー(npm)と同様に、RustにはCargoと呼ばれる独自のパッケージマネージャーが含まれています。 これにより、Rustクレートレジストリに公開されているパッケージを簡単にインストールできます。
依存関係を追加するには、 Cargo.toml
を編集して、依存関係セクションにpitch-detection
の行を追加します。
[dependencies] wasm-bindgen = "0.2.63" pitch-detection = "0.1"
これは、次のcargo build
中にpitch-detection
依存関係をダウンロードしてインストールするようにCargoに指示します。または、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
は、JavaScriptとRust間のバインディングを実装するのに役立つRustマクロです。 このマクロは、WebAssemblyにコンパイルされると、クラスへのJavaScriptバインディングを作成するようにコンパイラーに指示します。 上記のRustコードは、Wasmモジュールとの間の呼び出しの単なるシンラッパーであるJavaScriptバインディングに変換されます。 JavaScript間の直接共有メモリと組み合わされた抽象化のライトレイヤーは、Wasmが優れたパフォーマンスを提供するのに役立ちます。
#[wasm_bindgen] pub struct WasmPitchDetector { sample_rate: usize, fft_size: usize, detector: McLeodDetector<f32>, } #[wasm_bindgen] impl WasmPitchDetector { ... }
Rustにはクラスの概念がありません。 むしろ、オブジェクトのデータは、 struct
とその動作によってimpl
またはtrait
によって記述されます。
単純な関数ではなく、オブジェクトを介してピッチ検出機能を公開するのはなぜですか? そのため、 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()
を1回呼び出すと、ブラウザー開発ツールにパニックメッセージが表示されます。
次に、各分析FFTに適用されるゼロパディングの量であるfft_pad
を定義します。 パディングは、アルゴリズムで使用されるウィンドウ関数と組み合わせて、分析が入力されたサンプリングされたオーディオデータ間を移動するときに結果を「スムーズ」にするのに役立ちます。 FFTの長さの半分のパッドを使用すると、多くの機器でうまく機能します。
最後に、Rustは最後のステートメントの結果を自動的に返すため、 WasmPitchDetector
構造体ステートメントはnew()
の戻り値です。
impl WasmPitchDetector
Rustコードの残りの部分は、ピッチを検出するためのAPIを定義します。
pub fn detect_pitch(&mut self, audio_samples: Vec<f32>) -> f32 { ... }
これは、Rustでのメンバー関数定義の外観です。 パブリックメンバーdetect_pitch
がWasmPitchDetector
に追加されます。 その最初の引数は、 struct
フィールドとimpl
フィールドを含む同じタイプのインスタンス化されたオブジェクトへの可変参照( &mut
)ですが、これは、以下に示すように、呼び出し時に自動的に渡されます。
さらに、メンバー関数は32ビット浮動小数点数の任意のサイズの配列を取り、単一の数を返します。 ここで、それはそれらのサンプル全体で計算された結果のピッチになります(Hz単位)。
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()); }
上記のコードは、有効なピッチ分析を実行するために十分なサンプルが関数に提供されたかどうかを検出します。 そうでなければ、さびpanic!
マクロが呼び出され、Wasmがすぐに終了し、エラーメッセージがブラウザのdev-toolsコンソールに出力されます。
let optional_pitch = self.detector.get_pitch( &audio_samples, self.sample_rate, POWER_THRESHOLD, CLARITY_THRESHOLD, );
これにより、サードパーティのライブラリが呼び出され、最新のオーディオサンプルからピッチが計算されます。 POWER_THRESHOLD
およびCLARITY_THRESHOLD
は、アルゴリズムの感度を調整するために調整できます。
最後に、 match
キーワードを介した浮動小数点値の暗黙の戻りがあります。これは、他の言語のswitch
ステートメントと同様に機能します。 Some()
とNone
を使用すると、nullポインター例外が発生することなくケースを適切に処理できます。
WebAssemblyアプリケーションの構築
Rustアプリケーションを開発する場合、通常のビルド手順は、cargobuildを使用してビルドを呼び出すcargo build
です。 ただし、Wasmモジュールを生成しているため、 wasm-pack
を使用します。これにより、Wasmをターゲットにするときに構文が簡単になります。 (結果のJavaScriptバインディングをnpmレジストリに公開することもできますが、それはこのチュートリアルの範囲外です。)
wasm-pack
はさまざまなビルドターゲットをサポートしています。 モジュールはWebオーディオワークレットから直接使用するため、 web
オプションをターゲットにします。 その他のターゲットには、webpackなどのバンドラー用またはNode.jsからの消費用のビルドが含まれます。 これは、 wasm-audio/
サブディレクトリから実行します。
wasm-pack build --target web
成功すると、npmモジュールが./pkg
の下に作成されます。
これは、独自の自動生成されたpackage.json
を備えたJavaScriptモジュールです。 これは、必要に応じてnpmレジストリに公開できます。 今のところ簡単にするために、このpkg
をコピーして、 public/wasm-audio
フォルダーに貼り付けることができます。
cp -R ./wasm-audio/pkg ./public/wasm-audio
これにより、Webアプリ、より具体的にはPitchProcessor
ですぐに使用できるRustWasmモジュールを作成しました。
2. PitchProcessor
クラス(ネイティブ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);
PitchProcessor
はPitchNode
のコンパニオンですが、別のスレッドで実行されるため、メインスレッドで実行される作業をブロックすることなくオーディオ処理の計算を実行できます。

主に、 PitchProcessor
:
- Wasmモジュールをコンパイルしてワークレットにロードすることにより、
PitchNode
から送信された"send-wasm-module"
イベントを処理します。 完了すると、"wasm-module-loaded"
イベントを送信してPitchNode
に通知します。PitchNode
とPitchProcessor
の間のすべての通信はスレッドの境界を越え、同期的に実行できないため、このコールバックアプローチが必要です。 - また、
PitchNode
を構成することにより、WasmPitchDetector
からの"init-detector"
イベントに応答します。 - ブラウザのオーディオグラフから受信したオーディオサンプルを処理し、ピッチ検出の計算をWasmモジュールに委任してから、検出されたピッチを
PitchNode
に送り返します(onPitchDetectedCallback
を介してピッチをReactレイヤーに送ります)。 - 特定の一意の名前で自分自身を登録します。 このようにして、ブラウザは、
PitchNode
の基本クラスであるネイティブAudioWorkletNode
を介して、後でPitchProcessor
が構築されたときにPitchNode
をインスタンス化する方法を認識します。setupAudio.js
を参照してください。
次の図は、 PitchNode
とPitchProcessor
の間のイベントの流れを視覚化したものです。
3.Webオーディオワークレットコードを追加します
PitchNode.js
は、カスタムピッチ検出オーディオ処理へのインターフェイスを提供します。 PitchNode
オブジェクトは、 AudioWorklet
スレッドで動作するWebAssemblyモジュールを使用して検出されたピッチがメインスレッドに移動し、レンダリングのためにReactするメカニズムです。
src/PitchNode.js
で、WebAudioAPIの組み込み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モジュールをロードする方法です。 - Wasmが正常にコンパイルされたときに
PitchProcessor
によって送信されたイベントを処理し、ピッチ検出構成情報を渡す別のイベントを送信します。 - 検出されたピッチが
PitchProcessor
から到着したときに処理し、onPitchDetectedCallback()を介してUI関数setLatestPitch()
onPitchDetectedCallback()
転送します。
注:オブジェクトのこのコードはメインスレッドで実行されるため、これが高価でフレームレートの低下を引き起こす場合に備えて、検出されたピッチでそれ以上の処理を実行することは避けてください。
4.Webオーディオを設定するためのコードを追加します
Webアプリケーションがクライアントマシンのマイクからのライブ入力にアクセスして処理するには、次のことを行う必要があります。
- ブラウザが接続されているマイクにアクセスするためのユーザーの許可を取得します
- マイクの出力にオーディオストリームオブジェクトとしてアクセスします
- 着信オーディオストリームサンプルを処理し、検出されたピッチのシーケンスを生成するコードを添付します
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:
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:
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 anAudioWorklet
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 Engineeringブログでさらに読む:
- Web Audio API:コーディングできるのになぜ作曲するのですか?
- WebVRパート3:WebAssemblyとAssemblyScriptの可能性を解き放つ