WebAssembly/Rust Tutorial: Doskonałe przetwarzanie dźwięku
Opublikowany: 2022-03-11Obsługiwany przez wszystkie nowoczesne przeglądarki, WebAssembly (lub „Wasm”) zmienia sposób, w jaki rozwijamy doświadczenia użytkowników w sieci. Jest to prosty binarny format wykonywalny, który umożliwia uruchamianie w przeglądarce bibliotek, a nawet całych programów napisanych w innych językach programowania.
Deweloperzy często szukają sposobów na zwiększenie produktywności, takich jak:
- Używasz jednej bazy kodu aplikacji dla wielu platform docelowych, ale aplikacja działa dobrze na wszystkich z nich
- Tworzenie UX, który jest płynny i piękny w środowiskach stacjonarnych i mobilnych
- Korzystanie z ekosystemu bibliotek open-source, aby uniknąć „wymyślania koła na nowo” podczas tworzenia aplikacji
Dla programistów front-end, WebAssembly zapewnia wszystkie trzy, odpowiadając na poszukiwanie interfejsu użytkownika aplikacji internetowej, który naprawdę rywalizuje z natywnym środowiskiem mobilnym lub stacjonarnym. Pozwala nawet na korzystanie z bibliotek napisanych w językach innych niż JavaScript, takich jak C++ czy Go!
W tym samouczku Wasm/Rust stworzymy prostą aplikację do wykrywania tonacji, taką jak tuner gitarowy. Będzie korzystać z wbudowanych funkcji audio przeglądarki i działać z szybkością 60 klatek na sekundę (FPS) — nawet na urządzeniach mobilnych. Nie musisz rozumieć interfejsu Web Audio API ani nawet znać Rusta, aby śledzić ten samouczek; jednak oczekuje się komfortu z JavaScript.
Uwaga: Niestety, w chwili pisania tego tekstu technika zastosowana w tym artykule — specyficzna dla interfejsu Web Audio API — nie działa jeszcze w Firefoksie. Dlatego na razie zalecane są Chrome, Chromium lub Edge, pomimo doskonałej obsługi Wasm i Web Audio API w Firefoksie.
Co obejmuje ten samouczek WebAssembly/Rust
- Stworzenie prostej funkcji w Ruście i wywołanie jej z JavaScript (poprzez WebAssembly)
- Korzystanie z nowoczesnego interfejsu
AudioWorklet
API przeglądarki do wysokowydajnego przetwarzania dźwięku w przeglądarce - Komunikacja między pracownikami w JavaScript
- Łącząc to wszystko w jednolitą aplikację React
Uwaga: jeśli bardziej interesuje Cię „jak” niż „dlaczego” tego artykułu, możesz przejść od razu do samouczka.
Dlaczego Wasm?
Istnieje kilka powodów, dla których użycie WebAssembly może mieć sens:
- Pozwala na wykonanie kodu wewnątrz przeglądarki, który został napisany w dowolnym języku .
- Obejmuje to korzystanie z istniejących bibliotek (liczbowych, przetwarzania dźwięku, uczenia maszynowego itp.), które są napisane w językach innych niż JavaScript.
- W zależności od wybranego języka, Wasm może działać z prędkością bliską natywnej. Może to znacznie zbliżyć charakterystykę wydajności aplikacji internetowych do natywnych środowisk zarówno na urządzeniach mobilnych, jak i stacjonarnych .
Dlaczego nie zawsze używać Wasm?
Popularność WebAssembly z pewnością będzie rosła; jednak nie nadaje się do tworzenia wszystkich stron internetowych:
- W przypadku prostych projektów trzymanie się JavaScript, HTML i CSS prawdopodobnie zapewni działający produkt w krótszym czasie.
- Starsze przeglądarki, takie jak Internet Explorer, nie obsługują bezpośrednio Wasm.
- Typowe zastosowania WebAssembly wymagają dodania narzędzi, takich jak kompilator języka, do łańcucha narzędzi. Jeśli Twój zespół nada priorytet utrzymywaniu narzędzi programistycznych i ciągłej integracji tak prostymi, jak to tylko możliwe, korzystanie z Wasm będzie temu przeciwdziałać.
Dlaczego konkretnie samouczek Wasm/Rust?
Podczas gdy wiele języków programowania kompiluje się do Wasm, do tego przykładu wybrałem Rusta. Rust został stworzony przez Mozillę w 2010 roku i zyskuje na popularności. Rust zajmuje pierwsze miejsce w kategorii „najbardziej lubiany język” w ankiecie deweloperów 2020 przeprowadzonej przez Stack Overflow. Ale powody, dla których warto używać Rust z WebAssembly, wykraczają poza zwykłą modę:
- Przede wszystkim Rust ma krótki czas działania, co oznacza, że mniej kodu jest wysyłane do przeglądarki , gdy użytkownik uzyskuje dostęp do witryny, co pomaga utrzymać niski ślad witryny.
- Rust ma doskonałą obsługę Wasm, wspierając interoperacyjność wysokiego poziomu z JavaScript.
- Rust zapewnia wydajność zbliżoną do poziomu C/C++ , ale ma bardzo bezpieczny model pamięci . W porównaniu z innymi językami, Rust przeprowadza dodatkowe kontrole bezpieczeństwa podczas kompilowania kodu, znacznie zmniejszając ryzyko awarii spowodowanych przez puste lub niezainicjowane zmienne. Może to prowadzić do prostszej obsługi błędów i większej szansy na utrzymanie dobrego UX, gdy wystąpią nieoczekiwane problemy.
- Rdza nie jest wyrzucana na śmieci . Oznacza to, że kod Rust ma pełną kontrolę nad tym, kiedy pamięć jest przydzielana i czyszczona, co zapewnia stałą wydajność — kluczowe wymaganie w systemach czasu rzeczywistego.
Wiele zalet Rusta wiąże się również ze stromą krzywą uczenia się, więc wybór odpowiedniego języka programowania zależy od wielu czynników, takich jak skład zespołu, który będzie rozwijał i utrzymywał kod.
Wydajność WebAssembly: utrzymywanie jedwabiście gładkich aplikacji internetowych
Ponieważ programujemy w WebAssembly za pomocą Rusta, w jaki sposób możemy użyć Rusta, aby uzyskać korzyści związane z wydajnością, które doprowadziły nas do Wasm? Aby aplikacja z szybko aktualizującym się interfejsem graficznym wydawała się „gładka” użytkownikom, musi być w stanie odświeżać ekran tak regularnie, jak sprzęt ekranowy. Zwykle jest to 60 FPS, więc nasza aplikacja musi być w stanie przerysować swój interfejs użytkownika w ciągu ~16,7 ms (1000 ms / 60 FPS).
Nasza aplikacja wykrywa i pokazuje aktualną wysokość tonu w czasie rzeczywistym, co oznacza, że połączone obliczenia detekcji i rysowanie musiałyby mieścić się w zakresie 16,7 ms na klatkę. W następnej sekcji skorzystamy z obsługi przeglądarki do analizowania dźwięku w innym wątku , podczas gdy wątek główny wykonuje swoją pracę. Jest to duża wygrana dla wydajności, ponieważ obliczenia i rysowanie mają do dyspozycji 16,7 ms.
Podstawy audio w sieci
W tej aplikacji użyjemy wysokowydajnego modułu audio WebAssembly, aby wykonać wykrywanie wysokości tonu. Ponadto upewnimy się, że obliczenia nie będą działać w głównym wątku.
Dlaczego nie możemy zachować prostoty i wykonać wykrywania skoku w głównym wątku?
- Przetwarzanie dźwięku często wymaga intensywnych obliczeń. Wynika to z dużej liczby próbek, które muszą być przetwarzane co sekundę. Na przykład niezawodne wykrywanie wysokości dźwięku wymaga analizy widm 44100 próbek na sekundę.
- Kompilacja JIT i wyrzucanie elementów bezużytecznych JavaScript mają miejsce w głównym wątku i chcemy tego uniknąć w kodzie przetwarzania dźwięku, aby zapewnić stałą wydajność.
- Jeśli czas potrzebny na przetworzenie klatki audio miałby znacznie pochłonąć budżet klatki 16,7 ms, UX cierpiałby na niestabilną animację.
- Chcemy, aby nasza aplikacja działała płynnie nawet na mniej wydajnych urządzeniach mobilnych!
Worklety Web Audio umożliwiają aplikacjom dalsze osiąganie płynnych 60 klatek na sekundę, ponieważ przetwarzanie dźwięku nie może utrzymać głównego wątku. Jeśli przetwarzanie dźwięku jest zbyt wolne i jest opóźnione, pojawią się inne efekty, takie jak opóźniony dźwięk. Jednak UX pozostanie wrażliwy na użytkownika.
Samouczek WebAssembly/Rust: Pierwsze kroki
Ten samouczek zakłada, że masz zainstalowany Node.js, a także npx
. Jeśli nie masz jeszcze npx
, możesz użyć npm
(który jest dostarczany z Node.js), aby go zainstalować:
npm install -g npx
Utwórz aplikację internetową
W tym samouczku dotyczącym Wasm/Rust użyjemy Reacta.
W terminalu uruchomimy następujące polecenia:
npx create-react-app wasm-audio-app cd wasm-audio-app
Używa npx
do wykonania polecenia create-react-app
(zawartego w odpowiednim pakiecie utrzymywanym przez Facebooka) w celu utworzenia nowej aplikacji React w katalogu wasm-audio-app
.
create-react-app
to CLI do generowania aplikacji jednostronicowych (SPA) opartych na React. To sprawia, że rozpoczęcie nowego projektu z Reactem jest niezwykle łatwe. Jednak projekt wyjściowy zawiera kod wzorcowy, który będzie wymagał wymiany.
Po pierwsze, chociaż bardzo polecam testowanie jednostkowe Twojej aplikacji podczas tworzenia, testowanie wykracza poza zakres tego samouczka. Więc pójdziemy dalej i usuniemy src/App.test.js
i src/setupTests.js
.
Przegląd aplikacji
W naszej aplikacji będzie pięć głównych komponentów JavaScript:
-
public/wasm-audio/wasm-audio.js
zawiera powiązania JavaScript z modułem Wasm dostarczającym algorytm wykrywania tonu. -
public/PitchProcessor.js
to miejsce, w którym odbywa się przetwarzanie dźwięku. Działa w wątku renderowania Web Audio i zużywa interfejs Wasm API. -
src/PitchNode.js
zawiera implementację węzła Web Audio, który jest połączony z wykresem Web Audio i działa w głównym wątku. -
src/setupAudio.js
używa interfejsów API przeglądarki internetowej w celu uzyskania dostępu do dostępnego urządzenia do nagrywania dźwięku. -
src/App.js
isrc/App.css
stanowią interfejs użytkownika aplikacji.
Zagłębmy się w samo serce naszej aplikacji i zdefiniujmy kod Rust dla naszego modułu Wasm. Następnie zakodujemy różne części naszego kodu JavaScript związanego z Web Audio i zakończymy na interfejsie użytkownika.
1. Wykrywanie Pitch za pomocą Rust i WebAssembly
Nasz kod Rust obliczy tonację muzyczną z tablicy próbek audio.
Zdobądź rdzę
Możesz postępować zgodnie z tymi instrukcjami, aby zbudować łańcuch Rust do rozwoju.
Zainstaluj narzędzia do budowania komponentów WebAssembly w Rust
wasm-pack
umożliwia budowanie, testowanie i publikowanie komponentów WebAssembly generowanych przez Rusta. Jeśli jeszcze tego nie zrobiłeś, zainstaluj wasm-pack.
cargo-generate
pomaga uruchomić nowy projekt Rust, wykorzystując jako szablon istniejące repozytorium Git. Wykorzystamy to do załadowania prostego analizatora dźwięku w Rust, do którego można uzyskać dostęp za pomocą WebAssembly z przeglądarki.
Używając narzędzia cargo
dostarczonego z łańcuchem Rust, możesz zainstalować cargo-generate
:
cargo install cargo-generate
Po zakończeniu instalacji (która może zająć kilka minut) jesteśmy gotowi do stworzenia naszego projektu Rust.
Stwórz nasz moduł WebAssembly
Z folderu głównego naszej aplikacji sklonujemy szablon projektu:
$ cargo generate --git https://github.com/rustwasm/wasm-pack-template
Gdy zostaniesz poproszony o podanie nowej nazwy projektu, wprowadzimy wasm-audio
.
W katalogu wasm-audio
będzie teraz plik Cargo.toml
o następującej zawartości:
[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
służy do definiowania pakietu Rusta (który Rust nazywa „skrzynią”), obsługując podobną funkcję dla aplikacji Rust, jak package.json
dla aplikacji JavaScript.
Sekcja [package]
definiuje metadane używane podczas publikowania pakietu w oficjalnym rejestrze pakietów Rusta.
Sekcja [lib]
opisuje format wyjściowy z procesu kompilacji Rusta. Tutaj „cdylib” mówi Rustowi, aby utworzył „dynamiczną bibliotekę systemową”, którą można załadować z innego języka (w naszym przypadku JavaScript), a dołączenie „rlib” mówi Rustowi, aby dodał statyczną bibliotekę zawierającą metadane o wyprodukowanej bibliotece. Ten drugi specyfikator nie jest potrzebny do naszych celów - pomaga w rozwoju kolejnych modułów Rusta, które wykorzystują tę skrzynię jako zależność - ale można go bezpiecznie zostawić.
W [features]
prosimy Rusta o dodanie opcjonalnej funkcji console_error_panic_hook
, aby zapewnić funkcjonalność, która konwertuje mechanizm nieobsługiwanych błędów Rusta (zwany panic
) na błędy konsoli, które pojawiają się w narzędziach deweloperskich do debugowania.
Wreszcie, [dependencies]
zawiera listę wszystkich skrzyń, od których ta jest zależna. Jedyną dostarczoną z pudełka zależnością jest wasm-bindgen
, która zapewnia automatyczne generowanie powiązań JavaScript z naszym modułem Wasm.
Zaimplementuj Pitch Detector w Rust
Celem tej aplikacji jest wykrywanie głosu muzyka lub tonacji instrumentu w czasie rzeczywistym. Aby zapewnić, że zostanie to wykonane tak szybko, jak to możliwe, moduł WebAssembly ma za zadanie obliczenie skoku. Do wykrywania tonu pojedynczego głosu użyjemy metody “McLeod”, która jest zaimplementowana w istniejącej bibliotece pitch-detection
Rust.
Podobnie jak menedżer pakietów Node.js (npm), Rust zawiera własny menedżer pakietów o nazwie Cargo. Pozwala to na łatwą instalację pakietów, które zostały opublikowane w rejestrze skrzynek Rusta.
Aby dodać zależność, edytuj Cargo.toml
, dodając linię do pitch-detection
do sekcji zależności:
[dependencies] wasm-bindgen = "0.2.63" pitch-detection = "0.1"
To instruuje Cargo, aby pobrać i zainstalować zależność pitch-detection
podczas następnej cargo build
lub, ponieważ celujemy w WebAssembly, zostanie to wykonane w następnym wasm-pack
.
Stwórz w Rust wykrywacz Pitch obsługiwany przez JavaScript
Najpierw dodamy plik definiujący przydatne narzędzie, którego przeznaczenie omówimy później:
Utwórz wasm-audio/src/utils.rs
i wklej do niego zawartość tego pliku.
Wygenerowany kod w wasm-audio/lib.rs
następującym kodem, który wykrywa wysokość dźwięku za pomocą szybkiego algorytmu transformacji Fouriera (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, } } }
Przyjrzyjmy się temu bardziej szczegółowo:
#[wasm_bindgen]
wasm_bindgen
to makro Rusta, które pomaga zaimplementować wiązanie między JavaScript i Rustem. Po skompilowaniu do WebAssembly to makro nakazuje kompilatorowi utworzenie powiązania JavaScript z klasą. Powyższy kod Rust przełoży się na powiązania JavaScript, które są po prostu cienkimi opakowaniami dla wywołań do iz modułu Wasm. Lekka warstwa abstrakcji w połączeniu z bezpośrednią pamięcią współdzieloną między JavaScript jest tym, co pomaga Wasm w zapewnianiu doskonałej wydajności.
#[wasm_bindgen] pub struct WasmPitchDetector { sample_rate: usize, fft_size: usize, detector: McLeodDetector<f32>, } #[wasm_bindgen] impl WasmPitchDetector { ... }
Rust nie ma pojęcia o klasach. Dane obiektu są raczej opisywane przez struct
, a jej zachowanie poprzez impl
lub trait
.
Po co udostępniać funkcję wykrywania wysokości dźwięku za pomocą obiektu, a nie zwykłej funkcji? Ponieważ w ten sposób inicjujemy struktury danych używane przez wewnętrzny McLeodDetector tylko raz , podczas tworzenia WasmPitchDetector
. Dzięki temu funkcja detect_pitch
działa szybko, unikając kosztownej alokacji pamięci podczas działania.
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), } }
Kiedy aplikacja Rust napotka błąd, z którego nie jest w stanie łatwo naprawić, dość powszechne jest wywołanie panic!
makro. To nakazuje Rustowi zgłoszenie błędu i natychmiastowe zamknięcie aplikacji. Wykorzystywanie paniki może być przydatne zwłaszcza na wczesnym etapie rozwoju, zanim zostanie wprowadzona strategia obsługi błędów, ponieważ pozwala szybko wyłapać fałszywe założenia.
Jednokrotne wywołanie utils::set_panic_hook()
podczas instalacji zapewni, że komunikaty paniki pojawią się w narzędziach programistycznych przeglądarki.
Następnie definiujemy fft_pad
, ilość uzupełnienia zerami zastosowanego do każdej analizy FFT. Padding, w połączeniu z funkcją okienkowania używaną przez algorytm, pomaga „wygładzić” wyniki, gdy analiza porusza się po przychodzących próbkowanych danych audio. Używanie pada o połowie długości FFT działa dobrze w przypadku wielu instrumentów.
Wreszcie, Rust automatycznie zwraca wynik ostatniej instrukcji, więc instrukcja struktury WasmPitchDetector
jest wartością zwracaną przez new()
.
Reszta naszego impl WasmPitchDetector
Rust definiuje API do wykrywania skoków:
pub fn detect_pitch(&mut self, audio_samples: Vec<f32>) -> f32 { ... }
Tak wygląda definicja funkcji składowej w Ruście. Publiczny członek detect_pitch
został dodany do WasmPitchDetector
. Jego pierwszym argumentem jest zmienna referencja ( &mut
) do skonkretyzowanego obiektu tego samego typu zawierającego pola struct
i impl
— ale jest to przekazywane automatycznie podczas wywoływania, jak zobaczymy poniżej.
Ponadto nasza funkcja członkowska pobiera tablicę 32-bitowych liczb zmiennoprzecinkowych o dowolnej wielkości i zwraca pojedynczą liczbę. Tutaj będzie to wynikowa wysokość tonu obliczona dla tych próbek (w 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()); }
Powyższy kod wykrywa, czy do funkcji dostarczono wystarczającą ilość próbek, aby przeprowadzić prawidłową analizę wysokości tonu. Jeśli nie, panic!
wywoływane jest makro, które powoduje natychmiastowe wyjście z Wasm i wyświetlenie komunikatu o błędzie w konsoli narzędzi programistycznych przeglądarki.
let optional_pitch = self.detector.get_pitch( &audio_samples, self.sample_rate, POWER_THRESHOLD, CLARITY_THRESHOLD, );
To wywołuje bibliotekę innej firmy, aby obliczyć wysokość dźwięku na podstawie najnowszych próbek audio. POWER_THRESHOLD
i CLARITY_THRESHOLD
można dostosować w celu dostrojenia czułości algorytmu.
Kończymy dorozumianym zwrotem wartości zmiennoprzecinkowej za pomocą słowa kluczowego match
, które działa podobnie do instrukcji switch
w innych językach. Some()
i None
pozwalają nam odpowiednio obsługiwać przypadki bez uruchamiania wyjątku wskaźnika zerowego.
Budowanie aplikacji WebAssembly
Podczas tworzenia aplikacji Rust, zwykłą procedurą kompilacji jest wywołanie kompilacji przy użyciu cargo build
. Generujemy jednak moduł Wasm, więc użyjemy wasm-pack
, który zapewnia prostszą składnię podczas kierowania Wasm. (Umożliwia również publikowanie wynikowych powiązań JavaScript w rejestrze npm, ale wykracza to poza zakres tego samouczka).
wasm-pack
obsługuje różne cele kompilacji. Ponieważ zużyjemy moduł bezpośrednio z workletu Web Audio, skupimy się na opcji web
. Inne cele obejmują budowanie dla bundlera, takiego jak webpack lub do konsumpcji z Node.js. Uruchomimy to z podkatalogu wasm-audio/
:
wasm-pack build --target web
Jeśli się powiedzie, moduł npm zostanie utworzony w ./pkg
.
Jest to moduł JavaScript z własnym, automatycznie generowanym package.json
. W razie potrzeby można to opublikować w rejestrze npm. Aby na razie wszystko było proste, możemy po prostu skopiować i wkleić ten pakiet do naszego folderu public/ pkg
public/wasm-audio
:

cp -R ./wasm-audio/pkg ./public/wasm-audio
Dzięki temu stworzyliśmy moduł Rust Wasm gotowy do użycia przez aplikację internetową, a dokładniej przez PitchProcessor
.
2. Nasza klasa PitchProcessor
(oparta na AudioWorkletProcessor
)
W tej aplikacji użyjemy standardu przetwarzania dźwięku, który ostatnio zyskał szeroką kompatybilność z przeglądarkami. W szczególności użyjemy Web Audio API i przeprowadzimy kosztowne obliczenia w niestandardowym AudioWorkletProcessor
. Następnie utworzymy odpowiednią niestandardową klasę AudioWorkletNode
(którą nazwiemy PitchNode
) jako pomost z powrotem do głównego wątku.
Utwórz nowy plik public/PitchProcessor.js
i wklej do niego następujący kod:
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
jest towarzyszem PitchNode
ale działa w osobnym wątku, dzięki czemu obliczenia przetwarzania dźwięku mogą być wykonywane bez blokowania pracy wykonywanej w głównym wątku.
Głównie PitchProcessor
:
- Obsługuje zdarzenie
"send-wasm-module"
wysyłane zPitchNode
przez kompilowanie i ładowanie modułu Wasm do zadania. Po zakończeniuPitchNode
, wysyłając"wasm-module-loaded"
. To podejście wywołania zwrotnego jest potrzebne, ponieważ cała komunikacja międzyPitchNode
iPitchProcessor
przekracza granicę wątku i nie może być wykonywana synchronicznie. - Reaguje również na zdarzenie
"init-detector"
zPitchNode
, konfigurującWasmPitchDetector
. - Przetwarza próbki audio odebrane z wykresu audio przeglądarki, deleguje obliczenia wykrywania tonu do modułu Wasm, a następnie wysyła wykryty tok z powrotem do
PitchNode
(który wysyła tonu wraz z warstwą React za pośrednictwemonPitchDetectedCallback
). - Rejestruje się pod określoną, unikalną nazwą. W ten sposób przeglądarka wie — za pośrednictwem klasy bazowej
PitchNode
, natywnegoAudioWorkletNode
— jak utworzyć instancję naszegoPitchProcessor
później, gdy zostanie skonstruowanyPitchNode
. ZobaczsetupAudio.js
.
Poniższy diagram przedstawia przepływ zdarzeń między PitchNode
i PitchProcessor
:
3. Dodaj kod zadań Web Audio
PitchNode.js
zapewnia interfejs do naszego niestandardowego przetwarzania dźwięku z wykrywaniem tonu. Obiekt PitchNode
to mechanizm, dzięki któremu skoki wykryte za pomocą modułu WebAssembly działającego w wątku AudioWorklet
trafiają do głównego wątku i React do renderowania.
W src/PitchNode.js
podklasę wbudowanego AudioWorkletNode
interfejsu Web Audio API:
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); } } }
Kluczowe zadania wykonywane przez PitchNode
to:
- Wyślij moduł WebAssembly jako sekwencję nieprzetworzonych bajtów — tych przekazanych z
setupAudio.js
— doPitchProcessor
, który działa w wątkuAudioWorklet
. W ten sposóbPitchProcessor
ładuje moduł Wasm do wykrywania skoku. - Obsłuż zdarzenie wysłane przez
PitchProcessor
, gdy pomyślnie skompiluje Wasm, i wyślij mu inne zdarzenie, które przekazuje do niego informacje o konfiguracji wykrywania tonu. - Obsługuj wykryte tony, gdy dotrą one z
PitchProcessor
i przekaż je do funkcji interfejsu użytkownikasetLatestPitch()
przezonPitchDetectedCallback()
.
Uwaga: ten kod obiektu działa w głównym wątku, więc należy unikać dalszego przetwarzania wykrytych skoków, na wypadek gdyby było to kosztowne i powodowało spadki liczby klatek na sekundę.
4. Dodaj kod, aby skonfigurować Web Audio
Aby aplikacja internetowa mogła uzyskiwać dostęp i przetwarzać dane wejściowe na żywo z mikrofonu komputera klienckiego, musi:
- Uzyskaj pozwolenie użytkownika dla przeglądarki na dostęp do dowolnego podłączonego mikrofonu
- Uzyskaj dostęp do wyjścia mikrofonu jako obiektu strumienia audio
- Dołącz kod, aby przetworzyć przychodzące próbki strumienia audio i utworzyć sekwencję wykrytych tonów
W src/setupAudio.js
zrobimy to, a także załadujemy moduł Wasm asynchronicznie, abyśmy mogli zainicjować nim nasz PitchNode, przed dołączeniem naszego 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.
Dalsza lektura na blogu Toptal Engineering:
- Web Audio API: po co komponować, skoro można kodować?
- WebVR Część 3: Uwolnienie potencjału WebAssembly i AssemblyScript