Pattern di progettazione del worklet audio

Hongchan Choi

L'articolo precedente su Worklet audio illustra in dettaglio i concetti di base e l'utilizzo. Fin dal suo lancio in Chrome 66, sono state molte le richieste di ulteriori esempi su come utilizzarlo in applicazioni reali. Il worklet audio libera il pieno potenziale di WebAudio, ma sfruttarlo può essere difficile perché richiede la comprensione della programmazione simultanea criptata con diverse API JS. Anche per gli sviluppatori che hanno familiarità con WebAudio, l'integrazione del Worklet audio con altre API (ad es. WebAssembly) può risultare difficile.

Questo articolo aiuterà il lettore a comprendere meglio come utilizzare il Worklet audio in contesti reali e offrirà suggerimenti per sfruttare al massimo la sua potenza. Assicurati di consultare anche esempi di codice e demo dal vivo.

Riepilogo: Worklet audio

Prima di proseguire, facciamo un breve riepilogo dei termini e delle informazioni sul sistema di worklet audio che abbiamo presentato in precedenza in questo post.

  • BaseAudioContext: l'oggetto principale dell'API Web Audio.
  • Worklet audio: un caricatore di file di script speciale per l'operazione di Worklet audio. Appartiene a BaseAudioContext. Un BaseAudioContext può avere un worklet audio. Il file di script caricato viene valutato in AudioWorkletGlobalScope e utilizzato per creare le istanze AudioWorkletProcessor.
  • AudioWorkletGlobalScope: un ambito globale JS speciale per l'operazione di Worklet audio. Viene eseguito su un thread di rendering dedicato per WebAudio. Un BaseAudioContext può avere un solo AudioWorkletGlobalScope.
  • AudioWorkletNode: un AudioNode progettato per l'operazione Worklet audio. Viene creata un'istanza da un BaseAudioContext. Un BaseAudioContext può avere più AudioWorkletNodes simili agli AudioNodes nativi.
  • AudioWorkletProcessor: una controparte di AudioWorkletNode. Le parti reali dell'AudioWorkletNode che elaborano lo stream audio tramite il codice fornito dall'utente. Viene creata un'istanza in AudioWorkletGlobalScope al momento della creazione di un AudioWorkletNode. Un AudioWorkletNode può avere un AudioWorkletProcessor corrispondente.

Pattern di progettazione

Utilizzo di Worklet audio con WebAssembly

WebAssembly è un companion perfetto per AudioWorkletProcessor. La combinazione di queste due funzionalità porta una serie di vantaggi all'elaborazione audio sul web, ma i due principali vantaggi sono: a) portare il codice di elaborazione audio C/C++ esistente nell'ecosistema WebAudio e b) evitare l'overhead delle compilation JIT JS e la garbage collection nel codice di elaborazione audio.

La prima è importante per gli sviluppatori con un investimento esistente nel codice e nelle librerie di elaborazione audio, ma la seconda è fondamentale per quasi tutti gli utenti dell'API. Nel mondo di WebAudio, il budget della temporizzazione per uno stream audio stabile è piuttosto impegnativo: è di soli 3 ms alla frequenza di campionamento di 44,1 Khz. Anche un leggero singhiozzo nel codice di elaborazione audio può causare problemi. Lo sviluppatore deve ottimizzare il codice per un'elaborazione più rapida, ma anche per ridurre al minimo la quantità di rifiuti JS generati. L'utilizzo di WebAssembly può essere una soluzione che risolve entrambi i problemi contemporaneamente: è più veloce e non genera nessun rifiuto dal codice.

Nella sezione successiva viene descritto come è possibile utilizzare WebAssembly con un worklet audio. L'esempio di codice associato è disponibile qui. Per il tutorial di base su come utilizzare Emscripten e WebAssembly (in particolare il colla code Emscripten), consulta questo articolo.

Configurazione

Sembra tutto fantastico, ma abbiamo bisogno di un po' di struttura per configurarlo correttamente. La prima domanda sulla progettazione da porsi è come e dove creare l'istanza di un modulo WebAssembly. Dopo aver recuperato il codice colla di Emscripten, sono disponibili due percorsi per l'istanza del modulo:

  1. Crea l'istanza di un modulo WebAssembly caricando il codice colla in AudioWorkletGlobalScope tramite audioContext.audioWorklet.addModule().
  2. Crea un'istanza di un modulo WebAssembly nell'ambito principale, quindi trasferisci il modulo tramite le opzioni del costruttore di AudioWorkletNode.

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

Pattern di istanza di modulo WebAssembly A: utilizzo della chiamata .addModule()
Pattern di istanziazione 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 colla 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. Aggiunge inoltre la definizione della classe AudioWorkletProcessor in mycode.js, in modo che possa essere caricato dopo l'inizializzazione del modulo. Il motivo principale per utilizzare la compilazione sincrona è che la risoluzione delle promesse di audioWorklet.addModule() non attende la risoluzione delle promesse in AudioWorkletGlobalScope. Il caricamento sincrono o la compilazione nel thread principale non è generalmente consigliato perché blocca le altre attività nello stesso thread, ma qui possiamo ignorare la regola perché la compilazione avviene sull'AudioWorkletGlobalScope, che viene eseguito dal thread principale. Per saperne di più, consulta questa pagina.

Pattern di istanza del modulo WASM B: utilizzo del trasferimento cross-thread del costruttore AudioWorkletNode
Pattern di istanziazione del modulo WASM B: utilizzo del trasferimento tra thread del costruttore AudioWorkletNode

Il pattern B può essere utile se è richiesto un carico elevato in modo asincrono. Utilizza il thread principale per recuperare il codice colla dal server e compilare il modulo. Quindi trasferirà il modulo WASM tramite il costruttore di AudioWorkletNode. Questo pattern ha un senso ancora di più se devi caricare il modulo in modo dinamico dopo che AudioWorkletGlobalScope ha avviato il rendering dello stream audio. A seconda delle dimensioni del modulo, compilarlo durante il rendering può causare problemi dello stream.

Dati audio e heap WASM

Il codice WebAssembly funziona solo sulla memoria allocata all'interno di un heap WASM dedicato. Per utilizzarlo, i dati audio devono essere clonati avanti e indietro tra l'heap WASM e gli array di dati audio. La classe HeapAudioBuffer nel codice di esempio gestisce questa operazione in modo efficiente.

Classe HeapAudioBuffer per un utilizzo più semplice dell'heap WASM
Classe HeapAudioBuffer per semplificare l'utilizzo dell'heap WASM

È in corso una proposta iniziale per l'integrazione dell'heap WASM direttamente nel sistema di worklet dell'audio. Eliminare questa clonazione di dati ridondanti tra la memoria JS e l'heap WASM sembra naturale, ma i dettagli specifici devono essere risolti.

Mancata corrispondenza della dimensione del buffer di gestione

Una coppia 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 AudioNode standard elabora 128 frame alla volta, AudioWorkletProcessor deve fare lo stesso per diventare una funzionalità di base. Questo è uno dei vantaggi del design del worklet audio che garantisce l'assenza di latenza aggiuntiva dovuta al buffering interno all'interno dell'AudioWorkletProcessor, ma può rappresentare un problema se una funzione di elaborazione richiede una dimensione del buffer diversa da 128 frame. La soluzione comune per questo caso è l'uso di un buffer circolare, noto anche come buffer circolare o FIFO.

Ecco un diagramma di AudioWorkletProcessor che utilizza due buffer ad anello all'interno per una funzione WASM che prende 512 frame in entrata e in uscita. (Il numero 512 viene scelto in modo 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 sarebbe:

  1. AudioWorkletProcessor esegue il push di 128 frame nel Input RingBuffer dal suo input.
  2. Esegui i passaggi seguenti solo se il RingBuffer di input ha un numero di frame maggiore o uguale a 512.
    1. Esegui il pull di 512 frame da Input RingBuffer.
    2. Elabora 512 frame con la funzione WASM specificata.
    3. Esegui il push di 512 frame in Output RingBuffer.
  3. AudioWorkletProcessor estrae 128 frame dal RingBuffer di output per riempire il relativo Output.

Come mostrato nel diagramma, i frame di input vengono sempre accumulati nel RingBuffer di input e gestisce l'overflow del buffer sovrascrivendo il blocco di frame meno recente nel buffer. È una cosa ragionevole da fare per un'applicazione audio in tempo reale. Allo stesso modo, il blocco del frame di output viene sempre estratto dal sistema. L'underflow del buffer (dati insufficienti) nel RingBuffer di output genererà silenzio, causando un glitch nel flusso.

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

Tuttavia, è importante capire che questo design riconcilia solo la mancata corrispondenza delle dimensioni del buffer e non concede più tempo per eseguire il codice di script specificato. Se il codice non riesce a completare l'attività entro il budget della temporizzazione del quantico di rendering (circa 3 ms a 44,1 Khz), questo influirà sulla tempistica di inizio della funzione di callback successiva e, alla fine, causerà problemi.

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

La classe RingBuffer è disponibile qui.

WebAudio Powerhouse: Worklet audio e SharedArrayBuffer

L'ultimo pattern di progettazione di questo articolo consiste nel posizionare diverse API all'avanguardia in un'unica posizione: Audio Worklet, SharedArrayBuffer, Atomics e Worker. Con questa configurazione non banale, sblocca un percorso per l'esecuzione in un browser web dei software audio esistenti scritti in C/C++, mantenendo al contempo un'esperienza utente fluida.

Una panoramica dell'ultimo pattern di progettazione: Worklet audio, SharedArrayBuffer e Worker
Una panoramica dell'ultimo pattern di progettazione: Worklet audio, SharedArrayBuffer e Worker

Il più grande vantaggio di questo design è la possibilità di utilizzare DedicatedWorkerGlobalScope esclusivamente per l'elaborazione audio. In Chrome, WorkerGlobalScope viene eseguito su un thread con priorità più bassa rispetto al thread di rendering WebAudio, ma presenta diversi vantaggi rispetto a AudioWorkletGlobalScope. DedicatedWorkerGlobalScope è meno limitato in termini di superficie API disponibile nell'ambito. Inoltre, l'API Worker esiste da alcuni anni e offre un'assistenza migliore da parte di Emscripten.

SharedArrayBuffer svolge un ruolo fondamentale affinché questo design funzioni in modo efficiente. Sebbene sia Worker che AudioWorkletProcessor siano dotati di una messaggistica asincrona (MessagePort), non è ottimale per l'elaborazione dell'audio in tempo reale a causa della ripetitiva allocazione della memoria e della latenza della messaggistica. Abbiamo quindi assegnato in anticipo un blocco di memoria accessibile da entrambi i thread per il trasferimento di dati rapido e bidirezionale.

Dal punto di vista dei puristi dell'API Web Audio, questo design potrebbe sembrare non ottimale perché utilizza il Worklet audio come un semplice "sink audio" e fa tutte le operazioni 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 condivisi e Atomica

Quando utilizzi una memoria condivisa per i dati audio, l'accesso da entrambi i lati deve essere coordinato con attenzione. La condivisione di stati accessibili a livello atomico è una soluzione a questo problema. A questo scopo, possiamo utilizzare Int32Array sostenute da un'attività al domicilio del cliente.

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

Meccanismo di sincronizzazione: SharedArrayBuffer e Atomics

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

Tieni presente che la sincronizzazione di due fili è piuttosto lenta. L'attivazione di Worker.process() verrà attivata dal metodo AudioWorkletProcessor.process(), ma AudioWorkletProcessor non attende il completamento di Worker.process(). Questo è stato progettato: AudioWorkletProcessor si basa sul callback audio, quindi non deve essere bloccato in modo sincrono. Nel peggiore dei casi, lo stream audio potrebbe avere un duplicato o un'interruzione, ma alla fine verrà ripristinato quando le prestazioni di rendering si saranno stabilizzate.

Configurazione ed esecuzione

Come mostrato nel diagramma in alto, questo progetto prevede diversi componenti da organizzare: DedicatedWorkerGlobalScope (DWGS), AudioWorkletGlobalScope (AWGS), SharedArraybu e il thread principale. I passaggi seguenti descrivono cosa dovrebbe succedere nella fase di inizializzazione.

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

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

Loop di rendering
Rendering multi-thread con SharedArrayBuffers
Rendering multi-thread con SharedArrayBuffers
  1. [AWGS] AudioWorkletProcessor.process(inputs, outputs) viene chiamato per ogni quantistico di rendering.
    1. inputs verrà trasferito a Input SAB.
    2. Il campo outputs verrà compilato utilizzando dati audio in SAB di output.
    3. Aggiorna di conseguenza gli stati SAB con i nuovi indici di buffer.
    4. Se SAB di output si avvicina alla soglia di underflow, Wake Worker visualizza più dati audio.
  2. [DWGS] Il worker attende (in sospensione) il segnale di riattivazione da AudioWorkletProcessor.process(). Quando il dispositivo si accende:
    1. Recupera gli indici di buffer dallo States SAB.
    2. Esegui la funzione di processo con i dati di Input SAB per compilare SAB di output.
    3. Aggiorna gli stati SAB con gli indici di buffer di conseguenza.
    4. Entra in modalità di sospensione e aspetta il segnale successivo.

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

Conclusione

L'obiettivo principale dell'Audio Worklet è rendere l'API Web Audio davvero "estensibile". La progettazione, che ha richiesto più anni per rendere possibile l'implementazione del resto dell'API Web Audio con Audio Worklet. A sua volta, la progettazione è diventata più complessa e questa può essere una sfida inaspettata.

Fortunatamente, il motivo di questa complessità è unicamente quello di responsabilizzare gli sviluppatori. L'esecuzione di WebAssembly su AudioWorkletGlobalScope genera un enorme potenziale di 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 interessante.