Ontwerppatroon voor audiowerkje

In het vorige artikel over Audio Worklet werden de basisconcepten en het gebruik gedetailleerd beschreven. Sinds de lancering in Chrome 66 zijn er veel verzoeken geweest om meer voorbeelden van hoe het in daadwerkelijke toepassingen kan worden gebruikt. De Audio Worklet ontgrendelt het volledige potentieel van WebAudio, maar het benutten ervan kan een uitdaging zijn, omdat het inzicht in gelijktijdig programmeren vereist, verpakt in verschillende JS API's. Zelfs voor ontwikkelaars die bekend zijn met WebAudio kan het integreren van de Audio Worklet met andere API's (bijvoorbeeld WebAssembly) lastig zijn.

Dit artikel geeft de lezer een beter inzicht in het gebruik van de Audio Worklet in reële omstandigheden en geeft tips om de volledige kracht ervan te benutten. Bekijk ook zeker de codevoorbeelden en live demo's !

Samenvatting: Audiowerklet

Laten we, voordat we erin duiken, snel de termen en feiten rond het Audio Worklet-systeem samenvatten dat eerder in dit bericht werd geïntroduceerd.

  • BaseAudioContext : het primaire object van de Web Audio API.
  • Audio Worklet : een speciale scriptbestandslader voor de Audio Worklet-bewerking. Behoort tot BaseAudioContext. Een BaseAudioContext kan één audiowerklet hebben. Het geladen scriptbestand wordt geëvalueerd in AudioWorkletGlobalScope en wordt gebruikt om de AudioWorkletProcessor-instanties te maken.
  • AudioWorkletGlobalScope : een speciaal JS globaal bereik voor de Audio Worklet-bewerking. Draait op een speciale renderingthread voor WebAudio. Een BaseAudioContext kan één AudioWorkletGlobalScope hebben.
  • AudioWorkletNode : een AudioNode die is ontworpen voor de Audio Worklet-bewerking. Geïnstantieerd vanuit een BaseAudioContext. Een BaseAudioContext kan meerdere AudioWorkletNodes hebben, vergelijkbaar met de native AudioNodes.
  • AudioWorkletProcessor : een tegenhanger van de AudioWorkletNode. Het eigenlijke lef van de AudioWorkletNode die de audiostream verwerkt met behulp van de door de gebruiker aangeleverde code. Het wordt geïnstantieerd in de AudioWorkletGlobalScope wanneer een AudioWorkletNode wordt gebouwd. Een AudioWorkletNode kan één overeenkomende AudioWorkletProcessor hebben.

Ontwerp patronen

Audiowerklet gebruiken met WebAssembly

WebAssembly is een perfecte aanvulling op AudioWorkletProcessor. De combinatie van deze twee functies biedt verschillende voordelen voor audioverwerking op internet, maar de twee grootste voordelen zijn: a) het inbrengen van bestaande C/C++-audioverwerkingscode in het WebAudio-ecosysteem en b) het vermijden van de overhead van JS JIT-compilatie en garbagecollection in de audioverwerkingscode.

Het eerste is belangrijk voor ontwikkelaars met een bestaande investering in audioverwerkingscode en bibliotheken, maar het laatste is van cruciaal belang voor bijna alle gebruikers van de API. In de wereld van WebAudio is het timingbudget voor de stabiele audiostream behoorlijk veeleisend: het is slechts 3 ms bij de samplefrequentie van 44,1 Khz. Zelfs een klein probleempje in de audioverwerkingscode kan storingen veroorzaken. De ontwikkelaar moet de code optimaliseren voor snellere verwerking, maar ook de hoeveelheid gegenereerde JS-rommel minimaliseren. Het gebruik van WebAssembly kan een oplossing zijn die beide problemen tegelijkertijd aanpakt: het is sneller en genereert geen rommel uit de code.

In de volgende sectie wordt beschreven hoe WebAssembly kan worden gebruikt met een audiowerklet en het bijbehorende codevoorbeeld kunt u hier vinden. Voor de basistutorial over het gebruik van Emscripten en WebAssembly (vooral de Emscripten-lijmcode) kunt u dit artikel raadplegen.

Opzetten

Het klinkt allemaal mooi, maar we hebben wel wat structuur nodig om alles goed op te zetten. De eerste ontwerpvraag die moet worden gesteld is hoe en waar een WebAssembly-module moet worden geïnstantieerd. Na het ophalen van de lijmcode van Emscripten zijn er twee paden voor de instantiatie van de module:

  1. Instantieer een WebAssembly-module door de lijmcode in AudioWorkletGlobalScope te laden via audioContext.audioWorklet.addModule() .
  2. Instantieer een WebAssembly-module in het hoofdbereik en breng de module vervolgens over via de constructoropties van AudioWorkletNode.

De beslissing hangt grotendeels af van uw ontwerp en voorkeur, maar het idee is dat de WebAssembly-module een WebAssembly-instantie kan genereren in de AudioWorkletGlobalScope, die een audioverwerkingskernel wordt binnen een AudioWorkletProcessor-instantie.

Instantiatiepatroon voor WebAssembly-module A: Met behulp van de aanroep .addModule().
Instantiatiepatroon voor WebAssembly-module A: Met behulp van de aanroep .addModule()

Om patroon A correct te laten werken, heeft Emscripten een aantal opties nodig om de juiste WebAssembly-lijmcode voor onze configuratie te genereren:

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

Deze opties zorgen voor de synchrone compilatie van een WebAssembly-module in de AudioWorkletGlobalScope. Het voegt ook de klassedefinitie van AudioWorkletProcessor toe aan mycode.js , zodat deze kan worden geladen nadat de module is geïnitialiseerd. De belangrijkste reden om de synchrone compilatie te gebruiken is dat de belofte-resolutie van audioWorklet.addModule() niet wacht op de resolutie van beloften in AudioWorkletGlobalScope. Het synchroon laden of compileren in de hoofdthread wordt over het algemeen niet aanbevolen omdat het de andere taken in dezelfde thread blokkeert, maar hier kunnen we de regel omzeilen omdat de compilatie plaatsvindt op de AudioWorkletGlobalScope, die van de hoofdthread af loopt. (Zie dit voor meer informatie.)

Instantiatiepatroon van WASM-module B: Cross-thread-overdracht van de AudioWorkletNode-constructor gebruiken
Instantiatiepatroon van WASM-module B: Cross-thread-overdracht van de AudioWorkletNode-constructor gebruiken

Patroon B kan nuttig zijn als asynchroon zwaar tillen vereist is. Het gebruikt de hoofdthread om de lijmcode van de server op te halen en de module te compileren. Vervolgens zal het de WASM-module overdragen via de constructor van AudioWorkletNode. Dit patroon is zelfs nog logischer wanneer u de module dynamisch moet laden nadat AudioWorkletGlobalScope begint met het renderen van de audiostream. Afhankelijk van de grootte van de module kan het compileren ervan in het midden van de weergave storingen in de stream veroorzaken.

WASM Heap- en audiogegevens

WebAssembly-code werkt alleen op het geheugen dat is toegewezen binnen een speciale WASM-heap. Om hiervan te kunnen profiteren, moeten de audiogegevens heen en weer worden gekloond tussen de WASM-heap en de audiogegevensarrays. De klasse HeapAudioBuffer in de voorbeeldcode verwerkt deze bewerking goed.

HeapAudioBuffer-klasse voor eenvoudiger gebruik van WASM-heap
HeapAudioBuffer-klasse voor eenvoudiger gebruik van WASM-heap

Er wordt al een vroeg voorstel besproken om de WASM-heap rechtstreeks in het Audio Worklet-systeem te integreren. Het wegwerken van deze redundante dataklonering tussen het JS-geheugen en de WASM-heap lijkt vanzelfsprekend, maar de specifieke details moeten nog worden uitgewerkt.

Omgaan met buffergrootte komt niet overeen

Een AudioWorkletNode- en AudioWorkletProcessor-paar is ontworpen om te werken als een gewone AudioNode; AudioWorkletNode verzorgt de interactie met andere codes, terwijl AudioWorkletProcessor zorgt voor de interne audioverwerking. Omdat een gewone AudioNode 128 frames tegelijk verwerkt, moet AudioWorkletProcessor hetzelfde doen om een ​​kernfunctie te worden. Dit is een van de voordelen van het Audio Worklet-ontwerp dat ervoor zorgt dat er geen extra latentie wordt geïntroduceerd als gevolg van interne buffering binnen de AudioWorkletProcessor, maar het kan een probleem zijn als een verwerkingsfunctie een andere buffergrootte dan 128 frames vereist. De gebruikelijke oplossing voor dergelijke gevallen is het gebruik van een ringbuffer, ook bekend als een circulaire buffer of een FIFO.

Hier is een diagram van AudioWorkletProcessor die twee ringbuffers gebruikt om plaats te bieden aan een WASM-functie die 512 frames in- en uitschakelt. (Het getal 512 is hier willekeurig gekozen.)

RingBuffer gebruiken binnen de methode `process()` van AudioWorkletProcessor
RingBuffer gebruiken binnen de methode `process()` van AudioWorkletProcessor

Het algoritme voor het diagram zou zijn:

  1. AudioWorkletProcessor duwt 128 frames vanuit zijn invoer naar de Input RingBuffer .
  2. Voer de volgende stappen alleen uit als de Input RingBuffer groter dan of gelijk is aan 512 frames.
    1. Haal 512 frames uit de Input RingBuffer .
    2. Verwerk 512 frames met de gegeven WASM-functie.
    3. Duw 512 frames naar de Output RingBuffer .
  3. AudioWorkletProcessor haalt 128 frames uit de Output RingBuffer om de Output te vullen.

Zoals weergegeven in het diagram, worden invoerframes altijd verzameld in Input RingBuffer en wordt de bufferoverflow afgehandeld door het oudste frameblok in de buffer te overschrijven. Dat is redelijk om te doen voor een real-time audiotoepassing. Op dezelfde manier wordt het uitvoerframeblok altijd door het systeem getrokken. Bufferonderstroom (onvoldoende gegevens) in Output RingBuffer resulteert in stilte en veroorzaakt een storing in de stream.

Dit patroon is handig bij het vervangen van ScriptProcessorNode (SPN) door AudioWorkletNode. Omdat SPN de ontwikkelaar toestaat een buffergrootte tussen 256 en 16384 frames te kiezen, kan de drop-in vervanging van SPN door AudioWorkletNode moeilijk zijn en het gebruik van een ringbuffer biedt een mooie oplossing. Een audiorecorder zou een geweldig voorbeeld zijn dat bovenop dit ontwerp kan worden gebouwd.

Het is echter belangrijk om te begrijpen dat dit ontwerp alleen de niet-overeenkomende buffergrootte verzoent en niet meer tijd geeft om de gegeven scriptcode uit te voeren. Als de code de taak niet kan voltooien binnen het timingbudget van renderquantum (~3 ms bij 44,1 Khz), heeft dit invloed op de aanvangstijdstip van de daaropvolgende callback-functie en uiteindelijk storingen veroorzaken.

Het combineren van dit ontwerp met WebAssembly kan ingewikkeld zijn vanwege het geheugenbeheer rond de WASM-heap. Op het moment van schrijven moeten de gegevens die de WASM-heap in en uit gaan, worden gekloond, maar we kunnen de HeapAudioBuffer-klasse gebruiken om het geheugenbeheer iets eenvoudiger te maken. Het idee om door de gebruiker toegewezen geheugen te gebruiken om het klonen van redundante gegevens te verminderen, zal in de toekomst worden besproken.

De klasse RingBuffer is hier te vinden.

WebAudio-krachtpatser: audiowerklet en SharedArrayBuffer

Het laatste ontwerppatroon in dit artikel is om verschillende geavanceerde API's op één plek te plaatsen; Audiowerklet, SharedArrayBuffer , Atomics en Worker . Met deze niet-triviale opzet ontgrendelt het een pad voor bestaande audiosoftware geschreven in C/C++ om in een webbrowser te draaien, terwijl een soepele gebruikerservaring behouden blijft.

Een overzicht van het laatste ontwerppatroon: Audio Worklet, SharedArrayBuffer en Worker
Een overzicht van het laatste ontwerppatroon: Audio Worklet, SharedArrayBuffer en Worker

Het grootste voordeel van dit ontwerp is dat je een DedicatedWorkerGlobalScope uitsluitend voor audioverwerking kunt gebruiken. In Chrome draait WorkerGlobalScope op een thread met een lagere prioriteit dan de WebAudio-renderingthread, maar het heeft verschillende voordelen ten opzichte van AudioWorkletGlobalScope . DedicatedWorkerGlobalScope is minder beperkt wat betreft het API-oppervlak dat beschikbaar is in de scope. Ook kun je betere ondersteuning van Emscripten verwachten omdat de Worker API al enkele jaren bestaat.

SharedArrayBuffer speelt een cruciale rol om dit ontwerp efficiënt te laten werken. Hoewel zowel Worker als AudioWorkletProcessor zijn uitgerust met asynchrone berichtenuitwisseling ( MessagePort ), is het niet optimaal voor realtime audioverwerking vanwege repetitieve geheugentoewijzing en berichtlatentie. Daarom wijzen we vooraf een geheugenblok toe dat toegankelijk is vanuit beide threads voor snelle bidirectionele gegevensoverdracht.

Vanuit het standpunt van de Web Audio API-purist ziet dit ontwerp er misschien niet optimaal uit, omdat het de Audio Worklet gebruikt als een eenvoudige "audio-sink" en alles in de Worker doet. Maar aangezien de kosten voor het herschrijven van C/C++-projecten in JavaScript onbetaalbaar of zelfs onmogelijk kunnen zijn, kan dit ontwerp het meest efficiënte implementatietraject voor dergelijke projecten zijn.

Gedeelde staten en atomen

Bij gebruik van een gedeeld geheugen voor audiogegevens moet de toegang van beide kanten zorgvuldig worden gecoördineerd. Het delen van atomair toegankelijke toestanden is een oplossing voor een dergelijk probleem. Voor dit doel kunnen we profiteren van Int32Array ondersteund door een SAB.

Synchronisatiemechanisme: SharedArrayBuffer en Atomics
Synchronisatiemechanisme: SharedArrayBuffer en Atomics

Synchronisatiemechanisme: SharedArrayBuffer en Atomics

Elk veld van de States-array vertegenwoordigt essentiële informatie over de gedeelde buffers. De belangrijkste is een veld voor de synchronisatie ( REQUEST_RENDER ). Het idee is dat Worker wacht tot dit veld wordt aangeraakt door AudioWorkletProcessor en de audio verwerkt wanneer deze wordt geactiveerd. Samen met SharedArrayBuffer (SAB) maakt Atomics API dit mechanisme mogelijk.

Merk op dat de synchronisatie van twee threads nogal los is. Het begin van Worker.process() wordt geactiveerd door de methode AudioWorkletProcessor.process() , maar de AudioWorkletProcessor wacht niet totdat Worker.process() is voltooid. Dit is zo ontworpen; de AudioWorkletProcessor wordt aangestuurd door de audio-callback en mag dus niet synchroon worden geblokkeerd. In het ergste geval kan de audiostream last hebben van duplicaten of wegvallen, maar deze zal zich uiteindelijk herstellen wanneer de weergaveprestaties zijn gestabiliseerd.

Instellen en uitvoeren

Zoals weergegeven in het bovenstaande diagram, moet dit ontwerp verschillende componenten regelen: DedicatedWorkerGlobalScope (DWGS), AudioWorkletGlobalScope (AWGS), SharedArrayBuffer en de hoofdthread. De volgende stappen beschrijven wat er in de initialisatiefase moet gebeuren.

Initialisatie
  1. [Main] AudioWorkletNode-constructor wordt aangeroepen.
    1. Werknemer maken.
    2. De bijbehorende AudioWorkletProcessor wordt gemaakt.
  2. [DWGS] Werknemer maakt 2 SharedArrayBuffers. (één voor gedeelde statussen en de andere voor audiogegevens)
  3. [DWGS] Worker verzendt SharedArrayBuffer-verwijzingen naar AudioWorkletNode.
  4. [Hoofd] AudioWorkletNode verzendt SharedArrayBuffer-verwijzingen naar AudioWorkletProcessor.
  5. [AWGS] AudioWorkletProcessor meldt AudioWorkletNode dat de installatie is voltooid.

Zodra de initialisatie is voltooid, wordt AudioWorkletProcessor.process() aangeroepen. Het volgende is wat er zou moeten gebeuren in elke iteratie van de weergavelus.

Renderinglus
Rendering met meerdere threads met SharedArrayBuffers
Rendering met meerdere threads met SharedArrayBuffers
  1. [AWGS] AudioWorkletProcessor.process(inputs, outputs) wordt aangeroepen voor elk renderkwantum.
    1. inputs worden naar Input SAB gepusht.
    2. outputs worden gevuld door het verbruiken van audiogegevens in Output SAB .
    3. Werkt States SAB dienovereenkomstig bij met nieuwe bufferindexen.
    4. Als Output SAB de onderstroomdrempel nadert, zorgt Wake Worker ervoor dat er meer audiogegevens worden weergegeven.
  2. [DWGS] Werker wacht (slaapt) op het weksignaal van AudioWorkletProcessor.process() . Wanneer het wakker wordt:
    1. Haalt bufferindexen op van States SAB .
    2. Voer de procesfunctie uit met gegevens van Input SAB om Output SAB te vullen.
    3. Werkt States SAB dienovereenkomstig bij met bufferindexen.
    4. Gaat slapen en wacht op het volgende signaal.

De voorbeeldcode kunt u hier vinden, maar houd er rekening mee dat de experimentele vlag SharedArrayBuffer moet zijn ingeschakeld om deze demo te laten werken. De code is voor de eenvoud geschreven met pure JS-code, maar kan indien nodig worden vervangen door WebAssembly-code. In dergelijke gevallen moet extra voorzichtig worden omgegaan door geheugenbeheer te verpakken in de HeapAudioBuffer- klasse.

Conclusie

Het uiteindelijke doel van de Audio Worklet is om de Web Audio API echt "uitbreidbaar" te maken. Er is meerdere jaren aan het ontwerp gewerkt om het mogelijk te maken de rest van de Web Audio API te implementeren met de Audio Worklet. Op onze beurt hebben we nu een hogere complexiteit in het ontwerp en dit kan een onverwachte uitdaging zijn.

Gelukkig is de reden voor deze complexiteit puur om ontwikkelaars meer macht te geven. Door WebAssembly op AudioWorkletGlobalScope te kunnen draaien, ontgrendelt u een enorm potentieel voor hoogwaardige audioverwerking op internet. Voor grootschalige audiotoepassingen geschreven in C of C++ kan het gebruik van een Audio Worklet met SharedArrayBuffers en Workers een aantrekkelijke optie zijn om te verkennen.

Kredieten

Speciale dank aan Chris Wilson, Jason Miller, Joshua Bell en Raymond Toy voor het beoordelen van een concept van dit artikel en het geven van inzichtelijke feedback.