Padrão de design da worklet de áudio

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 requer 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 Audio Worklet, que foi apresentado anteriormente nesta postagem.

  • BaseAudioContext: o objeto principal da API Web Audio.
  • Worklet de áudio: um carregador de arquivos de script especial para a operação de worklet de áudio. 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 em 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 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 com investimento 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:

  1. Instância um módulo do WebAssembly carregando o código de união no AudioWorkletGlobalScope usando audioContext.audioWorklet.addModule().
  2. 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.

Padrão de instanciação de módulo do WebAssembly A: usar a chamada .addModule()
Padrão de instanciação de módulo do WebAssembly A: usar a chamada .addModule()

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.

Padrão de instanciação de módulo WASM B: usar a transferência entre linhas do
    contêiner do construtor AudioWorkletNode
Exemplo B de padrão de instanciação de módulo WASM: usar a transferência entre linhas do construtor AudioWorkletNode

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 o heap do WASM e as matrizes de dados de áudio. A classe HeapAudioBuffer no código de exemplo processa essa operação corretamente.

Classe HeapAudioBuffer para facilitar o uso do heap do WASM
Classe HeapAudioBuffer para facilitar o uso do heap do WASM

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 é 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 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.

Como usar o RingBuffer no método "process()" do AudioWorkletProcessor
Usando RingBuffer no método "process()" do AudioWorkletProcessor

O algoritmo do diagrama seria:

  1. O AudioWorkletProcessor envia 128 frames para o Input RingBuffer da entrada.
  2. Siga as etapas abaixo somente se o Input RingBuffer tiver mais ou igual a 512 frames.
    1. Extraia 512 frames do Input RingBuffer.
    2. Processa 512 frames com a função WASM especificada.
    3. Envie 512 frames para o Output RingBuffer.
  3. O AudioWorkletProcessor extrai 128 frames do Output RingBuffer para preencher a Output.

Como 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 16.384 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 da escrita, 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: worklet de áudio e SharedArrayBuffer

O último padrão de design neste 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.

Visão geral do último padrão de design: worklet de áudio, SharedArrayBuffer e worker
Uma visão geral do último padrão de design: worklet de áudio, SharedArrayBuffer e worker

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 menor 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 Audio Worklet 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 esse propósito.

Mecanismo de sincronização: SharedArrayBuffer e atômicas
Mecanismo de sincronização: SharedArrayBuffer e atômicos

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 stream 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
  1. [Main] O construtor AudioWorkletNode é chamado.
    1. Crie o worker.
    2. O AudioWorkletProcessor associado será criado.
  2. [DWGS] O worker cria dois SharedArrayBuffers. (uma para estados compartilhados e a outra para dados de áudio)
  3. [DWGS] O worker envia referências SharedArrayBuffer para AudioWorkletNode.
  4. [Main] O AudioWorkletNode envia referências SharedArrayBuffer para AudioWorkletProcessor.
  5. [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
Renderização com várias linhas de execução com SharedArrayBuffers
Renderização multithread com SharedArrayBuffers
  1. [AWGS] AudioWorkletProcessor.process(inputs, outputs) é chamado para cada quantum de renderização.
    1. O inputs será enviado para a Input SAB.
    2. O outputs será preenchido consumindo dados de áudio no SAB de saída.
    3. Atualiza o SAB de estados com novos índices de buffer.
    4. Se o Output SAB se aproximar do limite de underflow, o worker será ativado para renderizar mais dados de áudio.
  2. [DWGS] O worker aguarda (dorme) o sinal de ativação de AudioWorkletProcessor.process(). Quando ele for ativado:
    1. Busca índices de buffer do SAB dos Estados.
    2. Execute a função de processo com dados do SAB de entrada para preencher o SAB de saída.
    3. Atualiza o Estados SAB com índices de buffer.
    4. 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.