WebAssembly/Rust Tutorial: Pemrosesan Audio Pitch-sempurna

Diterbitkan: 2022-03-11

Didukung oleh semua browser modern, WebAssembly (atau “Wasm”) mengubah cara kami mengembangkan pengalaman pengguna untuk web. Ini adalah format biner sederhana yang dapat dieksekusi yang memungkinkan perpustakaan atau bahkan seluruh program yang telah ditulis dalam bahasa pemrograman lain untuk dijalankan di browser web.

Pengembang sering mencari cara untuk menjadi lebih produktif, seperti:

  • Menggunakan basis kode aplikasi tunggal untuk beberapa platform target, tetapi menjalankan aplikasi dengan baik di semuanya
  • Membuat UX yang mulus dan indah di lingkungan desktop dan seluler
  • Memanfaatkan ekosistem perpustakaan sumber terbuka untuk menghindari "menciptakan kembali roda" selama pengembangan aplikasi

Untuk pengembang front-end, WebAssembly menyediakan ketiganya, menjawab pencarian UI aplikasi web yang benar-benar menyaingi pengalaman seluler atau desktop asli. Bahkan memungkinkan penggunaan perpustakaan yang ditulis dalam bahasa non-JavaScript, seperti C++ atau Go!

Dalam tutorial Wasm/Rust ini, kita akan membuat aplikasi pendeteksi nada sederhana, seperti tuner gitar. Ini akan menggunakan kemampuan audio bawaan browser, dan berjalan pada 60 frame per detik (FPS)—bahkan di perangkat seluler. Anda tidak perlu memahami Web Audio API atau bahkan terbiasa dengan Rust untuk mengikuti tutorial ini; namun, kenyamanan dengan JavaScript diharapkan.

Catatan: Sayangnya, hingga tulisan ini dibuat, teknik yang digunakan dalam artikel ini—khusus untuk Web Audio API—belum berfungsi di Firefox. Oleh karena itu, untuk saat ini, Chrome, Chromium, atau Edge direkomendasikan untuk tutorial ini, terlepas dari dukungan Wasm dan Web Audio API yang sangat baik di Firefox.

Apa yang Dicakup oleh Tutorial WebAssembly/Rust Ini

  • Membuat fungsi sederhana di Rust dan memanggilnya dari JavaScript (melalui WebAssembly)
  • Menggunakan API AudioWorklet browser modern untuk pemrosesan audio performa tinggi di browser
  • Berkomunikasi antar pekerja dalam JavaScript
  • Mengikat semuanya menjadi satu aplikasi React yang sederhana

Catatan: Jika Anda lebih tertarik pada "bagaimana" daripada "mengapa" dari artikel ini, silakan langsung masuk ke tutorial.

Mengapa Wasm?

Ada beberapa alasan mengapa mungkin masuk akal untuk menggunakan WebAssembly:

  • Hal ini memungkinkan mengeksekusi kode di dalam browser yang ditulis dalam bahasa apapun .
    • Ini termasuk memanfaatkan perpustakaan yang ada (numerik, pemrosesan audio, pembelajaran mesin, dll.) yang ditulis dalam bahasa selain JavaScript.
  • Bergantung pada pilihan bahasa yang digunakan, Wasm dapat beroperasi pada kecepatan yang mendekati asli. Ini berpotensi membawa karakteristik kinerja aplikasi web lebih dekat ke pengalaman asli untuk seluler dan desktop .

Mengapa Tidak Selalu Menggunakan Wasm?

Popularitas WebAssembly pasti akan terus tumbuh; namun, ini tidak cocok untuk semua pengembangan web:

  • Untuk proyek sederhana, tetap menggunakan JavaScript, HTML, dan CSS kemungkinan akan menghasilkan produk yang berfungsi dalam waktu yang lebih singkat.
  • Browser lama seperti Internet Explorer tidak mendukung Wasm secara langsung.
  • Penggunaan khas WebAssembly memerlukan alat tambahan, seperti kompiler bahasa, ke dalam rantai alat Anda. Jika tim Anda memprioritaskan menjaga pengembangan dan alat integrasi berkelanjutan sesederhana mungkin, menggunakan Wasm akan bertentangan dengan ini.

Mengapa Tutorial Wasm / Karat, Secara Khusus?

Sementara banyak bahasa pemrograman dikompilasi ke Wasm, saya memilih Rust untuk contoh ini. Rust dibuat oleh Mozilla pada tahun 2010 dan semakin populer. Rust menempati posisi teratas untuk "bahasa yang paling disukai" dalam survei pengembang 2020 dari Stack Overflow. Tetapi alasan untuk menggunakan Rust dengan WebAssembly lebih dari sekadar tren:

  • Pertama dan terpenting, Rust memiliki runtime kecil yang berarti lebih sedikit kode yang dikirim ke browser saat pengguna mengakses situs, membantu menjaga jejak situs tetap rendah.
  • Rust memiliki dukungan Wasm yang sangat baik, mendukung interoperabilitas tingkat tinggi dengan JavaScript.
  • Rust menyediakan performa mendekati level C/C++ , namun memiliki model memori yang sangat aman . Jika dibandingkan dengan bahasa lain, Rust melakukan pemeriksaan keamanan ekstra saat mengkompilasi kode Anda, sangat mengurangi potensi crash yang disebabkan oleh variabel kosong atau tidak diinisialisasi. Hal ini dapat menyebabkan penanganan kesalahan yang lebih sederhana dan peluang yang lebih tinggi untuk mempertahankan UX yang baik saat terjadi masalah yang tidak terduga.
  • Karat tidak dikumpulkan dari sampah . Ini berarti bahwa kode Rust sepenuhnya mengendalikan kapan memori dialokasikan dan dibersihkan, memungkinkan kinerja yang konsisten —persyaratan utama dalam sistem waktu nyata.

Banyak manfaat Rust juga datang dengan kurva belajar yang curam, jadi memilih bahasa pemrograman yang tepat tergantung pada berbagai faktor, seperti susunan tim yang akan mengembangkan dan memelihara kode.

Performa WebAssembly: Mempertahankan Aplikasi Web yang Mulus

Karena kami memprogram di WebAssembly dengan Rust, bagaimana kami menggunakan Rust untuk mendapatkan manfaat kinerja yang membawa kami ke Wasm? Agar aplikasi dengan GUI yang diperbarui dengan cepat terasa "halus" bagi pengguna, aplikasi tersebut harus dapat menyegarkan tampilan secara teratur seperti perangkat keras layar. Ini biasanya 60 FPS sehingga aplikasi kita harus dapat menggambar ulang antarmuka penggunanya dalam ~16,7 md (1.000 md / 60 FPS).

Aplikasi kami mendeteksi dan menunjukkan nada saat ini secara real time, yang berarti komputasi deteksi gabungan dan gambar harus tetap dalam 16,7 ms per frame. Di bagian berikutnya, kami akan memanfaatkan dukungan browser untuk menganalisis audio di utas lain saat utas utama melakukan tugasnya. Ini adalah kemenangan besar untuk performa, karena komputasi dan menggambar, maka masing -masing memiliki 16,7 mdtk.

Dasar-dasar Audio Web

Dalam aplikasi ini, kami akan menggunakan modul audio WebAssembly berkinerja tinggi untuk melakukan deteksi nada. Selanjutnya, kami akan memastikan komputasi tidak berjalan di thread utama.

Mengapa kami tidak dapat menyederhanakan dan melakukan deteksi nada pada utas utama?

  • Pemrosesan audio seringkali membutuhkan komputasi yang intensif. Hal ini disebabkan banyaknya sampel yang perlu diproses setiap detiknya. Misalnya, mendeteksi nada audio secara andal memerlukan analisis spektrum 44.100 sampel setiap detik.
  • Kompilasi JIT dan pengumpulan sampah JavaScript terjadi di utas utama, dan kami ingin menghindari ini dalam kode pemrosesan audio untuk kinerja yang konsisten.
  • Jika waktu yang dibutuhkan untuk memproses bingkai audio memakan banyak anggaran bingkai 16,7 mdtk, UX akan mengalami animasi berombak.
  • Kami ingin aplikasi kami berjalan dengan lancar bahkan pada perangkat seluler berkinerja rendah!

Worklet Audio Web memungkinkan aplikasi untuk terus mencapai 60 FPS yang mulus karena pemrosesan audio tidak dapat menahan utas utama. Jika pemrosesan audio terlalu lambat dan tertinggal, akan ada efek lain, seperti audio yang tertinggal. Namun, UX akan tetap responsif terhadap pengguna.

WebAssembly/Tutorial Karat: Memulai

Tutorial ini mengasumsikan Anda telah menginstal Node.js, serta npx . Jika Anda belum memiliki npx , Anda dapat menggunakan npm (yang disertakan dengan Node.js) untuk menginstalnya:

 npm install -g npx

Buat Aplikasi Web

Untuk tutorial Wasm/Rust ini, kita akan menggunakan React.

Di terminal, kita akan menjalankan perintah berikut:

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

Ini menggunakan npx untuk menjalankan perintah create-react-app (terkandung dalam paket terkait yang dikelola oleh Facebook) untuk membuat aplikasi React baru di direktori wasm-audio-app .

create-react-app adalah CLI untuk menghasilkan aplikasi satu halaman (SPA) berbasis React. Itu membuatnya sangat mudah untuk memulai proyek baru dengan React. Namun, proyek keluaran menyertakan kode boilerplate yang perlu diganti.

Pertama, meskipun saya sangat merekomendasikan pengujian unit aplikasi Anda selama pengembangan, pengujian berada di luar cakupan tutorial ini. Jadi kita akan melanjutkan dan menghapus src/App.test.js dan src/setupTests.js .

Ikhtisar Aplikasi

Akan ada lima komponen JavaScript utama dalam aplikasi kita:

  • public/wasm-audio/wasm-audio.js berisi pengikatan JavaScript ke modul Wasm yang menyediakan algoritme deteksi nada.
  • public/PitchProcessor.js adalah tempat pemrosesan audio terjadi. Ini berjalan di utas rendering Audio Web dan akan menggunakan Wasm API.
  • src/PitchNode.js berisi implementasi node Audio Web, yang terhubung ke grafik Audio Web dan berjalan di utas utama.
  • src/setupAudio.js menggunakan API browser web untuk mengakses perangkat perekaman audio yang tersedia.
  • src/App.js dan src/App.css terdiri dari antarmuka pengguna aplikasi.

Diagram alur untuk aplikasi deteksi nada. Blok 1 dan 2 berjalan di utas Audio Web. Blok 1 adalah Wasm (Rust) Pitch Detector, dalam file wasm-audio/lib.rs. Blok 2 adalah Deteksi Audio Web + Komunikasi, dalam file PitchProcessor.js. Ini meminta detektor untuk menginisialisasi, dan detektor mengirimkan nada yang terdeteksi kembali ke antarmuka Audio Web. Blok 3, 4, dan 5 berjalan di utas utama. Blok 3 adalah Web Audio Controller, dalam file PitchNode.js. Ini mengirimkan modul Wasm ke PitchProcessor.js, dan menerima nada yang terdeteksi darinya. Blok 4 adalah Pengaturan Audio Web, di setupAudio.js. Itu menciptakan objek PitchNode. Blok 5 adalah UI Aplikasi Web, terdiri dari App.js dan App.css. Ini memanggil setupAudio.js saat startup. Itu juga menjeda atau melanjutkan perekaman audio dengan mengirim pesan ke PitchNode, dari mana ia menerima nada yang terdeteksi untuk ditampilkan kepada pengguna.
Ikhtisar aplikasi audio Wasm.

Mari selami langsung ke inti aplikasi kita dan tentukan kode Rust untuk modul Wasm kita. Kami kemudian akan mengkodekan berbagai bagian dari JavaScript terkait Audio Web kami dan diakhiri dengan UI.

1. Deteksi Pitch Menggunakan Karat dan WebAssembly

Kode Rust kami akan menghitung nada musik dari berbagai sampel audio.

Dapatkan Karat

Anda dapat mengikuti petunjuk ini untuk membangun rantai Rust untuk pengembangan.

Instal Alat untuk Membangun Komponen WebAssembly di Rust

wasm-pack memungkinkan Anda membuat, menguji, dan menerbitkan komponen WebAssembly yang dihasilkan Rust. Jika Anda belum melakukannya, instal wasm-pack.

cargo-generate membantu menjalankan proyek Rust baru dengan memanfaatkan repositori Git yang sudah ada sebelumnya sebagai template. Kami akan menggunakan ini untuk bootstrap penganalisis audio sederhana di Rust yang dapat diakses menggunakan WebAssembly dari browser.

Menggunakan alat cargo yang disertakan dengan rantai Rust, Anda dapat menginstal cargo-generate :

 cargo install cargo-generate

Setelah instalasi (yang mungkin memakan waktu beberapa menit) selesai, kami siap untuk membuat proyek Rust kami.

Buat Modul WebAssembly Kami

Dari folder root aplikasi kami, kami akan mengkloning template proyek:

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

Saat diminta untuk nama proyek baru, kami akan memasukkan wasm-audio .

Di direktori wasm-audio , sekarang akan ada file Cargo.toml dengan konten berikut:

 [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 digunakan untuk mendefinisikan paket Rust (yang disebut Rust sebagai "peti"), melayani fungsi serupa untuk aplikasi Rust yang dilakukan package.json untuk aplikasi JavaScript.

Bagian [package] mendefinisikan metadata yang digunakan saat menerbitkan paket ke registri paket resmi Rust.

Bagian [lib] menjelaskan format output dari proses kompilasi Rust. Di sini, "cdylib" memberi tahu Rust untuk menghasilkan "pustaka sistem dinamis" yang dapat dimuat dari bahasa lain (dalam kasus kami, JavaScript) dan termasuk "rlib" memberi tahu Rust untuk menambahkan pustaka statis yang berisi metadata tentang pustaka yang dihasilkan. Penentu kedua ini tidak diperlukan untuk tujuan kami - ini membantu pengembangan modul Rust lebih lanjut yang menggunakan peti ini sebagai ketergantungan - tetapi aman untuk ditinggalkan.

Di [features] , kami meminta Rust untuk menyertakan fitur opsional console_error_panic_hook untuk menyediakan fungsionalitas yang mengubah mekanisme kesalahan yang tidak tertangani dari Rust (disebut panic ) menjadi kesalahan konsol yang muncul di alat dev untuk debugging.

Akhirnya, [dependencies] mencantumkan semua peti tempat bergantungnya. Satu-satunya dependensi yang disediakan di luar kotak adalah wasm-bindgen , yang menyediakan pembuatan binding JavaScript secara otomatis ke modul Wasm kami.

Menerapkan Pendeteksi Nada di Rust

Tujuan dari aplikasi ini adalah untuk dapat mendeteksi suara musisi atau nada instrumen secara real time. Untuk memastikan ini dijalankan secepat mungkin, modul WebAssembly ditugaskan untuk menghitung nada. Untuk deteksi nada suara tunggal, kami akan menggunakan metode nada "McLeod" yang diterapkan di perpustakaan pitch-detection Rust yang ada.

Sama seperti manajer paket Node.js (npm), Rust menyertakan manajer paketnya sendiri, yang disebut Cargo. Hal ini memungkinkan dengan mudah menginstal paket yang telah diterbitkan ke registri peti Rust.

Untuk menambahkan dependensi, edit Cargo.toml , tambahkan baris untuk pitch-detection ke bagian dependensi:

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

Ini menginstruksikan Cargo untuk mengunduh dan menginstal dependensi pitch-detection selama pembuatan cargo build berikutnya atau, karena kami menargetkan WebAssembly, ini akan dilakukan di wasm-pack berikutnya.

Buat Pitch Detector yang dapat dipanggil dengan JavaScript di Rust

Pertama kita akan menambahkan file yang mendefinisikan utilitas berguna yang tujuannya akan kita bahas nanti:

Buat wasm-audio/src/utils.rs dan tempelkan konten file ini ke dalamnya.

Kami akan mengganti kode yang dihasilkan di wasm-audio/lib.rs dengan kode berikut, yang melakukan deteksi nada melalui algoritma transformasi Fourier cepat (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, } } }

Mari kita periksa ini lebih detail:

 #[wasm_bindgen]

wasm_bindgen adalah makro Rust yang membantu mengimplementasikan pengikatan antara JavaScript dan Rust. Saat dikompilasi ke WebAssembly, makro ini menginstruksikan kompiler untuk membuat pengikatan JavaScript ke kelas. Kode Rust di atas akan diterjemahkan ke binding JavaScript yang hanya merupakan pembungkus tipis untuk panggilan ke dan dari modul Wasm. Lapisan abstraksi yang ringan dikombinasikan dengan memori bersama langsung antara JavaScript adalah apa yang membantu Wasm memberikan kinerja yang sangat baik.

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

Rust tidak memiliki konsep kelas. Sebaliknya, data suatu objek dijelaskan oleh struct dan perilakunya melalui impl s atau trait s.

Mengapa mengekspos fungsionalitas deteksi nada melalui objek daripada fungsi biasa? Karena itu, kami hanya menginisialisasi struktur data yang digunakan oleh McLeodDetector internal sekali , selama pembuatan WasmPitchDetector . Ini membuat fungsi detect_pitch cepat dengan menghindari alokasi memori yang mahal selama operasi.

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

Ketika aplikasi Rust menemukan kesalahan yang tidak dapat dipulihkan dengan mudah, sangat umum untuk memanggil panic! makro. Ini menginstruksikan Rust untuk melaporkan kesalahan dan segera menghentikan aplikasi. Memanfaatkan kepanikan dapat berguna terutama untuk pengembangan awal sebelum strategi penanganan kesalahan diterapkan karena memungkinkan Anda untuk menangkap asumsi yang salah dengan cepat.

Memanggil utils::set_panic_hook() sekali selama penyiapan akan memastikan pesan panik muncul di alat pengembangan browser.

Selanjutnya, kita mendefinisikan fft_pad , jumlah zero-padding yang diterapkan pada setiap analisis FFT. Padding, dikombinasikan dengan fungsi windowing yang digunakan oleh algoritme, membantu "memperhalus" hasil saat analisis bergerak melintasi data audio sampel yang masuk. Menggunakan pad setengah panjang FFT bekerja dengan baik untuk banyak instrumen.

Akhirnya, Rust mengembalikan hasil dari pernyataan terakhir secara otomatis, sehingga pernyataan struct WasmPitchDetector adalah nilai kembalian dari new() .

Sisa dari kode impl WasmPitchDetector kami mendefinisikan API untuk mendeteksi nada:

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

Seperti inilah definisi fungsi anggota di Rust. detect_pitch anggota publik ditambahkan ke WasmPitchDetector . Argumen pertamanya adalah referensi yang dapat diubah ( &mut ) ke objek yang diinstansiasi dari jenis yang sama yang berisi bidang struct dan impl —tetapi ini diteruskan secara otomatis saat memanggil, seperti yang akan kita lihat di bawah.

Selain itu, fungsi anggota kami mengambil array berukuran sewenang-wenang dari angka floating point 32-bit dan mengembalikan satu angka. Di sini, itu akan menjadi nada yang dihasilkan yang dihitung di seluruh sampel tersebut (dalam 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()); }

Kode di atas mendeteksi apakah sampel yang cukup diberikan ke fungsi untuk analisis nada yang valid untuk dilakukan. Jika tidak, Rust panic! makro dipanggil yang menghasilkan keluar langsung dari Wasm dan pesan kesalahan dicetak ke konsol alat pengembang browser.

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

Ini memanggil perpustakaan pihak ketiga untuk menghitung nada dari sampel audio terbaru. POWER_THRESHOLD dan CLARITY_THRESHOLD dapat disesuaikan untuk menyesuaikan sensitivitas algoritme.

Kami mengakhiri dengan pengembalian tersirat dari nilai floating point melalui kata kunci match , yang bekerja mirip dengan pernyataan switch dalam bahasa lain. Some() dan None memungkinkan kita menangani kasus dengan tepat tanpa mengalami pengecualian null-pointer.

Membangun Aplikasi WebAssembly

Saat mengembangkan aplikasi Rust, prosedur build yang biasa adalah menjalankan build menggunakan cargo build . Namun, kami membuat modul Wasm, jadi kami akan menggunakan wasm-pack , yang menyediakan sintaks yang lebih sederhana saat menargetkan Wasm. (Ini juga memungkinkan penerbitan binding JavaScript yang dihasilkan ke registri npm, tetapi itu di luar cakupan tutorial ini.)

wasm-pack mendukung berbagai target build. Karena kita akan menggunakan modul langsung dari worklet Audio Web, kita akan menargetkan opsi web . Target lainnya termasuk membangun untuk bundler seperti webpack atau untuk konsumsi dari Node.js. Kami akan menjalankan ini dari wasm-audio/ :

 wasm-pack build --target web

Jika berhasil, modul npm dibuat di bawah ./pkg .

Ini adalah modul JavaScript dengan package.json yang dibuat secara otomatis. Ini dapat dipublikasikan ke registri npm jika diinginkan. Untuk mempermudah untuk saat ini, kita cukup menyalin dan menempel pkg ini di bawah folder kita public/wasm-audio :

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

Dengan itu, kami telah membuat modul Rust Wasm yang siap digunakan oleh aplikasi web, atau lebih khusus, oleh PitchProcessor .

2. Kelas PitchProcessor Kami (Berdasarkan Native AudioWorkletProcessor )

Untuk aplikasi ini kami akan menggunakan standar pemrosesan audio yang baru-baru ini mendapatkan kompatibilitas browser yang luas. Secara khusus, kami akan menggunakan Web Audio API dan menjalankan komputasi yang mahal dalam AudioWorkletProcessor kustom. Setelah itu kita akan membuat kelas AudioWorkletNode kustom yang sesuai (yang akan kita sebut PitchNode ) sebagai jembatan kembali ke utas utama.

Buat file baru public/PitchProcessor.js dan tempel kode berikut ke dalamnya:

 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 adalah pendamping PitchNode tetapi berjalan di utas terpisah sehingga komputasi pemrosesan audio dapat dilakukan tanpa menghalangi pekerjaan yang dilakukan di utas utama.

Terutama, PitchProcessor :

  • Menangani acara "send-wasm-module" yang dikirim dari PitchNode dengan mengompilasi dan memuat modul Wasm ke dalam worklet. Setelah selesai, ini memberi tahu PitchNode dengan mengirimkan acara "wasm-module-loaded" . Pendekatan callback ini diperlukan karena semua komunikasi antara PitchNode dan PitchProcessor melintasi batas thread dan tidak dapat dilakukan secara sinkron.
  • Juga merespons acara "init-detector" dari PitchNode dengan mengonfigurasi WasmPitchDetector .
  • Memproses sampel audio yang diterima dari grafik audio browser, mendelegasikan perhitungan deteksi nada ke modul Wasm, dan kemudian mengirimkan nada yang terdeteksi kembali ke PitchNode (yang mengirimkan nada ke lapisan React melalui onPitchDetectedCallback ).
  • Mendaftarkan dirinya dengan nama yang spesifik dan unik. Dengan cara ini browser mengetahui—melalui kelas dasar PitchNode , AudioWorkletNode asli —cara membuat instance PitchProcessor kita nanti saat PitchNode dibuat. Lihat setupAudio.js .

Diagram berikut memvisualisasikan aliran peristiwa antara PitchNode dan PitchProcessor :

Diagram alur yang lebih detail membandingkan interaksi antara objek PitchNode dan PitchProcess saat waktu proses. Selama penyiapan awal, PitchNode mengirimkan modul Wasm sebagai larik byte ke PitchProcessor, yang mengompilasinya dan mengirimkannya kembali ke PitchNode, yang akhirnya merespons dengan pesan peristiwa yang meminta PitchProcessor menginisialisasi dirinya sendiri. Saat merekam audio, PitchNode tidak mengirim apa pun, dan menerima dua jenis pesan peristiwa dari PitchProcessor: Nada yang terdeteksi atau kesalahan, jika salah satunya terjadi dari Wasm atau worklet.
Pesan acara runtime.

3. Tambahkan Kode Worklet Audio Web

PitchNode.js menyediakan antarmuka untuk pemrosesan audio deteksi nada kustom kami. Objek PitchNode adalah mekanisme di mana nada yang terdeteksi menggunakan modul WebAssembly yang bekerja di utas AudioWorklet akan menuju ke utas utama dan Bereaksi untuk rendering.

Di src/PitchNode.js , kami akan membuat subkelas AudioWorkletNode dari Web Audio API:

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

Tugas utama yang dilakukan oleh PitchNode adalah:

  • Kirim modul WebAssembly sebagai urutan byte mentah—yang diteruskan dari setupAudio.js —ke PitchProcessor , yang berjalan di utas AudioWorklet . Beginilah cara PitchProcessor memuat modul Wasm deteksi nada.
  • Tangani peristiwa yang dikirim oleh PitchProcessor saat berhasil mengompilasi Wasm, dan kirimkan peristiwa lain yang meneruskan informasi konfigurasi deteksi nada ke sana.
  • Tangani nada yang terdeteksi saat tiba dari PitchProcessor dan teruskan ke fungsi UI setLatestPitch() melalui onPitchDetectedCallback() .

Catatan: Kode objek ini berjalan di utas utama, jadi kode ini harus menghindari pemrosesan lebih lanjut pada nada yang terdeteksi jika ini mahal dan menyebabkan penurunan kecepatan bingkai.

4. Tambahkan Kode untuk Mengatur Audio Web

Agar aplikasi web dapat mengakses dan memproses input langsung dari mikrofon mesin klien, aplikasi tersebut harus:

  1. Dapatkan izin pengguna untuk browser mengakses mikrofon apa pun yang terhubung
  2. Akses output mikrofon sebagai objek audio-stream
  3. Lampirkan kode untuk memproses sampel aliran audio yang masuk dan menghasilkan urutan nada yang terdeteksi

Di src/setupAudio.js , kami akan melakukannya, dan juga memuat modul Wasm secara asinkron sehingga kami dapat menginisialisasi PitchNode kami dengannya, sebelum melampirkan PitchNode kami:

 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.


Bacaan Lebih Lanjut di Blog Teknik Toptal:

  • Web Audio API: Mengapa Menulis Ketika Anda Bisa Membuat Kode?
  • WebVR Bagian 3: Membuka Potensi WebAssembly dan AssemblyScript