En el artículo anterior sobre Audio Worklet, se detallaron los conceptos básicos y el uso. Desde su lanzamiento en Chrome 66, recibimos muchas solicitudes de más ejemplos de cómo se puede usar en aplicaciones reales. El Audio Worklet desbloquea 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 conocen WebAudio, integrar Audio Worklet con otras APIs (p.ej., WebAssembly) puede ser difícil.
En este artículo, el lector comprenderá mejor cómo usar la Worklet de audio en entornos reales y obtendrá sugerencias para aprovechar al máximo 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 los datos sobre el sistema de Audio Worklet que se presentó anteriormente en esta publicación.
- BaseAudioContext: Es el objeto principal de la API de Web Audio.
- Audio Worklet: Es un cargador de archivos de secuencia de comandos especiales para la operación de Audio Worklet. Pertenece a BaseAudioContext. Un BaseAudioContext puede tener un Audio Worklet. El archivo de secuencia de comandos cargado se evalúa en AudioWorkletGlobalScope y se usa para crear instancias de AudioWorkletProcessor.
- AudioWorkletGlobalScope: Es un alcance global especial de JS para la operación de Audio Worklet. 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 de Audio Worklet. Se crea una instancia a partir de un BaseAudioContext. Un BaseAudioContext puede tener varios AudioWorkletNodes de manera similar a los AudioNodes nativos.
- AudioWorkletProcessor: Es un equivalente de AudioWorkletNode. Los elementos reales de AudioWorkletNode que procesan la transmisión de audio a través del código proporcionado por el usuario. Se crea una instancia en AudioWorkletGlobalScope cuando se construye un AudioWorkletNode. Un AudioWorkletNode puede tener un AudioWorkletProcessor que coincida.
Patrones de diseño
Cómo usar Audio Worklet con WebAssembly
WebAssembly es un complemento perfecto para AudioWorkletProcessor. La combinación de estas dos funciones brinda una variedad de ventajas al procesamiento de audio en la Web, pero los dos beneficios más importantes son: a) llevar el código de procesamiento de audio C/C++ existente al ecosistema de WebAudio y b) evitar la sobrecarga de la compilación de JS JIT y la recolección de basura en el código de procesamiento de audio.
El primero es importante para los desarrolladores que tienen una inversión existente en código y bibliotecas de procesamiento de audio, 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: son solo 3 ms a la tasa de muestreo de 44.1 KHz. Incluso un pequeño error en el código de procesamiento de audio puede causar fallas. El desarrollador debe optimizar el código para que el procesamiento sea más rápido, pero también minimizar la cantidad de basura de JS que se genera. El uso de WebAssembly puede ser una solución que aborde ambos problemas al mismo tiempo: es más rápido y no genera basura del código.
En la siguiente sección, se describe cómo se puede usar WebAssembly con un elemento Audio Worklet, y el ejemplo de código que lo acompaña se puede encontrar aquí. Si quieres ver el instructivo básico sobre cómo usar Emscripten y WebAssembly (en especial, el código de unión de Emscripten), consulta este artículo.
Configura
Todo suena bien, pero necesitamos un poco de estructura para configurar todo 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 de unión de Emscripten, hay dos rutas para la instanciación del módulo:
- Para crear una instancia de un módulo de WebAssembly, carga el código de unión en AudioWorkletGlobalScope a través de
audioContext.audioWorklet.addModule()
. - Crea una instancia de un módulo de 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 preferencia, pero la idea es que el módulo WebAssembly puede generar una instancia de WebAssembly en AudioWorkletGlobalScope, que se convierte en un kernel de procesamiento de audio dentro de una instancia de AudioWorkletProcessor.
Para que el patrón A funcione correctamente, Emscripten necesita un par de opciones para generar el código de unión correcto de WebAssembly 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 de 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 promesas de audioWorklet.addModule()
no espera la resolución de promesas en AudioWorkletGlobalScope. Por lo general, no se recomienda la carga o compilación síncrona en el subproceso principal, ya que bloquea las otras tareas en el mismo subproceso, pero aquí podemos omitir la regla porque la compilación se realiza en AudioWorkletGlobalScope, que se ejecuta desde el subproceso principal. (consulta este vínculo para obtener más información).
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 unión del servidor y compilar el módulo. Luego, transferirá el módulo WASM a través del 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 medio de la renderización puede causar fallas en la transmisión.
Heap de WASM y datos de audio
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 en el código de ejemplo controla esta operación de forma correcta.
Se está analizando una propuesta preliminar para integrar el montón de WASM directamente en el sistema de Audio Worklet. Eliminar 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 la discrepancia del tamaño del búfer
Un par de AudioWorkletNode y AudioWorkletProcessor está diseñado para funcionar como un AudioNode normal. AudioWorkletNode controla 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 de Audio Worklet 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 fotogramas. La solución común para ese caso es usar un búfer circular, también conocido como búfer circular o FIFO.
Este es un diagrama de AudioWorkletProcessor que usa dos búferes circulares para admitir una función WASM que toma 512 fotogramas de entrada y salida. (el número 512 se elige de forma arbitraria).
El algoritmo del diagrama sería el siguiente:
- AudioWorkletProcessor envía 128 fotogramas al Input RingBuffer desde su entrada.
- Realiza los siguientes pasos solo si el Input RingBuffer tiene más de 512 fotogramas o igual a esa cantidad.
- Extrae 512 fotogramas del Input RingBuffer.
- Procesa 512 fotogramas con la función WASM proporcionada.
- Envía 512 fotogramas al RingBuffer de salida.
- AudioWorkletProcessor extrae 128 fotogramas del Output RingBuffer para completar su Output.
Como se muestra en el diagrama, los fotogramas de entrada siempre se acumulan en el búfer circular de entrada y controlan el desbordamiento del búfer reemplazando el bloque de fotogramas más antiguo en el búfer. Eso es algo razonable para una aplicación de audio en tiempo real. De manera similar, el sistema siempre extraerá el bloque de fotogramas de salida. La insuficiencia del búfer (no hay suficientes datos) en el búfer circular de salida provocará silencios que causarán fallas en la transmisión.
Este patrón es útil cuando se reemplaza ScriptProcessorNode (SPN) por AudioWorkletNode. Dado que SPN permite que el desarrollador elija un tamaño de búfer entre 256 y 16,384 fotogramas, la sustitución directa de SPN con AudioWorkletNode puede ser difícil, y usar un búfer circular proporciona una buena solución alternativa. Una grabadora de audio sería un gran ejemplo que se puede compilar sobre este diseño.
Sin embargo, es importante comprender que este diseño solo concilia la discrepancia de tamaño del búfer y no da más tiempo para ejecutar el código de la secuencia de comandos determinada. Si el código no puede terminar la tarea dentro del presupuesto de tiempo de renderización cuántica (~3 ms a 44.1 kHz), se verá afectado el tiempo de inicio de la función de devolución de llamada posterior y, con el tiempo, se producirán fallas.
Mezclar este diseño con WebAssembly puede ser complicado debido a la administración de memoria alrededor del montón de WASM. En el momento de escribir este artículo, 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. La idea de usar memoria asignada por el usuario para reducir la clonación de datos redundantes se analizará en el futuro.
Puedes encontrar la clase RingBuffer aquí.
WebAudio Powerhouse: Audio Worklet y SharedArrayBuffer
El último patrón de diseño de este artículo es 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 para que el software de audio existente escrito en C/C++ se ejecute en un navegador web y, al mismo tiempo, se mantenga una experiencia del usuario fluida.
La mayor ventaja de este diseño es poder usar un DedicatedWorkerGlobalScope solo para el procesamiento de audio. En Chrome, WorkerGlobalScope se ejecuta en un subproceso de prioridad inferior al 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, puedes esperar una mejor compatibilidad con Emscripten, ya que la API de Worker existe desde hace algunos años.
SharedArrayBuffer desempeña un papel fundamental para que este diseño funcione de manera eficiente. Aunque tanto Worker como AudioWorkletProcessor están equipados con mensajería asíncrona (MessagePort), no es la mejor opción para el procesamiento de audio en tiempo real debido a la asignación de memoria repetitiva y la latencia de los mensajes. 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 de los puristas de la API de Web Audio, este diseño podría parecer poco óptimo porque usa la Audio Worklet como un "sumidero de audio" simple y hace todo en el trabajador. Sin embargo, teniendo en cuenta que el costo de reescribir proyectos de C/C++ en JavaScript puede ser prohibitivo o incluso imposible, este diseño puede ser la ruta de implementación más eficiente para esos proyectos.
Estados y elementos atómicos compartidos
Cuando se usa una memoria compartida para los datos de audio, el acceso desde ambos lados debe coordinarse con cuidado. Compartir estados de acceso atómico es una solución para este problema. Para ello, podemos aprovechar Int32Array
respaldado por un SAB.
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 el trabajador espera 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 floja. El método AudioWorkletProcessor.process()
activará el inicio de Worker.process()
, pero AudioWorkletProcessor no espera hasta que finalice Worker.process()
. Esto es por diseño. AudioWorkletProcessor está controlado por la devolución de llamada de audio, por lo que no se debe bloquear de forma síncrona. En el peor de los casos, la transmisión de audio podría sufrir de duplicados o interrupciones, pero 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
- [Principal] Se llama al constructor de AudioWorkletNode.
- Crea un trabajador.
- Se creará el AudioWorkletProcessor asociado.
- [DWGS] El trabajador crea 2 SharedArrayBuffers. (uno para los estados compartidos y el otro para los datos de audio).
- [DWGS] El trabajador envía referencias de SharedArrayBuffer a AudioWorkletNode.
- [Principal] AudioWorkletNode envía referencias de SharedArrayBuffer a AudioWorkletProcessor.
- [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()
. A continuación, se indica lo que debería suceder en cada iteración del bucle de renderización.
Bucle de renderización
- [AWGS] Se llama a
AudioWorkletProcessor.process(inputs, outputs)
para cada quantum de renderización.inputs
se enviará a Input SAB.outputs
se completará con el consumo de datos de audio en SAB de salida.- Actualiza el SAB de estados con nuevos índices de búfer según corresponda.
- Si el SAB de salida se acerca al umbral de desbordamiento, activa el trabajador para renderizar más datos de audio.
- [DWGS] El trabajador espera (duerme) el indicador de activación de
AudioWorkletProcessor.process()
. Cuando se despierte, haz lo siguiente:- Recupera índices de búfer de Estados SAB.
- Ejecuta la función de procesamiento con datos de SAB de entrada para completar SAB de salida.
- Actualiza el SAB de estados con índices de búfer según corresponda.
- Se pone en suspensión y espera el siguiente indicador.
Puedes encontrar el código de ejemplo aquí, pero ten en cuenta que la marca experimental de SharedArrayBuffer debe estar habilitada para que funcione esta demostración. El código se escribió con código JS puro para simplificarlo, pero se puede reemplazar por código WebAssembly si es necesario. En ese caso, se debe tener mucho cuidado y unir la administración de la memoria con la clase HeapAudioBuffer.
Conclusión
El objetivo final de la Audio Worklet es hacer que la API de Web Audio sea realmente “expandible”. Se dedicó un esfuerzo de varios años a su diseño para que fuera posible implementar el resto de la API de Web Audio con la Audio Worklet. A su vez, ahora tenemos una complejidad más alta en su diseño, y esto puede ser un desafío inesperado.
Por suerte, el motivo de esta complejidad es solo para fortalecer a los desarrolladores. Poder ejecutar WebAssembly en AudioWorkletGlobalScope libera un gran potencial para el procesamiento de audio de alto rendimiento en la Web. En el caso de las aplicaciones de audio a gran escala escritas en C o C++, usar un Worklet de audio con SharedArrayBuffers y trabajadores puede ser una opción atractiva para explorar.
Créditos
Un agradecimiento especial a Chris Wilson, Jason Miller, Joshua Bell y Raymond Toy por revisar un borrador de este artículo y enviar comentarios valiosos.