Padrão de design da worklet de áudio

O artigo anterior sobre o Worklet de áudio detalhou os conceitos básicos e o uso. Desde o lançamento no Chrome 66, houve muitas solicitações de mais exemplos de como ele pode ser usado em aplicativos reais. A Worklet de áudio aproveita todo o potencial do WebAudio, mas aproveitá-la pode ser desafiador, porque é necessário entender a programação simultânea vinculada a várias APIs JS. Mesmo para desenvolvedores familiarizados com o WebAudio, integrar a Worklet de áudio com outras APIs (por exemplo, WebAssembly) pode ser difícil.

Neste artigo, o leitor vai entender melhor como usar o Worklet de áudio em situações reais e oferecer dicas para aproveitar todo o poder dele. Não deixe de conferir também os exemplos de código e demonstrações ao vivo.

Recapitulação: ferramenta de áudio

Antes de começar, vamos recapitular rapidamente os termos e fatos sobre o sistema de Worklet de áudio, que foi apresentado anteriormente nesta postagem.

  • BaseAudioContext: 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 especial de JS para a operação de Worklet de áudio. É executada em uma linha de execução de renderização dedicada para o WebAudio. Um BaseAudioContext pode ter um AudioWorkletGlobalScope.
  • AudioWorkletNode: um AudioNode projetado para operação de Worklet de áudio. Instanciado de um BaseAudioContext. Um BaseAudioContext pode ter vários AudioWorkletNodes de forma semelhante aos AudioNodes nativos.
  • AudioWorkletProcessor: uma contraparte do AudioWorkletNode. As partes reais do AudioWorkletNode que processa o stream de áudio pelo código fornecido pelo usuário. Ele é instanciado no AudioWorkletGlobalScope quando um AudioWorkletNode é criado. Um AudioWorkletNode pode ter um AudioWorkletProcessor correspondente.

Padrões de projeto

Como usar o Worklet de áudio 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 maiores são: a) trazer o código de processamento de áudio C/C++ existente para o ecossistema WebAudio e b) evitar a sobrecarga da compilação JIT JS e a 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 é fundamental para quase todos os usuários da API. No mundo da WebAudio, o orçamento de tempo para o stream de áudio estável é bastante exigente: é de apenas 3 ms na taxa de amostragem de 44,1 Khz. Mesmo um pequeno problema no código de processamento de áudio pode causar falhas. O desenvolvedor precisa otimizar o código para agilizar o processamento, mas também minimizar a quantidade de lixo JS gerada. Usar o WebAssembly pode ser uma solução que resolve os dois problemas ao mesmo tempo: é mais rápido e não gera lixo no código.

A próxima seção descreve como o WebAssembly pode ser usado com um Worklet de áudio. O exemplo de código acompanhado pode ser encontrado neste link. Para ver o tutorial básico sobre como usar o Emscripten e o WebAssembly (especialmente o código agrupador Emscripten), confira este artigo.

Como configurar

Parece ótimo, mas precisamos de um pouco de estrutura para configurar tudo corretamente. A primeira pergunta de design a ser feita é como e onde instanciar um módulo WebAssembly. Depois de buscar o código agrupador do Emscripten, há dois caminhos para a instanciação do módulo:

  1. Instancie um módulo WebAssembly carregando o código agrupador no AudioWorkletGlobalScope via audioContext.audioWorklet.addModule().
  2. Instancie um módulo WebAssembly no escopo principal e transfira o módulo usando as opções do construtor do AudioWorkletNode.

A decisão depende em grande parte do seu projeto e preferência, mas a ideia é que o módulo WebAssembly possa gerar uma instância WebAssembly no AudioWorkletGlobalScope, que se torna um kernel de processamento de áudio dentro de uma instância AudioWorkletProcessor.

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

Para que o padrão A funcione corretamente, o Emscripten precisa de algumas opções para gerar o código agrupador correto do WebAssembly 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 WebAssembly no AudioWorkletGlobalScope. Ele também anexa a definição de classe do AudioWorkletProcessor em mycode.js para que ele possa ser carregado 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 aguarda a resolução de promessas no AudioWorkletGlobalScope. Geralmente, o carregamento ou a compilação síncronos na linha de execução principal não é recomendado porque bloqueia as outras tarefas na mesma linha de execução. Mas aqui podemos ignorar a regra porque a compilação acontece no AudioWorkletGlobalScope, que sai da linha de execução principal. Para mais informações, consulte este link.

Padrão de instanciação do módulo WASM B: usando a transferência entre linhas de execução
    do construtor AudioWorkletNode.
Padrão B de instanciação do módulo WASM: uso da transferência entre linhas de execução do construtor AudioWorkletNode.

O padrão B pode ser útil quando um trabalho pesado assíncrono é necessário. Ele usa a linha de execução principal para buscar o código agrupador do servidor e compilar o módulo. Em seguida, ele transfere 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çar a renderizar o stream de áudio. Dependendo do tamanho do módulo, compilá-lo no meio da renderização pode causar falhas no stream.

Dados de áudio e heap WASM

O código 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 WASM e as matrizes de dados de áudio. A classe HeapAudioBuffer no código de exemplo lida muito bem com essa operação.

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

Há uma proposta inicial em discussão para integrar o heap WASM diretamente no sistema de Worklet de áudio. Livrar-se dessa clonagem de dados redundantes entre a memória JS e o heap WaSM parece natural, mas os detalhes específicos precisam ser resolvidos.

Tratar divergências de tamanho de buffer

Um par AudioWorkletNode e AudioWorkletProcessor é projetado para funcionar como um AudioNode normal. O AudioWorkletNode processa a interação com outros códigos enquanto o AudioWorkletProcessor cuida do processamento interno do áudio. Como um AudioNode normal processa 128 frames por vez, o AudioWorkletProcessor precisa fazer o mesmo para se tornar um recurso principal. Essa é uma das vantagens do design da Worklet de áudio, que garante que nenhuma latência adicional devido ao buffer interno seja introduzida no AudioWorkletProcessor, mas poderá 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 de anel, também conhecido como buffer circular ou FIFO.

Aqui está um diagrama do AudioWorkletProcessor usando dois buffers de anel dentro para acomodar uma função WASM que usa 512 frames de entrada e saída. O número 512 aqui é escolhido arbitrariamente.

Como usar o RingBuffer dentro do método `process()` do AudioWorkletProcessor
Como usar o RingBuffer no método `process()` do AudioWorkletProcessor

O algoritmo do diagrama seria:

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

Como mostrado no diagrama, os frames de entrada sempre são acumulados no Input RingBuffer e processam o estouro do buffer substituindo o bloco de frames mais antigo no buffer. Isso é razoável para um aplicativo de áudio em tempo real. Da mesma forma, o bloco do frame de saída sempre será extraído pelo sistema. O subfluxo do buffer (dados insuficientes) no RingBuffer de saída vai resultar em silêncio, causando uma falha no stream.

Esse padrão é útil ao substituir ScriptProcessorNode (SPN) pelo AudioWorkletNode. Como a SPN permite que o desenvolvedor escolha um tamanho de buffer entre 256 e 16.384 frames, a substituição simples da SPN por AudioWorkletNode pode ser difícil, e usar um buffer de anel é uma boa solução alternativa. Um gravador de áudio seria um ótimo exemplo que pode ser criado sobre esse 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 de script fornecido. Se o código não puder concluir a tarefa dentro do orçamento de tempo do quântico de renderização (aproximadamente 3 ms a 44,1 Khz), isso vai afetar o tempo de início da função de callback subsequente e causar falhas.

Misturar esse design com o WebAssembly pode ser complicado devido ao gerenciamento de memória no heap WASM. No momento da gravação, os dados que entram e saem do heap WASM precisam ser clonados, mas podemos usar a classe HeapAudioBuffer para facilitar um pouco o gerenciamento de memória. A ideia de usar a memória alocada pelo usuário para reduzir a clonagem redundante de dados será discutida no futuro.

A classe RingBuffer pode ser encontrada neste link.

WebAudio Powerhouse: Worklet de áudio e SharedArrayBuffer

O último padrão de design deste artigo foi reunir várias APIs modernas em um só lugar: Audio Worklet, SharedArrayBuffer, Atomics e Worker. Com essa configuração não trivial, ela abre um caminho para o software de áudio existente escrito em C/C++ ser executado em um navegador da Web, mantendo uma boa experiência do usuário.

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 é a possibilidade de usar um DedicatedWorkerGlobalScope exclusivamente para o processamento de áudio. No Chrome, o WorkerGlobalScope é executado em uma linha de execução de prioridade mais baixa que a linha de execução de renderização WebAudio, mas tem várias vantagens em relação ao AudioWorkletGlobalScope (link em inglês). O DedicadoWorkerGlobalScope é menos restrito em termos da superfície de API disponível no escopo. Além disso, o Emscripten oferece um suporte melhor, porque a API Worker já existe há alguns anos.

O SharedArrayBuffer desempenha um papel fundamental para que esse design funcione com eficiência. Embora Worker e AudioWorkletProcessor estejam equipados com mensagens assíncronas (MessagePort), ele não é o ideal para o processamento de áudio em tempo real devido à alocação repetitiva de memória e latência de mensagens. Assim, alocamos um bloco de memória antecipadamente que pode ser acessado de ambas as 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 abaixo do ideal porque usa o Worklet de áudio como um "coletor de áudio" simples e faz tudo no Worker. Mas, considerando que o custo de reescrever projetos em 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 atomicamente acessíveis é uma solução para esse problema. Para isso, podemos aproveitar o Int32Array apoiado por uma empresa que atende no local do cliente.

Mecanismo de sincronização: SharedArrayBuffer e Atomics
Mecanismo de sincronização: SharedArrayBuffer e Atomics

Mecanismo de sincronização: SharedArrayBuffer e Atomics

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 aguarde até que esse campo seja tocado pelo AudioWorkletProcessor e processe o áudio quando ele for ativado. Junto com o SharedArrayBuffer (SAB), a API Atomics possibilita esse mecanismo.

Observe que a sincronização de duas linhas de execução é bastante flexível. O início de Worker.process() será acionado pelo método AudioWorkletProcessor.process(), mas o AudioWorkletProcessor não aguarda a conclusão do Worker.process(). Isso ocorre por design. O AudioWorkletProcessor é acionado pelo callback de áudio, portanto, não pode ser bloqueado de maneira síncrona. Na pior das hipóteses, o fluxo de áudio pode ser duplicado ou descartado, mas será recuperado quando o desempenho de renderização estiver estabilizado.

Configuração e execução

Como mostrado no diagrama acima, esse design tem vários componentes a serem organizados: dedicadWorkerGlobalScope (DWGS), AudioWorkletGlobalScope (AWGS), SharedArrayBuffer e linha de execução principal. As etapas a seguir descrevem o que precisa acontecer na fase de inicialização.

Inicialização
  1. [Principal] O construtor AudioWorkletNode é chamado.
    1. Criar worker.
    2. O AudioWorkletProcessor associado será criado.
  2. [DWGS] O worker cria dois SharedArrayBuffers. (um para estados compartilhados e outro para dados de áudio)
  3. [DWGS] O worker envia referências de SharedArrayBuffer para AudioWorkletNode.
  4. [Principal] O AudioWorkletNode envia referências de SharedArrayBuffer para AudioWorkletProcessor.
  5. [AWGS] O AudioWorkletProcessor notifica o AudioWorkletNode de que a configuração foi concluída.

Quando a inicialização for concluída, AudioWorkletProcessor.process() vai começar a ser chamado. Confira a seguir o que acontece em cada iteração do loop de renderização.

Repetição de renderização
Renderização com várias linhas de execução com SharedArrayBuffers
Renderização com várias linhas de execução com SharedArrayBuffers
  1. [AWGS] O AudioWorkletProcessor.process(inputs, outputs) é chamado para cada quântico de renderização.
    1. inputs será enviado à SAB de entrada.
    2. outputs vai ser preenchido pelo consumo de dados de áudio na SAB de saída.
    3. Atualiza States SAB com novos índices de buffer adequadamente.
    4. Se a SAB de saída se aproximar do limite de subfluxo, o worker de ativação vai renderizar mais dados de áudio.
  2. [DWGS] O worker aguarda (em suspensão) pelo sinal de ativação de AudioWorkletProcessor.process(). Quando ele for ativado:
    1. Busca índices de buffer de States SAB.
    2. Execute a função do processo com dados da SAB de entrada para preencher a SAB de saída.
    3. Atualiza States SAB com índices de buffer adequadamente.
    4. Vai entrar em suspensão e aguardar o próximo sinal.

O código de exemplo 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 pelo código WebAssembly, se necessário. Esse caso precisa ser tratado com muito cuidado, unindo o gerenciamento de memória com a classe HeapAudioBuffer.

Conclusão

O objetivo final da Worklet de áudio é tornar a API de áudio da Web realmente "extensível". Um esforço de vários anos foi desenvolvido para possibilitar a implementação do restante da API de áudio da Web com a Worklet de áudio. Por sua vez, agora temos mais complexidade no design, e isso pode ser um desafio inesperado.

Felizmente, o motivo dessa complexidade é puramente capacitar os desenvolvedores. A execução do WebAssembly no AudioWorkletGlobalScope gera um enorme potencial para o processamento de áudio de alto desempenho na Web. Para aplicativos de áudio em grande escala escritos em C ou C++, usar um Worklet de áudio com SharedArrayBuffers e workers pode ser uma opção interessante.

Créditos

Agradecimentos especiais a Chris Wilson, Jason Miller, Joshua Bell e Raymond Toy por revisarem um rascunho deste artigo e fornecerem feedback útil.