Pattern di progettazione del worklet audio

Hongchan Choi

L'articolo precedente sui worklet audio illustrava i concetti di base e l'utilizzo. Dal suo lancio in Chrome 66, sono state richieste molte altre esempi di come può essere utilizzato in applicazioni reali. Il worklet audio sblocca tutto il potenziale di WebAudio, ma sfruttarlo può essere complicato perché richiede la comprensione della programmazione concorrente con diverse API JS. Anche per gli sviluppatori che hanno dimestichezza con WebAudio, l'integrazione di Audio Worklet con altre API (ad es. WebAssembly) può essere difficile.

Questo articolo aiuterà il lettore a comprendere meglio come utilizzare il worklet Audio in ambienti reali e offrirà suggerimenti per sfruttarne al meglio le potenzialità. Assicurati di dare un'occhiata anche ai codici di esempio e alle demo dal vivo.

Recap: Worklet audio

Prima di iniziare, riepiloghiamo brevemente i termini e le informazioni sul sistema di worklet audio, che è stato introdotto in precedenza in questo post.

  • BaseAudioContext: l'oggetto principale dell'API Web Audio.
  • Audio Worklet: un caricatore di file di script speciale per l'operazione Audio Worklet. Appartiene a BaseAudioContext. Un BaseAudioContext può avere un Audio Worklet. Il file script caricato viene valutato in AudioWorkletGlobalScope e utilizzato per creare le istanze di AudioWorkletProcessor.
  • AudioWorkletGlobalScope: un ambito globale JS speciale per l'operazione Audio Worklet. Viene eseguito su un thread di rendering dedicato per WebAudio. Un BaseAudioContext può avere un AudioWorkletGlobalScope.
  • AudioWorkletNode: un AudioNode progettato per l'operazione Audio Worklet. Viene creato da un BaseAudioContext. Un BaseAudioContext può avere più AudioWorkletNode in modo simile agli AudioNode nativi.
  • AudioWorkletProcessor: un corrispettivo di AudioWorkletNode. La parte interna di AudioWorkletNode che elabora lo stream audio in base al codice fornito dall'utente. Viene eseguito in un AudioWorkletGlobalScope quando viene creato un AudioWorkletNode. Un AudioWorkletNode può avere un AudioWorkletProcessor corrispondente.

Pattern di progettazione

Utilizzare Audio Worklet con WebAssembly

WebAssembly è un compagno perfetto per AudioWorkletProcessor. La combinazione di queste due funzionalità offre una serie di vantaggi all'elaborazione audio sul web, ma i due vantaggi principali sono: a) l'integrazione del codice di elaborazione audio C/C++ esistente nell'ecosistema WebAudio e b) l'evitare il sovraccarico della compilazione JIT di JS e della raccolta dei rifiuti nel codice di elaborazione audio.

Il primo è importante per gli sviluppatori che hanno già investito in codice e librerie di elaborazione audio, mentre il secondo è fondamentale per quasi tutti gli utenti dell'API. Nel mondo di WebAudio, il budget di tempo per lo stream audio stabile è piuttosto elevato: sono solo 3 ms con una frequenza di campionamento di 44,1 KHz. Anche un minimo disturbo nel codice di elaborazione dell'audio può causare dei glitch. Lo sviluppatore deve ottimizzare il codice per un'elaborazione più rapida, ma anche ridurre al minimo la quantità di codice JS spazzatura generato. L'utilizzo di WebAssembly può essere una soluzione che risolve entrambi i problemi contemporaneamente: è più veloce e non genera codice spazzatura.

La sezione successiva descrive come utilizzare WebAssembly con un worklet audio e l'esempio di codice associato è disponibile qui. Per il tutorial di base su come utilizzare Emscripten e WebAssembly (in particolare il codice di collegamento Emscripten), consulta questo articolo.

Configurazione

Sembra tutto perfetto, ma abbiamo bisogno di un po' di struttura per configurare tutto correttamente. La prima domanda di progettazione da porsi è come e dove creare un'istanza di un modulo WebAssembly. Dopo aver recuperato il codice glue di Emscripten, esistono due percorsi per l'inizializzazione del modulo:

  1. Carica il codice di collegamento in AudioWorkletGlobalScope tramite audioContext.audioWorklet.addModule() per creare un'istanza di un modulo WebAssembly.
  2. Crea un'istanza di un modulo WebAssembly nello spazio di elaborazione principale, quindi trasferisci il modulo tramite le opzioni del costruttore di AudioWorkletNode.

La decisione dipende in gran parte dal design e dalle preferenze, ma l'idea è che il modulo WebAssembly possa generare un'istanza WebAssembly in AudioWorkletGlobalScope, che diventa un kernel di elaborazione audio all'interno di un'istanza AudioWorkletProcessor.

Pattern di istanza del modulo WebAssembly A: utilizzo della chiamata .addModule()
Modello di istanza del modulo WebAssembly A: utilizzo della chiamata .addModule()

Affinché il pattern A funzioni correttamente, Emscripten ha bisogno di un paio di opzioni per generare il codice di collegamento WebAssembly corretto per la nostra configurazione:

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

Queste opzioni assicurano la compilazione sincrona di un modulo WebAssembly in AudioWorkletGlobalScope. Inoltre, aggiunge la definizione della classe AudioWorkletProcessor in mycode.js in modo che possa essere caricata dopo l'inizializzazione del modulo. Il motivo principale per utilizzare la compilazione sincrona è che la risoluzione della promessa di audioWorklet.addModule() non attende la risoluzione delle promesse nell'ambito AudioWorkletGlobalScope. In genere, il caricamento o la compilazione sincrona nel thread principale non è consigliata perché blocca le altre attività nello stesso thread, ma qui possiamo bypassare la regola perché la compilazione avviene in AudioWorkletGlobalScope, che viene eseguito nel thread principale. Per maggiori informazioni, consulta questa pagina.

Pattern di istanza del modulo WASM B: utilizzo del trasferimento tra thread del costruttore di AudioWorkletNode
Modello di istanza del modulo WASM B: utilizzo del trasferimento tra thread del costruttore di AudioWorkletNode

Il pattern B può essere utile se sono richieste operazioni complesse asincrone. Utilizza il thread principale per recuperare il codice di collegamento dal server e compilare il modulo. Trasferirà quindi il modulo WASM tramite il costruttore di AudioWorkletNode. Questo pattern è ancora più utile quando devi caricare il modulo in modo dinamico dopo che AudioWorkletGlobalScope inizia a eseguire il rendering dello stream audio. A seconda delle dimensioni del modulo, la compilazione nel mezzo del rendering può causare glitch nello stream.

Dati audio e heap WASM

Il codice WebAssembly funziona solo nella memoria allocata all'interno di un heap WASM dedicato. Per sfruttarlo, i dati audio devono essere clonati tra la heap WASM e gli array di dati audio. La classe HeapAudioBuffer nel codice di esempio gestisce bene questa operazione.

Classe HeapAudioBuffer per un utilizzo più facile dell'heap WASM
La classe HeapAudioBuffer per un utilizzo più semplice dell'heap WASM

È in discussione una proposta iniziale per integrare l'heap WASM direttamente nel sistema di Audio Worklet. Sbarazzarsi di questa clonazione di dati ridondanti tra la memoria JS e la heap WASM sembra naturale, ma i dettagli specifici devono essere definiti.

Gestione della mancata corrispondenza della dimensione del buffer

Una coppia di AudioWorkletNode e AudioWorkletProcessor è progettata per funzionare come un normale AudioNode. AudioWorkletNode gestisce l'interazione con altri codici, mentre AudioWorkletProcessor si occupa dell'elaborazione audio interna. Poiché un normale AudioNode elabora 128 frame contemporaneamente, AudioWorkletProcessor deve fare lo stesso per diventare una funzionalità di base. Questo è uno dei vantaggi del design di Audio Worklet che garantisce che non venga introdotta alcuna latenza aggiuntiva dovuta al buffering interno in AudioWorkletProcessor, ma può essere un problema se una funzione di elaborazione richiede una dimensione del buffer diversa da 128 frame. La soluzione comune per questo caso è utilizzare un buffer circolare, noto anche come buffer circolare o FIFO.

Ecco un diagramma di AudioWorkletProcessor che utilizza due anelli di memoria all'interno per supportare una funzione WASM che riceve e invia 512 frame. Il numero 512 scelto qui è arbitrario.

Utilizzo di RingBuffer all'interno del metodo "process()` di AudioWorkletProcessor
Utilizzo di RingBuffer all'interno del metodo "process()` di AudioWorkletProcessor

L'algoritmo per il diagramma è:

  1. AudioWorkletProcessor invia 128 frame nell'Input RingBuffer dal suo input.
  2. Esegui i seguenti passaggi solo se Input RingBuffer ha più o uguale a 512 frame.
    1. Estrai 512 frame dall'Input RingBuffer.
    2. Elabora 512 frame con la funzione WASM specificata.
    3. Invia 512 frame all'Output RingBuffer.
  3. AudioWorkletProcessor estrae 128 frame dall'Output RingBuffer per completarne l'Output.

Come mostrato nel diagramma, i frame di input vengono sempre accumulati nell'Input RingBuffer e gestiscono l'overflow del buffer sovrascrivendo il blocco di frame più antico nel buffer. È una cosa ragionevole da fare per un'applicazione audio in tempo reale. Analogamente, il blocco Inquadratura di output verrà sempre recuperato dal sistema. Un sottoflusso del buffer (dati insufficienti) nell'Output RingBuffer causerà un silenzio che causerà un glitch nello stream.

Questo pattern è utile quando si sostituisce ScriptProcessorNode (SPN) con AudioWorkletNode. Poiché SPN consente allo sviluppatore di scegliere una dimensione del buffer compresa tra 256 e 16384 frame, la sostituzione di SPN con AudioWorkletNode può essere difficile e l'utilizzo di un buffer circolare offre una buona soluzione alternativa. Un registratore audio sarebbe un ottimo esempio di cosa è possibile creare sulla base di questo design.

Tuttavia, è importante capire che questo design concilia solo la mancata corrispondenza della dimensione del buffer e non concede più tempo per eseguire il codice dello script specificato. Se il codice non riesce a completare l'attività entro il budget di tempo del quantum di rendering (~3 ms a 44,1 KHz), influirà sulla tempistica di inizio della funzione di callback successiva e alla fine causerà dei glitch.

La combinazione di questo design con WebAssembly può essere complicata a causa della gestione della memoria intorno all'heap WASM. Al momento della stesura di questo articolo, i dati in entrata e in uscita dell'heap WASM devono essere clonati, ma possiamo utilizzare la classe HeapAudioBuffer per semplificare leggermente la gestione della memoria. In futuro verrà discussa l'idea di utilizzare la memoria allocata dall'utente per ridurre la clonazione di dati ridondanti.

La classe RingBuffer è disponibile qui.

WebAudio Powerhouse: Audio Worklet e SharedArrayBuffer

L'ultimo pattern di progettazione in questo articolo consiste nel riunire in un unico posto diverse API all'avanguardia: Audio Worklet, SharedArrayBuffer, Atomics e Worker. Con questa configurazione non banale, viene aperto un percorso per il software audio esistente scritto in C/C++ per l'esecuzione in un browser web, mantenendo un'esperienza utente fluida.

Panoramica dell'ultimo pattern di progettazione: Audio Worklet, SharedArrayBuffer e Worker
Panoramica dell'ultimo pattern di progettazione: Audio Worklet, SharedArrayBuffer e worker

Il vantaggio principale di questo design è la possibilità di utilizzare un DedicatedWorkerGlobalScope esclusivamente per l'elaborazione audio. In Chrome, WorkerGlobalScope viene eseguito in un thread con priorità inferiore rispetto al thread di rendering di WebAudio, ma presenta diversi vantaggi rispetto a AudioWorkletGlobalScope. DedicatedWorkerGlobalScope è meno vincolato in termini di API disponibili nell'ambito. Inoltre, puoi aspettarti un supporto migliore da Emscripten perché l'API Worker esiste da alcuni anni.

SharedArrayBuffer svolge un ruolo fondamentale per il funzionamento efficiente di questo design. Sebbene sia Worker che AudioWorkletProcessor siano dotati di messaggistica asincrona (MessagePort), non sono ottimali per l'elaborazione audio in tempo reale a causa dell'allocazione di memoria ripetitiva e della latenza della messaggistica. Pertanto, allochiamo in anticipo un blocco di memoria a cui è possibile accedere da entrambi i thread per un trasferimento di dati bidirezionale rapido.

Dal punto di vista dei puristi dell'API Web Audio, questo design potrebbe sembrare non ottimale perché utilizza il worklet audio come un semplice "destinatario audio" e fa tutto nel worker. Tuttavia, poiché il costo della riscrittura di progetti C/C++ in JavaScript può essere proibitivo o addirittura impossibile, questo design può essere il percorso di implementazione più efficiente per questi progetti.

Stati e atomici condivisi

Quando si utilizza una memoria condivisa per i dati audio, l'accesso da entrambe le parti deve essere coordinato con attenzione. La condivisione di stati accessibili a livello atomico è una soluzione a questo problema. Per questo scopo, possiamo utilizzare Int32Array supportato da un SAB.

Meccanismo di sincronizzazione: SharedArrayBuffer e Atomics
Meccanismo di sincronizzazione: SharedArrayBuffer e Atomics

Meccanismo di sincronizzazione: SharedArrayBuffer e Atomics

Ogni campo dell'array Stati rappresenta informazioni vitali sui buffer condivisi. Il più importante è un campo per la sincronizzazione (REQUEST_RENDER). L'idea è che il worker attenda che questo campo venga attivato da AudioWorkletProcessor ed elabori l'audio quando si riattiva. Insieme a SharedArrayBuffer (SAB), l'API Atomics rende possibile questo meccanismo.

Tieni presente che la sincronizzazione di due thread è piuttosto libera. L'inizio di Worker.process() verrà attivato dal metodo AudioWorkletProcessor.process(), ma AudioWorkletProcessor non attende il termine di Worker.process(). Questo è intenzionale; AudioWorkletProcessor è basato sul callback audio, pertanto non deve essere bloccato in modo sincrono. Nel peggiore dei casi, lo stream audio potrebbe essere duplicato o subire interruzioni, ma alla fine si riprenderà quando le prestazioni di rendering saranno stabili.

Configurazione ed esecuzione

Come mostrato nel diagramma sopra, questo design ha diversi componenti da organizzare: DedicatedWorkerGlobalScope (DWGS), AudioWorkletGlobalScope (AWGS), SharedArrayBuffer e il thread principale. I passaggi riportati di seguito descrivono cosa dovrebbe accadere nella fase di inizializzazione.

Inizializzazione
  1. [Main] Viene chiamato il costruttore AudioWorkletNode.
    1. Crea un worker.
    2. Verrà creato l'AudioWorkletProcessor associato.
  2. [DWGS] Il worker crea 2 SharedArrayBuffer. (uno per gli stati condivisi e l'altro per i dati audio)
  3. [DWGS] Il worker invia riferimenti a SharedArrayBuffer ad AudioWorkletNode.
  4. [Main] AudioWorkletNode invia riferimenti a SharedArrayBuffer a AudioWorkletProcessor.
  5. [AWGS] AudioWorkletProcessor comunica ad AudioWorkletNode che la configurazione è completata.

Una volta completata l'inizializzazione, AudioWorkletProcessor.process() inizia a essere chiamato. Di seguito è descritto ciò che dovrebbe accadere in ogni iterazione del loop di rendering.

Loop di rendering
Rendering multi-thread con SharedArrayBuffer
Rendering multithread con SharedArrayBuffers
  1. [AWGS] AudioWorkletProcessor.process(inputs, outputs) viene chiamato per ogni quantum di rendering.
    1. inputs verrà inserito in Input SAB.
    2. outputs verrà compilato utilizzando i dati audio nell'Output SAB.
    3. Aggiorna States SAB con i nuovi indici buffer di conseguenza.
    4. Se Output SAB si avvicina alla soglia di sottoflusso, riattiva il worker per eseguire il rendering di più dati audio.
  2. [DWGS] Il worker attende (in stato di sospensione) il segnale di risveglio da AudioWorkletProcessor.process(). Quando si riattiva:
    1. Recupera gli indici buffer da States SAB.
    2. Esegui la funzione di elaborazione con i dati di Input SAB per compilare Output SAB.
    3. Aggiorna States SAB con gli indici del buffer di conseguenza.
    4. Va in sospensione e attende il segnale successivo.

Il codice di esempio è disponibile qui, ma tieni presente che il flag sperimentale SharedArrayBuffer deve essere attivato affinché questa demo funzioni. Per semplicità, il codice è stato scritto con codice JS puro, ma può essere sostituito con codice WebAssembly se necessario. Questo caso deve essere gestito con particolare attenzione avvolgendo la gestione della memoria con la classe HeapAudioBuffer.

Conclusione

L'obiettivo finale dell'Audio Worklet è rendere l'API Web Audio davvero "espandibile". La sua progettazione ha richiesto un impegno pluriennale per consentire di implementare il resto dell'API Web Audio con l'Audio Worklet. Di conseguenza, ora il design è più complesso e questa può essere una sfida inaspettata.

Fortunatamente, la ragione di questa complessità è puramente per dare potere agli sviluppatori. La possibilità di eseguire WebAssembly su AudioWorkletGlobalScope sblocca un enorme potenziale per l'elaborazione audio ad alte prestazioni sul web. Per le applicazioni audio su larga scala scritte in C o C++, l'utilizzo di un worklet audio con SharedArrayBuffers e Workers può essere un'opzione interessante da esplorare.

Crediti

Un ringraziamento speciale a Chris Wilson, Jason Miller, Joshua Bell e Raymond Toy per aver esaminato una bozza di questo articolo e aver fornito un feedback utile.