O artigo anterior sobre o Audio Worklet detalhou os conceitos básicos e o uso. Desde o lançamento do Chrome 66, houve muitas solicitações de mais exemplos de como ele pode ser usado em aplicativos reais. O Audio Worklet libera todo o potencial do WebAudio, mas aproveitar isso pode ser desafiador porque exige compreensão da programação simultânea combinada com várias APIs JS. Mesmo para desenvolvedores que conhecem o WebAudio, integrar o Audio Worklet a outras APIs (por exemplo, WebAssembly) pode ser difícil.
Este artigo vai ajudar o leitor a entender melhor como usar o Audio Worklet em configurações reais e oferecer dicas para aproveitar ao máximo o recurso. Confira também exemplos de código e demonstrações ao vivo.
Resumo: worklet de áudio
Antes de entrar em detalhes, vamos recapitular rapidamente os termos e fatos sobre o sistema de worklets de áudio, que foi apresentado anteriormente nesta postagem.
- BaseAudioContext: o objeto principal da API Web Audio.
- Audio Worklet: um carregador de arquivos de script especial para a operação Audio Worklet. Pertence a BaseAudioContext. Um BaseAudioContext pode ter um worklet de áudio. O arquivo de script carregado é avaliado no AudioWorkletGlobalScope e usado para criar as instâncias do AudioWorkletProcessor.
- AudioWorkletGlobalScope: um escopo global JS especial para a operação do Audio Worklet. É executado em uma linha de execução de renderização dedicada para o WebAudio. Um BaseAudioContext pode ter um AudioWorkletGlobalScope.
- AudioWorkletNode: um AudioNode projetado para a operação de AudioWorklet. Instanciado de um BaseAudioContext. Um BaseAudioContext pode ter vários AudioWorkletNodes de forma semelhante aos AudioNodes nativos.
- AudioWorkletProcessor: uma contraparte do AudioWorkletNode. O que realmente importa no AudioWorkletNode que processa o stream de áudio pelo código fornecido pelo usuário. Ele é instanciado no AudioWorkletGlobalScope quando um AudioWorkletNode é construído. Um AudioWorkletNode pode ter um AudioWorkletProcessor correspondente.
Padrões de design
Como usar o Audio Worklet com o WebAssembly
O WebAssembly é um complemento perfeito para o AudioWorkletProcessor. A combinação desses dois recursos traz várias vantagens para o processamento de áudio na Web, mas os dois principais benefícios são: a) trazer o código de processamento de áudio C/C++ para o ecossistema WebAudio e b) evitar a sobrecarga da compilação JIT do JS e da coleta de lixo no código de processamento de áudio.
O primeiro é importante para desenvolvedores que já investiram em código e bibliotecas de processamento de áudio, mas o segundo é essencial para quase todos os usuários da API. No mundo do WebAudio, o orçamento de tempo para o stream de áudio estável é bastante exigente: são apenas 3 ms na taxa de amostragem de 44,1 kHz. Até mesmo um pequeno problema no código de processamento de áudio pode causar falhas. O desenvolvedor precisa otimizar o código para um processamento mais rápido, mas também minimizar a quantidade de lixo JS gerado. O uso do WebAssembly pode ser uma solução que resolve os dois problemas ao mesmo tempo: é mais rápido e não gera lixo do código.
A próxima seção descreve como o WebAssembly pode ser usado com um worklet de áudio, e o exemplo de código correspondente pode ser encontrado aqui. Para conferir o tutorial básico sobre como usar o Emscripten e o WebAssembly (especialmente o código de união do Emscripten), consulte este artigo.
Como configurar
Parece ótimo, mas precisamos de um pouco de estrutura para configurar as coisas corretamente. A primeira pergunta de design a ser feita é como e onde instanciar um módulo do WebAssembly. Depois de buscar o código de cola do Emscripten, há dois caminhos para a instanciação do módulo:
- Instância um módulo do WebAssembly carregando o código de união no
AudioWorkletGlobalScope usando
audioContext.audioWorklet.addModule()
. - Instância um módulo WebAssembly no escopo principal e transfira o módulo pelas opções do construtor do AudioWorkletNode.
A decisão depende muito do seu design e preferência, mas a ideia é que o módulo WebAssembly possa gerar uma instância de WebAssembly no AudioWorkletGlobalScope, que se torna um kernel de processamento de áudio em uma instância do AudioWorkletProcessor.
Para que o padrão A funcione corretamente, o Emscripten precisa de algumas opções para gerar o código de união da WebAssembly correto para nossa configuração:
-s BINARYEN_ASYNC_COMPILATION=0 -s SINGLE_FILE=1 --post-js mycode.js
Essas opções garantem a compilação síncrona de um módulo do WebAssembly no
AudioWorkletGlobalScope. Ele também anexa a definição de classe do AudioWorkletProcessor
em mycode.js
para que ela possa ser carregada após a inicialização do módulo.
O principal motivo para usar a compilação síncrona é que a resolução
da promessa de audioWorklet.addModule()
não espera a resolução de
promessas no AudioWorkletGlobalScope. O carregamento ou a compilação síncrona
na linha de execução principal geralmente não é recomendado porque bloqueia as outras
tarefas na mesma linha de execução. No entanto, aqui podemos ignorar a regra porque a
compilação acontece no AudioWorkletGlobalScope, que é executado fora da linha de execução
principal. Consulte
este link
para mais informações.
O padrão B pode ser útil se for necessário realizar um trabalho pesado assíncrono. Ele usa a linha de execução principal para buscar o código de união do servidor e compilar o módulo. Em seguida, ele vai transferir o módulo WASM pelo construtor do AudioWorkletNode. Esse padrão faz ainda mais sentido quando você precisa carregar o módulo dinamicamente depois que o AudioWorkletGlobalScope começa a renderizar o fluxo de áudio. Dependendo do tamanho do módulo, a compilação no meio da renderização pode causar falhas no stream.
Dados de áudio e pilha do WASM
O código do WebAssembly só funciona na memória alocada em um heap WASM dedicado. Para aproveitar isso, os dados de áudio precisam ser clonados entre a pilha WASM e as matrizes de dados de áudio. A classe HeapAudioBuffer no código de exemplo processa essa operação corretamente.
Há uma proposta inicial em discussão para integrar a pilha WASM diretamente ao sistema Audio Worklet. Livrar-se dessa clonagem de dados redundantes entre a memória JS e a pilha WASM parece natural, mas os detalhes específicos precisam ser resolvidos.
Como lidar com incompatibilidades de tamanho do buffer
Um par de AudioWorkletNode e AudioWorkletProcessor foi projetado para funcionar como um AudioNode normal. O AudioWorkletNode lida com a interação com outros códigos, enquanto o AudioWorkletProcessor cuida do processamento de áudio interno. Como um AudioNode regular processa 128 frames de cada vez, o AudioWorkletProcessor precisa fazer o mesmo para se tornar um recurso principal. Essa é uma das vantagens do design do Audio Worklet, que garante que nenhuma latência adicional devido ao armazenamento em buffer interno seja introduzida no AudioWorkletProcessor, mas pode ser um problema se uma função de processamento exigir um tamanho de buffer diferente de 128 frames. A solução comum para esse caso é usar um buffer circular, também conhecido como buffer circular ou FIFO.
Este é um diagrama do AudioWorkletProcessor usando dois buffers de anel para acomodar uma função WASM que recebe e envia 512 frames. O número 512 aqui é escolhido de forma arbitrária.
O algoritmo do diagrama seria:
- O AudioWorkletProcessor envia 128 frames para o Input RingBuffer da entrada.
- Siga as etapas abaixo somente se o Input RingBuffer tiver mais
ou igual a 512 frames.
- Extraia 512 frames do Input RingBuffer.
- Processa 512 frames com a função WASM especificada.
- Envie 512 frames para o Output RingBuffer.
- O AudioWorkletProcessor extrai 128 frames do Output RingBuffer para preencher a Output.
Conforme mostrado no diagrama, os frames de entrada sempre são acumulados no Input RingBuffer e ele processa o buffer overflow substituindo o bloco de frame mais antigo no buffer. Isso é razoável para um aplicativo de áudio em tempo real. Da mesma forma, o bloco de frame de saída sempre será puxado pelo sistema. O buffer underflow (não há dados suficientes) no Output RingBuffer vai resultar em silêncio, causando uma falha no stream.
Esse padrão é útil ao substituir o ScriptProcessorNode (SPN) por AudioWorkletNode. Como o SPN permite que o desenvolvedor escolha um tamanho de buffer entre 256 e 16384 frames, a substituição de SPN com AudioWorkletNode pode ser difícil, e o uso de um buffer circular oferece uma boa solução alternativa. Um gravador de áudio seria um ótimo exemplo que pode ser criado com base nesse design.
No entanto, é importante entender que esse design apenas reconcilia a incompatibilidade de tamanho do buffer e não dá mais tempo para executar o código do script fornecido. Se o código não conseguir concluir a tarefa dentro do orçamento de tempo de quantum de renderização (~3ms a 44,1Khz), isso afetará o tempo de início da função de callback posterior e, eventualmente, causará falhas.
Misturar esse design com o WebAssembly pode ser complicado devido ao gerenciamento de memória em torno do heap do WASM. No momento em que este artigo foi escrito, os dados que entram e saem do heap do WASM precisam ser clonados, mas podemos usar a classe HeapAudioBuffer para facilitar o gerenciamento de memória. A ideia de usar a memória alocada pelo usuário para reduzir a clonagem de dados redundantes será discutida no futuro.
A classe RingBuffer pode ser encontrada aqui.
Powerhouse do WebAudio: Audio Worklet e SharedArrayBuffer
O último padrão de design deste artigo é colocar várias APIs de ponta em um só lugar: Audio Worklet, SharedArrayBuffer, Atomics e Worker. Com essa configuração não trivial, ele desbloqueia um caminho para que o software de áudio atual escrito em C/C++ seja executado em um navegador da Web, mantendo uma experiência do usuário tranquila.
A maior vantagem desse design é poder usar um DedicatedWorkerGlobalScope exclusivamente para processamento de áudio. No Chrome, o WorkerGlobalScope é executado em uma linha de execução de prioridade mais baixa do que a linha de execução de renderização do WebAudio, mas tem várias vantagens em relação ao AudioWorkletGlobalScope. O DedicatedWorkerGlobalScope é menos restrito em termos da superfície da API disponível no escopo. Além disso, você pode esperar um melhor suporte do Emscripten porque a API Worker existe há alguns anos.
O SharedArrayBuffer tem um papel fundamental para que esse design funcione de maneira eficiente. Embora o worker e o AudioWorkletProcessor tenham mensagens assíncronas (MessagePort), elas não são ideais para processamento de áudio em tempo real devido à alocação de memória repetitiva e à latência de mensagens. Assim, alocamos um bloco de memória antecipadamente, que pode ser acessado pelas duas linhas de execução para uma transferência de dados bidirecional rápida.
Do ponto de vista do purista da API Web Audio, esse design pode parecer subótimo porque usa o worklet de áudio como um "sink" de áudio simples e faz tudo no Worker. No entanto, considerando que o custo de reescrever projetos C/C++ em JavaScript pode ser proibitivo ou até mesmo impossível, esse design pode ser o caminho de implementação mais eficiente para esses projetos.
Estados compartilhados e atômicos
Ao usar uma memória compartilhada para dados de áudio, o acesso de ambos os lados precisa ser
coordenado com cuidado. O compartilhamento de estados acessíveis de forma atômica é uma solução para
esse problema. Podemos aproveitar o Int32Array
com o suporte de um SAB para essa
finalidade.
Mecanismo de sincronização: SharedArrayBuffer e atômicas
Cada campo da matriz de estados representa informações vitais sobre os buffers
compartilhados. O mais importante é um campo para a sincronização
(REQUEST_RENDER
). A ideia é que o worker espere que esse campo seja tocado
pelo AudioWorkletProcessor e processe o áudio quando ele for ativado. Junto com
SharedArrayBuffer (SAB), a API Atomics possibilita esse mecanismo.
A sincronização de duas linhas de execução é bastante frouxa. O início de
Worker.process()
será acionado pelo método
AudioWorkletProcessor.process()
, mas o AudioWorkletProcessor não vai esperar até que o Worker.process()
seja concluído. Isso é intencional. O AudioWorkletProcessor é controlado pelo callback
de áudio, então ele não pode ser bloqueado de forma síncrona. No pior cenário, o
fluxo de áudio pode sofrer duplicação ou queda, mas ele será
recuperado quando o desempenho de renderização for estabilizado.
Configuração e execução
Como mostrado no diagrama acima, esse design tem vários componentes para organizar: DedicatedWorkerGlobalScope (DWGS), AudioWorkletGlobalScope (AWGS), SharedArrayBuffer e a linha de execução principal. As etapas a seguir descrevem o que precisa acontecer na fase de inicialização.
Inicialização
- [Main] O construtor AudioWorkletNode é chamado.
- Crie o worker.
- O AudioWorkletProcessor associado será criado.
- [DWGS] O worker cria dois SharedArrayBuffers. (uma para estados compartilhados e a outra para dados de áudio)
- [DWGS] O worker envia referências SharedArrayBuffer para AudioWorkletNode.
- [Main] O AudioWorkletNode envia referências SharedArrayBuffer para AudioWorkletProcessor.
- [AWGS] O AudioWorkletProcessor notifica o AudioWorkletNode de que a configuração foi concluída.
Quando a inicialização é concluída, AudioWorkletProcessor.process()
começa
a ser chamada. O que acontece em cada iteração do
ciclo de renderização é o seguinte:
Loop de renderização
- [AWGS]
AudioWorkletProcessor.process(inputs, outputs)
é chamado para cada quantum de renderização.- O
inputs
será enviado para a Input SAB. - O
outputs
será preenchido consumindo dados de áudio no SAB de saída. - Atualiza o SAB de estados com novos índices de buffer.
- Se o Output SAB se aproximar do limite de underflow, o worker será ativado para renderizar mais dados de áudio.
- O
- [DWGS] O worker aguarda (dorme) o sinal de ativação de
AudioWorkletProcessor.process()
. Quando ele for ativado:- Busca índices de buffer do SAB dos Estados Unidos.
- Execute a função de processo com dados do SAB de entrada para preencher o SAB de saída.
- Atualiza o Estados SAB com índices de buffer.
- Entra em repouso e aguarda o próximo sinal.
O exemplo de código pode ser encontrado aqui, mas observe que a flag experimental SharedArrayBuffer precisa estar ativada para que essa demonstração funcione. O código foi escrito com código JS puro para simplificar, mas pode ser substituído por código WebAssembly se necessário. Esse caso precisa ser tratado com muito cuidado, agrupando o gerenciamento de memória com a classe HeapAudioBuffer.
Conclusão
O objetivo final do Audio Worklet é tornar a API Web Audio realmente "extensível". Um esforço de vários anos foi feito no design para permitir a implementação do restante da API Web Audio com o worklet de áudio. Por outro lado, agora temos uma complexidade maior no design, e isso pode ser um desafio inesperado.
Felizmente, a razão para essa complexidade é puramente para capacitar os desenvolvedores. A capacidade de executar o WebAssembly no AudioWorkletGlobalScope libera um enorme potencial para processamento de áudio de alto desempenho na Web. Para aplicativos de áudio de grande escala escritos em C ou C++, o uso de um worklet de áudio com SharedArrayBuffers e workers pode ser uma opção interessante.
Créditos
Agradecemos especialmente a Chris Wilson, Jason Miller, Joshua Bell e Raymond Toy por analisar um rascunho deste artigo e dar feedback útil.