Patrón de diseño de worklet de audio

En el artículo anterior sobre Audio Worklet, se detallan el uso y los conceptos básicos. Desde su lanzamiento en Chrome 66, se han solicitado más ejemplos de cómo se puede utilizar en aplicaciones reales. El Worklet de audio libera todo el potencial de WebAudio, pero aprovecharlo puede ser un desafío porque requiere comprender la programación simultánea unida a varias APIs de JS. Incluso para los desarrolladores que están familiarizados con WebAudio, puede ser difícil integrar el Worklet de audio con otras APIs (p.ej., WebAssembly).

Este artículo le permitirá al lector comprender mejor cómo usar el Worklet de audio en entornos reales y brindar sugerencias para aprovechar todo su potencial. Asegúrate de consultar también los ejemplos de código y las demostraciones en vivo.

Resumen: Worklet de audio

Antes de comenzar, repasemos rápidamente los términos y datos sobre el sistema de Worklet de audio que se presentó anteriormente en esta publicación.

  • BaseAudioContext: Es el objeto principal de la API de Web Audio.
  • Worklet de audio: Es un cargador especial de archivos de secuencia de comandos para la operación del Worklet de audio. Pertenece a BaseAudioContext. Un BaseAudioContext puede tener un Worklet de audio. El archivo de secuencia de comandos cargado se evalúa en AudioWorkletGlobalScope y se usa para crear las instancias de AudioWorkletProcessor.
  • AudioWorkletGlobalScope: Es un alcance global de JS especial para la operación del Worklet de audio. Se ejecuta en un subproceso de renderización dedicado para WebAudio. Un BaseAudioContext puede tener un AudioWorkletGlobalScope.
  • AudioWorkletNode: Es un AudioNode diseñado para la operación del Worklet de audio. Se crea una instancia a partir de BaseAudioContext. Un BaseAudioContext puede tener varios AudioWorkletNodes de manera similar a los AudioNodes nativos.
  • AudioWorkletProcessor: Es un equivalente de AudioWorkletNode. Las partes reales del AudioWorkletNode que procesan la transmisión de audio por el código proporcionado por el usuario. Se crea una instancia en AudioWorkletGlobalScope cuando se construye un AudioWorkletNode. Un AudioWorkletNode puede tener un AudioWorkletProcessor correspondiente.

Patrones de diseño

Usa Worklet de audio con WebAssembly

WebAssembly es un compañero perfecto para AudioWorkletProcessor. La combinación de estas dos funciones aporta una variedad de ventajas al procesamiento de audio en la Web, pero los dos beneficios más importantes son: a) incorporar el código de procesamiento de audio C/C++ existente en el ecosistema de WebAudio y b) evitar la sobrecarga de la compilación de JS JIT y la recolección de elementos no utilizados en el código de procesamiento de audio.

El primero es importante para los desarrolladores que ya tienen una inversión en código de procesamiento de audio y bibliotecas, pero el segundo es fundamental para casi todos los usuarios de la API. En el mundo de WebAudio, el presupuesto de tiempo para la transmisión de audio estable es bastante exigente: solo es de 3 ms a una tasa de muestreo de 44.1 Khz. Incluso un leve problema en el código de procesamiento de audio puede causar fallas. El desarrollador debe optimizar el código para un procesamiento más rápido, pero también minimizar la cantidad de elementos no utilizados de JS que se generan. Usar WebAssembly puede ser una solución que aborde ambos problemas al mismo tiempo: es más rápido y no genera elementos no utilizados del código.

En la siguiente sección, se describe cómo se puede usar WebAssembly con un Worklet de audio. Puedes encontrar el ejemplo de código acompañado aquí. Para ver el instructivo básico sobre cómo usar Emscripten y WebAssembly (especialmente el código de adhesión Emscripten), consulta este artículo.

Configura

Todo suena genial, pero necesitamos un poco de estructura para configurarlo correctamente. La primera pregunta de diseño que debes hacer es cómo y dónde crear una instancia de un módulo de WebAssembly. Después de recuperar el código glue de Emscripten, hay dos rutas de acceso para la creación de instancias del módulo:

  1. Para crear una instancia de un módulo de WebAssembly, carga el código de adhesión en AudioWorkletGlobalScope a través de audioContext.audioWorklet.addModule().
  2. Crea una instancia de un módulo WebAssembly en el alcance principal y, luego, transfiere el módulo a través de las opciones del constructor de AudioWorkletNode.

La decisión depende en gran medida de tu diseño y tus preferencias, pero la idea es que el módulo WebAssembly pueda generar una instancia de WebAssembly en AudioWorkletGlobalScope, que se convierte en un kernel de procesamiento de audio dentro de una instancia de AudioWorkletProcessor.

Patrón de creación de instancias del módulo WebAssembly: uso de la llamada .addModule()
Patrón de creación de instancias del módulo de WebAssembly: uso de la llamada .addModule()

A fin de que el patrón A funcione correctamente, Emscripten necesita algunas opciones para generar el código de adhesión de WebAssembly correcto para nuestra configuración:

-s BINARYEN_ASYNC_COMPILATION=0 -s SINGLE_FILE=1 --post-js mycode.js

Estas opciones garantizan la compilación síncrona de un módulo WebAssembly en AudioWorkletGlobalScope. También agrega la definición de clase de AudioWorkletProcessor en mycode.js para que se pueda cargar después de que se inicialice el módulo. El motivo principal para usar la compilación síncrona es que la resolución de la promesa de audioWorklet.addModule() no espera la resolución de las promesas en AudioWorkletGlobalScope. Por lo general, no se recomienda la carga o compilación síncronas en el subproceso principal, ya que bloquea las otras tareas del mismo subproceso, pero aquí podemos omitir la regla porque la compilación se realiza en AudioWorkletGlobalScope, que se ejecuta fuera del subproceso principal. (Consulta aquí para obtener más información).

Patrón de creación de instancias del módulo WASM B: usa la transferencia entre subprocesos del constructor AudioWorkletNode.
Patrón de instancia de módulo WASM B: uso de la transferencia entre subprocesos del constructor AudioWorkletNode

El patrón B puede ser útil si se requiere un trabajo pesado asíncrono. Utiliza el subproceso principal para recuperar el código de adhesión del servidor y compilar el módulo. Luego, transferirá el módulo WASM mediante el constructor de AudioWorkletNode. Este patrón tiene aún más sentido cuando tienes que cargar el módulo de forma dinámica después de que AudioWorkletGlobalScope comienza a renderizar la transmisión de audio. Según el tamaño del módulo, compilarlo en el medio de la renderización puede causar fallas en la transmisión.

Datos de audio y montón de WASM

El código de WebAssembly solo funciona en la memoria asignada dentro de un montón WASM dedicado. Para aprovecharlo, los datos de audio deben clonarse entre el montón de WASM y los arrays de datos de audio. La clase HeapAudioBuffer del código de ejemplo controla muy bien esta operación.

Es la clase HeapAudioBuffer para facilitar el uso del montón de WASM.
Clase HeapAudioBuffer para facilitar el uso del montón de WASM

Hay una propuesta inicial en debate para integrar el montón de WASM directamente en el sistema de Worklet de audio. Deshacerse de esta clonación de datos redundante entre la memoria de JS y el montón de WASM parece natural, pero se deben resolver los detalles específicos.

Controla las discrepancias del tamaño del búfer

Un par de AudioWorkletNode y AudioWorkletProcessor se diseñó para funcionar como un AudioNode normal. AudioWorkletNode maneja la interacción con otros códigos, mientras que AudioWorkletProcessor se encarga del procesamiento de audio interno. Debido a que un AudioNode normal procesa 128 fotogramas a la vez, AudioWorkletProcessor debe hacer lo mismo para convertirse en una función principal. Esta es una de las ventajas del diseño del Worklet de audio que garantiza que no se introduzca latencia adicional debido al almacenamiento en búfer interno en AudioWorkletProcessor, pero puede ser un problema si una función de procesamiento requiere un tamaño de búfer diferente de 128 tramas. La solución común para ese caso es usar un búfer de anillo, también conocido como búfer circular o FIFO.

Este es un diagrama de AudioWorkletProcessor que usa dos búferes de anillo dentro para una función WASM que recibe y quita 512 tramas. (El número 512 se elige arbitrariamente).

Cómo usar RingBuffer dentro del método `process()` de AudioWorkletProcessor
Cómo usar RingBuffer dentro del método `process()` de AudioWorkletProcessor

El algoritmo del diagrama sería el siguiente:

  1. AudioWorkletProcessor envía 128 tramas al Input RingBuffer desde su entrada.
  2. Realiza los siguientes pasos solo si Input RingBuffer tiene un valor mayor o igual que 512 marcos.
    1. Extraer marcos 512 del RingBuffer de entrada.
    2. Procesar 512 fotogramas con la función WASM determinada
    3. Envía 512 fotogramas al RingBuffer de salida.
  3. AudioWorkletProcessor extrae 128 tramas de Output RingBuffer para completar su Output.

Como se muestra en el diagrama, las tramas de entrada siempre se acumulan en Input RingBuffer y controlan el desbordamiento del búfer reemplazando el bloque de marcos más antiguo del búfer. Eso es algo razonable que puedes hacer para una aplicación de audio en tiempo real. Del mismo modo, el sistema siempre extraerá el bloque del marco de salida. El subdesbordamiento del búfer (no hay suficientes datos) en el RingBuffer de salida provocará un silenciamiento, lo que provocará una falla en la transmisión.

Este patrón es útil cuando se reemplaza ScriptProcessorNode (SPN) con AudioWorkletNode. Dado que SPN permite al desarrollador elegir un tamaño de búfer entre 256 y 16,384 tramas, la sustitución directa de SPN por AudioWorkletNode puede ser difícil y el uso de un búfer de anillo proporciona una buena solución alternativa. Una grabadora de audio sería un gran ejemplo que se puede crear sobre este diseño.

Sin embargo, es importante comprender que este diseño solo concilia la falta de coincidencia del tamaño del búfer y no da más tiempo para ejecutar el código de secuencia de comandos dado. Si el código no puede finalizar la tarea dentro del presupuesto de tiempo de renderización cuántica (~3 ms a 44.1 Khz), afectará el tiempo de inicio de la función de devolución de llamada posterior y, finalmente, causará fallas.

Combinar este diseño con WebAssembly puede ser complicado debido a la administración de la memoria en torno al montón de WASM. Al momento de escribir, los datos que entran y salen del montón de WASM deben clonarse, pero podemos usar la clase HeapAudioBuffer para facilitar un poco la administración de la memoria. Más adelante, se analizará la idea de usar memoria asignada por el usuario para reducir la clonación de datos redundante.

Puedes encontrar la clase RingBuffer aquí.

WebAudio Powerhouse: Worklet de audio y SharedArrayBuffer

El último patrón de diseño de este artículo consiste en colocar varias APIs de vanguardia en un solo lugar: Audio Worklet, SharedArrayBuffer, Atomics y Worker. Con esta configuración no trivial, se desbloquea una ruta de acceso para que el software de audio existente escrito en C/C++ se ejecute en un navegador web y, al mismo tiempo, mantenga una experiencia del usuario fluida.

Descripción general del último patrón de diseño: Worklet de audio, SharedArrayBuffer y Worker
Descripción general del último patrón de diseño: Worklet de audio, SharedArrayBuffer y Worker

La mayor ventaja de este diseño es poder usar un DedicatedWorkerGlobalScope exclusivamente para el procesamiento de audio. En Chrome, WorkerGlobalScope se ejecuta en un subproceso de menor prioridad que el subproceso de renderización de WebAudio, pero tiene varias ventajas sobre AudioWorkletGlobalScope. DedicatedWorkerGlobalScope tiene menos restricciones en términos de la plataforma de la API disponible en el alcance. Además, Emscripten brinda una mejor asistencia, ya que la API de trabajadores existe desde hace algunos años.

SharedArrayBuffer desempeña un papel fundamental para que este diseño funcione de manera eficiente. Aunque Worker y AudioWorkletProcessor están equipados con mensajería asíncrona (MessagePort), no es óptimo para el procesamiento de audio en tiempo real debido a la asignación de memoria repetitiva y la latencia de la mensajería. Por lo tanto, asignamos un bloque de memoria por adelantado al que se puede acceder desde ambos subprocesos para una transferencia de datos bidireccional rápida.

Desde el punto de vista purista de la API de Web Audio, este diseño podría parecer poco óptimo porque usa el Worklet de audio como un simple "receptor de audio" y hace todo en el trabajador. Sin embargo, teniendo en cuenta el costo de reescribir proyectos de C/C++ en JavaScript puede ser restrictivo o incluso imposible, este diseño puede ser la ruta de implementación más eficiente para esos proyectos.

Estados compartidos y funciones atómicas

Cuando se usa una memoria compartida para datos de audio, el acceso desde ambos lados se debe coordinar con cuidado. Compartir estados de acceso atómica es una solución para este problema. Para ello, podemos aprovechar Int32Array respaldado por una ESA.

Mecanismo de sincronización: SharedArrayBuffer y Atomics
Mecanismo de sincronización: SharedArrayBuffer y Atomics

Mecanismo de sincronización: SharedArrayBuffer y Atomics

Cada campo del array de estados representa información vital sobre los búferes compartidos. El más importante es un campo para la sincronización (REQUEST_RENDER). La idea es que Worker espere a que AudioWorkletProcessor toque este campo y procese el audio cuando se active. Junto con SharedArrayBuffer (SAB), la API de Atomics hace posible este mecanismo.

Ten en cuenta que la sincronización de dos subprocesos es bastante flexible. El método AudioWorkletProcessor.process() activará la aparición de Worker.process(), pero AudioWorkletProcessor no espera hasta que Worker.process() finalice. Esto es así: el AudioWorkletProcessor es controlado por la devolución de llamada de audio, por lo que no debe bloquearse de forma síncrona. En el peor de los casos, la transmisión de audio puede duplicarse o abandonarse, pero con el tiempo se recuperará cuando se estabilice el rendimiento de la renderización.

Configuración y ejecución

Como se muestra en el diagrama anterior, este diseño tiene varios componentes para organizar: DedicatedWorkerGlobalScope (DWGS), AudioWorkletGlobalScope (AWGS), SharedArrayBuffer y el subproceso principal. En los siguientes pasos, se describe lo que debería suceder en la fase de inicialización.

Inicialización
  1. [Principal] Se llama al constructor AudioWorkletNode.
    1. Create Worker.
    2. Se creará el AudioWorkletProcessor asociado.
  2. [DWGS] El trabajador crea 2 SharedArrayBuffers. (uno para estados compartidos y el otro para datos de audio)
  3. [DWGS] El trabajador envía referencias de SharedArrayBuffer a AudioWorkletNode.
  4. [Principal] AudioWorkletNode envía referencias de SharedArrayBuffer a AudioWorkletProcessor.
  5. [AWGS] AudioWorkletProcessor notifica a AudioWorkletNode que se completó la configuración.

Una vez que se completa la inicialización, se comienza a llamar a AudioWorkletProcessor.process(). Lo siguiente es lo que debe suceder en cada iteración del bucle de renderización.

Bucle de renderización
Renderización de varios subprocesos con SharedArrayBuffers
Renderización de varios subprocesos con SharedArrayBuffers
  1. [AWGS] Se llama a AudioWorkletProcessor.process(inputs, outputs) para cada cuántico de renderización.
    1. inputs se enviará a SAB de entrada.
    2. outputs se completará con el consumo de datos de audio en SAB de salida.
    3. Actualiza States SAB con los índices de búfer nuevos según corresponda.
    4. Si la SAB de salida se acerca al umbral de subdesbordamiento, Wake Worker renderiza más datos de audio.
  2. [DWGS] El trabajador espera (suspende) la señal de activación de AudioWorkletProcessor.process(). Cuando se active, sucederá lo siguiente:
    1. Recupera los índices de búfer de ESA de estados.
    2. Ejecuta la función de proceso con datos de la ESA de entrada para completar la ESA de salida.
    3. Actualiza States SAB con los índices de búfer según corresponda.
    4. Se suspende y espera la siguiente señal.

El código de ejemplo se puede encontrar aquí, pero ten en cuenta que la marca experimental SharedArrayBuffer debe estar habilitada para que funcione esta demostración. El código se escribió con código JS puro para mayor simplicidad, pero se puede reemplazar por código WebAssembly si es necesario. Este caso se debe controlar con mucho cuidado uniendo la administración de la memoria con la clase HeapAudioBuffer.

Conclusión

El objetivo final del Worklet de audio es hacer que la API de Web Audio realmente sea "extensible". En su diseño, se dedicó un esfuerzo de varios años para permitir la implementación del resto de la API de Web Audio con Audio Worklet. A su vez, ahora el diseño es más complejo, lo que puede ser un desafío inesperado.

Afortunadamente, la razón de esa complejidad es únicamente empoderar a los desarrolladores. La posibilidad de ejecutar WebAssembly en AudioWorkletGlobalScope ofrece un gran potencial para el procesamiento de audio de alto rendimiento en la Web. Para aplicaciones de audio a gran escala escritas en C o C++, el uso de un Worklet de audio con SharedArrayBuffers y Workers puede ser una opción atractiva para explorar.

Créditos

Queremos dar un agradecimiento especial a Chris Wilson, Jason Miller, Joshua Bell y Raymond Toy por revisar el borrador de este artículo y brindarnos sus comentarios reveladores.