Tutorial WebAssembly/Rust: Processamento de áudio perfeito
Publicados: 2022-03-11Suportado por todos os navegadores modernos, o WebAssembly (ou “Wasm”) está transformando a maneira como desenvolvemos experiências de usuário para a web. É um formato executável binário simples que permite que bibliotecas ou até mesmo programas inteiros escritos em outras linguagens de programação sejam executados no navegador da web.
Os desenvolvedores geralmente procuram maneiras de serem mais produtivos, como:
- Usar uma única base de código de aplicativo para várias plataformas de destino, mas fazer o aplicativo funcionar bem em todas elas
- Criando um UX que é suave e bonito em ambientes de desktop e móveis
- Aproveitando o ecossistema de bibliotecas de código aberto para evitar “reinventar a roda” durante o desenvolvimento de aplicativos
Para desenvolvedores de front-end, o WebAssembly fornece todos os três, respondendo à busca por uma interface de usuário de aplicativo da Web que realmente rivalize com a experiência nativa móvel ou de desktop. Ele ainda permite o uso de bibliotecas escritas em linguagens não JavaScript, como C++ ou Go!
Neste tutorial Wasm/Rust, vamos criar um aplicativo detector de tom simples, como um afinador de guitarra. Ele usará os recursos de áudio integrados do navegador e será executado a 60 quadros por segundo (FPS) - mesmo em dispositivos móveis. Você não precisa entender a API de áudio da Web ou mesmo estar familiarizado com Rust para acompanhar este tutorial; no entanto, o conforto com JavaScript é esperado.
Nota: Infelizmente, no momento em que este artigo foi escrito, a técnica usada neste artigo—específica para a API de áudio da Web—ainda não funciona no Firefox. Portanto, por enquanto, Chrome, Chromium ou Edge são recomendados para este tutorial, apesar do excelente suporte Wasm e Web Audio API no Firefox.
O que este tutorial de WebAssembly/Rust abrange
- Criando uma função simples em Rust e chamando-a de JavaScript (via WebAssembly)
- Usando a API
AudioWorklet
moderna do navegador para processamento de áudio de alto desempenho no navegador - Comunicação entre trabalhadores em JavaScript
- Unindo tudo em um aplicativo React básico
Nota: Se você estiver mais interessado no “como” do que no “porquê” deste artigo, sinta-se à vontade para ir direto ao tutorial.
Por que Wasm?
Existem várias razões pelas quais pode fazer sentido usar o WebAssembly:
- Ele permite a execução de código dentro do navegador que foi escrito em qualquer linguagem concebivelmente .
- Isso inclui o uso de bibliotecas existentes (numérica, processamento de áudio, aprendizado de máquina, etc.) escritas em linguagens diferentes do JavaScript.
- Dependendo da escolha do idioma usado, o Wasm pode operar em velocidades quase nativas. Isso tem o potencial de aproximar muito mais as características de desempenho de aplicativos da Web das experiências nativas para dispositivos móveis e desktops .
Por que nem sempre usar Wasm?
A popularidade do WebAssembly certamente continuará a crescer; no entanto, não é adequado para todo o desenvolvimento web:
- Para projetos simples, manter JavaScript, HTML e CSS provavelmente resultará em um produto funcional em menos tempo.
- Navegadores mais antigos, como o Internet Explorer, não suportam o Wasm diretamente.
- Os usos típicos do WebAssembly exigem a adição de ferramentas, como um compilador de linguagem, em sua cadeia de ferramentas. Se sua equipe prioriza manter as ferramentas de desenvolvimento e integração contínua o mais simples possível, o uso do Wasm será contrário a isso.
Por que um tutorial Wasm/Rust, especificamente?
Enquanto muitas linguagens de programação compilam para Wasm, eu escolhi Rust para este exemplo. Rust foi criado pela Mozilla em 2010 e está crescendo em popularidade. Rust ocupa o primeiro lugar para “idioma mais amado” na pesquisa de desenvolvedores de 2020 do Stack Overflow. Mas as razões para usar Rust com WebAssembly vão além da mera moda:
- Em primeiro lugar, o Rust tem um tempo de execução pequeno, o que significa que menos código é enviado ao navegador quando um usuário acessa o site, ajudando a manter a pegada do site baixa.
- Rust tem excelente suporte a Wasm, suportando interoperabilidade de alto nível com JavaScript.
- Rust fornece desempenho próximo ao nível C/C++ , mas possui um modelo de memória muito seguro . Quando comparado a outras linguagens, o Rust realiza verificações extras de segurança ao compilar seu código, reduzindo enormemente o potencial de falhas causadas por variáveis vazias ou não inicializadas. Isso pode levar a um tratamento de erros mais simples e a uma chance maior de manter uma boa UX quando ocorrerem problemas imprevistos.
- A ferrugem não é coletada como lixo . Isso significa que o código Rust está totalmente no controle de quando a memória é alocada e limpa, permitindo um desempenho consistente – um requisito fundamental em sistemas em tempo real.
Os muitos benefícios do Rust também vêm com uma curva de aprendizado íngreme, portanto, escolher a linguagem de programação certa depende de vários fatores, como a composição da equipe que desenvolverá e manterá o código.
Desempenho do WebAssembly: Mantendo aplicativos Web suaves e macios
Já que estamos programando em WebAssembly com Rust, como podemos usar Rust para obter os benefícios de desempenho que nos levaram ao Wasm em primeiro lugar? Para um aplicativo com uma GUI de atualização rápida parecer “suave” para os usuários, ele deve ser capaz de atualizar a exibição com a mesma regularidade do hardware da tela. Normalmente, isso é 60 FPS, portanto, nosso aplicativo deve ser capaz de redesenhar sua interface de usuário em ~ 16,7 ms (1.000 ms / 60 FPS).
Nosso aplicativo detecta e mostra o tom atual em tempo real, o que significa que a computação e o desenho combinados da detecção teriam que permanecer dentro de 16,7 ms por quadro. Na próxima seção, aproveitaremos o suporte do navegador para analisar o áudio em outro encadeamento enquanto o encadeamento principal faz seu trabalho. Esta é uma grande vitória para o desempenho, já que a computação e o desenho têm 16,7 ms à sua disposição.
Noções básicas de áudio da Web
Nesta aplicação, usaremos um módulo de áudio WebAssembly de alto desempenho para realizar a detecção de pitch. Além disso, garantiremos que a computação não seja executada no encadeamento principal.
Por que não podemos manter as coisas simples e realizar a detecção de pitch na thread principal?
- O processamento de áudio é muitas vezes computacionalmente intensivo. Isso se deve ao grande número de amostras que precisam ser processadas a cada segundo. Por exemplo, detectar o tom de áudio de forma confiável requer a análise dos espectros de 44.100 amostras a cada segundo.
- A compilação JIT e a coleta de lixo do JavaScript acontecem no thread principal e queremos evitar isso no código de processamento de áudio para um desempenho consistente.
- Se o tempo necessário para processar um quadro de áudio consumisse significativamente o orçamento de quadros de 16,7 ms, o UX sofreria com a animação instável.
- Queremos que nosso aplicativo funcione sem problemas, mesmo em dispositivos móveis de baixo desempenho!
Os worklets de áudio da Web permitem que os aplicativos continuem alcançando 60 FPS suaves porque o processamento de áudio não pode conter o encadeamento principal. Se o processamento de áudio for muito lento e ficar para trás, haverá outros efeitos, como áudio atrasado. No entanto, o UX permanecerá responsivo ao usuário.
Tutorial WebAssembly/Rust: Introdução
Este tutorial pressupõe que você tenha o Node.js instalado, bem como o npx
. Se você ainda não tem o npx
, pode usar npm
(que vem com o Node.js) para instalá-lo:
npm install -g npx
Criar um aplicativo da Web
Para este tutorial Wasm/Rust, usaremos React.
Em um terminal, executaremos os seguintes comandos:
npx create-react-app wasm-audio-app cd wasm-audio-app
Isso usa npx
para executar o comando create-react-app
(contido no pacote correspondente mantido pelo Facebook) para criar um novo aplicativo React no diretório wasm-audio-app
.
create-react-app
é uma CLI para gerar aplicativos de página única (SPAs) baseados em React. Isso torna incrivelmente fácil iniciar um novo projeto com o React. No entanto, o projeto de saída inclui código clichê que precisará ser substituído.
Primeiro, embora eu recomende o teste de unidade do seu aplicativo durante todo o desenvolvimento, o teste está além do escopo deste tutorial. Então vamos em frente e excluir src/App.test.js
e src/setupTests.js
.
Visão geral do aplicativo
Haverá cinco componentes JavaScript principais em nosso aplicativo:
-
public/wasm-audio/wasm-audio.js
contém ligações JavaScript para o módulo Wasm que fornece o algoritmo de detecção de tom. -
public/PitchProcessor.js
é onde o processamento de áudio acontece. Ele é executado no thread de renderização de áudio da Web e consumirá a API Wasm. -
src/PitchNode.js
contém uma implementação de um nó Web Audio, que é conectado ao gráfico Web Audio e executado no encadeamento principal. -
src/setupAudio.js
usa APIs de navegador da web para acessar um dispositivo de gravação de áudio disponível. -
src/App.js
esrc/App.css
compõem a interface do usuário do aplicativo.
Vamos direto ao coração de nosso aplicativo e definir o código Rust para nosso módulo Wasm. Em seguida, codificaremos as várias partes de nosso JavaScript relacionado ao áudio da Web e terminaremos com a interface do usuário.
1. Detecção de pitch usando Rust e WebAssembly
Nosso código Rust calculará um tom musical a partir de uma matriz de amostras de áudio.
Obter ferrugem
Você pode seguir estas instruções para construir a cadeia Rust para desenvolvimento.
Instalar ferramentas para construir componentes WebAssembly em Rust
wasm-pack
permite construir, testar e publicar componentes WebAssembly gerados por Rust. Se você ainda não o fez, instale o wasm-pack.
cargo-generate
ajuda a colocar um novo projeto Rust em funcionamento, aproveitando um repositório Git preexistente como modelo. Usaremos isso para inicializar um analisador de áudio simples em Rust que pode ser acessado usando WebAssembly a partir do navegador.
Usando a ferramenta de cargo
que acompanha a corrente Rust, você pode instalar cargo-generate
:
cargo install cargo-generate
Depois que a instalação (que pode levar vários minutos) estiver concluída, estamos prontos para criar nosso projeto Rust.
Crie nosso módulo WebAssembly
Na pasta raiz do nosso aplicativo, clonaremos o modelo de projeto:
$ cargo generate --git https://github.com/rustwasm/wasm-pack-template
Quando for solicitado um novo nome de projeto, inseriremos wasm-audio
.
No diretório wasm-audio
, agora haverá um arquivo Cargo.toml
com o seguinte conteúdo:
[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
é usado para definir um pacote Rust (que Rust chama de “caixa”), servindo uma função semelhante para aplicativos Rust que package.json
faz para aplicativos JavaScript.
A seção [package]
define os metadados que são usados ao publicar o pacote no registro oficial de pacotes do Rust.
A seção [lib]
descreve o formato de saída do processo de compilação Rust. Aqui, “cdylib” diz ao Rust para produzir uma “biblioteca de sistema dinâmica” que pode ser carregada de outra linguagem (no nosso caso, JavaScript) e incluir “rlib” diz ao Rust para adicionar uma biblioteca estática contendo metadados sobre a biblioteca produzida. Este segundo especificador não é necessário para nossos propósitos - ele auxilia no desenvolvimento de outros módulos Rust que consomem esta caixa como uma dependência - mas é seguro deixá-lo.
Em [features]
, pedimos ao Rust para incluir um recurso opcional console_error_panic_hook
para fornecer funcionalidade que converte o mecanismo de erros não tratados do Rust (chamado panic
) em erros do console que aparecem nas ferramentas de desenvolvimento para depuração.
Finalmente, [dependencies]
lista todos os caixotes dos quais este depende. A única dependência fornecida pronta para uso é wasm-bindgen
, que fornece geração automática de ligações JavaScript para nosso módulo Wasm.
Implementar um detector de pitch no Rust
O objetivo deste app é detectar a voz de um músico ou o tom de um instrumento em tempo real. Para garantir que isso seja executado o mais rápido possível, um módulo WebAssembly é encarregado de calcular o pitch. Para detecção de tom de voz única, usaremos o método de tom “McLeod” que é implementado na biblioteca pitch-detection
Rust existente.
Assim como o gerenciador de pacotes Node.js (npm), o Rust inclui um gerenciador de pacotes próprio, chamado Cargo. Isso permite instalar facilmente os pacotes que foram publicados no registro da caixa Rust.
Para adicionar a dependência, edite Cargo.toml
, adicionando a linha para pitch-detection
na seção de dependências:
[dependencies] wasm-bindgen = "0.2.63" pitch-detection = "0.1"
Isso instrui o Cargo a baixar e instalar a dependência pitch-detection
durante a próxima cargo build
ou, como estamos direcionando o WebAssembly, isso será executado no próximo wasm-pack
.
Crie um Pitch Detector que pode ser chamado por JavaScript no Rust
Primeiro vamos adicionar um arquivo definindo um utilitário útil cuja finalidade discutiremos mais tarde:
Crie wasm-audio/src/utils.rs
e cole o conteúdo deste arquivo nele.
Substituiremos o código gerado em wasm-audio/lib.rs
pelo código a seguir, que realiza a detecção de tom por meio de um 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, } } }
Vamos examinar isso com mais detalhes:
#[wasm_bindgen]
wasm_bindgen
é uma macro Rust que ajuda a implementar a ligação entre JavaScript e Rust. Quando compilada para WebAssembly, essa macro instrui o compilador a criar uma associação de JavaScript a uma classe. O código Rust acima será traduzido para ligações JavaScript que são simplesmente wrappers finos para chamadas de e para o módulo Wasm. A leve camada de abstração combinada com a memória compartilhada direta entre JavaScript é o que ajuda o Wasm a oferecer excelente desempenho.
#[wasm_bindgen] pub struct WasmPitchDetector { sample_rate: usize, fft_size: usize, detector: McLeodDetector<f32>, } #[wasm_bindgen] impl WasmPitchDetector { ... }
Rust não tem um conceito de classes. Em vez disso, os dados de um objeto são descritos por uma struct
e seu comportamento por meio de impl
s ou trait
.
Por que expor a funcionalidade de detecção de tom por meio de um objeto em vez de uma função simples? Porque dessa forma, apenas inicializamos as estruturas de dados usadas pelo McLeodDetector interno uma vez , durante a criação do WasmPitchDetector
. Isso mantém a função detect_pitch
rápida, evitando a dispendiosa alocação de memória durante a operação.
pub fn new(sample_rate: usize, fft_size: usize) -> WasmPitchDetector { utils::set_panic_hook(); let fft_pad = fft_size / 2; WasmPitchDetector { sample_rate, fft_size, detector: McLeodDetector::<f32>::new(fft_size, fft_pad), } }
Quando um aplicativo Rust encontra um erro do qual não pode se recuperar facilmente, é bastante comum invocar um panic!
macro. Isso instrui o Rust a relatar um erro e encerrar o aplicativo imediatamente. O uso de panics pode ser útil principalmente para o desenvolvimento inicial, antes que uma estratégia de tratamento de erros esteja em vigor, pois permite detectar rapidamente suposições falsas.
Chamar utils::set_panic_hook()
uma vez durante a configuração garantirá que mensagens de pânico apareçam nas ferramentas de desenvolvimento do navegador.
Em seguida, definimos fft_pad
, a quantidade de preenchimento de zero aplicada a cada FFT de análise. O preenchimento, em combinação com a função de janela usada pelo algoritmo, ajuda a “suavizar” os resultados à medida que a análise se move pelos dados de áudio amostrados recebidos. Usar um pad com metade do comprimento da FFT funciona bem para muitos instrumentos.
Finalmente, Rust retorna o resultado da última instrução automaticamente, então a instrução de estrutura WasmPitchDetector
é o valor de retorno de new()
.
O resto do nosso código impl WasmPitchDetector
Rust define a API para detectar arremessos:
pub fn detect_pitch(&mut self, audio_samples: Vec<f32>) -> f32 { ... }
É assim que uma definição de função membro se parece em Rust. Um membro público detect_pitch
é adicionado a WasmPitchDetector
. Seu primeiro argumento é uma referência mutável ( &mut
) para um objeto instanciado do mesmo tipo contendo campos struct
e impl
— mas isso é passado automaticamente ao chamar, como veremos abaixo.
Além disso, nossa função membro recebe uma matriz de tamanho arbitrário de números de ponto flutuante de 32 bits e retorna um único número. Aqui, esse será o tom resultante calculado entre essas amostras (em 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()); }
O código acima detecta se amostras suficientes foram fornecidas à função para que uma análise de pitch válida seja realizada. Se não, o panic!
macro é chamada, o que resulta na saída imediata do Wasm e na mensagem de erro impressa no console do dev-tools do navegador.
let optional_pitch = self.detector.get_pitch( &audio_samples, self.sample_rate, POWER_THRESHOLD, CLARITY_THRESHOLD, );
Isso chama a biblioteca de terceiros para calcular o tom das amostras de áudio mais recentes. POWER_THRESHOLD
e CLARITY_THRESHOLD
podem ser ajustados para ajustar a sensibilidade do algoritmo.
Terminamos com um retorno implícito de um valor de ponto flutuante por meio da palavra-chave match
, que funciona de maneira semelhante a uma instrução switch
em outras linguagens. Some()
e None
nos permitem tratar os casos apropriadamente sem nos depararmos com uma exceção de ponteiro nulo.
Criando aplicativos WebAssembly
Ao desenvolver aplicativos Rust, o procedimento de compilação usual é invocar uma compilação usando cargo build
. No entanto, estamos gerando um módulo Wasm, portanto, usaremos wasm-pack
, que fornece uma sintaxe mais simples ao direcionar o Wasm. (Ele também permite publicar as ligações JavaScript resultantes no registro npm, mas isso está fora do escopo deste tutorial.)
wasm-pack
suporta uma variedade de destinos de compilação. Como consumiremos o módulo diretamente de um worklet de áudio da Web, direcionaremos a opção da web
. Outros destinos incluem a criação para um empacotador como webpack ou para consumo de Node.js. Vamos executar isso no subdiretório wasm-audio/
:
wasm-pack build --target web
Se for bem-sucedido, um módulo npm será criado em ./pkg
.
Este é um módulo JavaScript com seu próprio package.json
gerado automaticamente. Isso pode ser publicado no registro npm, se desejado. Para manter as coisas simples por enquanto, podemos simplesmente copiar e colar este pkg
em nossa pasta public/wasm-audio
:

cp -R ./wasm-audio/pkg ./public/wasm-audio
Com isso, criamos um módulo Rust Wasm pronto para ser consumido pelo web app, ou mais especificamente, pelo PitchProcessor
.
2. Nossa classe PitchProcessor
(baseada no AudioWorkletProcessor
nativo)
Para este aplicativo, usaremos um padrão de processamento de áudio que recentemente ganhou ampla compatibilidade com navegadores. Especificamente, usaremos a API de áudio da Web e executaremos cálculos caros em um AudioWorkletProcessor
personalizado. Depois, criaremos a classe AudioWorkletNode
personalizada correspondente (que chamaremos de PitchNode
) como uma ponte de volta ao thread principal.
Crie um novo arquivo public/PitchProcessor.js
e cole o seguinte código nele:
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);
O PitchProcessor
é um companheiro do PitchNode
mas é executado em um thread separado para que a computação de processamento de áudio possa ser executada sem bloquear o trabalho feito no thread principal.
Principalmente, o PitchProcessor
:
- Manipula o evento
"send-wasm-module"
enviado doPitchNode
compilando e carregando o módulo Wasm no worklet. Uma vez feito, ele informa oPitchNode
enviando um"wasm-module-loaded"
. Essa abordagem de retorno de chamada é necessária porque toda a comunicação entrePitchNode
ePitchProcessor
cruza um limite de thread e não pode ser executada de forma síncrona. - Também responde ao evento
"init-detector"
doPitchNode
configurando oWasmPitchDetector
. - Processa amostras de áudio recebidas do gráfico de áudio do navegador, delega a computação de detecção de tom ao módulo Wasm e, em seguida, envia qualquer tom detectado de volta ao
PitchNode
(que envia o tom para a camada React por meio de seuonPitchDetectedCallback
). - Registra-se com um nome específico e exclusivo. Dessa forma, o navegador sabe - por meio da classe base do
PitchNode
, oAudioWorkletNode
nativo - como instanciar nossoPitchProcessor
posteriormente quandoPitchNode
for construído. ConsultesetupAudio.js
.
O diagrama a seguir visualiza o fluxo de eventos entre o PitchNode
e o PitchProcessor
:
3. Adicionar código de worklet de áudio da web
PitchNode.js
fornece a interface para nosso processamento de áudio de detecção de tom personalizado. O objeto PitchNode
é o mecanismo pelo qual os pitches detectados usando o módulo WebAssembly trabalhando no thread AudioWorklet
irão para o thread principal e o React para renderização.
Em src/PitchNode.js
, criaremos uma subclasse do AudioWorkletNode
da API de áudio da 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); } } }
As principais tarefas executadas pelo PitchNode
são:
- Envie o módulo WebAssembly como uma sequência de bytes brutos — aqueles passados de
setupAudio.js
— para oPitchProcessor
, que é executado no encadeamentoAudioWorklet
. É assim que oPitchProcessor
carrega o módulo Wasm de detecção de pitch. - Manipule o evento enviado pelo
PitchProcessor
quando ele compilar o Wasm com sucesso e envie outro evento que transmita informações de configuração de detecção de pitch para ele. - Manipule os pitches detectados à medida que eles chegam do
PitchProcessor
e os encaminhe para a função de interface do usuáriosetLatestPitch()
viaonPitchDetectedCallback()
.
Nota: Este código do objeto é executado no encadeamento principal, portanto, ele deve evitar a execução de processamento adicional em pitches detectados, caso isso seja caro e cause quedas na taxa de quadros.
4. Adicione o código para configurar o áudio da Web
Para que o aplicativo da Web acesse e processe a entrada ao vivo do microfone da máquina cliente, ele deve:
- Obtenha a permissão do usuário para que o navegador acesse qualquer microfone conectado
- Acesse a saída do microfone como um objeto de fluxo de áudio
- Anexe o código para processar as amostras de fluxo de áudio recebidas e produzir uma sequência de tons detectados
Em src/setupAudio.js
, faremos isso e também carregaremos o módulo Wasm de forma assíncrona para que possamos inicializar nosso PitchNode com ele, antes de anexar nosso 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:
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.
Leitura adicional no Blog da Toptal Engineering:
- API de áudio da Web: por que compor quando você pode codificar?
- WebVR Parte 3: Desbloqueando o potencial do WebAssembly e do AssemblyScript