Tutoriel WebAssembly/Rust : traitement audio parfait
Publié: 2022-03-11Pris en charge par tous les navigateurs modernes, WebAssembly (ou « Wasm ») transforme la façon dont nous développons des expériences utilisateur pour le Web. Il s'agit d'un simple format exécutable binaire qui permet à des bibliothèques ou même à des programmes entiers écrits dans d'autres langages de programmation de s'exécuter dans le navigateur Web.
Les développeurs recherchent souvent des moyens d'être plus productifs, tels que :
- Utilisation d'une seule base de code d'application pour plusieurs plates-formes cibles, mais exécution correcte de l'application sur chacune d'entre elles
- Créer une UX fluide et belle sur les environnements de bureau et mobiles
- Tirer parti de l'écosystème de la bibliothèque open source pour éviter de "réinventer la roue" lors du développement de l'application
Pour les développeurs front-end, WebAssembly fournit les trois, répondant à la recherche d'une interface utilisateur d'application Web qui rivalise vraiment avec l'expérience mobile ou de bureau native. Il permet même l'utilisation de bibliothèques écrites dans des langages non JavaScript, tels que C++ ou Go!
Dans ce didacticiel Wasm/Rust, nous allons créer une simple application de détection de hauteur, comme un accordeur de guitare. Il utilisera les capacités audio intégrées du navigateur et fonctionnera à 60 images par seconde (FPS), même sur les appareils mobiles. Vous n'avez pas besoin de comprendre l'API Web Audio ni même de connaître Rust pour suivre ce didacticiel. cependant, une aisance avec JavaScript est attendue.
Remarque : Malheureusement, au moment d'écrire ces lignes, la technique utilisée dans cet article, spécifique à l'API Web Audio, ne fonctionne pas encore dans Firefox. Par conséquent, pour le moment, Chrome, Chromium ou Edge sont recommandés pour ce didacticiel, malgré l'excellente prise en charge de l'API Wasm et Web Audio dans Firefox.
Ce que couvre ce tutoriel WebAssembly/Rust
- Créer une fonction simple dans Rust et l'appeler depuis JavaScript (via WebAssembly)
- Utilisation de l'API
AudioWorklet
moderne du navigateur pour un traitement audio haute performance dans le navigateur - Communiquer entre les travailleurs en JavaScript
- Rassembler le tout dans une application React simple
Remarque : Si vous êtes plus intéressé par le « comment » que par le « pourquoi » de cet article, n'hésitez pas à vous lancer directement dans le didacticiel.
Pourquoi Wasm ?
Il y a plusieurs raisons pour lesquelles il peut être judicieux d'utiliser WebAssembly :
- Il permet d'exécuter du code à l'intérieur du navigateur qui a été écrit dans pratiquement n'importe quelle langue .
- Cela inclut l'utilisation de bibliothèques existantes (numériques, traitement audio, apprentissage automatique, etc.) qui sont écrites dans des langages autres que JavaScript.
- Selon le choix de la langue utilisée, Wasm est capable de fonctionner à des vitesses quasi natives. Cela a le potentiel de rapprocher les caractéristiques de performance des applications Web des expériences natives pour les mobiles et les ordinateurs de bureau .
Pourquoi ne pas toujours utiliser Wasm ?
La popularité de WebAssembly continuera sûrement de croître ; cependant, il ne convient pas à tous les développements Web :
- Pour les projets simples, s'en tenir à JavaScript, HTML et CSS fournira probablement un produit fonctionnel dans un délai plus court.
- Les navigateurs plus anciens tels qu'Internet Explorer ne prennent pas directement en charge Wasm.
- Les utilisations typiques de WebAssembly nécessitent l'ajout d'outils, tels qu'un compilateur de langage, dans votre chaîne d'outils. Si votre équipe donne la priorité à la simplicité des outils de développement et d'intégration continue, l'utilisation de Wasm ira à l'encontre de cela.
Pourquoi un tutoriel Wasm/Rust, en particulier ?
Alors que de nombreux langages de programmation se compilent en Wasm, j'ai choisi Rust pour cet exemple. Rust a été créé par Mozilla en 2010 et gagne en popularité. Rust occupe la première place du «langage le plus aimé» dans l'enquête auprès des développeurs 2020 de Stack Overflow. Mais les raisons d'utiliser Rust avec WebAssembly vont au-delà de la simple tendance :
- Tout d'abord, Rust a une petite durée d'exécution, ce qui signifie que moins de code est envoyé au navigateur lorsqu'un utilisateur accède au site, ce qui contribue à réduire l'empreinte du site Web.
- Rust a un excellent support Wasm, prenant en charge une interopérabilité de haut niveau avec JavaScript.
- Rust fournit des performances proches du niveau C/C++ , tout en ayant un modèle de mémoire très sûr . Par rapport à d'autres langages, Rust effectue des contrôles de sécurité supplémentaires lors de la compilation de votre code, ce qui réduit considérablement le risque de plantages causés par des variables vides ou non initialisées. Cela peut conduire à une gestion des erreurs plus simple et à une plus grande chance de maintenir une bonne UX lorsque des problèmes imprévus surviennent.
- Rust n'est pas ramassé . Cela signifie que le code Rust contrôle entièrement le moment où la mémoire est allouée et nettoyée, permettant des performances constantes , une exigence clé dans les systèmes en temps réel.
Les nombreux avantages de Rust s'accompagnent également d'une courbe d'apprentissage abrupte. Le choix du bon langage de programmation dépend donc de divers facteurs, tels que la composition de l'équipe qui développera et maintiendra le code.
Performances WebAssembly : Maintenir des applications Web fluides
Puisque nous programmons en WebAssembly avec Rust, comment pourrions-nous utiliser Rust pour obtenir les avantages de performances qui nous ont conduits à Wasm en premier lieu ? Pour qu'une application avec une interface graphique à mise à jour rapide soit "fluide" pour les utilisateurs, elle doit être capable de rafraîchir l'affichage aussi régulièrement que le matériel de l'écran. Il s'agit généralement de 60 FPS, donc notre application doit être capable de redessiner son interface utilisateur dans un délai d'environ 16,7 ms (1 000 ms / 60 FPS).
Notre application détecte et affiche la hauteur actuelle en temps réel, ce qui signifie que le calcul de détection et le dessin combinés devraient rester dans les 16,7 ms par image. Dans la section suivante, nous tirerons parti de la prise en charge du navigateur pour analyser l'audio sur un autre thread pendant que le thread principal fait son travail. C'est un gain de performance majeur puisque le calcul et le dessin disposent alors chacun de 16,7 ms.
Bases de l'audio Web
Dans cette application, nous utiliserons un module audio WebAssembly hautes performances pour effectuer la détection de hauteur. De plus, nous nous assurerons que le calcul ne s'exécute pas sur le thread principal.
Pourquoi ne pouvons-nous pas garder les choses simples et effectuer une détection de hauteur sur le thread principal ?
- Le traitement audio est souvent gourmand en calculs. Cela est dû au grand nombre d'échantillons qui doivent être traités chaque seconde. Par exemple, la détection fiable de la hauteur audio nécessite l'analyse des spectres de 44 100 échantillons par seconde.
- La compilation JIT et la récupération de place de JavaScript se produisent sur le thread principal, et nous voulons éviter cela dans le code de traitement audio pour des performances cohérentes.
- Si le temps nécessaire au traitement d'une image audio devait peser de manière significative sur le budget d'image de 16,7 ms, l'UX souffrirait d'une animation saccadée.
- Nous voulons que notre application fonctionne sans problème, même sur des appareils mobiles moins performants !
Les worklets Web Audio permettent aux applications de continuer à atteindre une fluidité de 60 FPS, car le traitement audio ne peut pas retenir le fil principal. Si le traitement audio est trop lent et prend du retard, il y aura d'autres effets, tels qu'un son en retard. Cependant, l'UX restera réactif à l'utilisateur.
Tutoriel WebAssembly/Rust : Mise en route
Ce didacticiel suppose que vous avez installé Node.js, ainsi que npx
. Si vous n'avez pas encore npx
, vous pouvez utiliser npm
(fourni avec Node.js) pour l'installer :
npm install -g npx
Créer une application Web
Pour ce tutoriel Wasm/Rust, nous utiliserons React.
Dans un terminal, nous exécuterons les commandes suivantes :
npx create-react-app wasm-audio-app cd wasm-audio-app
Cela utilise npx
pour exécuter la commande create-react-app
(contenue dans le package correspondant maintenu par Facebook) pour créer une nouvelle application React dans le répertoire wasm-audio-app
.
create-react-app
est une CLI pour générer des applications monopage (SPA) basées sur React. Il est incroyablement facile de démarrer un nouveau projet avec React. Cependant, le projet de sortie comprend un code passe-partout qui devra être remplacé.
Tout d'abord, bien que je recommande fortement de tester unitairement votre application tout au long du développement, les tests dépassent le cadre de ce didacticiel. Nous allons donc continuer et supprimer src/App.test.js
et src/setupTests.js
.
Aperçu des applications
Il y aura cinq principaux composants JavaScript dans notre application :
-
public/wasm-audio/wasm-audio.js
contient des liaisons JavaScript au module Wasm fournissant l'algorithme de détection de hauteur. -
public/PitchProcessor.js
est l'endroit où le traitement audio se produit. Il s'exécute dans le thread de rendu Web Audio et consommera l'API Wasm. -
src/PitchNode.js
contient une implémentation d'un nœud Web Audio, qui est connecté au graphique Web Audio et s'exécute dans le thread principal. -
src/setupAudio.js
utilise des API de navigateur Web pour accéder à un périphérique d'enregistrement audio disponible. -
src/App.js
etsrc/App.css
comprennent l'interface utilisateur de l'application.
Plongeons directement au cœur de notre application et définissons le code Rust pour notre module Wasm. Nous coderons ensuite les différentes parties de notre JavaScript lié à Web Audio et terminerons avec l'interface utilisateur.
1. Détection du pitch à l'aide de Rust et WebAssembly
Notre code Rust calculera une hauteur musicale à partir d'un ensemble d'échantillons audio.
Obtenez de la rouille
Vous pouvez suivre ces instructions pour construire la chaîne Rust pour le développement.
Installer des outils pour créer des composants WebAssembly dans Rust
wasm-pack
vous permet de créer, tester et publier des composants WebAssembly générés par Rust. Si vous ne l'avez pas déjà fait, installez wasm-pack.
cargo-generate
aide à mettre en place un nouveau projet Rust en exploitant un référentiel Git préexistant comme modèle. Nous allons l'utiliser pour amorcer un analyseur audio simple dans Rust accessible à l'aide de WebAssembly à partir du navigateur.
À l'aide de l'outil cargo
fourni avec la chaîne Rust, vous pouvez installer cargo-generate
:
cargo install cargo-generate
Une fois l'installation (qui peut prendre plusieurs minutes) terminée, nous sommes prêts à créer notre projet Rust.
Créer notre module WebAssembly
À partir du dossier racine de notre application, nous allons cloner le modèle de projet :
$ cargo generate --git https://github.com/rustwasm/wasm-pack-template
Lorsque vous êtes invité à entrer un nouveau nom de projet, nous entrerons wasm-audio
.
Dans le wasm-audio
, il y aura maintenant un fichier Cargo.toml
avec le contenu suivant :
[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
est utilisé pour définir un package Rust (que Rust appelle une "caisse"), servant une fonction similaire pour les applications Rust que package.json
fait pour les applications JavaScript.
La section [package]
définit les métadonnées utilisées lors de la publication du package dans le registre officiel des packages de Rust.
La section [lib]
décrit le format de sortie du processus de compilation Rust. Ici, "cdylib" dit à Rust de produire une "bibliothèque système dynamique" qui peut être chargée à partir d'un autre langage (dans notre cas, JavaScript) et incluant "rlib" dit à Rust d'ajouter une bibliothèque statique contenant des métadonnées sur la bibliothèque produite. Ce deuxième spécificateur n'est pas nécessaire pour nos besoins - il aide au développement d'autres modules Rust qui consomment cette caisse en tant que dépendance - mais peut être laissé en toute sécurité.
Dans [features]
, nous demandons à Rust d'inclure une fonctionnalité optionnelle console_error_panic_hook
pour fournir une fonctionnalité qui convertit le mécanisme d'erreurs non gérées de Rust (appelé panic
) en erreurs de console qui apparaissent dans les outils de développement pour le débogage.
Enfin, [dependencies]
répertorie toutes les caisses dont celle-ci dépend. La seule dépendance fournie prête à l'emploi est wasm-bindgen
, qui fournit la génération automatique de liaisons JavaScript à notre module Wasm.
Implémenter un détecteur de pas dans Rust
Le but de cette application est de pouvoir détecter la voix d'un musicien ou la hauteur d'un instrument en temps réel. Pour garantir que cela s'exécute le plus rapidement possible, un module WebAssembly est chargé de calculer le pitch. Pour la détection de hauteur d'une seule voix, nous utiliserons la méthode de hauteur "McLeod" qui est implémentée dans la bibliothèque pitch-detection
Rust existante.
Tout comme le gestionnaire de packages Node.js (npm), Rust inclut son propre gestionnaire de packages, appelé Cargo. Cela permet d'installer facilement des packages qui ont été publiés dans le registre Rust crate.
Pour ajouter la dépendance, modifiez Cargo.toml
, en ajoutant la ligne pour pitch-detection
à la section des dépendances :
[dependencies] wasm-bindgen = "0.2.63" pitch-detection = "0.1"
Cela demande à Cargo de télécharger et d'installer la dépendance pitch-detection
lors de la prochaine cargo build
ou, puisque nous ciblons WebAssembly, cela sera effectué dans le prochain wasm-pack
.
Créer un détecteur de pitch appelable par JavaScript dans Rust
Nous allons d'abord ajouter un fichier définissant un utilitaire utile dont nous parlerons plus tard :
Créez wasm-audio/src/utils.rs
et collez-y le contenu de ce fichier.
Nous remplacerons le code généré dans wasm-audio/lib.rs
par le code suivant, qui effectue la détection de hauteur via un algorithme de transformée de Fourier rapide (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, } } }
Examinons cela plus en détail :
#[wasm_bindgen]
wasm_bindgen
est une macro Rust qui aide à implémenter la liaison entre JavaScript et Rust. Lorsqu'elle est compilée en WebAssembly, cette macro demande au compilateur de créer une liaison JavaScript à une classe. Le code Rust ci-dessus se traduira par des liaisons JavaScript qui sont simplement des enveloppes minces pour les appels vers et depuis le module Wasm. La légère couche d'abstraction combinée à la mémoire partagée directe entre JavaScript est ce qui permet à Wasm d'offrir d'excellentes performances.
#[wasm_bindgen] pub struct WasmPitchDetector { sample_rate: usize, fft_size: usize, detector: McLeodDetector<f32>, } #[wasm_bindgen] impl WasmPitchDetector { ... }
Rust n'a pas de concept de classes. Au lieu de cela, les données d'un objet sont décrites par une struct
et son comportement par des impl
ou des trait
.
Pourquoi exposer la fonctionnalité de détection de hauteur via un objet plutôt qu'une simple fonction ? Parce que de cette façon, nous n'initialisons qu'une seule fois les structures de données utilisées par le McLeodDetector interne, lors de la création du WasmPitchDetector
. Cela maintient la fonction detect_pitch
rapide en évitant une allocation de mémoire coûteuse pendant le fonctionnement.
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), } }
Lorsqu'une application Rust rencontre une erreur dont elle ne peut pas facilement se remettre, il est assez courant d'invoquer une panic!
macro. Cela indique à Rust de signaler une erreur et de terminer l'application immédiatement. L'utilisation des paniques peut être particulièrement utile pour le développement précoce avant qu'une stratégie de gestion des erreurs ne soit en place, car elle vous permet de détecter rapidement les fausses hypothèses.
Appeler utils::set_panic_hook()
une fois lors de l'installation garantira l'apparition de messages de panique dans les outils de développement du navigateur.
Ensuite, nous définissons fft_pad
, la quantité de zéro-padding appliquée à chaque FFT d'analyse. Le rembourrage, en combinaison avec la fonction de fenêtrage utilisée par l'algorithme, aide à "lisser" les résultats au fur et à mesure que l'analyse se déplace sur les données audio échantillonnées entrantes. L'utilisation d'un pad de la moitié de la longueur FFT fonctionne bien pour de nombreux instruments.
Enfin, Rust renvoie automatiquement le résultat de la dernière instruction, de sorte que l'instruction struct WasmPitchDetector
est la valeur de retour de new()
.
Le reste de notre code impl WasmPitchDetector
Rust définit l'API pour détecter les pitchs :
pub fn detect_pitch(&mut self, audio_samples: Vec<f32>) -> f32 { ... }
Voici à quoi ressemble une définition de fonction membre dans Rust. Un membre public detect_pitch
est ajouté à WasmPitchDetector
. Son premier argument est une référence mutable ( &mut
) à un objet instancié du même type contenant les champs struct
et impl
, mais cela est passé automatiquement lors de l'appel, comme nous le verrons ci-dessous.
De plus, notre fonction membre prend un tableau de taille arbitraire de nombres à virgule flottante 32 bits et renvoie un seul nombre. Ici, ce sera la hauteur résultante calculée sur ces échantillons (en 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()); }
Le code ci-dessus détecte si suffisamment d'échantillons ont été fournis à la fonction pour qu'une analyse de hauteur valide soit effectuée. Sinon, la panic!
La macro est appelée, ce qui entraîne une sortie immédiate de Wasm et le message d'erreur est imprimé sur la console des outils de développement du navigateur.
let optional_pitch = self.detector.get_pitch( &audio_samples, self.sample_rate, POWER_THRESHOLD, CLARITY_THRESHOLD, );
Cela appelle la bibliothèque tierce pour calculer la hauteur à partir des derniers échantillons audio. POWER_THRESHOLD
et CLARITY_THRESHOLD
peuvent être ajustés pour régler la sensibilité de l'algorithme.
Nous terminons par un retour implicite d'une valeur à virgule flottante via le mot-clé match
, qui fonctionne de manière similaire à une instruction switch
dans d'autres langages. Some()
et None
nous permettent de gérer les cas de manière appropriée sans tomber dans une exception de pointeur nul.
Création d'applications WebAssembly
Lors du développement d'applications Rust, la procédure de construction habituelle consiste à invoquer une construction à l'aide cargo build
. Cependant, nous générons un module Wasm, nous utiliserons wasm-pack
, qui fournit une syntaxe plus simple lors du ciblage de Wasm. (Cela permet également de publier les liaisons JavaScript résultantes dans le registre npm, mais cela sort du cadre de ce didacticiel.)

wasm-pack
prend en charge une variété de cibles de construction. Comme nous allons consommer le module directement depuis une worklet Web Audio, nous allons cibler l'option web
. D'autres cibles incluent la création d'un bundler tel que webpack ou pour la consommation à partir de Node.js. Nous allons l'exécuter à partir du sous-répertoire wasm-audio/
:
wasm-pack build --target web
En cas de succès, un module npm est créé sous ./pkg
.
Il s'agit d'un module JavaScript avec son propre package.json
généré automatiquement. Cela peut être publié dans le registre npm si vous le souhaitez. Pour garder les choses simples pour l'instant, nous pouvons simplement copier et coller ce pkg
dans notre dossier public/wasm-audio
:
cp -R ./wasm-audio/pkg ./public/wasm-audio
Avec cela, nous avons créé un module Rust Wasm prêt à être consommé par l'application Web, ou plus précisément par PitchProcessor
.
2. Notre classe PitchProcessor
(Basée sur le Native AudioWorkletProcessor
)
Pour cette application, nous utiliserons une norme de traitement audio qui a récemment gagné en compatibilité avec les navigateurs. Plus précisément, nous utiliserons l'API Web Audio et exécuterons des calculs coûteux dans un AudioWorkletProcessor
personnalisé. Ensuite, nous créerons la classe AudioWorkletNode
personnalisée correspondante (que nous appellerons PitchNode
) en tant que pont vers le thread principal.
Créez un nouveau fichier public/PitchProcessor.js
et collez-y le code suivant :
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);
Le PitchProcessor
est un compagnon du PitchNode
mais s'exécute dans un thread séparé afin que le calcul du traitement audio puisse être effectué sans bloquer le travail effectué sur le thread principal.
Principalement, le PitchProcessor
:
- Gère l'événement
"send-wasm-module"
envoyé depuisPitchNode
en compilant et en chargeant le module Wasm dans le worklet. Une fois cela fait, il le fait savoir àPitchNode
en envoyant un"wasm-module-loaded"
. Cette approche de rappel est nécessaire car toutes les communications entrePitchNode
etPitchProcessor
traversent une limite de thread et ne peuvent pas être effectuées de manière synchrone. - Répond également à l'événement
"init-detector"
dePitchNode
en configurantWasmPitchDetector
. - Traite les échantillons audio reçus du graphique audio du navigateur, délègue le calcul de détection de hauteur au module Wasm, puis renvoie toute hauteur détectée à
PitchNode
(qui envoie la hauteur à la couche React via sononPitchDetectedCallback
). - S'enregistre sous un nom spécifique et unique. De cette façon, le navigateur sait, via la classe de base de
PitchNode
, leAudioWorkletNode
natif, comment instancier notrePitchProcessor
plus tard lorsquePitchNode
est construit. VoirsetupAudio.js
.
Le diagramme suivant visualise le flux d'événements entre le PitchNode
et le PitchProcessor
:
3. Ajouter un code de travail audio Web
PitchNode.js
fournit l'interface de notre traitement audio de détection de hauteur personnalisé. L'objet PitchNode
est le mécanisme par lequel les hauteurs détectées à l'aide du module WebAssembly fonctionnant dans le thread AudioWorklet
seront acheminées vers le thread principal et React pour le rendu.
Dans src/PitchNode.js
, nous allons sous-classer le AudioWorkletNode
de l'API Web Audio :
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); } } }
Les principales tâches effectuées par PitchNode
sont :
- Envoyez le module WebAssembly sous la forme d'une séquence d'octets bruts (ceux transmis depuis
setupAudio.js
auPitchProcessor
, qui s'exécute sur le threadAudioWorklet
. C'est ainsi que lePitchProcessor
charge le module Wasm de détection de hauteur. - Gérez l'événement envoyé par
PitchProcessor
lorsqu'il compile avec succès le Wasm et envoyez-lui un autre événement qui lui transmet les informations de configuration de détection de hauteur. - Gérez les hauteurs détectées à mesure qu'elles arrivent du
PitchProcessor
et transférez-les à la fonction d'interface utilisateursetLatestPitch()
viaonPitchDetectedCallback()
.
Remarque : Ce code de l'objet s'exécute sur le thread principal, il doit donc éviter d'effectuer un traitement supplémentaire sur les hauteurs détectées au cas où cela coûterait cher et entraînerait des baisses de fréquence d'images.
4. Ajouter du code pour configurer Web Audio
Pour que l'application Web accède et traite les entrées en direct du microphone de la machine cliente, elle doit :
- Obtenir l'autorisation de l'utilisateur pour que le navigateur accède à n'importe quel microphone connecté
- Accéder à la sortie du microphone en tant qu'objet de flux audio
- Attachez du code pour traiter les échantillons de flux audio entrants et produire une séquence de hauteurs détectées
Dans src/setupAudio.js
, nous ferons cela, et chargerons également le module Wasm de manière asynchrone afin que nous puissions initialiser notre PitchNode avec, avant d'attacher notre 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.
Lectures complémentaires sur le blog Toptal Engineering :
- API Web Audio : pourquoi composer quand on peut coder ?
- WebVR Partie 3 : Libérer le potentiel de WebAssembly et AssemblyScript