Tutorial WebAssembly/Rust: elaborazione audio perfetta
Pubblicato: 2022-03-11Supportato da tutti i browser moderni, WebAssembly (o "Wasm") sta trasformando il modo in cui sviluppiamo le esperienze degli utenti per il web. È un semplice formato eseguibile binario che consente l'esecuzione nel browser web di librerie o anche interi programmi scritti in altri linguaggi di programmazione.
Gli sviluppatori spesso cercano modi per essere più produttivi, ad esempio:
- Utilizzo di una singola base di codice dell'app per più piattaforme di destinazione, ma con l'esecuzione corretta dell'app su tutte
- Creazione di un'esperienza utente fluida e bella su ambienti desktop e mobili
- Sfruttare l'ecosistema di librerie open source per evitare di "reinventare la ruota" durante lo sviluppo dell'app
Per gli sviluppatori front-end, WebAssembly fornisce tutti e tre, rispondendo alla ricerca di un'interfaccia utente dell'app Web che rivaleggia davvero con l'esperienza mobile o desktop nativa. Consente anche l'uso di librerie scritte in linguaggi non JavaScript, come C++ o Go!
In questo tutorial di Wasm/Rust creeremo una semplice app per il rilevamento dell'intonazione, come un accordatore per chitarra. Utilizzerà le funzionalità audio integrate del browser e funzionerà a 60 frame al secondo (FPS), anche su dispositivi mobili. Non è necessario comprendere l'API Web Audio o anche avere familiarità con Rust per seguire questo tutorial; tuttavia, è previsto il comfort con JavaScript.
Nota: Sfortunatamente, al momento della stesura di questo articolo, la tecnica utilizzata in questo articolo, specifica per l'API Web Audio, non funziona ancora in Firefox. Pertanto, per il momento, Chrome, Chromium o Edge sono consigliati per questo tutorial, nonostante l'altrimenti eccellente supporto delle API Wasm e Web Audio in Firefox.
Di cosa tratta questo tutorial WebAssembly/Rust
- Creare una semplice funzione in Rust e chiamarla da JavaScript (tramite WebAssembly)
- Utilizzo della moderna API
AudioWorklet
del browser per l'elaborazione audio ad alte prestazioni nel browser - Comunicazione tra lavoratori in JavaScript
- Unendo il tutto in una semplice applicazione React
Nota: se sei più interessato al "come" che al "perché" di questo articolo, sentiti libero di passare direttamente al tutorial.
Perché Wasm?
Ci sono diversi motivi per cui potrebbe avere senso utilizzare WebAssembly:
- Consente di eseguire codice all'interno del browser che è stato scritto possibilmente in qualsiasi lingua .
- Ciò include l'utilizzo di librerie esistenti (numeriche, elaborazione audio, apprendimento automatico, ecc.) che sono scritte in linguaggi diversi da JavaScript.
- A seconda della scelta della lingua utilizzata, Wasm è in grado di operare a velocità quasi native. Questo ha il potenziale per avvicinare le caratteristiche delle prestazioni delle applicazioni Web alle esperienze native sia per dispositivi mobili che desktop .
Perché non usare sempre Wasm?
La popolarità di WebAssembly continuerà sicuramente a crescere; tuttavia, non è adatto a tutto lo sviluppo web:
- Per progetti semplici, attenersi a JavaScript, HTML e CSS probabilmente fornirà un prodotto funzionante in un tempo più breve.
- I browser meno recenti come Internet Explorer non supportano direttamente Wasm.
- Gli usi tipici di WebAssembly richiedono l'aggiunta di strumenti, come un compilatore di linguaggio, nella toolchain. Se il tuo team dà la priorità a mantenere gli strumenti di sviluppo e integrazione continua il più semplice possibile, l'utilizzo di Wasm andrà contro questo.
Perché un tutorial Wasm/Rust, in particolare?
Sebbene molti linguaggi di programmazione vengano compilati in Wasm, ho scelto Rust per questo esempio. Rust è stato creato da Mozilla nel 2010 e sta diventando sempre più popolare. Rust occupa il primo posto per "linguaggio più amato" nel sondaggio degli sviluppatori del 2020 di Stack Overflow. Ma i motivi per utilizzare Rust con WebAssembly vanno oltre la semplice tendenza:
- Innanzitutto, Rust ha un runtime ridotto, il che significa che viene inviato meno codice al browser quando un utente accede al sito, contribuendo a mantenere basso l'ingombro del sito Web.
- Rust ha un eccellente supporto Wasm, che supporta l'interoperabilità di alto livello con JavaScript.
- Rust offre prestazioni simili a C/C++ , ma ha un modello di memoria molto sicuro . Rispetto ad altri linguaggi, Rust esegue controlli di sicurezza aggiuntivi durante la compilazione del codice, riducendo notevolmente il rischio di arresti anomali causati da variabili vuote o non inizializzate. Ciò può comportare una gestione degli errori più semplice e una maggiore possibilità di mantenere una buona UX quando si verificano problemi imprevisti.
- La ruggine non viene raccolta nell'immondizia . Ciò significa che il codice Rust ha il pieno controllo di quando la memoria viene allocata e ripulita, consentendo prestazioni coerenti , un requisito fondamentale nei sistemi in tempo reale.
I numerosi vantaggi di Rust derivano anche da una curva di apprendimento ripida, quindi la scelta del linguaggio di programmazione giusto dipende da una varietà di fattori, come la composizione del team che svilupperà e manterrà il codice.
Prestazioni di WebAssembly: mantenimento di app Web fluide
Dato che stiamo programmando in WebAssembly con Rust, come potremmo utilizzare Rust per ottenere i vantaggi in termini di prestazioni che ci hanno portato a Wasm in primo luogo? Affinché un'applicazione con una GUI che si aggiorna rapidamente sia "fluida" per gli utenti, deve essere in grado di aggiornare il display con la stessa regolarità dell'hardware dello schermo. Si tratta in genere di 60 FPS, quindi la nostra applicazione deve essere in grado di ridisegnare la propria interfaccia utente entro ~16,7 ms (1.000 ms / 60 FPS).
La nostra applicazione rileva e mostra l'altezza attuale in tempo reale, il che significa che il calcolo combinato del rilevamento e il disegno dovrebbero rimanere entro 16,7 ms per frame. Nella prossima sezione, sfrutteremo il supporto del browser per analizzare l'audio su un altro thread mentre il thread principale fa il suo lavoro. Questa è una grande vittoria per le prestazioni, poiché il calcolo e il disegno hanno ciascuno 16,7 ms a loro disposizione.
Nozioni di base sull'audio web
In questa applicazione, utilizzeremo un modulo audio WebAssembly ad alte prestazioni per eseguire il rilevamento dell'intonazione. Inoltre, ci assicureremo che il calcolo non venga eseguito sul thread principale.
Perché non possiamo mantenere le cose semplici ed eseguire il rilevamento del passo sul thread principale?
- L'elaborazione audio è spesso ad alta intensità di calcolo. Ciò è dovuto al gran numero di campioni che devono essere elaborati ogni secondo. Ad esempio, il rilevamento affidabile del tono dell'audio richiede l'analisi degli spettri di 44.100 campioni al secondo.
- La compilazione JIT e la raccolta dei rifiuti di JavaScript si verificano sul thread principale e vogliamo evitarlo nel codice di elaborazione audio per prestazioni coerenti.
- Se il tempo impiegato per elaborare un frame di audio dovesse intaccare in modo significativo il budget del frame di 16,7 ms, l'UX soffrirebbe di animazioni instabili.
- Vogliamo che la nostra app funzioni senza problemi anche su dispositivi mobili a prestazioni inferiori!
I worklet Web Audio consentono alle app di continuare a raggiungere 60 FPS fluidi perché l'elaborazione audio non può reggere il thread principale. Se l'elaborazione dell'audio è troppo lenta e rimane indietro, ci saranno altri effetti, come il ritardo dell'audio. Tuttavia, l'UX rimarrà sensibile all'utente.
Tutorial WebAssembly/Rust: Guida introduttiva
Questo tutorial presuppone che tu abbia installato Node.js, oltre a npx
. Se non hai già npx
, puoi usare npm
(fornito con Node.js) per installarlo:
npm install -g npx
Crea un'app Web
Per questo tutorial su Wasm/Rust, useremo React.
In un terminale, eseguiremo i seguenti comandi:
npx create-react-app wasm-audio-app cd wasm-audio-app
Questo utilizza npx
per eseguire il comando create-react-app
(contenuto nel pacchetto corrispondente gestito da Facebook) per creare una nuova applicazione React nella directory wasm-audio-app
.
create-react-app
è una CLI per la generazione di applicazioni a pagina singola (SPA) basate su React. Rende incredibilmente facile iniziare un nuovo progetto con React. Tuttavia, il progetto di output include il codice standard che dovrà essere sostituito.
In primo luogo, anche se consiglio vivamente di testare l'applicazione durante lo sviluppo, il test va oltre lo scopo di questo tutorial. Quindi andremo avanti ed elimineremo src/App.test.js
e src/setupTests.js
.
Panoramica dell'applicazione
Ci saranno cinque componenti JavaScript principali nella nostra applicazione:
-
public/wasm-audio/wasm-audio.js
contiene collegamenti JavaScript al modulo Wasm che fornisce l'algoritmo di rilevamento del tono. -
public/PitchProcessor.js
è dove avviene l'elaborazione audio. Viene eseguito nel thread di rendering Web Audio e consumerà l'API Wasm. -
src/PitchNode.js
contiene un'implementazione di un nodo Web Audio, che è connesso al grafico Web Audio e viene eseguito nel thread principale. -
src/setupAudio.js
utilizza le API del browser Web per accedere a un dispositivo di registrazione audio disponibile. -
src/App.js
esrc/App.css
costituiscono l'interfaccia utente dell'applicazione.
Entriamo subito nel vivo della nostra applicazione e definiamo il codice Rust per il nostro modulo Wasm. Codificheremo quindi le varie parti del nostro JavaScript relativo all'audio Web e termineremo con l'interfaccia utente.
1. Rilevamento del passo mediante ruggine e WebAssembly
Il nostro codice Rust calcolerà un tono musicale da una serie di campioni audio.
Ottieni ruggine
Puoi seguire queste istruzioni per costruire la catena Rust per lo sviluppo.
Installa gli strumenti per la creazione di componenti WebAssembly in Rust
wasm-pack
consente di creare, testare e pubblicare componenti WebAssembly generati da Rust. Se non l'hai già fatto, installa wasm-pack.
cargo-generate
aiuta a far funzionare un nuovo progetto Rust sfruttando un repository Git preesistente come modello. Lo useremo per avviare un semplice analizzatore audio in Rust a cui è possibile accedere utilizzando WebAssembly dal browser.
Utilizzando lo strumento cargo
fornito con la catena Rust, puoi installare cargo-generate
:
cargo install cargo-generate
Una volta completata l'installazione (che potrebbe richiedere diversi minuti), siamo pronti per creare il nostro progetto Rust.
Crea il nostro modulo WebAssembly
Dalla cartella principale della nostra app, cloneremo il modello di progetto:
$ cargo generate --git https://github.com/rustwasm/wasm-pack-template
Quando viene richiesto un nuovo nome per il progetto, inseriremo wasm-audio
.
Nella directory wasm-audio
, ora ci sarà un file Cargo.toml
con i seguenti contenuti:
[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
viene utilizzato per definire un pacchetto Rust (che Rust chiama "cassa"), che svolge una funzione simile per le app Rust che package.json
fa per le applicazioni JavaScript.
La sezione [package]
definisce i metadati utilizzati durante la pubblicazione del pacchetto nel registro dei pacchetti ufficiale di Rust.
La sezione [lib]
descrive il formato di output dal processo di compilazione di Rust. Qui, "cdylib" dice a Rust di produrre una "libreria di sistema dinamica" che può essere caricata da un altro linguaggio (nel nostro caso, JavaScript) e includendo "rlib" dice a Rust di aggiungere una libreria statica contenente metadati sulla libreria prodotta. Questo secondo specificatore non è necessario per i nostri scopi - aiuta con lo sviluppo di ulteriori moduli Rust che consumano questa cassa come dipendenza - ma è sicuro lasciarlo dentro.
In [features]
, chiediamo a Rust di includere una funzionalità facoltativa console_error_panic_hook
per fornire funzionalità che convertano il meccanismo degli errori non gestiti di Rust (chiamato panic
) in errori di console che vengono visualizzati negli strumenti di sviluppo per il debug.
Infine, [dependencies]
elenca tutte le casse da cui dipende. L'unica dipendenza fornita immediatamente è wasm-bindgen
, che fornisce la generazione automatica di collegamenti JavaScript al nostro modulo Wasm.
Implementare un rilevatore di passo in Rust
Lo scopo di questa app è di essere in grado di rilevare la voce di un musicista o il tono di uno strumento in tempo reale. Per garantire che ciò avvenga il più rapidamente possibile, un modulo WebAssembly ha il compito di calcolare il passo. Per il rilevamento dell'intonazione a voce singola, utilizzeremo il metodo dell'intonazione "McLeod" implementato nella libreria pitch-detection
Rust esistente.
Proprio come il gestore di pacchetti Node.js (npm), Rust include un gestore di pacchetti proprio, chiamato Cargo. Ciò consente di installare facilmente i pacchetti che sono stati pubblicati nel registro delle casse di Rust.
Per aggiungere la dipendenza, modifica Cargo.toml
, aggiungendo la riga per pitch-detection
alla sezione delle dipendenze:
[dependencies] wasm-bindgen = "0.2.63" pitch-detection = "0.1"
Questo indica a Cargo di scaricare e installare la dipendenza pitch-detection
durante la prossima cargo build
o, dal momento che stiamo prendendo di mira WebAssembly, questo verrà eseguito nel prossimo wasm-pack
.
Crea un Pitch Detector richiamabile da JavaScript in Rust
Per prima cosa aggiungeremo un file che definisce un'utilità utile il cui scopo discuteremo in seguito:
Crea wasm-audio/src/utils.rs
e incollaci il contenuto di questo file.
Sostituiremo il codice generato in wasm-audio/lib.rs
con il codice seguente, che esegue il rilevamento dell'intonazione tramite un algoritmo di trasformata di Fourier veloce (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, } } }
Esaminiamo questo in modo più dettagliato:
#[wasm_bindgen]
wasm_bindgen
è una macro Rust che aiuta a implementare l'associazione tra JavaScript e Rust. Quando viene compilata in WebAssembly, questa macro indica al compilatore di creare un'associazione JavaScript a una classe. Il codice Rust sopra si tradurrà in binding JavaScript che sono semplicemente thin wrapper per le chiamate in entrata e in uscita dal modulo Wasm. Il leggero strato di astrazione combinato con la memoria condivisa diretta tra JavaScript è ciò che aiuta Wasm a fornire prestazioni eccellenti.
#[wasm_bindgen] pub struct WasmPitchDetector { sample_rate: usize, fft_size: usize, detector: McLeodDetector<f32>, } #[wasm_bindgen] impl WasmPitchDetector { ... }
Rust non ha un concetto di classi. Piuttosto, i dati di un oggetto sono descritti da una struct
e il suo comportamento attraverso impl
o trait
s.
Perché esporre la funzionalità di rilevamento dell'intonazione tramite un oggetto anziché una semplice funzione? Perché in questo modo, le strutture di dati utilizzate dal McLeodDetector interno vengono inizializzate solo una volta , durante la creazione di WasmPitchDetector
. Ciò mantiene veloce la funzione detect_pitch
evitando costose allocazioni di memoria durante il funzionamento.
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), } }
Quando un'applicazione Rust incontra un errore dal quale non può essere facilmente ripristinato, è abbastanza comune invocare il panic!
macro. Questo indica a Rust di segnalare un errore e di terminare immediatamente l'applicazione. L'uso del panico può essere utile in particolare per lo sviluppo iniziale prima che venga attuata una strategia di gestione degli errori in quanto consente di cogliere rapidamente le false ipotesi.
Chiamare utils::set_panic_hook()
una volta durante l'installazione assicurerà la visualizzazione di messaggi di panico negli strumenti di sviluppo del browser.
Successivamente, definiamo fft_pad
, la quantità di riempimento zero applicata a ciascuna analisi FFT. Il riempimento, in combinazione con la funzione di windowing utilizzata dall'algoritmo, aiuta a "smussare" i risultati mentre l'analisi si sposta sui dati audio campionati in ingresso. L'uso di un pad di metà della lunghezza FFT funziona bene per molti strumenti.
Infine, Rust restituisce automaticamente il risultato dell'ultima istruzione, quindi l'istruzione struct WasmPitchDetector
è il valore restituito di new()
.
Il resto del nostro codice impl WasmPitchDetector
Rust definisce l'API per il rilevamento dei toni:
pub fn detect_pitch(&mut self, audio_samples: Vec<f32>) -> f32 { ... }
Ecco come appare una definizione di funzione membro in Rust. Un membro pubblico detect_pitch
viene aggiunto a WasmPitchDetector
. Il suo primo argomento è un riferimento mutevole ( &mut
) a un oggetto istanziato dello stesso tipo contenente campi struct
e impl
, ma questo viene passato automaticamente durante la chiamata, come vedremo di seguito.
Inoltre, la nostra funzione membro prende una matrice di dimensioni arbitrarie di numeri in virgola mobile a 32 bit e restituisce un singolo numero. Qui, quella sarà l'altezza risultante calcolata su quei campioni (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()); }
Il codice precedente rileva se alla funzione sono stati forniti campioni sufficienti per eseguire un'analisi del passo valida. In caso contrario, il panic!
viene chiamata la macro che provoca l'uscita immediata da Wasm e il messaggio di errore stampato sulla console dev-tools del browser.
let optional_pitch = self.detector.get_pitch( &audio_samples, self.sample_rate, POWER_THRESHOLD, CLARITY_THRESHOLD, );
Questo chiama la libreria di terze parti per calcolare l'intonazione dagli ultimi campioni audio. POWER_THRESHOLD
e CLARITY_THRESHOLD
possono essere regolati per ottimizzare la sensibilità dell'algoritmo.
Concludiamo con un ritorno implicito di un valore in virgola mobile tramite la parola chiave match
, che funziona in modo simile a un'istruzione switch
in altre lingue. Some()
e None
ci consentono di gestire i casi in modo appropriato senza incorrere in un'eccezione di puntatore nullo.
Creazione di applicazioni WebAssembly
Quando si sviluppano applicazioni Rust, la normale procedura di build consiste nell'invocare una build utilizzando cargo build
. Tuttavia, stiamo generando un modulo Wasm, quindi utilizzeremo wasm-pack
, che fornisce una sintassi più semplice quando ci si rivolge a Wasm. (Consente inoltre di pubblicare i collegamenti JavaScript risultanti nel registro npm, ma non rientra nell'ambito di questo tutorial.)
wasm-pack
supporta una varietà di build target. Poiché utilizzeremo il modulo direttamente da un worklet Web Audio, punteremo all'opzione web
. Altri obiettivi includono la creazione per un bundler come webpack o per il consumo da Node.js. Lo eseguiremo dalla sottodirectory wasm-audio/
:

wasm-pack build --target web
In caso di successo, viene creato un modulo npm in ./pkg
.
Questo è un modulo JavaScript con il suo package.json
generato automaticamente. Questo può essere pubblicato nel registro npm se lo si desidera. Per semplificare le cose per ora, possiamo semplicemente copiare e incollare questo pkg
nella nostra cartella public/wasm-audio
:
cp -R ./wasm-audio/pkg ./public/wasm-audio
Con ciò, abbiamo creato un modulo Rust Wasm pronto per essere consumato dalla web app, o più precisamente, da PitchProcessor
.
2. La nostra classe PitchProcessor
(basata sul Native AudioWorkletProcessor
)
Per questa applicazione utilizzeremo uno standard di elaborazione audio che ha recentemente ottenuto un'ampia compatibilità con i browser. In particolare, utilizzeremo l'API Web Audio ed eseguiremo calcoli costosi in un AudioWorkletProcessor
personalizzato. Successivamente creeremo la corrispondente classe AudioWorkletNode
personalizzata (che chiameremo PitchNode
) come ponte di ritorno al thread principale.
Crea un nuovo file public/PitchProcessor.js
e incolla il seguente codice al suo interno:
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
è un compagno di PitchNode
ma viene eseguito in un thread separato in modo che il calcolo dell'elaborazione audio possa essere eseguito senza bloccare il lavoro svolto sul thread principale.
Principalmente, il PitchProcessor
:
- Gestisce l'evento
"send-wasm-module"
inviato daPitchNode
compilando e caricando il modulo Wasm nel worklet. Una volta fatto,PitchNode
inviando un"wasm-module-loaded"
. Questo approccio di callback è necessario perché tutte le comunicazioni traPitchNode
ePitchProcessor
attraversano un limite di thread e non possono essere eseguite in modo sincrono. - Risponde anche all'evento
"init-detector"
diPitchNode
configurandoWasmPitchDetector
. - Elabora i campioni audio ricevuti dal grafico audio del browser, delega il calcolo del rilevamento dell'intonazione al modulo Wasm, quindi invia l'intonazione rilevata a
PitchNode
(che invia l'intonazione al livello React tramite il suoonPitchDetectedCallback
). - Si registra con un nome specifico e univoco. In questo modo il browser sa, tramite la classe base di
PitchNode
, il nativoAudioWorkletNode
, come creare un'istanza del nostroPitchProcessor
in un secondo momento, quandoPitchNode
viene costruito. VedisetupAudio.js
.
Il diagramma seguente visualizza il flusso di eventi tra PitchNode
e PitchProcessor
:
3. Aggiungere il codice Worklet Audio Web
PitchNode.js
fornisce l'interfaccia per la nostra elaborazione audio personalizzata di rilevamento dell'intonazione. L'oggetto PitchNode
è il meccanismo per cui le altezze rilevate utilizzando il modulo WebAssembly che lavora nel thread AudioWorklet
si dirigono verso il thread principale e Reagiscono per il rendering.
In src/PitchNode.js
, sottoclasseremo l' AudioWorkletNode
integrato dell'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); } } }
Le attività chiave svolte da PitchNode
sono:
- Invia il modulo WebAssembly come sequenza di byte grezzi, quelli passati da
setupAudio.js
, aPitchProcessor
, che viene eseguito sul threadAudioWorklet
. Ecco come ilPitchProcessor
carica il modulo Wasm di rilevamento dell'intonazione. - Gestisci l'evento inviato da
PitchProcessor
quando compila correttamente il Wasm e inviagli un altro evento che gli passa le informazioni di configurazione di rilevamento del passo. - Gestisci le tonalità rilevate quando arrivano da
PitchProcessor
e inoltrale alla funzione dell'interfaccia utentesetLatestPitch()
tramiteonPitchDetectedCallback()
.
Nota: questo codice dell'oggetto viene eseguito sul thread principale, quindi dovrebbe evitare di eseguire ulteriori elaborazioni sui passi rilevati nel caso in cui ciò sia costoso e causi cali di frame rate.
4. Aggiungi il codice per configurare l'audio web
Affinché l'applicazione Web possa accedere ed elaborare l'input live dal microfono della macchina client, deve:
- Ottieni l'autorizzazione dell'utente affinché il browser acceda a qualsiasi microfono connesso
- Accedi all'uscita del microfono come oggetto flusso audio
- Allega il codice per elaborare i campioni del flusso audio in ingresso e produrre una sequenza di altezze rilevate
In src/setupAudio.js
, lo faremo e caricheremo anche il modulo Wasm in modo asincrono in modo da poter inizializzare il nostro PitchNode con esso, prima di collegare il nostro 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.
Ulteriori letture sul blog di Toptal Engineering:
- API Web Audio: perché comporre quando puoi codificare?
- WebVR Parte 3: Sbloccare il potenziale di WebAssembly e AssemblyScript