Tutorial WebAssembly/Rust: elaborazione audio perfetta

Pubblicato: 2022-03-11

Supportato 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 e src/App.css costituiscono l'interfaccia utente dell'applicazione.

Un diagramma di flusso per l'app di rilevamento del passo. I blocchi 1 e 2 vengono eseguiti sul thread Web Audio. Il blocco 1 è il rilevatore di passo Wasm (Rust), nel file wasm-audio/lib.rs. Il blocco 2 è Web Audio Detection + Communication, nel file PitchProcessor.js. Chiede al rilevatore di inizializzarsi e il rilevatore invia le tonalità rilevate all'interfaccia Web Audio. I blocchi 3, 4 e 5 vengono eseguiti sul thread principale. Block 3 è il Web Audio Controller, nel file PitchNode.js. Invia il modulo Wasm a PitchProcessor.js e riceve da esso i toni rilevati. Il blocco 4 è Configurazione audio Web, in setupAudio.js. Crea un oggetto PitchNode. Block 5 è l'interfaccia utente dell'applicazione Web, composta da App.js e App.css. All'avvio chiama setupAudio.js. Sospende o riprende anche la registrazione audio inviando un messaggio a PitchNode, dal quale riceve le tonalità rilevate da visualizzare all'utente.
Panoramica dell'app audio Wasm.

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 da PitchNode 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 tra PitchNode e PitchProcessor attraversano un limite di thread e non possono essere eseguite in modo sincrono.
  • Risponde anche all'evento "init-detector" di PitchNode configurando WasmPitchDetector .
  • 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 suo onPitchDetectedCallback ).
  • Si registra con un nome specifico e univoco. In questo modo il browser sa, tramite la classe base di PitchNode , il nativo AudioWorkletNode , come creare un'istanza del nostro PitchProcessor in un secondo momento, quando PitchNode viene costruito. Vedi setupAudio.js .

Il diagramma seguente visualizza il flusso di eventi tra PitchNode e PitchProcessor :

Un diagramma di flusso più dettagliato che confronta le interazioni tra gli oggetti PitchNode e PitchProcess in fase di esecuzione. Durante la configurazione iniziale, PitchNode invia il modulo Wasm come una matrice di byte a PitchProcessor, che li compila e li rimanda a PitchNode, che alla fine risponde con un messaggio di evento che richiede che PitchProcessor si inizializzi da solo. Durante la registrazione dell'audio, PitchNode non invia nulla e riceve due tipi di messaggi di evento da PitchProcessor: un tono rilevato o un errore, se si verifica da Wasm o dal worklet.
Messaggi di eventi di runtime.

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 , a PitchProcessor , che viene eseguito sul thread AudioWorklet . Ecco come il PitchProcessor 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 utente setLatestPitch() tramite onPitchDetectedCallback() .

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:

  1. Ottieni l'autorizzazione dell'utente affinché il browser acceda a qualsiasi microfono connesso
  2. Accedi all'uscita del microfono come oggetto flusso audio
  3. 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:

At wasm_audio.js line 24 there's the error, "Uncaught ReferenceError: TextDecoder is not defined," followed by one at setupAudio.js line 84 triggered by the async onClick from App.js line 43, which reads, "Uncaught (in promise) Error: Failed to load audio analyzer WASM module. Further info: Failed to construct 'AudioWorkletNode': AudioWorkletNode cannot be created: The node name 'PitchProcessor' is not defined in AudioWorkletGlobalScope."
Wasm requirements have wide support—just not yet in the Worklet spec.

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:

A screenshot of the app showing its title,
Real-time pitch detection.

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 an AudioWorklet 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