WebAssembly/Rust Tutorial: Pemrosesan Audio Pitch-sempurna
Diterbitkan: 2022-03-11Didukung 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
dansrc/App.css
terdiri dari antarmuka pengguna aplikasi.
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 dariPitchNode
dengan mengompilasi dan memuat modul Wasm ke dalam worklet. Setelah selesai, ini memberi tahuPitchNode
dengan mengirimkan acara"wasm-module-loaded"
. Pendekatan callback ini diperlukan karena semua komunikasi antaraPitchNode
danPitchProcessor
melintasi batas thread dan tidak dapat dilakukan secara sinkron. - Juga merespons acara
"init-detector"
dariPitchNode
dengan mengonfigurasiWasmPitchDetector
. - 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 melaluionPitchDetectedCallback
). - Mendaftarkan dirinya dengan nama yang spesifik dan unik. Dengan cara ini browser mengetahui—melalui kelas dasar
PitchNode
,AudioWorkletNode
asli —cara membuat instancePitchProcessor
kita nanti saatPitchNode
dibuat. LihatsetupAudio.js
.
Diagram berikut memvisualisasikan aliran peristiwa antara PitchNode
dan PitchProcessor
:
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
—kePitchProcessor
, yang berjalan di utasAudioWorklet
. Beginilah caraPitchProcessor
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 UIsetLatestPitch()
melaluionPitchDetectedCallback()
.
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:
- Dapatkan izin pengguna untuk browser mengakses mikrofon apa pun yang terhubung
- Akses output mikrofon sebagai objek audio-stream
- 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:
The first error, TextDecoder is not defined
, occurs when the browser attempts to execute the contents of wasm_audio.js
. This in turn results in the failure to load the Wasm JavaScript wrapper, which produces the second error we see in the console.
The underlying cause of the issue is that modules produced by the Wasm package generator of Rust assume that TextDecoder
(and TextEncoder
) will be provided by the browser. This assumption holds for modern browsers when the Wasm module is being run from the main thread or even a worker thread. However, for worklets (such as the AudioWorklet
context needed in this tutorial), TextDecoder
and TextEncoder
are not yet part of the spec and so are not available.
TextDecoder
is needed by the Rust Wasm code generator to convert from the flat, packed, shared-memory representation of Rust to the string format that JavaScript uses. Put another way, in order to see strings produced by the Wasm code generator, TextEncoder
and TextDecoder
must be defined.
This issue is a symptom of the relative newness of WebAssembly. As browser support improves to support common WebAssembly patterns out of the box, these issues will likely disappear.
For now, we are able to work around it by defining a polyfill for TextDecoder
.
Create a new file public/TextEncoder.js
and import it from public/PitchProcessor.js
:
import "./TextEncoder.js";
Make sure that this import
statement comes before the wasm_audio
import.
Finally, paste this implementation into TextEncoder.js
(courtesy of @Yaffle on GitHub).
The Firefox Question
As mentioned earlier, the way we combine Wasm with Web Audio worklets in our app will not work in Firefox. Even with the above shim, clicking the “Start Listening” button will result in this:
Unhandled Rejection (Error): Failed to load audio analyzer WASM module. Further info: Failed to load audio analyzer worklet at url: PitchProcessor.js. Further info: The operation was aborted.
That's because Firefox doesn't yet support importing modules from AudioWorklets
—for us, that's PitchProcessor.js
running in the AudioWorklet
thread.
The Completed Application
Once done, we simply reload the page. The app should load without error. Click “Start Listening” and allow your browser to access your microphone. You'll see a very basic pitch detector written in JavaScript using Wasm:
Programming in WebAssembly with Rust: A Real-time Web Audio Solution
In this tutorial, we have built a web application from scratch that performs computationally expensive audio processing using WebAssembly. WebAssembly allowed us to take advantage of near-native performance of Rust to perform the pitch detection. Further, this work could be performed on another thread, allowing the main JavaScript thread to focus on rendering to support silky-smooth frame rates even on mobile devices.
Wasm/Rust and Web Audio Takeaways
- Modern browsers provide performant audio (and video) capture and processing inside web apps.
- Rust has great tooling for Wasm, which helps recommend it as the language of choice for projects incorporating WebAssembly.
- Compute-intensive work can be performed efficiently in the browser using Wasm.
Despite the many WebAssembly advantages, there are a couple Wasm pitfalls to watch out for:
- Tooling for Wasm within worklets is still evolving. For example, we needed to implement our own versions of TextEncoder and TextDecoder functionality required for passing strings between JavaScript and Wasm because they were missing from the
AudioWorklet
context. That, and importing Javascript bindings for our Wasm support from anAudioWorklet
is not yet available in Firefox. - Although the application we developed was very simple, building the WebAssembly module and loading it from the
AudioWorklet
required significant setup. Introducing Wasm to projects does introduce an increase in tooling complexity, which is important to keep in mind.
For your convenience, this GitHub repo contains the final, completed project. If you also do back-end development, you may also be interested in using Rust via WebAssembly within Node.js.
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