WebAssembly/Rust-Tutorial: Perfekte Audioverarbeitung
Veröffentlicht: 2022-03-11WebAssembly (oder „Wasm“) wird von allen modernen Browsern unterstützt und verändert die Art und Weise, wie wir Benutzererfahrungen für das Web entwickeln. Es ist ein einfaches binäres ausführbares Format, das es ermöglicht, Bibliotheken oder sogar ganze Programme, die in anderen Programmiersprachen geschrieben wurden, im Webbrowser auszuführen.
Entwickler suchen oft nach Wegen, um produktiver zu sein, wie zum Beispiel:
- Verwenden einer einzigen App-Codebasis für mehrere Zielplattformen, aber die App läuft auf allen gut
- Erstellen einer UX, die auf Desktop- und mobilen Umgebungen reibungslos und schön ist
- Nutzen Sie das Open-Source-Bibliotheks-Ökosystem, um bei der App-Entwicklung nicht „das Rad neu erfinden“ zu müssen
Für Front-End-Entwickler bietet WebAssembly alle drei und beantwortet die Suche nach einer Web-App-Benutzeroberfläche, die wirklich mit der nativen Mobil- oder Desktop-Erfahrung konkurriert. Es ermöglicht sogar die Verwendung von Bibliotheken, die in Nicht-JavaScript-Sprachen wie C++ oder Go!
In diesem Wasm/Rust-Tutorial erstellen wir eine einfache Tonhöhenerkennungs-App, wie ein Gitarrenstimmgerät. Es nutzt die integrierten Audiofunktionen des Browsers und läuft mit 60 Bildern pro Sekunde (FPS) – sogar auf Mobilgeräten. Sie müssen die Web Audio API nicht verstehen oder mit Rust vertraut sein, um diesem Tutorial zu folgen; Komfort mit JavaScript wird jedoch erwartet.
Hinweis: Zum jetzigen Zeitpunkt funktioniert die in diesem Artikel verwendete Technik – spezifisch für die Web-Audio-API – leider noch nicht in Firefox. Daher werden für dieses Tutorial vorerst Chrome, Chromium oder Edge empfohlen, trotz der ansonsten hervorragenden Wasm- und Web-Audio-API-Unterstützung in Firefox.
Was dieses WebAssembly/Rust-Tutorial abdeckt
- Erstellen einer einfachen Funktion in Rust und Aufrufen von JavaScript (über WebAssembly)
- Nutzung der modernen
AudioWorklet
API des Browsers für performante Audioverarbeitung im Browser - Kommunikation zwischen Arbeitern in JavaScript
- Binden Sie alles zu einer einfachen React-Anwendung zusammen
Hinweis: Wenn Sie mehr am „Wie“ als am „Warum“ dieses Artikels interessiert sind, können Sie direkt zum Tutorial springen.
Warum Wasm?
Es gibt mehrere Gründe, warum es sinnvoll sein kann, WebAssembly zu verwenden:
- Es ermöglicht die Ausführung von Code innerhalb des Browsers, der in denkbar jeder Sprache geschrieben wurde.
- Dazu gehört die Nutzung vorhandener Bibliotheken (numerisch, Audioverarbeitung, maschinelles Lernen usw.), die in anderen Sprachen als JavaScript geschrieben sind.
- Abhängig von der Wahl der verwendeten Sprache kann Wasm mit nahezu nativer Geschwindigkeit arbeiten. Dies hat das Potenzial, die Leistungsmerkmale von Webanwendungen sowohl für Mobilgeräte als auch für Desktops viel näher an native Erfahrungen zu bringen.
Warum nicht immer Wasm verwenden?
Die Popularität von WebAssembly wird sicherlich weiter zunehmen; Es ist jedoch nicht für alle Webentwicklungen geeignet:
- Bei einfachen Projekten wird das Festhalten an JavaScript, HTML und CSS wahrscheinlich in kürzerer Zeit ein funktionierendes Produkt liefern.
- Ältere Browser wie Internet Explorer unterstützen Wasm nicht direkt.
- Typische Anwendungen von WebAssembly erfordern das Hinzufügen von Tools, wie z. B. einem Sprachcompiler, zu Ihrer Toolchain. Wenn Ihr Team Priorität darauf legt, die Entwicklungs- und Continuous-Integration-Tools so einfach wie möglich zu halten, läuft die Verwendung von Wasm dem zuwider.
Warum gerade ein Wasm/Rost-Tutorial?
Während viele Programmiersprachen zu Wasm kompilieren, habe ich für dieses Beispiel Rust gewählt. Rust wurde 2010 von Mozilla entwickelt und erfreut sich wachsender Beliebtheit. Rust belegt in der Entwicklerumfrage 2020 von Stack Overflow den Spitzenplatz für „beliebteste Sprache“. Aber die Gründe, Rust mit WebAssembly zu verwenden, gehen über bloße Trendigkeit hinaus:
- In erster Linie hat Rust eine kurze Laufzeit, was bedeutet, dass weniger Code an den Browser gesendet wird, wenn ein Benutzer auf die Website zugreift, was dazu beiträgt, den Fußabdruck der Website gering zu halten.
- Rust verfügt über eine hervorragende Wasm -Unterstützung, die eine Interoperabilität auf hohem Niveau mit JavaScript unterstützt.
- Rust bietet Leistung auf C/C++-Niveau , hat aber ein sehr sicheres Speichermodell . Im Vergleich zu anderen Sprachen führt Rust zusätzliche Sicherheitsprüfungen durch, während Ihr Code kompiliert wird, wodurch das Potenzial für Abstürze, die durch leere oder nicht initialisierte Variablen verursacht werden, erheblich reduziert wird. Dies kann zu einer einfacheren Fehlerbehandlung und einer höheren Chance führen, eine gute UX aufrechtzuerhalten, wenn unerwartete Probleme auftreten.
- Rost wird nicht müllgesammelt . Dies bedeutet, dass Rust-Code die vollständige Kontrolle darüber hat, wann Speicher zugewiesen und bereinigt wird, was eine konsistente Leistung ermöglicht – eine Schlüsselanforderung in Echtzeitsystemen.
Die vielen Vorteile von Rust sind auch mit einer steilen Lernkurve verbunden, sodass die Wahl der richtigen Programmiersprache von einer Vielzahl von Faktoren abhängt, z. B. der Zusammensetzung des Teams, das den Code entwickelt und pflegt.
WebAssembly-Leistung: Wartung seidenweicher Web-Apps
Da wir in WebAssembly mit Rust programmieren, wie könnten wir Rust verwenden, um die Leistungsvorteile zu erzielen, die uns überhaupt zu Wasm geführt haben? Damit sich eine Anwendung mit einer sich schnell aktualisierenden GUI für Benutzer „flüssig“ anfühlt, muss sie in der Lage sein, die Anzeige so regelmäßig wie die Bildschirmhardware zu aktualisieren. Dies sind normalerweise 60 FPS, sodass unsere Anwendung in der Lage sein muss, ihre Benutzeroberfläche innerhalb von ~16,7 ms (1.000 ms / 60 FPS) neu zu zeichnen.
Unsere Anwendung erkennt und zeigt die aktuelle Tonhöhe in Echtzeit, was bedeutet, dass die kombinierte Erkennungsberechnung und Zeichnung innerhalb von 16,7 ms pro Frame bleiben müssten. Im nächsten Abschnitt nutzen wir die Browserunterstützung, um Audiodaten in einem anderen Thread zu analysieren, während der Hauptthread seine Arbeit erledigt. Das ist ein großer Performance-Gewinn, da Rechnen und Zeichnen dann jeweils 16,7 ms zur Verfügung haben.
Web-Audio-Grundlagen
In dieser Anwendung verwenden wir ein leistungsstarkes WebAssembly-Audiomodul, um die Tonhöhenerkennung durchzuführen. Außerdem stellen wir sicher, dass die Berechnung nicht im Hauptthread ausgeführt wird.
Warum können wir die Dinge nicht einfach halten und die Tonhöhenerkennung im Hauptthread durchführen?
- Die Audioverarbeitung ist oft rechenintensiv. Dies liegt an der großen Anzahl von Proben, die jede Sekunde verarbeitet werden müssen. Beispielsweise erfordert die zuverlässige Erkennung der Tonhöhe die Analyse der Spektren von 44.100 Samples pro Sekunde.
- Die JIT-Kompilierung und die Garbage Collection von JavaScript erfolgen im Hauptthread, und wir möchten dies im Audioverarbeitungscode vermeiden, um eine konsistente Leistung zu erzielen.
- Wenn die Zeit, die für die Verarbeitung eines Audio-Frames benötigt wird, das Frame-Budget von 16,7 ms erheblich auffressen würde, würde die UX unter abgehackten Animationen leiden.
- Wir möchten, dass unsere App auch auf leistungsschwächeren Mobilgeräten reibungslos läuft!
Web-Audio-Worklets ermöglichen es Apps, weiterhin flüssige 60 FPS zu erreichen, da die Audioverarbeitung den Hauptstrang nicht aufhalten kann. Wenn die Audioverarbeitung zu langsam ist und ins Hintertreffen gerät, treten andere Effekte auf, z. B. verzögertes Audio. Die UX reagiert jedoch weiterhin auf den Benutzer.
WebAssembly/Rust-Tutorial: Erste Schritte
In diesem Tutorial wird davon ausgegangen, dass Sie Node.js sowie npx
installiert haben. Wenn Sie npx
noch nicht haben, können Sie npm
(das mit Node.js geliefert wird) verwenden, um es zu installieren:
npm install -g npx
Erstellen Sie eine Web-App
Für dieses Wasm/Rust-Tutorial verwenden wir React.
In einem Terminal führen wir die folgenden Befehle aus:
npx create-react-app wasm-audio-app cd wasm-audio-app
Dieser verwendet npx
, um den Befehl create-react-app
auszuführen (enthalten in dem entsprechenden von Facebook verwalteten Paket), um eine neue React-Anwendung im Verzeichnis wasm-audio-app
zu erstellen.
create-react-app
ist eine CLI zum Generieren von React-basierten Single-Page-Anwendungen (SPAs). Es macht es unglaublich einfach, ein neues Projekt mit React zu starten. Das Ausgabeprojekt enthält jedoch Boilerplate-Code, der ersetzt werden muss.
Erstens, obwohl ich dringend empfehle, Ihre Anwendung während der gesamten Entwicklung zu testen, geht das Testen über den Rahmen dieses Tutorials hinaus. Also fahren wir fort und löschen src/App.test.js
und src/setupTests.js
.
Anwendungsübersicht
Unsere Anwendung wird fünf Haupt-JavaScript-Komponenten enthalten:
-
public/wasm-audio/wasm-audio.js
enthält JavaScript-Anbindungen an das Wasm-Modul, das den Tonhöhenerkennungsalgorithmus bereitstellt. -
public/PitchProcessor.js
findet die Audioverarbeitung statt. Es wird im Web-Audio-Rendering-Thread ausgeführt und nutzt die Wasm-API. -
src/PitchNode.js
enthält eine Implementierung eines Web-Audio-Knotens, der mit dem Web-Audio-Graphen verbunden ist und im Hauptthread ausgeführt wird. -
src/setupAudio.js
verwendet Webbrowser-APIs, um auf ein verfügbares Audioaufzeichnungsgerät zuzugreifen. -
src/App.js
undsrc/App.css
die Benutzeroberfläche der Anwendung.
Lassen Sie uns direkt zum Kern unserer Anwendung vordringen und den Rust-Code für unser Wasm-Modul definieren. Wir werden dann die verschiedenen Teile unseres Web-Audio-bezogenen JavaScripts codieren und mit der Benutzeroberfläche enden.
1. Tonhöhenerkennung mit Rust und WebAssembly
Unser Rust-Code berechnet eine Tonhöhe aus einer Reihe von Audio-Samples.
Holen Sie sich Rost
Sie können diesen Anweisungen folgen, um die Rust-Kette für die Entwicklung zu erstellen.
Installieren Sie Tools zum Erstellen von WebAssembly-Komponenten in Rust
wasm-pack
können Sie von Rust generierte WebAssembly-Komponenten erstellen, testen und veröffentlichen. Falls noch nicht geschehen, installieren Sie wasm-pack.
cargo-generate
hilft dabei, ein neues Rust-Projekt zum Laufen zu bringen, indem es ein bereits vorhandenes Git-Repository als Vorlage nutzt. Wir werden dies verwenden, um einen einfachen Audioanalysator in Rust zu booten, auf den über WebAssembly vom Browser aus zugegriffen werden kann.
Mit dem cargo
Tool, das mit der Rust-Kette geliefert wurde, können Sie cargo-generate
installieren:
cargo install cargo-generate
Sobald die Installation (die einige Minuten dauern kann) abgeschlossen ist, können wir unser Rust-Projekt erstellen.
Erstellen Sie unser WebAssembly-Modul
Aus dem Stammordner unserer App klonen wir die Projektvorlage:
$ cargo generate --git https://github.com/rustwasm/wasm-pack-template
Wenn wir nach einem neuen Projektnamen gefragt werden, geben wir wasm-audio
ein.
Im wasm-audio
Verzeichnis befindet sich nun eine Cargo.toml
-Datei mit folgendem Inhalt:
[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
wird verwendet, um ein Rust-Paket zu definieren (das Rust „Kiste“ nennt), das eine ähnliche Funktion für Rust-Apps erfüllt wie package.json
für JavaScript-Anwendungen.
Der Abschnitt [package]
definiert Metadaten, die verwendet werden, wenn das Paket in der offiziellen Paketregistrierung von Rust veröffentlicht wird.
Der Abschnitt [lib]
beschreibt das Ausgabeformat des Rust-Kompilierungsprozesses. Hier weist „cdylib“ Rust an, eine „dynamische Systembibliothek“ zu erstellen, die aus einer anderen Sprache (in unserem Fall JavaScript) geladen werden kann, und das Einfügen von „rlib“ weist Rust an, eine statische Bibliothek hinzuzufügen, die Metadaten über die produzierte Bibliothek enthält. Dieser zweite Spezifizierer ist für unsere Zwecke nicht erforderlich – er hilft bei der Entwicklung weiterer Rust-Module, die diese Kiste als Abhängigkeit verbrauchen – kann aber sicher belassen werden.
In [features]
bitten wir Rust, ein optionales Feature console_error_panic_hook
, um eine Funktionalität bereitzustellen, die den Mechanismus für unbehandelte Fehler von Rust (genannt panic
) in Konsolenfehler umwandelt, die in den Dev-Tools zum Debuggen angezeigt werden.
Schließlich listet [dependencies]
alle Crates auf, von denen dieser abhängt. Die einzige standardmäßig bereitgestellte Abhängigkeit ist wasm-bindgen
, die eine automatische Generierung von JavaScript-Bindungen für unser Wasm-Modul bereitstellt.
Implementieren Sie einen Tonhöhendetektor in Rust
Der Zweck dieser App ist es, die Stimme eines Musikers oder die Tonhöhe eines Instruments in Echtzeit zu erkennen. Damit dies so schnell wie möglich abläuft, wird ein WebAssembly-Modul mit der Berechnung der Tonhöhe beauftragt. Für die Tonhöhenerkennung einer einzelnen Stimme verwenden wir die „McLeod“-Tonhöhenmethode, die in der bestehenden Rust pitch-detection
implementiert ist.
Ähnlich wie der Node.js-Paketmanager (npm) enthält Rust einen eigenen Paketmanager namens Cargo. Dies ermöglicht die einfache Installation von Paketen, die in der Rust-Kistenregistrierung veröffentlicht wurden.
Um die Abhängigkeit hinzuzufügen, bearbeiten Cargo.toml
und fügen die Zeile für die pitch-detection
zum Abschnitt Abhängigkeiten hinzu:
[dependencies] wasm-bindgen = "0.2.63" pitch-detection = "0.1"
Dies weist Cargo an, die pitch-detection
während des nächsten cargo build
-Builds herunterzuladen und zu installieren, oder, da wir auf WebAssembly abzielen, wird dies im nächsten wasm-pack
durchgeführt.
Erstellen Sie einen JavaScript-aufrufbaren Tonhöhendetektor in Rust
Zuerst fügen wir eine Datei hinzu, die ein nützliches Dienstprogramm definiert, dessen Zweck wir später besprechen werden:
Erstellen Sie wasm-audio/src/utils.rs
und fügen Sie den Inhalt dieser Datei darin ein.
Wir ersetzen den generierten Code in wasm-audio/lib.rs
durch den folgenden Code, der die Tonhöhenerkennung über einen schnellen Fourier-Transformations-Algorithmus (FFT) durchführt:
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, } } }
Lassen Sie uns dies genauer untersuchen:
#[wasm_bindgen]
wasm_bindgen
ist ein Rust-Makro, das hilft, die Bindung zwischen JavaScript und Rust zu implementieren. Bei der Kompilierung zu WebAssembly weist dieses Makro den Compiler an, eine JavaScript-Bindung an eine Klasse zu erstellen. Der obige Rust-Code wird in JavaScript-Bindungen übersetzt, die einfach dünne Wrapper für Aufrufe in und aus dem Wasm-Modul sind. Die leichte Abstraktionsebene in Kombination mit dem direkten gemeinsamen Speicher zwischen JavaScript hilft Wasm dabei, eine hervorragende Leistung zu erzielen.
#[wasm_bindgen] pub struct WasmPitchDetector { sample_rate: usize, fft_size: usize, detector: McLeodDetector<f32>, } #[wasm_bindgen] impl WasmPitchDetector { ... }
Rust hat kein Konzept von Klassen. Vielmehr werden die Daten eines Objekts durch eine struct
und sein Verhalten durch impl
s oder trait
beschrieben.
Warum sollte die Tonhöhenerkennungsfunktion über ein Objekt und nicht über eine einfache Funktion verfügbar gemacht werden? Denn auf diese Weise initialisieren wir die vom internen McLeodDetector verwendeten Datenstrukturen nur einmal , während der Erstellung des WasmPitchDetector
. Dies hält die Funktion detect_pitch
schnell, indem eine teure Speicherzuordnung während des Betriebs vermieden wird.
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), } }
Wenn eine Rust-Anwendung auf einen Fehler stößt, den sie nicht einfach beheben kann, ist es ziemlich üblich, eine panic!
Makro. Dies weist Rust an, einen Fehler zu melden und die Anwendung sofort zu beenden. Der Einsatz von Panikattacken kann insbesondere für die frühe Entwicklung nützlich sein, bevor eine Fehlerbehandlungsstrategie vorhanden ist, da Sie damit falsche Annahmen schnell erkennen können.
Der einmalige Aufruf utils::set_panic_hook()
während des Setups stellt sicher, dass Panikmeldungen in den Browser-Entwicklungstools erscheinen.
Als nächstes definieren wir fft_pad
, die Menge an Nullauffüllung, die auf jede Analyse-FFT angewendet wird. Padding hilft in Kombination mit der vom Algorithmus verwendeten Windowing-Funktion, die Ergebnisse zu „glätten“, während sich die Analyse über die eingehenden gesampelten Audiodaten bewegt. Die Verwendung eines Pads mit halber FFT-Länge funktioniert für viele Instrumente gut.
Schließlich gibt Rust das Ergebnis der letzten Anweisung automatisch zurück, sodass die WasmPitchDetector
Strukturanweisung der Rückgabewert von new()
ist.
Der Rest unseres impl WasmPitchDetector
Rust-Codes definiert die API zum Erkennen von Tonhöhen:
pub fn detect_pitch(&mut self, audio_samples: Vec<f32>) -> f32 { ... }
So sieht eine Elementfunktionsdefinition in Rust aus. WasmPitchDetector wird ein öffentliches Mitglied detect_pitch
WasmPitchDetector
. Sein erstes Argument ist eine veränderliche Referenz ( &mut
) auf ein instanziiertes Objekt desselben Typs, das struct
- und impl
-Felder enthält – aber dies wird beim Aufruf automatisch übergeben, wie wir weiter unten sehen werden.
Darüber hinaus nimmt unsere Member-Funktion ein beliebig großes Array von 32-Bit-Gleitkommazahlen und gibt eine einzelne Zahl zurück. Hier ist dies die resultierende Tonhöhe, die über diese Samples berechnet wird (in 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()); }
Der obige Code erkennt, ob der Funktion genügend Samples bereitgestellt wurden, damit eine gültige Tonhöhenanalyse durchgeführt werden kann. Wenn nicht, die Rust panic!
Das Makro wird aufgerufen, was zum sofortigen Beenden von Wasm und zur Ausgabe der Fehlermeldung an die Browser-Dev-Tools-Konsole führt.
let optional_pitch = self.detector.get_pitch( &audio_samples, self.sample_rate, POWER_THRESHOLD, CLARITY_THRESHOLD, );
Dies ruft die Bibliothek des Drittanbieters auf, um die Tonhöhe aus den neuesten Audio-Samples zu berechnen. POWER_THRESHOLD
und CLARITY_THRESHOLD
können angepasst werden, um die Empfindlichkeit des Algorithmus zu optimieren.
Wir enden mit einer impliziten Rückgabe eines Fließkommawerts über das Schlüsselwort match
, das ähnlich wie eine switch
Anweisung in anderen Sprachen funktioniert. Some()
und None
können wir Fälle angemessen behandeln, ohne auf eine Nullzeiger-Ausnahme zu stoßen.
Erstellen von WebAssembly-Anwendungen
Bei der Entwicklung von Rust-Anwendungen besteht die übliche Build-Prozedur darin, einen Build mit cargo build
aufzurufen. Wir generieren jedoch ein Wasm-Modul, also verwenden wir wasm wasm-pack
, das eine einfachere Syntax bietet, wenn es auf Wasm abzielt. (Es ermöglicht auch die Veröffentlichung der resultierenden JavaScript-Bindungen in der npm-Registrierung, aber das würde den Rahmen dieses Tutorials sprengen.)
wasm-pack
unterstützt eine Vielzahl von Build-Targets. Da wir das Modul direkt von einem Web-Audio-Worklet verwenden, zielen wir auf die web
Option ab. Andere Ziele umfassen das Erstellen für einen Bundler wie Webpack oder für den Verbrauch von Node.js. Wir führen dies aus dem Unterverzeichnis wasm-audio/
aus:

wasm-pack build --target web
Bei Erfolg wird ein npm-Modul unter ./pkg
erstellt.
Dies ist ein JavaScript-Modul mit einer eigenen automatisch generierten package.json
. Dies kann bei Bedarf in der npm-Registrierung veröffentlicht werden. Um die Dinge vorerst einfach zu halten, können wir dieses Paket einfach kopieren und in unseren Ordner public/wasm-audio
pkg
:
cp -R ./wasm-audio/pkg ./public/wasm-audio
Damit haben wir ein Rust Wasm-Modul erstellt, das von der Web-App oder genauer gesagt von PitchProcessor
werden kann.
2. Unsere PitchProcessor
Klasse (basierend auf dem AudioWorkletProcessor
)
Für diese Anwendung verwenden wir einen Audioverarbeitungsstandard, der kürzlich weit verbreitete Browserkompatibilität erlangt hat. Insbesondere verwenden wir die Web-Audio-API und führen teure Berechnungen in einem benutzerdefinierten AudioWorkletProcessor
. Anschließend erstellen wir die entsprechende benutzerdefinierte AudioWorkletNode
-Klasse (die wir PitchNode
nennen) als Brücke zurück zum Hauptthread.
Erstellen Sie eine neue Datei public/PitchProcessor.js
und fügen Sie den folgenden Code darin ein:
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);
Der PitchProcessor
ist ein Begleiter des PitchNode
, wird jedoch in einem separaten Thread ausgeführt, sodass die Audioverarbeitungsberechnung durchgeführt werden kann, ohne die Arbeit am Hauptthread zu blockieren.
Hauptsächlich der PitchProcessor
:
- Verarbeitet das von PitchNode gesendete
"send-wasm-module"
PitchNode
durch Kompilieren und Laden des Wasm-Moduls in das Worklet. Sobald dies erledigt ist, teilt esPitchNode
dies mit, indem es ein"wasm-module-loaded"
-Ereignis sendet. Dieser Callback-Ansatz ist erforderlich, da die gesamte Kommunikation zwischenPitchNode
undPitchProcessor
eine Thread-Grenze überschreitet und nicht synchron ausgeführt werden kann. - Reagiert auch auf das
"init-detector"
-Ereignis vonPitchNode
indem es denWasmPitchDetector
konfiguriert. - Verarbeitet vom Browser-Audiodiagramm empfangene Audio-Samples, delegiert die Berechnung der Tonhöhenerkennung an das Wasm-Modul und sendet dann jede erkannte Tonhöhe zurück an
PitchNode
(der die Tonhöhe über seinenonPitchDetectedCallback
an die React-Schicht sendet). - Registriert sich unter einem bestimmten, eindeutigen Namen. Auf diese Weise weiß der Browser über die Basisklasse von
PitchNode
, dem nativenAudioWorkletNode
, wie er unserenPitchProcessor
später instanziiert, wennPitchNode
wird. SiehesetupAudio.js
.
Das folgende Diagramm visualisiert den Ereignisfluss zwischen PitchNode
und PitchProcessor
:
3. Fügen Sie Web-Audio-Worklet-Code hinzu
PitchNode.js
bietet die Schnittstelle zu unserer benutzerdefinierten Tonhöhenerkennungs-Audioverarbeitung. Das PitchNode
Objekt ist der Mechanismus, durch den Tonhöhen, die mithilfe des im AudioWorklet
Thread arbeitenden WebAssembly-Moduls erkannt werden, ihren Weg zum Hauptthread finden und zum Rendern reagieren.
In src/PitchNode.js
werden wir den integrierten AudioWorkletNode
der Web-Audio-API ableiten:
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); } } }
Die wichtigsten Aufgaben von PitchNode
sind:
- Senden Sie das WebAssembly-Modul als eine Folge von Rohbytes – die von
setupAudio.js
– an denPitchProcessor
, der imAudioWorklet
Thread ausgeführt wird. So lädt derPitchProcessor
das Tonhöhenerkennungs-Wasm-Modul. - Behandeln Sie das von
PitchProcessor
gesendete Ereignis, wenn es den Wasm erfolgreich kompiliert, und senden Sie ihm ein weiteres Ereignis, das ihm Konfigurationsinformationen zur Tonhöhenerkennung übergibt. - Behandeln Sie erkannte Tonhöhen, wenn sie vom
PitchProcessor
ankommen, und leiten Sie sie überonPitchDetectedCallback()
an die UI-FunktionsetLatestPitch()
weiter.
Hinweis: Dieser Code des Objekts wird im Hauptthread ausgeführt, daher sollte eine weitere Verarbeitung der erkannten Tonhöhen vermieden werden, falls dies teuer ist und zu Framerate-Einbrüchen führt.
4. Fügen Sie Code hinzu, um Web-Audio einzurichten
Damit die Webanwendung auf Live-Eingaben vom Mikrofon des Client-Computers zugreifen und diese verarbeiten kann, muss sie:
- Holen Sie die Erlaubnis des Benutzers ein, damit der Browser auf jedes angeschlossene Mikrofon zugreifen kann
- Greifen Sie auf die Ausgabe des Mikrofons als Audio-Stream-Objekt zu
- Fügen Sie Code hinzu, um die eingehenden Audio-Stream-Samples zu verarbeiten und eine Folge erkannter Tonhöhen zu erzeugen
In src/setupAudio.js
wir das und laden auch das Wasm-Modul asynchron, damit wir unseren PitchNode damit initialisieren können, bevor wir unseren PitchNode anhängen:
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.
Weiterführende Literatur im Toptal Engineering Blog:
- Web-Audio-API: Warum komponieren, wenn Sie programmieren können?
- WebVR Teil 3: Erschließen des Potenzials von WebAssembly und AssemblyScript