L'article précédent sur les Worklets audio décrit les concepts de base et l'utilisation. Depuis son lancement dans Chrome 66, de nombreuses demandes ont été formulées pour obtenir plus d'exemples de son utilisation dans des applications réelles. Le worklet audio exploite tout le potentiel de WebAudio, mais en tirer parti peut s'avérer difficile, car il nécessite de comprendre la programmation concurrente encapsulée avec plusieurs API JS. Même pour les développeurs qui connaissent WebAudio, l'intégration du worklet audio à d'autres API (par exemple, WebAssembly) peut s'avérer difficile.
Cet article vous aidera à mieux comprendre comment utiliser le Worklet Audio dans des environnements réels et à exploiter tout son potentiel. N'oubliez pas de consulter les exemples de code et les démonstrations en direct.
Récapitulatif: Worklet audio
Avant de commencer, récapitulons rapidement les termes et les informations sur le système Audio Worklet, qui a été présenté dans cet article.
- BaseAudioContext : objet principal de l'API Web Audio.
- Worklet audio: chargeur de fichier de script spécial pour l'opération Worklet audio. Appartient à BaseAudioContext. Un BaseAudioContext peut avoir un seul worklet audio. Le fichier de script chargé est évalué dans AudioWorkletGlobalScope et est utilisé pour créer les instances AudioWorkletProcessor.
- AudioWorkletGlobalScope : champ d'application global JavaScript spécial pour l'opération Audio Worklet. S'exécute sur un thread de rendu dédié pour WebAudio. Un BaseAudioContext peut avoir un AudioWorkletGlobalScope.
- AudioWorkletNode : AudioNode conçu pour l'opération Audio Worklet. Instancié à partir d'un BaseAudioContext. Un BaseAudioContext peut comporter plusieurs AudioWorkletNodes, comme les AudioNodes natifs.
- AudioWorkletProcessor : équivalent d'AudioWorkletNode. Noyau d'AudioWorkletNode qui traite le flux audio à l'aide du code fourni par l'utilisateur. Il est instancié dans AudioWorkletGlobalScope lors de la création d'un AudioWorkletNode. Un AudioWorkletNode peut avoir un seul AudioWorkletProcessor correspondant.
Modèles de conception
Utiliser un worklet audio avec WebAssembly
WebAssembly est un compagnon idéal pour AudioWorkletProcessor. La combinaison de ces deux fonctionnalités offre de nombreux avantages au traitement audio sur le Web, mais les deux plus importants sont les suivants: 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 collecte des déchets dans le code de traitement audio.
Le premier est important pour les développeurs qui ont déjà investi dans du code et des bibliothèques de traitement audio, mais le second est essentiel pour presque tous les utilisateurs de l'API. Dans le monde de WebAudio, le budget de synchronisation du flux audio stable est assez exigeant: il n'est que de 3 ms au taux d'échantillonnage de 44,1 kHz. Même un léger problème dans le code de traitement audio peut entraîner des problèmes. Le développeur doit optimiser le code pour accélérer le traitement, mais aussi réduire la quantité de déchets JS générés. 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 garbage à partir du code.
La section suivante décrit comment WebAssembly peut être utilisé avec un worklet audio. L'exemple de code associé est disponible sur cette page. Pour obtenir un tutoriel de base sur l'utilisation d'Emscripten et de WebAssembly (en particulier du code de liaison Emscripten), consultez cet article.
Configurer
Tout cela a l'air génial, mais nous avons besoin d'un peu de structure pour configurer correctement les choses. La première question de conception à se poser est comment et où instancier un module WebAssembly. Après avoir récupéré le code de liaison d'Emscripten, deux chemins s'offrent à vous pour l'instanciation du module:
- Instanciez un module WebAssembly en chargeant le code de liaison dans AudioWorkletGlobalScope via
audioContext.audioWorklet.addModule()
. - Instanciez un module WebAssembly dans le champ d'application principal, puis transférez le module via les options de 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.
Pour que le modèle A fonctionne correctement, Emscripten a besoin de quelques options pour générer le code de liaison 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 la classe AudioWorkletProcessor dans mycode.js
afin qu'elle puisse être chargée 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 synchrone dans le thread principal n'est généralement pas recommandé, car il bloque les autres tâches du même thread. Toutefois, ici, nous pouvons contourner la règle, car la compilation se produit sur AudioWorkletGlobalScope, qui s'exécute à partir du thread principal. (Pour en savoir plus, consultez cette page.)
Le modèle B peut être utile si des tâches lourdes asynchrones sont requises. Il utilise le thread principal pour extraire le code de liaison du serveur et compiler le module. Il transfère ensuite le module WASM via le constructeur d'AudioWorkletNode. Ce modèle est encore plus pertinent lorsque vous devez charger le module de manière dynamique après le début du rendu du flux audio par AudioWorkletGlobalScope. Selon la taille du module, la compilation au milieu du rendu peut entraîner des problèmes dans le flux.
Pile WASM et données audio
Le code WebAssembly ne fonctionne que sur la mémoire allouée dans un tas WASM dédié. Pour en profiter, les données audio doivent être clonées entre la pile WASM et les tableaux de données audio. La classe HeapAudioBuffer de l'exemple de code gère parfaitement cette opération.
Une première proposition est en cours d'examen pour intégrer le tas de mémoire WASM directement dans le système Audio Worklet. Il semble naturel de se débarrasser de ce clonage de données redondant entre la mémoire JS et la pile WASM, mais les détails spécifiques doivent être étudiés.
Gérer les incohérences entre la taille de la mémoire tampon
Une paire AudioWorkletNode et AudioWorkletProcessor est conçue pour fonctionner comme un AudioNode standard. AudioWorkletNode gère l'interaction avec d'autres codes, tandis qu'AudioWorkletProcessor s'occupe du traitement audio interne. Étant donné qu'un AudioNode standard traite 128 frames à la fois, AudioWorkletProcessor doit faire de même pour devenir une fonctionnalité de base. C'est l'un des avantages de la conception du worklet audio qui garantit qu'aucune latence supplémentaire n'est introduite dans AudioWorkletProcessor en raison de la mise en mémoire tampon interne. Toutefois, cela peut poser problème si une fonction de traitement nécessite une taille de tampon différente de 128 frames. La solution courante dans ce cas consiste à utiliser un tampon en anneau, également appelé tampon circulaire ou FIFO.
Voici un schéma d'AudioWorkletProcessor qui utilise deux tampons en anneau pour accueillir une fonction WASM qui prend 512 frames en entrée et en sortie. (Le nombre 512 est choisi arbitrairement.)
L'algorithme du diagramme est le suivant:
- AudioWorkletProcessor insère 128 frames dans le tampon circulaire d'entrée à partir de son entrée.
- Ne procédez aux étapes suivantes que si le tampon circulaire d'entrée comporte au moins 512 cadres.
- Extrayez 512 cadres de la tampon circulaire d'entrée.
- Traitez 512 cadres avec la fonction WASM donnée.
- Transférez 512 cadres vers le tampon circulaire de sortie.
- AudioWorkletProcessor extrait 128 frames de la boucle tampon de sortie pour remplir sa sortie.
Comme illustré dans le diagramme, les trames d'entrée sont toujours accumulées dans Input RingBuffer, qui gère le débordement du tampon en écrasant le bloc de trame le plus ancien du tampon. C'est une chose raisonnable à faire pour une application audio en temps réel. De même, le bloc de frame de sortie sera toujours extrait par le système. Un sous-débit de tampon (données insuffisantes) dans Output RingBuffer entraîne un silence, ce qui provoque un problème dans le flux.
Ce modèle est utile lorsque vous remplacez ScriptProcessorNode (SPN) par AudioWorkletNode. Étant donné que SPN permet au développeur de choisir une taille de tampon comprise entre 256 et 16 384 frames, la substitution de SPN par AudioWorkletNode peut être difficile. L'utilisation d'une mémoire tampon en anneau constitue une bonne solution de contournement. Un enregistreur audio est un excellent exemple de ce qui peut être créé sur la base de cette conception.
Toutefois, il est important de comprendre que cette conception ne résout que le décalage de taille de tampon et 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 budget de temps du quantum de rendu (~3 ms à 44,1 kHz), cela affectera le moment de début de la fonction de rappel ultérieure et finira par provoquer des glitchs.
Mélanger cette conception avec WebAssembly peut s'avérer compliqué en raison de la gestion de la mémoire autour de la pile WASM. Au moment de la rédaction de cet article, les données entrantes et sortantes de la pile WASM doivent être clonées, mais nous pouvons utiliser la classe HeapAudioBuffer pour faciliter la gestion de la mémoire. L'idée d'utiliser de la mémoire allouée par l'utilisateur pour réduire le clonage de données redondantes sera abordée plus tard.
Pour en savoir plus sur la classe RingBuffer, cliquez ici.
WebAudio Powerhouse: Audio Worklet et SharedArrayBuffer
Le dernier modèle de conception de cet article consiste à regrouper plusieurs API de pointe : Audio Worklet, SharedArrayBuffer, Atomics et Worker. Cette configuration non triviale permet d'exécuter des logiciels audio existants écrits en C/C++ dans un navigateur Web tout en offrant une expérience utilisateur fluide.
Le plus grand avantage de cette conception est de pouvoir utiliser un 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. DedicatedWorkerGlobalScope est moins limité en termes de surface d'API disponible dans le champ d'application. Vous pouvez également vous attendre à une meilleure assistance de la part d'Emscripten, car l'API Worker existe depuis quelques années.
SharedArrayBuffer joue un rôle essentiel pour que cette conception fonctionne efficacement. Bien que le Worker et AudioWorkletProcessor soient équipés de la messagerie asynchrone (MessagePort), cette approche n'est pas optimale 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 un bloc de mémoire à l'avance, auquel les deux threads peuvent accéder pour un transfert de données bidirectionnel rapide.
Du point de vue d'un puriste de l'API Web Audio, cette conception peut sembler non optimale, car elle utilise le worklet audio comme un simple "point de sortie audio" et effectue tout dans le worker. Toutefois, étant donné que le coût de réécriture de projets C/C++ en JavaScript peut être prohibitif, voire impossible, cette conception peut être le chemin d'implémentation le plus efficace pour de tels projets.
États partagés et atomes
Lorsque vous utilisez une mémoire partagée pour les données audio, l'accès des deux côtés doit être soigneusement coordonné. Le partage d'états accessibles de manière atomique est une solution à ce problème. Nous pouvons utiliser Int32Array
avec un SAB à cette fin.
Mécanisme de synchronisation: SharedArrayBuffer et Atomics
Chaque champ du tableau "États" représente des informations essentielles sur les tampons partagés. Le plus important est un champ de synchronisation (REQUEST_RENDER
). L'idée est que le Worker attend que ce champ soit touché par AudioWorkletProcessor et traite l'audio lorsqu'il se réveille. Avec SharedArrayBuffer (SAB), l'API Atomics rend ce mécanisme possible.
Notez que la synchronisation de deux threads est plutôt lâche. Le début de Worker.process()
sera déclenché par la méthode AudioWorkletProcessor.process()
, mais AudioWorkletProcessor n'attend pas la fin de Worker.process()
. Il s'agit d'une conception : l'AudioWorkletProcessor est piloté par le rappel audio. Il ne doit donc pas être bloqué de manière synchrone. Dans le pire des cas, le flux audio peut être dupliqué ou interrompu, mais il finira par se rétablir lorsque les performances de rendu seront stabilisées.
Configuration et exécution
Comme le montre le schéma ci-dessus, cette conception comporte plusieurs composants à organiser : DedicatedWorkerGlobalScope (DWGS), AudioWorkletGlobalScope (AWGS), SharedArrayBuffer et le thread principal. Les étapes suivantes décrivent ce qui doit se produire lors de la phase d'initialisation.
Initialisation
- [Main] Le constructeur AudioWorkletNode est appelé.
- Créez un nœud de calcul.
- Le AudioWorkletProcessor associé sera créé.
- [DWGS] Le nœud de calcul crée deux SharedArrayBuffers. (l'un pour les états partagés et l'autre pour les données audio)
- [DWGS] Le worker envoie des références SharedArrayBuffer à AudioWorkletNode.
- [Main] AudioWorkletNode envoie des références SharedArrayBuffer à AudioWorkletProcessor.
- [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 produire à chaque itération de la boucle de rendu.
Boucle de rendu
- [AWGS]
AudioWorkletProcessor.process(inputs, outputs)
est appelé pour chaque quantum de rendu.inputs
sera transféré dans Input SAB (SAB d'entrée).outputs
sera rempli en consommant des données audio dans le SAB de sortie.- Met à jour States SAB avec de nouveaux index de tampon en conséquence.
- Si le SAB de sortie se rapproche du seuil de sous-dépassement, réveillez le worker pour afficher plus de données audio.
- [DWGS] Le nœud de calcul attend (est en veille) le signal de réveil de
AudioWorkletProcessor.process()
. Lorsque l'appareil se réveille :- Récupère les index de tampon à partir de la SAB des États.
- Exécutez la fonction de traitement avec les données de Input SAB pour remplir Output SAB.
- Met à jour le SAB d'états avec les index de tampon en conséquence.
- Passe en veille et attend le prochain signal.
Vous trouverez l'exemple de code ici, mais notez 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 beaucoup de soin 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 nécessité plusieurs années d'efforts pour permettre d'implémenter le reste de l'API Web Audio avec le worklet audio. Par conséquent, la conception est plus complexe, ce qui peut constituer un défi inattendu.
Heureusement, cette complexité a pour but de donner aux développeurs plus de pouvoir. La possibilité d'exécuter WebAssembly sur AudioWorkletGlobalScope offre un potentiel énorme pour le 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 des SharedArrayBuffers et des workers peut être une option intéressante à explorer.
Crédits
Merci tout particulièrement à Chris Wilson, Jason Miller, Joshua Bell et Raymond Toy d'avoir examiné un brouillon de cet article et de nous avoir fait part de leurs commentaires pertinents.