WebAssembly/Rust Tutorial: Procesare audio perfectă

Publicat: 2022-03-11

Sprijinit de toate browserele moderne, WebAssembly (sau „Wasm”) transformă modul în care dezvoltăm experiențe pentru utilizatori pentru web. Este un simplu format binar executabil care permite biblioteci sau chiar programe întregi care au fost scrise în alte limbaje de programare să ruleze în browserul web.

Dezvoltatorii caută adesea modalități de a fi mai productivi, cum ar fi:

  • Folosind o singură bază de cod de aplicație pentru mai multe platforme țintă, dar aplicația rulează bine pe toate
  • Crearea unui UX neted și frumos pe medii desktop și mobile
  • Profitând de ecosistemul bibliotecii open-source pentru a evita „reinventarea roții” în timpul dezvoltării aplicației

Pentru dezvoltatorii front-end, WebAssembly oferă toate trei, răspunzând căutării unei interfețe de utilizare a aplicației web care rivalizează cu adevărat cu experiența nativă pentru mobil sau desktop. Permite chiar și utilizarea bibliotecilor scrise în limbaje non-JavaScript, cum ar fi C++ sau Go!

În acest tutorial Wasm/Rust, vom crea o aplicație simplă pentru detector de înălțime, cum ar fi un tuner de chitară. Acesta va folosi capabilitățile audio încorporate ale browserului și va rula la 60 de cadre pe secundă (FPS), chiar și pe dispozitive mobile. Nu trebuie să înțelegeți API-ul Web Audio sau chiar să fiți familiarizat cu Rust pentru a urma acest tutorial; cu toate acestea, este de așteptat confort cu JavaScript.

Notă: Din păcate, la momentul scrierii acestui articol, tehnica folosită în acest articol, specifică API-ului Web Audio, nu funcționează încă în Firefox. Prin urmare, pentru moment, Chrome, Chromium sau Edge sunt recomandate pentru acest tutorial, în ciuda suportului de altfel excelent Wasm și Web Audio API în Firefox.

Ce acoperă acest tutorial WebAssembly/Rust

  • Crearea unei funcții simple în Rust și apelarea acesteia din JavaScript (prin WebAssembly)
  • Utilizarea API-ului AudioWorklet modern al browserului pentru procesarea audio de înaltă performanță în browser
  • Comunicarea între lucrători în JavaScript
  • Legând totul împreună într-o aplicație React simplă

Notă: dacă sunteți mai interesat de „cum” decât de „de ce” al acestui articol, nu ezitați să treceți direct la tutorial.

De ce Wasm?

Există mai multe motive pentru care ar putea avea sens să utilizați WebAssembly:

  • Permite executarea codului în interiorul browserului care a fost scris în orice limbă .
    • Aceasta include utilizarea bibliotecilor existente (numerice, procesare audio, învățare automată etc.) care sunt scrise în alte limbi decât JavaScript.
  • În funcție de alegerea limbii utilizate, Wasm este capabil să funcționeze la viteze aproape native. Acest lucru are potențialul de a aduce caracteristicile de performanță a aplicațiilor web mult mai aproape de experiențele native atât pentru mobil, cât și pentru desktop .

De ce nu folosiți întotdeauna Wasm?

Popularitatea WebAssembly va continua cu siguranță să crească; cu toate acestea, nu este potrivit pentru toate dezvoltările web:

  • Pentru proiecte simple, respectarea JavaScript, HTML și CSS va oferi probabil un produs funcțional într-un timp mai scurt.
  • Browserele mai vechi, cum ar fi Internet Explorer, nu acceptă Wasm direct.
  • Utilizările tipice ale WebAssembly necesită adăugarea de instrumente, cum ar fi un compilator de limbă, în lanțul de instrumente. Dacă echipa dvs. prioritizează menținerea instrumentelor de dezvoltare și integrare continuă cât mai simple posibil, utilizarea Wasm va fi împotriva acestui lucru.

De ce un tutorial Wasm/Rust, mai exact?

În timp ce multe limbaje de programare se compilează în Wasm, am ales Rust pentru acest exemplu. Rust a fost creat de Mozilla în 2010 și este în creștere în popularitate. Rust ocupă primul loc pentru „cel mai iubit limbaj” în sondajul 2020 pentru dezvoltatori de la Stack Overflow. Dar motivele pentru a utiliza Rust cu WebAssembly depășesc simpla tendință:

  • În primul rând, Rust are o durată de rulare mică, ceea ce înseamnă că mai puțin cod este trimis către browser atunci când un utilizator accesează site-ul, contribuind la menținerea amprentei site-ului la un nivel scăzut.
  • Rust are suport excelent pentru Wasm, care acceptă interoperabilitatea la nivel înalt cu JavaScript.
  • Rust oferă performanțe aproape de nivel C/C++ , dar are un model de memorie foarte sigur . În comparație cu alte limbi, Rust efectuează verificări suplimentare de siguranță în timp ce compilează codul, reducând considerabil potențialul de blocări cauzate de variabile goale sau neinițializate. Acest lucru poate duce la o gestionare mai simplă a erorilor și la o șansă mai mare de a menține un UX bun atunci când apar probleme neprevăzute.
  • Rugina nu este colectată de gunoi . Aceasta înseamnă că codul Rust controlează pe deplin momentul în care memoria este alocată și curățată, permițând performanță constantă - o cerință cheie în sistemele în timp real.

Multe beneficii ale Rust vin și cu o curbă de învățare abruptă, așa că alegerea limbajului de programare potrivit depinde de o varietate de factori, cum ar fi componența echipei care va dezvolta și menține codul.

Performanța WebAssembly: Menținerea aplicațiilor web netedă

Deoarece programăm în WebAssembly cu Rust, cum am putea folosi Rust pentru a obține beneficiile de performanță care ne-au condus la Wasm în primul rând? Pentru ca o aplicație cu o interfață grafică care se actualizează rapid să se simtă „netedă” pentru utilizatori, trebuie să poată reîmprospăta afișajul la fel de regulat ca hardware-ul ecranului. Acesta este de obicei 60 FPS, așa că aplicația noastră trebuie să își poată redesena interfața cu utilizatorul în aproximativ 16,7 ms (1.000 ms / 60 FPS).

Aplicația noastră detectează și arată pasul curent în timp real, ceea ce înseamnă că calculul combinat de detectare și desenul ar trebui să rămână în 16,7 ms pe cadru. În secțiunea următoare, vom profita de suportul browserului pentru analiza audio pe un alt fir în timp ce firul principal își face treaba. Acesta este un câștig major pentru performanță, deoarece calculul și desenul au fiecare 16,7 ms la dispoziție.

Noțiuni de bază audio web

În această aplicație, vom folosi un modul audio WebAssembly de înaltă performanță pentru a efectua detectarea înălțimii. În plus, ne vom asigura că calculul nu rulează pe firul principal.

De ce nu putem menține lucrurile simple și să efectuăm detectarea pitch-ului pe firul principal?

  • Procesarea audio necesită adesea calcule intensive. Acest lucru se datorează numărului mare de mostre care trebuie procesate în fiecare secundă. De exemplu, detectarea în mod fiabil a înălțimii audio necesită analizarea spectrelor a 44.100 de mostre în fiecare secundă.
  • Compilarea JIT și colectarea de gunoi a JavaScript au loc pe firul principal și dorim să evităm acest lucru în codul de procesare audio pentru o performanță consistentă.
  • Dacă timpul necesar procesării unui cadru audio ar afecta în mod semnificativ bugetul de cadre de 16,7 ms, UX-ul ar suferi de animație agitată.
  • Ne dorim ca aplicația noastră să funcționeze fără probleme chiar și pe dispozitive mobile cu performanță scăzută!

Worklet-urile Web Audio permit aplicațiilor să continue să atingă o viteză uniformă de 60 FPS, deoarece procesarea audio nu poate reține firul principal. Dacă procesarea audio este prea lentă și rămâne în urmă, vor exista alte efecte, cum ar fi întârzierea sunetului. Cu toate acestea, UX-ul va rămâne receptiv la utilizator.

WebAssembly/Rust Tutorial: Noțiuni introductive

Acest tutorial presupune că aveți instalat Node.js, precum și npx . Dacă nu aveți deja npx , puteți utiliza npm (care vine cu Node.js) pentru a-l instala:

 npm install -g npx

Creați o aplicație web

Pentru acest tutorial Wasm/Rust, vom folosi React.

Într-un terminal, vom rula următoarele comenzi:

 npx create-react-app wasm-audio-app cd wasm-audio-app

Aceasta folosește npx pentru a executa comanda create-react-app (conținută în pachetul corespunzător menținut de Facebook) pentru a crea o nouă aplicație React în directorul wasm-audio-app .

create-react-app este un CLI pentru generarea de aplicații pe o singură pagină (SPA) bazate pe React. Face incredibil de ușor să începeți un nou proiect cu React. Cu toate acestea, proiectul de ieșire include un cod standard care va trebui înlocuit.

În primul rând, deși vă recomand cu căldură testarea aplicației pe parcursul dezvoltării, testarea depășește scopul acestui tutorial. Deci vom merge mai departe și vom șterge src/App.test.js și src/setupTests.js .

Prezentare generală a aplicației

În aplicația noastră vor exista cinci componente JavaScript principale:

  • public/wasm-audio/wasm-audio.js conține legături JavaScript la modulul Wasm care furnizează algoritmul de detectare a înălțimii.
  • public/PitchProcessor.js este locul unde are loc procesarea audio. Se rulează în firul de redare Web Audio și va consuma API-ul Wasm.
  • src/PitchNode.js conține o implementare a unui nod Web Audio, care este conectat la graficul Web Audio și rulează în firul principal.
  • src/setupAudio.js utilizează API-urile browserului web pentru a accesa un dispozitiv de înregistrare audio disponibil.
  • src/App.js și src/App.css cuprind interfața cu utilizatorul aplicației.

O diagramă flux pentru aplicația de detectare a tonului. Blocurile 1 și 2 rulează pe firul Web Audio. Blocul 1 este Detectorul de înălțime Wasm (Rugina), în fișierul wasm-audio/lib.rs. Blocul 2 este Web Audio Detection + Communication, în fișierul PitchProcessor.js. Acesta cere detectorului să se inițialeze, iar detectorul trimite tonurile detectate înapoi către interfața Web Audio. Blocurile 3, 4 și 5 rulează pe firul principal. Blocul 3 este controlerul Web Audio, în fișierul PitchNode.js. Trimite modulul Wasm la PitchProcessor.js și primește de la acesta pitch-uri detectate. Blocul 4 este Web Audio Setup, în setupAudio.js. Acesta creează un obiect PitchNode. Blocul 5 este interfața de utilizare a aplicației web, compusă din App.js și App.css. Apelează setupAudio.js la pornire. De asemenea, întrerupe sau reia înregistrarea audio prin trimiterea unui mesaj către PitchNode, de la care primește pitch-uri detectate pentru a le afișa utilizatorului.
Prezentare generală a aplicației audio Wasm.

Să pătrundem direct în inima aplicației noastre și să definim codul Rust pentru modulul nostru Wasm. Apoi vom codifica diferitele părți ale JavaScript-ului nostru Web Audio și vom termina cu interfața de utilizare.

1. Detectarea pasului folosind Rust și WebAssembly

Codul nostru Rust va calcula o înălțime muzicală dintr-o serie de mostre audio.

Ia-o pe Rust

Puteți urma aceste instrucțiuni pentru a construi lanțul Rust pentru dezvoltare.

Instalați instrumente pentru construirea de componente WebAssembly în Rust

wasm-pack vă permite să construiți, să testați și să publicați componente WebAssembly generate de Rust. Dacă nu ați făcut-o deja, instalați wasm-pack.

cargo-generate ajută la punerea în funcțiune a unui nou proiect Rust prin folosirea unui depozit Git preexistent ca șablon. Vom folosi acest lucru pentru a porni un simplu analizor audio în Rust, care poate fi accesat folosind WebAssembly din browser.

Folosind instrumentul de cargo livrat împreună cu lanțul Rust, puteți instala cargo-generate :

 cargo install cargo-generate

Odată ce instalarea (care poate dura câteva minute) este finalizată, suntem gata să creăm proiectul nostru Rust.

Creați modulul nostru WebAssembly

Din folderul rădăcină al aplicației noastre, vom clona șablonul de proiect:

 $ cargo generate --git https://github.com/rustwasm/wasm-pack-template

Când vi se solicită un nou nume de proiect, vom introduce wasm-audio .

În directorul wasm-audio , va exista acum un fișier Cargo.toml cu următorul conținut:

 [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 este folosit pentru a defini un pachet Rust (pe care Rust îl numește „ladă”), servind o funcție similară pentru aplicațiile Rust pe care package.json o face pentru aplicațiile JavaScript.

Secțiunea [package] definește metadatele care sunt utilizate la publicarea pachetului în registrul oficial de pachete al Rust.

Secțiunea [lib] descrie formatul de ieșire din procesul de compilare Rust. Aici, „cdylib” îi spune lui Rust să producă o „bibliotecă de sistem dinamic” care poate fi încărcată dintr-o altă limbă (în cazul nostru, JavaScript) și includerea „rlib” îi spune lui Rust să adauge o bibliotecă statică care să conțină metadate despre biblioteca produsă. Acest al doilea specificator nu este necesar pentru scopurile noastre - ajută la dezvoltarea de module Rust ulterioare care consumă această ladă ca dependență - dar este sigur să îl lăsați.

În [features] , îi cerem lui Rust să includă o caracteristică opțională console_error_panic_hook pentru a oferi funcționalitate care convertește mecanismul de erori netratate din Rust (numit panic ) în erori de consolă care apar în instrumentele de dezvoltare pentru depanare.

În cele din urmă, [dependencies] listează toate lăzile de care aceasta depinde. Singura dependență furnizată din cutie este wasm-bindgen , care oferă generarea automată de legături JavaScript la modulul nostru Wasm.

Implementați un detector de pas în Rust

Scopul acestei aplicații este de a putea detecta vocea unui muzician sau înălțimea unui instrument în timp real. Pentru a se asigura că acest lucru se execută cât mai repede posibil, un modul WebAssembly are sarcina de a calcula pasul. Pentru detectarea tonului cu o singură voce, vom folosi metoda de înălțime „McLeod”, care este implementată în biblioteca existentă pitch-detection a înălțimii Rust.

La fel ca managerul de pachete Node.js (npm), Rust include un manager de pachete propriu, numit Cargo. Acest lucru permite instalarea cu ușurință a pachetelor care au fost publicate în registrul Rust crate.

Pentru a adăuga dependența, editați Cargo.toml , adăugând linia pentru pitch-detection la secțiunea de dependențe:

 [dependencies] wasm-bindgen = "0.2.63" pitch-detection = "0.1"

Acest lucru îi indică Cargo să descarce și să instaleze dependența pitch-detection în timpul următoarei cargo build sau, deoarece vizam WebAssembly, acest lucru va fi realizat în următorul wasm-pack .

Creați un detector de înclinare apelabil prin JavaScript în Rust

Mai întâi vom adăuga un fișier care definește un utilitar util al cărui scop îl vom discuta mai târziu:

Creați wasm-audio/src/utils.rs și inserați conținutul acestui fișier în el.

Vom înlocui codul generat în wasm-audio/lib.rs cu următorul cod, care efectuează detectarea înălțimii printr-un algoritm rapid de transformare Fourier (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, } } }

Să examinăm acest lucru mai detaliat:

 #[wasm_bindgen]

wasm_bindgen este o macrocomandă Rust care ajută la implementarea legăturii dintre JavaScript și Rust. Când este compilată în WebAssembly, această macrocomandă instruiește compilatorului să creeze o legătură JavaScript la o clasă. Codul Rust de mai sus se va traduce în legături JavaScript care sunt pur și simplu învelișuri subțiri pentru apelurile în și dinspre modulul Wasm. Stratul ușor de abstractizare combinat cu memoria partajată directă între JavaScript este ceea ce ajută Wasm să ofere performanțe excelente.

 #[wasm_bindgen] pub struct WasmPitchDetector { sample_rate: usize, fft_size: usize, detector: McLeodDetector<f32>, } #[wasm_bindgen] impl WasmPitchDetector { ... }

Rust nu are un concept de clase. Mai degrabă, datele unui obiect sunt descrise de o struct și comportamentul său prin impl sau trait .

De ce să expuneți funcționalitatea de detectare a înălțimii prin intermediul unui obiect, mai degrabă decât printr-o funcție simplă? Deoarece astfel, inițializam structurile de date utilizate de McLeodDetector intern o singură dată , în timpul creării WasmPitchDetector . Aceasta menține funcția detect_pitch rapidă, evitând alocarea costisitoare de memorie în timpul funcționării.

 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), } }

Când o aplicație Rust întâlnește o eroare din care nu se poate recupera cu ușurință, este destul de comun să invoci o panic! macro. Aceasta îi indică lui Rust să raporteze o eroare și să închidă imediat aplicația. Folosirea panicilor poate fi utilă în special pentru dezvoltarea timpurie înainte de implementarea unei strategii de gestionare a erorilor, deoarece vă permite să înțelegeți rapid presupuneri false.

Apelarea utils::set_panic_hook() o dată în timpul instalării va asigura că mesajele de panică apar în instrumentele de dezvoltare ale browserului.

În continuare, definim fft_pad , cantitatea de zero-padding aplicată fiecărei analize FFT. Umplutura, în combinație cu funcția de fereastră utilizată de algoritm, ajută la „netezirea” rezultatelor pe măsură ce analiza trece prin datele audio eșantionate primite. Folosirea unui pad de jumătate din lungimea FFT funcționează bine pentru multe instrumente.

În cele din urmă, Rust returnează automat rezultatul ultimei instrucțiuni, astfel încât WasmPitchDetector este valoarea returnată a lui new() .

Restul codului nostru impl WasmPitchDetector Rust definește API-ul pentru detectarea pitch-urilor:

 pub fn detect_pitch(&mut self, audio_samples: Vec<f32>) -> f32 { ... }

Așa arată definiția funcției membre în Rust. Un membru public detect_pitch este adăugat la WasmPitchDetector . Primul său argument este o referință mutabilă ( &mut ) la un obiect instanțiat de același tip care conține câmpuri struct și impl - dar aceasta este transmisă automat la apelare, așa cum vom vedea mai jos.

În plus, funcția noastră membru ia o matrice de dimensiuni arbitrare de numere în virgulă mobilă pe 32 de biți și returnează un singur număr. Aici, acesta va fi pasul rezultat calculat pentru acele mostre (în 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()); }

Codul de mai sus detectează dacă au fost furnizate suficiente eșantioane funcției pentru a fi efectuată o analiză validă a pasului. Dacă nu, Rust intră în panic! este apelată macro, ceea ce duce la ieșirea imediată din Wasm și mesajul de eroare tipărit pe consola dev-tools a browserului.

 let optional_pitch = self.detector.get_pitch( &audio_samples, self.sample_rate, POWER_THRESHOLD, CLARITY_THRESHOLD, );

Acest lucru apelează la biblioteca terță parte pentru a calcula tonul din cele mai recente mostre audio. POWER_THRESHOLD și CLARITY_THRESHOLD pot fi ajustate pentru a regla sensibilitatea algoritmului.

Încheiem cu o returnare implicită a unei valori în virgulă mobilă prin cuvântul cheie match , care funcționează similar cu o instrucțiune switch în alte limbi. Some() și None ne permit să gestionăm cazurile în mod corespunzător, fără a întâlni o excepție de tip nul-pointer.

Construirea de aplicații WebAssembly

Când dezvoltați aplicații Rust, procedura obișnuită de construire este să invocați o construcție folosind cargo build . Cu toate acestea, generăm un modul Wasm, așa că vom folosi wasm-pack , care oferă o sintaxă mai simplă atunci când vizam Wasm. (De asemenea, permite publicarea legăturilor JavaScript rezultate în registrul npm, dar acest lucru nu intră în domeniul de aplicare al acestui tutorial.)

wasm-pack acceptă o varietate de ținte de construcție. Pentru că vom consuma modulul direct dintr-un worklet Web Audio, vom viza opțiunea web . Alte obiective includ construirea pentru un bundler, cum ar fi webpack sau pentru consumul din Node.js. Vom rula asta din subdirectorul wasm-audio/ :

 wasm-pack build --target web

Dacă are succes, un modul npm este creat sub ./pkg .

Acesta este un modul JavaScript cu propriul său package.json generat automat. Acest lucru poate fi publicat în registrul npm, dacă se dorește. Pentru a menține lucrurile simple pentru moment, putem pur și simplu să copiam și să pkg acest pachet în folderul nostru public/wasm-audio :

 cp -R ./wasm-audio/pkg ./public/wasm-audio

Cu asta, am creat un modul Rust Wasm gata de a fi consumat de aplicația web sau, mai precis, de PitchProcessor .

2. Clasa noastră PitchProcessor (bazată pe Native AudioWorkletProcessor )

Pentru această aplicație vom folosi un standard de procesare audio care a câștigat recent compatibilitate pe scară largă cu browserul. Mai exact, vom folosi API-ul Web Audio și vom rula calcule costisitoare într-un AudioWorkletProcessor personalizat. După aceea, vom crea clasa AudioWorkletNode personalizată corespunzătoare (pe care o vom numi PitchNode ) ca o punte înapoi la firul principal.

Creați un nou fișier public/PitchProcessor.js și inserați următorul cod în el:

 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 este un însoțitor al PitchNode , dar rulează într-un fir separat, astfel încât calculul de procesare audio poate fi efectuat fără a bloca munca efectuată pe firul principal.

În principal, PitchProcessor :

  • Gestionează evenimentul "send-wasm-module" trimis de la PitchNode prin compilarea și încărcarea modulului Wasm în worklet-ul. Odată terminat, îl informează pe PitchNode trimițând un eveniment "wasm-module-loaded" . Această abordare de apel invers este necesară deoarece toată comunicarea dintre PitchNode și PitchProcessor traversează o limită de fir și nu poate fi efectuată sincron.
  • Răspunde, de asemenea, la evenimentul "init-detector" de la PitchNode prin configurarea WasmPitchDetector .
  • Prelucrează mostre audio primite de la graficul audio al browserului, delegă calculul de detectare a înălțimii la modulul Wasm și apoi trimite orice înălțime detectată înapoi la PitchNode (care trimite tonul de-a lungul stratului React prin intermediul său onPitchDetectedCallback ).
  • Se înregistrează sub un nume specific, unic. În acest fel, browserul știe - prin clasa de bază a PitchNode , AudioWorkletNode nativ - cum să instanțieze PitchProcessor nostru mai târziu, când PitchNode este construit. Vedeți setupAudio.js .

Următoarea diagramă vizualizează fluxul de evenimente între PitchNode și PitchProcessor :

O diagramă mai detaliată care compară interacțiunile dintre obiectele PitchNode și PitchProcess în timpul execuției. În timpul configurării inițiale, PitchNode trimite modulul Wasm ca o matrice de octeți către PitchProcessor, care îi compilează și îi trimite înapoi către PitchNode, care în cele din urmă răspunde cu un mesaj de eveniment care solicită ca PitchProcessor să se inițialeze. În timpul înregistrării audio, PitchNode nu trimite nimic și primește două tipuri de mesaje de eveniment de la PitchProcessor: un pitch detectat sau o eroare, dacă apare una fie de la Wasm, fie de la setul de lucru.
Mesaje de evenimente de rulare.

3. Adăugați codul Web Audio Worklet

PitchNode.js oferă interfața pentru procesarea audio personalizată pentru detectarea tonului. Obiectul PitchNode este mecanismul prin care pitch-urile detectate folosind modulul WebAssembly care lucrează în firul AudioWorklet își vor face drum spre firul principal și React pentru randare.

În src/PitchNode.js , vom subclasa AudioWorkletNode din API-ul 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); } } }

Sarcinile cheie efectuate de PitchNode sunt:

  • Trimiteți modulul WebAssembly ca o secvență de octeți bruti — cei trecuți din setupAudio.js — către PitchProcessor , care rulează pe firul AudioWorklet . Acesta este modul în care PitchProcessor încarcă modulul Wasm de detectare a înălțimii.
  • Gestionați evenimentul trimis de PitchProcessor atunci când compilează cu succes Wasm și trimiteți-i un alt eveniment care îi transmite informații de configurare de detecție a tonului.
  • Gestionați pitch-urile detectate pe măsură ce sosesc de la PitchProcessor și redirecționați-le către funcția UI setLatestPitch() prin onPitchDetectedCallback() .

Notă: Acest cod al obiectului rulează pe firul principal, așa că ar trebui să evite efectuarea de procesări ulterioare pe pitch-uri detectate în cazul în care acest lucru este costisitor și provoacă scăderi ale ratei cadrelor.

4. Adăugați cod pentru a configura Web Audio

Pentru ca aplicația web să acceseze și să proceseze intrarea live de la microfonul computerului client, trebuie să:

  1. Obțineți permisiunea utilizatorului pentru ca browserul să acceseze orice microfon conectat
  2. Accesați ieșirea microfonului ca obiect de flux audio
  3. Atașați codul pentru a procesa mostrele de flux audio primite și pentru a produce o secvență de tonuri detectate

În src/setupAudio.js , vom face asta și, de asemenea, vom încărca modulul Wasm asincron, astfel încât să ne putem inițializa PitchNode cu el, înainte de a atașa 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.


Citiți suplimentare pe blogul Toptal Engineering:

  • Web Audio API: De ce să compuneți când puteți codifica?
  • WebVR Partea 3: Deblocarea potențialului WebAssembly și AssemblyScript