Modèle de conception du worklet audio

Hongchan Choi

L'article précédent sur le Worklet audio présentait en détail les concepts de base et leur utilisation. Depuis son lancement dans Chrome 66, de nombreuses personnes ont demandé d'autres exemples d'utilisation dans des applications réelles. Le Worklet audio libère tout le potentiel de WebAudio, mais il peut s'avérer difficile de l'exploiter, car il nécessite de comprendre la programmation simultanée encapsulée avec plusieurs API JS. Même pour les développeurs qui connaissent bien WebAudio, l'intégration du workflow audio à d'autres API (par exemple, WebAssembly) peut s'avérer difficile.

Cet article permettra au lecteur de mieux comprendre comment utiliser le Worklet audio en conditions réelles et de lui donner des conseils pour exploiter toute sa puissance. N'oubliez pas de consulter également des exemples de code et des démonstrations en direct.

Résumé: Worklet audio

Avant d'entrer dans le vif du sujet, récapitulons rapidement les termes et les faits concernant le système de Worklet audio, que nous avons vus précédemment dans cet article.

  • BaseAudioContext : objet principal de l'API Web Audio.
  • Worklet audio: chargeur de fichier de script spécial pour l'opération du Worklet audio. Appartient à BaseAudioContext. Un BaseAudioContext peut avoir un workflow audio. Le fichier de script chargé est évalué dans AudioWorkletGlobalScope et permet de créer les instances AudioWorkletProcessor.
  • AudioWorkletGlobalScope : champ d'application global JS spécial pour l'opération du Worklet audio. S'exécute sur un thread de rendu dédié à WebAudio. Un BaseAudioContext peut avoir un AudioWorkletGlobalScope.
  • AudioWorkletNode : AudioWorkNode conçu pour l'opération du Worklet audio. Instancié à partir d'un BaseAudioContext. Un BaseAudioContext peut comporter plusieurs AudioWorkletNodes semblables aux AudioNodes natifs.
  • AudioWorkletProcessor : équivalent de AudioWorkletNode. Les boyaux réels de l'AudioWorkletNode qui traite le flux audio par le code fourni par l'utilisateur. Il est instancié dans AudioWorkletGlobalScope lorsqu'un AudioWorkletNode est construit. Un AudioWorkletNode peut avoir un AudioWorkletProcessor correspondant.

Modèles de conception

Utiliser un workflow audio avec WebAssembly

WebAssembly est le compagnon idéal d'AudioWorkletProcessor. La combinaison de ces deux fonctionnalités apporte de nombreux avantages au traitement audio sur le Web, mais les deux principaux avantages sont: a) intégrer le code de traitement audio C/C++ existant dans l'écosystème WebAudio et b) éviter les frais généraux de la compilation JIT JS et de la récupération de mémoire dans le code de traitement audio.

Le premier est important pour les développeurs qui ont déjà investi dans le code et les bibliothèques de traitement audio, mais le second est essentiel pour presque tous les utilisateurs de l'API. Dans l'univers de WebAudio, le budget de timing pour le flux audio stable est assez contraignant: il n'est que de 3 ms avec un taux d'échantillonnage de 44,1 kHz. Même un léger problème dans le code de traitement audio peut provoquer des glitchs. Le développeur doit optimiser le code pour accélérer le traitement, mais également minimiser la quantité de mémoire JS générée. L'utilisation de WebAssembly peut être une solution qui résout ces deux problèmes en même temps: elle est plus rapide et ne génère aucun gaspillage du code.

La section suivante décrit comment WebAssembly peut être utilisé avec un Worklet audio. L'exemple de code accompagné est disponible sur cette page. Pour suivre le tutoriel de base sur l'utilisation d'Emscripten et de WebAssembly (en particulier le code de la colle Emscripten), consultez cet article.

Configurer

Cela semble très intéressant, mais nous avons besoin d'un peu de structure pour configurer correctement les choses. La première question de conception à vous poser est de savoir comment et où instancier un module WebAssembly. Après avoir récupéré le code Glue d'Emmscripten, deux chemins sont disponibles pour l'instanciation du module:

  1. Instanciez un module WebAssembly en chargeant le code Glue dans AudioWorkletGlobalScope via audioContext.audioWorklet.addModule().
  2. Instanciez un module WebAssembly dans le champ d'application principal, puis transférez le module via les options du constructeur d'AudioWorkletNode.

La décision dépend en grande partie de votre conception et de vos préférences, mais l'idée est que le module WebAssembly peut générer une instance WebAssembly dans AudioWorkletGlobalScope, qui devient un noyau de traitement audio dans une instance AudioWorkletProcessor.

Modèle d'instanciation du module WebAssembly A: utilisation de l'appel .addModule()
Modèle d'instanciation A du module WebAssembly: utilisation de l'appel .addModule()

Pour que le modèle A fonctionne correctement, Emscripten a besoin de deux options afin de générer le code glue WebAssembly approprié pour notre configuration:

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

Ces options garantissent la compilation synchrone d'un module WebAssembly dans AudioWorkletGlobalScope. Il ajoute également la définition de classe de AudioWorkletProcessor dans mycode.js afin qu'il puisse être chargé après l'initialisation du module. La principale raison d'utiliser la compilation synchrone est que la résolution de la promesse de audioWorklet.addModule() n'attend pas la résolution des promesses dans AudioWorkletGlobalScope. Le chargement ou la compilation synchrones dans le thread principal n'est généralement pas recommandé, car il bloque les autres tâches du même thread. Toutefois, nous pouvons contourner la règle, car la compilation a lieu sur AudioWorkletGlobalScope, qui s'exécute à partir du thread principal. (Pour en savoir plus, consultez cet article.)

Modèle d'instanciation de module WASM B: utilisation du transfert interthread du constructeur AudioWorkletNode
Modèle d'instanciation de module WASM B: utiliser le transfert multithread du constructeur AudioWorkletNode

Le modèle B peut être utile si une opération lourde asynchrone est nécessaire. Il utilise le thread principal pour récupérer le code Glue du serveur et compiler le module. Il transférera ensuite le module WASM via le constructeur d'AudioWorkletNode. Ce modèle est encore plus logique lorsque vous devez charger le module de manière dynamique après que AudioWorkletGlobalScope a commencé à afficher le flux audio. Selon la taille du module, sa compilation au milieu du rendu peut entraîner des glitchs dans le flux.

Segment de mémoire et données audio WASM

Le code WebAssembly ne fonctionne que sur la mémoire allouée dans un tas de mémoire WASM dédié. Pour en profiter, les données audio doivent être clonées dans les deux sens entre le tas de mémoire WASM et les tableaux de données audio. Dans l'exemple de code, la classe HeapAudioBuffer gère bien cette opération.

Classe HeapAudioBuffer pour faciliter l'utilisation du tas de mémoire WASM
Classe HeapAudioBuffer pour une utilisation simplifiée des segments de mémoire WASM

Une proposition préliminaire est en cours de discussion pour intégrer le tas de mémoire WASM directement dans le système de Worklet audio. Se débarrasser de ce clonage de données redondant entre la mémoire JS et le tas de mémoire WASM semble naturel, mais les détails spécifiques doivent être résolus.

Gérer les différences de taille de tampon

Une paire AudioWorkletNode et AudioWorkletProcessor est conçue pour fonctionner comme un AudioWorkletNode standard. AudioWorkletNode gère les interactions avec d'autres codes tandis qu'AudioWorkletProcessor gère le traitement audio interne. Étant donné qu'un AudioNode standard traite 128 frames à la fois, AudioWorkletProcessor doit faire de même pour devenir une fonctionnalité essentielle. C'est l'un des avantages de la conception du Worklet audio : aucune latence supplémentaire due à la mise en mémoire tampon interne n'est introduite dans AudioWorkletProcessor. Toutefois, cela peut poser problème si une fonction de traitement nécessite une taille de mémoire tampon différente de 128 images. Dans ce cas, la solution courante consiste à utiliser un tampon en anneau, également appelé tampon circulaire ou FIFO.

Voici un schéma d'AudioWorkletProcessor utilisant deux tampons annulaires à l'intérieur pour prendre en charge une fonction WASM qui accepte 512 trames. Ici, le nombre 512 est choisi arbitrairement.

Utiliser RingBuffer dans la méthode "process()" d'AudioWorkletProcessor
Utiliser RingBuffer dans la méthode `process()` d'AudioWorkletProcessor

L'algorithme du diagramme est le suivant:

  1. AudioWorkletProcessor transfère 128 trames dans la classe Input RingBuffer à partir de son entrée.
  2. Ne procédez comme suit que si la valeur du RingBuffer d'entrée comporte 512 images ou plus.
    1. Extrayez 512 images du RingBuffer d'entrée.
    2. Traiter 512 trames avec la fonction WASM donnée.
    3. Envoi de 512 images au RingBuffer de sortie.
  3. AudioWorkletProcessor extrait 128 frames du RingBuffer de sortie pour remplir sa Sortie.

Comme le montre le schéma, les trames d'entrée sont toujours accumulées dans le tampon d'entrée InputRingBuffer et elle gère le dépassement de tampon en écrasant le bloc de frames le plus ancien dans le tampon. C'est une approche raisonnable pour une application audio en temps réel. De même, le bloc de frames de sortie est toujours extrait par le système. Une sous-diffusion du tampon (données insuffisantes) dans le RingBuffer de sortie entraîne une mise sous silence dans le flux, ce qui entraîne un glitch.

Ce modèle est utile lorsque vous remplacez ScriptProcessorNode (SPN) par AudioWorkletNode. Étant donné que le SPN permet au développeur de choisir une taille de mémoire tampon comprise entre 256 et 16 384 frames, le remplacement direct du SPN par AudioWorkletNode peut s'avérer difficile et l'utilisation d'un tampon circulaire constitue une solution de contournement intéressante. Un enregistreur audio serait un excellent exemple qui pourrait être construit sur cette conception.

Cependant, il est important de comprendre que cette conception ne fait que rapprocher la non-concordance de la taille de la mémoire tampon et qu'elle ne donne pas plus de temps pour exécuter le code de script donné. Si le code ne peut pas terminer la tâche dans le délai de rendu quantique (environ 3 ms à 44,1 kHz), cela affectera le délai d'activation de la fonction de rappel suivante et finira par provoquer des glitchs.

Associer cette conception avec WebAssembly peut s'avérer compliqué en raison de la gestion de la mémoire autour du tas de mémoire WASM. Au moment de la rédaction de cet article, les données entrantes et sortantes du tas de mémoire WASM doivent être clonées. Toutefois, nous pouvons utiliser la classe HeapAudioBuffer pour faciliter légèrement la gestion de la mémoire. Nous évoquerons l'utilisation de mémoire allouée par l'utilisateur pour réduire le clonage de données redondant.

La classe RingBuffer est disponible ici.

WebAudio Powerhouse: Worklet audio et SharedArrayBuffer

Le dernier modèle de conception de cet article consiste à regrouper plusieurs API de pointe en un seul endroit : Audio Worklet, SharedArrayBuffer, Atomics et Worker. Grâce à cette configuration complexe, il permet aux logiciels audio existants écrits en C/C++ de s'exécuter dans un navigateur Web tout en préservant la fluidité de l'expérience utilisateur.

Présentation du dernier modèle de conception: Audio Worklet, SharedArrayBuffer et Worker
Présentation du dernier modèle de conception: Worklet audio, SharedArrayBuffer et Worker

Le plus grand avantage de cette conception est de pouvoir utiliser DedicatedWorkerGlobalScope uniquement pour le traitement audio. Dans Chrome, WorkerGlobalScope s'exécute sur un thread de priorité inférieure à celui du thread de rendu WebAudio, mais il présente plusieurs avantages par rapport à AudioWorkletGlobalScope. La contrainte WorkerGlobalScope est moins limitée en termes de surface d'API disponible dans le champ d'application. Emscripten propose également une meilleure prise en charge, car l'API Worker existe depuis quelques années.

SharedArrayBuffer joue un rôle essentiel pour le bon fonctionnement de cette conception. Bien que Worker et AudioWorkletProcessor soient tous deux équipés de la messagerie asynchrone (MessagePort), il n'est pas optimal pour le traitement audio en temps réel en raison de l'allocation de mémoire répétitive et de la latence de la messagerie. Nous allouons donc à l'avance un bloc de mémoire accessible depuis les deux threads pour un transfert de données bidirectionnel rapide.

Du point de vue des puristes de l'API Web Audio, cette conception peut ne pas sembler optimale, car elle utilise le workflow audio comme un simple "récepteur audio" et fait tout dans le nœud de calcul. Toutefois, si l'on considère le coût de la réécriture de projets C/C++ en JavaScript qui peut s'avérer prohibitif, voire impossible, cette conception peut s'avérer la méthode d'implémentation la plus efficace pour ces projets.

États partagés et atomiques

Lorsque vous utilisez une mémoire partagée pour des données audio, l'accès des deux côtés doit être coordonné avec soin. Le partage des états accessibles atomiquement est une solution à ce problème. À cette fin, nous pouvons exploiter Int32Array reposant sur un établissement de services de proximité à domicile.

Mécanisme de synchronisation: SharedArrayBuffer et Atomics
Mécanisme de synchronisation: SharedArrayBuffer et Atomics

Mécanisme de synchronisation: SharedArrayBuffer et Atomics

Chaque champ du tableau "States" (États) représente des informations essentielles sur les tampons partagés. Le plus important est un champ pour la synchronisation (REQUEST_RENDER). L'idée est que le nœud de calcul attend que ce champ soit touché par AudioWorkletProcessor et traite le contenu audio à son activation. Avec SharedArrayBuffer (SAB), l'API Atomics rend ce mécanisme possible.

Notez que la synchronisation de deux threads n'est pas assez large. L'apparition de Worker.process() sera déclenchée par la méthode AudioWorkletProcessor.process(), mais l'AudioWorkletProcessor n'attend pas que l'Worker.process() soit terminée. Cela est volontaire. AudioWorkletProcessor est géré par le rappel audio et ne doit donc pas être bloqué de manière synchrone. Dans le pire des cas, le flux audio peut souffrir d'un doublon ou d'une interruption, mais il finira par récupérer une fois les performances d'affichage stabilisées.

Configuration et utilisation

Comme le montre le schéma ci-dessus, plusieurs composants doivent être organisés dans cette conception : DDWorkerGlobalScope (DWGS), AudioWorkletGlobalScope (AWGS), SharedArrayBuffer et le thread principal. Les étapes suivantes décrivent ce qui doit se passer lors de la phase d'initialisation.

Initialisation
  1. [Principal] Le constructeur AudioWorkletNode est appelé.
    1. Créer un nœud de calcul.
    2. Le AudioWorkletProcessor associé sera créé.
  2. [DWGS] Le nœud de calcul crée deux SharedArrayBuffer. (l'un pour les états partagés et l'autre pour les données audio)
  3. [DWGS] Le nœud de calcul envoie des références SharedArrayBuffer à AudioWorkletNode.
  4. [Principal] AudioWorkletNode envoie des références SharedArrayBuffer à AudioWorkletProcessor.
  5. [AWGS] AudioWorkletProcessor informe AudioWorkletNode que la configuration est terminée.

Une fois l'initialisation terminée, AudioWorkletProcessor.process() commence à être appelé. Voici ce qui doit se passer à chaque itération de la boucle de rendu.

Boucle de rendu
Rendu multithread avec SharedArrayBuffers
Rendu multithread avec SharedArrayBuffers
  1. [AWGS] AudioWorkletProcessor.process(inputs, outputs) est appelé pour chaque quantum de rendu.
    1. inputs sera transmis à Input SAB.
    2. outputs sera renseigné en consommant des données audio dans l'SAB de sortie.
    3. Met à jour les States SAB avec les nouveaux index de tampon en conséquence.
    4. Si le SAB de sortie s'approche de dépasser le seuil, activez le nœud de calcul pour afficher plus de données audio.
  2. [DWGS] Le nœud de calcul attend (en veille) le signal de réveil de AudioWorkletProcessor.process(). Au réveil :
    1. Récupère les index de tampon de States SAB.
    2. Exécutez la fonction process avec les données de Input SAB pour remplir Output SAB.
    3. Met à jour les States SAB avec les index de tampon en conséquence.
    4. Il se met en veille et attend le signal suivant.

Vous trouverez l'exemple de code ici. Notez toutefois que l'indicateur expérimental SharedArrayBuffer doit être activé pour que cette démonstration fonctionne. Le code a été écrit avec du code JS pur pour plus de simplicité, mais il peut être remplacé par du code WebAssembly si nécessaire. Ce cas doit être traité avec une attention particulière en encapsulant la gestion de la mémoire avec la classe HeapAudioBuffer.

Conclusion

L'objectif ultime du Worklet audio est de rendre l'API Web Audio véritablement "extensible". Sa conception a fait l'objet d'un effort pluriannuel pour permettre la mise en œuvre du reste de l'API Web Audio avec le Worklet audio. À notre tour, sa conception est désormais plus complexe, ce qui peut s'avérer un défi inattendu.

Heureusement, la raison de cette complexité est simplement de donner aux développeurs les moyens d'agir. La possibilité d'exécuter WebAssembly sur AudioWorkletGlobalScope offre un énorme potentiel de traitement audio hautes performances sur le Web. Pour les applications audio à grande échelle écrites en C ou C++, l'utilisation d'un Worklet audio avec SharedArrayBuffers et Workers peut être une option intéressante à explorer.

Crédits

Nous remercions tout particulièrement Chris Wilson, Jason Miller, Joshua Bell et Raymond Toy pour avoir relu une ébauche de cet article et avoir fourni des commentaires pertinents.