Tutorial de WebAssembly/Rust: Procesamiento de audio perfecto

Publicado: 2022-03-11

Compatible con todos los navegadores modernos, WebAssembly (o "Wasm") está transformando la forma en que desarrollamos experiencias de usuario para la web. Es un formato ejecutable binario simple que permite que las bibliotecas o incluso los programas completos que se han escrito en otros lenguajes de programación se ejecuten en el navegador web.

Los desarrolladores a menudo buscan formas de ser más productivos, como:

  • Usar una sola base de código de aplicación para múltiples plataformas de destino, pero hacer que la aplicación funcione bien en todas ellas
  • Crear una experiencia de usuario fluida y hermosa en entornos móviles y de escritorio
  • Aprovechar el ecosistema de bibliotecas de código abierto para evitar “reinventar la rueda” durante el desarrollo de aplicaciones

Para los desarrolladores front-end, WebAssembly ofrece los tres, respondiendo a la búsqueda de una interfaz de usuario de aplicación web que realmente compita con la experiencia móvil o de escritorio nativa. Incluso permite el uso de bibliotecas escritas en lenguajes que no sean JavaScript, como C++ o Go!

En este tutorial de Wasm/Rust, crearemos una aplicación simple de detección de tono, como un afinador de guitarra. Utilizará las capacidades de audio integradas del navegador y se ejecutará a 60 fotogramas por segundo (FPS), incluso en dispositivos móviles. No necesita comprender la API de audio web o incluso estar familiarizado con Rust para seguir este tutorial; sin embargo, se espera comodidad con JavaScript.

Nota: Lamentablemente, en el momento de escribir este artículo, la técnica utilizada en este artículo, específica de la API de audio web, aún no funciona en Firefox. Por lo tanto, por el momento, se recomiendan Chrome, Chromium o Edge para este tutorial, a pesar de la excelente compatibilidad con Wasm y Web Audio API en Firefox.

Qué cubre este tutorial de WebAssembly/Rust

  • Crear una función simple en Rust y llamarla desde JavaScript (a través de WebAssembly)
  • Uso de la moderna API AudioWorklet del navegador para un procesamiento de audio de alto rendimiento en el navegador
  • Comunicación entre trabajadores en JavaScript
  • Uniéndolo todo en una aplicación React básica

Nota: Si está más interesado en el "cómo" que en el "por qué" de este artículo, no dude en pasar directamente al tutorial.

¿Por qué Wasm?

Hay varias razones por las que podría tener sentido usar WebAssembly:

  • Permite ejecutar código dentro del navegador que fue escrito posiblemente en cualquier idioma .
    • Esto incluye hacer uso de bibliotecas existentes (numéricas, de procesamiento de audio, de aprendizaje automático, etc.) que están escritas en lenguajes distintos a JavaScript.
  • Dependiendo de la elección del idioma utilizado, Wasm puede operar a velocidades casi nativas. Esto tiene el potencial de acercar mucho más las características de rendimiento de las aplicaciones web a las experiencias nativas para dispositivos móviles y de escritorio .

¿Por qué no usar siempre Wasm?

La popularidad de WebAssembly seguramente seguirá creciendo; sin embargo, no es adecuado para todo el desarrollo web:

  • Para proyectos simples, seguir con JavaScript, HTML y CSS probablemente entregará un producto que funcione en menos tiempo.
  • Los navegadores más antiguos, como Internet Explorer, no son compatibles con Wasm directamente.
  • Los usos típicos de WebAssembly requieren agregar herramientas, como un compilador de lenguaje, a su cadena de herramientas. Si su equipo prioriza mantener las herramientas de desarrollo e integración continua lo más simple posible, el uso de Wasm irá en contra de esto.

¿Por qué un tutorial de Wasm/Rust, específicamente?

Si bien muchos lenguajes de programación se compilan en Wasm, elegí Rust para este ejemplo. Rust fue creado por Mozilla en 2010 y está creciendo en popularidad. Rust ocupa el primer lugar como "lenguaje más querido" en la encuesta de desarrolladores de 2020 de Stack Overflow. Pero las razones para usar Rust con WebAssembly van más allá de la mera moda:

  • En primer lugar, Rust tiene un tiempo de ejecución pequeño, lo que significa que se envía menos código al navegador cuando un usuario accede al sitio, lo que ayuda a mantener bajo el espacio ocupado por el sitio web.
  • Rust tiene una excelente compatibilidad con Wasm, que admite interoperabilidad de alto nivel con JavaScript.
  • Rust proporciona un rendimiento cercano al nivel de C/C++ , pero tiene un modelo de memoria muy seguro . En comparación con otros lenguajes, Rust realiza comprobaciones de seguridad adicionales mientras compila su código, lo que reduce enormemente la posibilidad de bloqueos causados ​​por variables vacías o no inicializadas. Esto puede conducir a un manejo de errores más simple y una mayor probabilidad de mantener una buena UX cuando ocurren problemas imprevistos.
  • El óxido no se recoge en la basura . Esto significa que el código de Rust tiene el control total de cuándo se asigna y limpia la memoria, lo que permite un rendimiento constante , un requisito clave en los sistemas en tiempo real.

Los muchos beneficios de Rust también vienen con una curva de aprendizaje pronunciada, por lo que elegir el lenguaje de programación correcto depende de una variedad de factores, como la composición del equipo que desarrollará y mantendrá el código.

Rendimiento de WebAssembly: mantenimiento de aplicaciones web suaves como la seda

Dado que estamos programando en WebAssembly con Rust, ¿cómo podríamos usar Rust para obtener los beneficios de rendimiento que nos llevaron a Wasm en primer lugar? Para que una aplicación con una GUI que se actualiza rápidamente se sienta "fluida" para los usuarios, debe poder actualizar la pantalla con la misma frecuencia que el hardware de la pantalla. Suele ser 60 FPS, por lo que nuestra aplicación debe poder volver a dibujar su interfaz de usuario en ~16,7 ms (1000 ms/60 FPS).

Nuestra aplicación detecta y muestra el tono actual en tiempo real, lo que significa que el dibujo y el cálculo de detección combinados tendrían que permanecer dentro de los 16,7 ms por cuadro. En la siguiente sección, aprovecharemos la compatibilidad del navegador para analizar audio en otro subproceso mientras el subproceso principal hace su trabajo. Esta es una gran victoria para el rendimiento, ya que el cálculo y el dibujo tienen cada uno 16,7 ms a su disposición.

Conceptos básicos de audio web

En esta aplicación, utilizaremos un módulo de audio WebAssembly de alto rendimiento para realizar la detección de tono. Además, nos aseguraremos de que el cálculo no se ejecute en el subproceso principal.

¿Por qué no podemos simplificar las cosas y realizar una detección de tono en el hilo principal?

  • El procesamiento de audio suele ser intensivo en computación. Esto se debe a la gran cantidad de muestras que deben procesarse cada segundo. Por ejemplo, la detección confiable del tono de audio requiere analizar los espectros de 44,100 muestras por segundo.
  • La compilación JIT y la recolección de elementos no utilizados de JavaScript ocurren en el subproceso principal y queremos evitar esto en el código de procesamiento de audio para lograr un rendimiento constante.
  • Si el tiempo necesario para procesar un cuadro de audio se comiera significativamente el presupuesto de cuadro de 16,7 ms, la UX sufriría una animación entrecortada.
  • ¡Queremos que nuestra aplicación funcione sin problemas incluso en dispositivos móviles de bajo rendimiento!

Los worklets de audio web permiten que las aplicaciones continúen logrando 60 FPS fluidos porque el procesamiento de audio no puede detener el hilo principal. Si el procesamiento de audio es demasiado lento y se retrasa, habrá otros efectos, como retrasos en el audio. Sin embargo, la UX seguirá respondiendo al usuario.

Tutorial de WebAssembly/Rust: Primeros pasos

Este tutorial asume que tiene instalado Node.js, así como npx . Si aún no tiene npx , puede usar npm (que viene con Node.js) para instalarlo:

 npm install -g npx

Crear una aplicación web

Para este tutorial de Wasm/Rust, usaremos React.

En una terminal, ejecutaremos los siguientes comandos:

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

Esto usa npx para ejecutar el comando create-react-app (contenido en el paquete correspondiente mantenido por Facebook) para crear una nueva aplicación React en el directorio wasm-audio-app .

create-react-app es una CLI para generar aplicaciones de una sola página (SPA) basadas en React. Hace que sea increíblemente fácil comenzar un nuevo proyecto con React. Sin embargo, el proyecto de salida incluye un código repetitivo que deberá reemplazarse.

En primer lugar, aunque recomiendo enfáticamente realizar pruebas unitarias de su aplicación durante el desarrollo, las pruebas están más allá del alcance de este tutorial. Así que seguiremos adelante y eliminaremos src/App.test.js y src/setupTests.js .

Descripción general de la aplicación

Habrá cinco componentes JavaScript principales en nuestra aplicación:

  • public/wasm-audio/wasm-audio.js contiene enlaces de JavaScript al módulo Wasm que proporciona el algoritmo de detección de tono.
  • public/PitchProcessor.js es donde ocurre el procesamiento de audio. Se ejecuta en el subproceso de representación de Web Audio y consumirá la API de Wasm.
  • src/PitchNode.js contiene una implementación de un nodo de audio web, que está conectado al gráfico de audio web y se ejecuta en el subproceso principal.
  • src/setupAudio.js utiliza las API del navegador web para acceder a un dispositivo de grabación de audio disponible.
  • src/App.js y src/App.css comprenden la interfaz de usuario de la aplicación.

Un diagrama de flujo para la aplicación de detección de tono. Los bloques 1 y 2 se ejecutan en el hilo Web Audio. El bloque 1 es el detector de tono Wasm (Rust), en el archivo wasm-audio/lib.rs. El bloque 2 es Web Audio Detection + Communication, en el archivo PitchProcessor.js. Le pide al detector que se inicialice y el detector envía los tonos detectados a la interfaz de audio web. Los bloques 3, 4 y 5 se ejecutan en el subproceso principal. El bloque 3 es el controlador de audio web, en el archivo PitchNode.js. Envía el módulo Wasm a PitchProcessor.js y recibe de él los tonos detectados. El bloque 4 es Configuración de audio web, en setupAudio.js. Crea un objeto PitchNode. El bloque 5 es la interfaz de usuario de la aplicación web, compuesta por App.js y App.css. Llama a setupAudio.js al inicio. También pausa o reanuda la grabación de audio mediante el envío de un mensaje a PitchNode, desde el cual recibe los tonos detectados para mostrarlos al usuario.
Descripción general de la aplicación de audio Wasm.

Profundicemos directamente en el corazón de nuestra aplicación y definamos el código Rust para nuestro módulo Wasm. Luego codificaremos las diversas partes de nuestro JavaScript relacionado con Web Audio y terminaremos con la interfaz de usuario.

1. Detección de tono usando Rust y WebAssembly

Nuestro código Rust calculará un tono musical a partir de una serie de muestras de audio.

Obtener óxido

Puede seguir estas instrucciones para construir la cadena Rust para el desarrollo.

Instalar herramientas para construir componentes WebAssembly en Rust

wasm-pack le permite compilar, probar y publicar componentes de WebAssembly generados por Rust. Si aún no lo ha hecho, instale wasm-pack.

cargo-generate ayuda a poner en marcha un nuevo proyecto de Rust aprovechando un repositorio Git preexistente como plantilla. Usaremos esto para iniciar un analizador de audio simple en Rust al que se puede acceder usando WebAssembly desde el navegador.

Usando la herramienta de cargo que viene con la cadena Rust, puedes instalar cargo-generate :

 cargo install cargo-generate

Una vez que se completa la instalación (que puede demorar varios minutos), estamos listos para crear nuestro proyecto Rust.

Crear nuestro módulo WebAssembly

Desde la carpeta raíz de nuestra aplicación, clonaremos la plantilla del proyecto:

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

Cuando se le solicite un nuevo nombre de proyecto, ingresaremos wasm-audio .

En el directorio wasm-audio , ahora habrá un archivo Cargo.toml con el siguiente contenido:

 [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 se usa para definir un paquete de Rust (que Rust llama una "caja"), y cumple una función similar para las aplicaciones de Rust que package.json hace para las aplicaciones de JavaScript.

La sección [package] define los metadatos que se utilizan al publicar el paquete en el registro oficial de paquetes de Rust.

La sección [lib] describe el formato de salida del proceso de compilación de Rust. Aquí, "cdylib" le dice a Rust que produzca una "biblioteca de sistema dinámico" que se pueda cargar desde otro idioma (en nuestro caso, JavaScript) e incluir "rlib" le dice a Rust que agregue una biblioteca estática que contenga metadatos sobre la biblioteca producida. Este segundo especificador no es necesario para nuestros propósitos: ayuda con el desarrollo de más módulos de Rust que consumen esta caja como una dependencia, pero es seguro dejarlo.

En [features] , le pedimos a Rust que incluya una función opcional console_error_panic_hook para proporcionar una funcionalidad que convierte el mecanismo de errores no controlados de Rust (llamado panic ) en errores de consola que aparecen en las herramientas de desarrollo para la depuración.

Finalmente, [dependencies] enumera todas las cajas de las que depende. La única dependencia provista de fábrica es wasm-bindgen , que proporciona la generación automática de enlaces de JavaScript a nuestro módulo Wasm.

Implementar un detector de tono en Rust

El objetivo de esta aplicación es poder detectar la voz de un músico o el tono de un instrumento en tiempo real. Para garantizar que esto se ejecute lo más rápido posible, un módulo WebAssembly tiene la tarea de calcular el tono. Para la detección de tono de una sola voz, utilizaremos el método de tono "McLeod" que se implementa en la biblioteca pitch-detection Rust existente.

Al igual que el administrador de paquetes de Node.js (npm), Rust incluye un administrador de paquetes propio, llamado Cargo. Esto permite instalar fácilmente paquetes que se han publicado en el registro de cajas de Rust.

Para agregar la dependencia, edite Cargo.toml y agregue la línea para pitch-detection en la sección de dependencias:

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

Esto le indica a Cargo que descargue e instale la dependencia pitch-detection durante la próxima cargo build o, dado que estamos apuntando a WebAssembly, esto se realizará en el próximo wasm-pack .

Cree un detector de tono invocable por JavaScript en Rust

Primero agregaremos un archivo que defina una utilidad útil cuyo propósito discutiremos más adelante:

Cree wasm-audio/src/utils.rs y pegue el contenido de este archivo en él.

Reemplazaremos el código generado en wasm-audio/lib.rs con el siguiente código, que realiza la detección de tono a través de un algoritmo de transformada rápida de 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, } } }

Examinemos esto con más detalle:

 #[wasm_bindgen]

wasm_bindgen es una macro de Rust que ayuda a implementar el enlace entre JavaScript y Rust. Cuando se compila en WebAssembly, esta macro indica al compilador que cree un enlace de JavaScript a una clase. El código de Rust anterior se traducirá en enlaces de JavaScript que son simplemente envoltorios delgados para llamadas hacia y desde el módulo Wasm. La ligera capa de abstracción combinada con la memoria compartida directa entre JavaScript es lo que ayuda a Wasm a ofrecer un rendimiento excelente.

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

Rust no tiene un concepto de clases. Más bien, los datos de un objeto se describen mediante una struct y su comportamiento a través de impl s o trait s.

¿Por qué exponer la funcionalidad de detección de tono a través de un objeto en lugar de una función simple? Porque de esa manera, solo inicializamos las estructuras de datos utilizadas por el McLeodDetector interno una vez , durante la creación del WasmPitchDetector . Esto mantiene la función detect_pitch rápida al evitar la costosa asignación de memoria durante la operación.

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

Cuando una aplicación Rust encuentra un error del que no puede recuperarse fácilmente, es bastante común invocar un panic! macro. Esto le indica a Rust que informe un error y finalice la aplicación de inmediato. Hacer uso de pánicos puede ser útil particularmente para el desarrollo temprano antes de que se implemente una estrategia de manejo de errores, ya que le permite detectar suposiciones falsas rápidamente.

Llamar utils::set_panic_hook() una vez durante la configuración garantizará que aparezcan mensajes de pánico en las herramientas de desarrollo del navegador.

A continuación, definimos fft_pad , la cantidad de relleno cero aplicada a cada FFT de análisis. El relleno, en combinación con la función de ventana utilizada por el algoritmo, ayuda a "suavizar" los resultados a medida que el análisis avanza a través de los datos de audio muestreados entrantes. El uso de un pad de la mitad de la longitud de FFT funciona bien para muchos instrumentos.

Finalmente, Rust devuelve el resultado de la última declaración automáticamente, por lo que la declaración de estructura WasmPitchDetector es el valor de retorno de new() .

El resto de nuestro código impl WasmPitchDetector Rust define la API para detectar tonos:

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

Así es como se ve una definición de función miembro en Rust. Se agrega un miembro público detect_pitch a WasmPitchDetector . Su primer argumento es una referencia mutable ( &mut ) a un objeto instanciado del mismo tipo que contiene campos struct e impl , pero esto se pasa automáticamente al llamar, como veremos a continuación.

Además, nuestra función miembro toma una matriz de tamaño arbitrario de números de coma flotante de 32 bits y devuelve un solo número. Aquí, ese será el tono resultante calculado a través de esas muestras (en 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()); }

El código anterior detecta si se proporcionaron suficientes muestras a la función para realizar un análisis de tono válido. Si no, ¡el panic! se llama a la macro, lo que da como resultado la salida inmediata de Wasm y el mensaje de error impreso en la consola de herramientas de desarrollo del navegador.

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

Esto llama a la biblioteca de terceros para calcular el tono de las últimas muestras de audio. POWER_THRESHOLD y CLARITY_THRESHOLD se pueden ajustar para ajustar la sensibilidad del algoritmo.

Terminamos con un retorno implícito de un valor de punto flotante a través de la palabra clave de match , que funciona de manera similar a una declaración de switch en otros idiomas. Some() y None nos permiten manejar los casos de manera adecuada sin encontrarnos con una excepción de puntero nulo.

Creación de aplicaciones WebAssembly

Al desarrollar aplicaciones de Rust, el procedimiento de compilación habitual es invocar una compilación usando cargo build . Sin embargo, estamos generando un módulo Wasm, por lo que utilizaremos wasm-pack , que proporciona una sintaxis más simple al apuntar a Wasm. (También permite publicar los enlaces JavaScript resultantes en el registro npm, pero eso está fuera del alcance de este tutorial).

wasm-pack admite una variedad de objetivos de compilación. Debido a que consumiremos el módulo directamente desde un worklet de audio web, apuntaremos a la opción web . Otros objetivos incluyen la creación de un paquete como un paquete web o para el consumo de Node.js. Ejecutaremos esto desde el subdirectorio wasm-audio/ :

 wasm-pack build --target web

Si tiene éxito, se crea un módulo npm en ./pkg .

Este es un módulo de JavaScript con su propio package.json generado automáticamente. Esto se puede publicar en el registro npm si lo desea. Para simplificar las cosas por ahora, simplemente podemos copiar y pegar este pkg en nuestra carpeta public/wasm-audio :

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

Con eso, hemos creado un módulo Rust Wasm listo para ser consumido por la aplicación web, o más específicamente, por PitchProcessor .

2. Nuestra clase PitchProcessor (basada en Native AudioWorkletProcessor )

Para esta aplicación, utilizaremos un estándar de procesamiento de audio que recientemente obtuvo una amplia compatibilidad con navegadores. Específicamente, usaremos la API de audio web y ejecutaremos cálculos costosos en un AudioWorkletProcessor personalizado. Luego, crearemos la clase AudioWorkletNode personalizada correspondiente (a la que llamaremos PitchNode ) como un puente de regreso al hilo principal.

Cree un nuevo archivo public/PitchProcessor.js y pegue el siguiente código en él:

 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 es un complemento de PitchNode , pero se ejecuta en un subproceso separado para que el cálculo del procesamiento de audio se pueda realizar sin bloquear el trabajo realizado en el subproceso principal.

Principalmente, el PitchProcessor :

  • Maneja el evento "send-wasm-module" enviado desde PitchNode compilando y cargando el módulo Wasm en el worklet. Una vez hecho esto, le avisa a PitchNode enviando un evento "wasm-module-loaded" . Este enfoque de devolución de llamada es necesario porque toda la comunicación entre PitchNode y PitchProcessor cruza un límite de subproceso y no se puede realizar de forma síncrona.
  • También responde al evento "init-detector" de PitchNode configurando WasmPitchDetector .
  • Procesa muestras de audio recibidas del gráfico de audio del navegador, delega el cálculo de detección de tono al módulo Wasm y luego envía cualquier tono detectado a PitchNode (que envía el tono a la capa React a través onPitchDetectedCallback ).
  • Se registra bajo un nombre único y específico. De esta forma, el navegador sabe, a través de la clase base de PitchNode , el AudioWorkletNode nativo, cómo instanciar nuestro PitchProcessor más adelante cuando se construya PitchNode . Consulte setupAudio.js .

El siguiente diagrama visualiza el flujo de eventos entre PitchNode y PitchProcessor :

Un diagrama de flujo más detallado que compara las interacciones entre los objetos PitchNode y PitchProcess en tiempo de ejecución. Durante la configuración inicial, PitchNode envía el módulo Wasm como una matriz de bytes a PitchProcessor, que los compila y los envía de regreso a PitchNode, que finalmente responde con un mensaje de evento que solicita que PitchProcessor se inicialice. Mientras graba audio, PitchNode no envía nada y recibe dos tipos de mensajes de eventos de PitchProcessor: un tono detectado o un error, si ocurre desde Wasm o el worklet.
Mensajes de eventos de tiempo de ejecución.

3. Agregar código de Worklet de audio web

PitchNode.js proporciona la interfaz para nuestro procesamiento de audio de detección de tono personalizado. El objeto PitchNode es el mecanismo mediante el cual los tonos detectados usando el módulo WebAssembly que trabaja en el subproceso AudioWorklet se dirigirán al subproceso principal y React para su procesamiento.

En src/PitchNode.js , crearemos una subclase del AudioWorkletNode de la API de audio web:

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

Las tareas clave realizadas por PitchNode son:

  • Envíe el módulo WebAssembly como una secuencia de bytes sin procesar, los que se pasan desde setupAudio.js , al PitchProcessor , que se ejecuta en el subproceso AudioWorklet . Así es como PitchProcessor carga el módulo Wasm de detección de tono.
  • Manejar el evento enviado por PitchProcessor cuando compila correctamente el Wasm y enviarle otro evento que le pase información de configuración de detección de tono.
  • Maneje los lanzamientos detectados a medida que llegan del PitchProcessor y reenvíelos a la función de UI setLatestPitch() a través onPitchDetectedCallback() .

Nota: este código del objeto se ejecuta en el subproceso principal, por lo que debe evitar realizar un procesamiento adicional en los tonos detectados en caso de que sea costoso y provoque caídas en la velocidad de fotogramas.

4. Agregar código para configurar audio web

Para que la aplicación web acceda y procese la entrada en vivo desde el micrófono de la máquina cliente, debe:

  1. Obtenga el permiso del usuario para que el navegador acceda a cualquier micrófono conectado
  2. Acceda a la salida del micrófono como un objeto de transmisión de audio
  3. Adjunte código para procesar las muestras de transmisión de audio entrantes y producir una secuencia de tonos detectados

En src/setupAudio.js , haremos eso y también cargaremos el módulo Wasm de forma asíncrona para que podamos inicializar nuestro PitchNode con él, antes de adjuntar nuestro 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.


Lecturas adicionales en el blog de ingeniería de Toptal:

  • Web Audio API: ¿Por qué componer cuando puedes programar?
  • WebVR Parte 3: Desbloqueo del potencial de WebAssembly y AssemblyScript