Designmuster für Audio-Worklet

Hongchan Choi

Im vorherigen Artikel zum Audio Worklet wurden die grundlegenden Konzepte und die Verwendung beschrieben. Seit der Einführung in Chrome 66 wurden viele Nutzer nach weiteren Beispielen für die Verwendung in realen Anwendungen gefragt. Das Audio Worklet entfaltet das volle Potenzial von WebAudio, aber die Nutzung kann schwierig sein, da es Verständnis für gleichzeitiges Programmieren erfordert, das in mehrere JS APIs eingebunden ist. Selbst für Entwickler, die mit WebAudio vertraut sind, kann es schwierig sein, das Audio Worklet in andere APIs (z.B. WebAssembly) zu integrieren.

In diesem Artikel erfahren Sie, wie Sie das Audio-Worklet in der Praxis verwenden können und wie Sie es optimal nutzen können. Sehen Sie sich auch Codebeispiele und Live-Demos an.

Zusammenfassung: Audio-Worklet

Bevor wir uns näher damit befassen, fassen wir kurz Begriffe und Fakten rund um das Audio Worklet-System zusammen, das bereits in diesem Beitrag vorgestellt wurde.

  • BaseAudioContext: Das primäre Objekt der Web Audio API.
  • Audio Worklet: Ein spezielles Skriptdateiladeprogramm für den Audio Worklet-Vorgang. Gehört zu „BaseAudioContext“. Ein BaseAudioContext kann ein Audio-Worklet haben. Die geladene Skriptdatei wird im AudioWorkletGlobalScope ausgewertet und zum Erstellen der AudioWorkletProcessor-Instanzen verwendet.
  • AudioWorkletGlobalScope: Ein spezieller globaler JS-Bereich für den Audio Worklet-Vorgang. Wird in einem dedizierten Rendering-Thread für WebAudio ausgeführt. Ein BaseAudioContext kann einen AudioWorkletGlobalScope.
  • AudioWorkletNode: Ein AudioNode, der für den Audio Worklet-Vorgang entwickelt wurde. Von einem BaseAudioContext instanziiert. Ein BaseAudioContext kann, ähnlich wie die nativen AudioNodes, mehrere AudioWorkletNodes haben.
  • AudioWorkletProcessor: Ein Gegenstück von AudioWorkletNode. Die tatsächlichen Einsätze des AudioWorkletNode, die den Audiostream durch den vom Nutzer bereitgestellten Code verarbeiten. Er wird im AudioWorkletGlobalScope instanziiert, wenn ein AudioWorkletNode erstellt wird. Ein AudioWorkletNode kann einen übereinstimmenden AudioWorkletProcessor haben.

Designmuster

Audio-Worklet mit WebAssembly verwenden

WebAssembly ist eine perfekte Ergänzung für AudioWorkletProcessor. Die Kombination dieser beiden Funktionen bringt eine Vielzahl von Vorteilen für die Audioverarbeitung im Web, aber die beiden größten Vorteile sind: a) das Einbinden des vorhandenen C/C++-Audioverarbeitungscodes in das WebAudio-Ökosystem und b) Vermeidung des Aufwands von JS JIT-Kompilierung und Speicherbereinigung im Audioverarbeitungscode.

Ersteres ist wichtig für Entwickler, die bereits in Audioverarbeitungscode und Bibliotheken investiert haben, Letzteres ist jedoch für fast alle Nutzer der API von entscheidender Bedeutung. In der Welt von WebAudio ist das Timing-Budget für den stabilen Audiostream recht anspruchsvoll: Es beträgt nur 3 ms bei einer Abtastrate von 44, 1 kHz. Selbst eine kleine Störung im Audioverarbeitungscode kann zu Störungen führen. Der Entwickler muss den Code für eine schnellere Verarbeitung optimieren, aber auch die Menge des generierten JS-automatischen Speichers minimieren. Die Verwendung von WebAssembly kann eine Lösung sein, die beide Probleme gleichzeitig löst: Es ist schneller und erzeugt keinen überflüssigen Code aus dem Code.

Im nächsten Abschnitt wird beschrieben, wie WebAssembly mit einem Audio-Worklet verwendet werden kann. Das zugehörige Codebeispiel finden Sie hier. Eine grundlegende Anleitung zur Verwendung von Emscripten und WebAssembly (insbesondere zum Glue Code von Emscripten) finden Sie in diesem Artikel.

Einrichten

Das klingt alles großartig, aber wir brauchen eine gewisse Struktur, um alles richtig einzurichten. Die erste Designfrage, die Sie sich stellen sollten, lautet, wie und wo ein WebAssembly-Modul instanziiert werden soll. Nach dem Abrufen des Glue-Codes von Emscripten gibt es zwei Pfade für die Modulinstanziierung:

  1. Instanziieren Sie ein WebAssembly-Modul. Laden Sie dazu den Glue-Code über audioContext.audioWorklet.addModule() in den AudioWorkletGlobalScope.
  2. Instanziieren Sie ein WebAssembly-Modul im Hauptbereich und übertragen Sie das Modul dann über die Konstruktoroptionen des AudioWorkletNode.

Die Entscheidung hängt weitgehend von Ihrem Design und Ihren Präferenzen ab. Die Idee ist, dass das WebAssembly-Modul eine WebAssembly-Instanz im AudioWorkletGlobalScope generieren kann, die innerhalb einer AudioWorkletProcessor-Instanz zu einem Audioverarbeitungs-Kernel wird.

Instanziierungsmuster für WebAssembly-Modul A: Verwendung von .addModule()-Aufruf
Instanziierungsmuster des WebAssembly-Moduls A: Mit .addModule()-Aufruf

Damit Muster A korrekt funktioniert, benötigt Emscripten einige Optionen, um den richtigen WebAssembly-Glue-Code für unsere Konfiguration zu generieren:

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

Diese Optionen sorgen für die synchrone Kompilierung eines WebAssembly-Moduls im AudioWorkletGlobalScope. Außerdem wird die Klassendefinition des AudioWorkletProcessor in mycode.js angehängt, damit sie nach der Initialisierung des Moduls geladen werden kann. Der Hauptgrund für die Verwendung der synchronen Kompilierung besteht darin, dass die Promise-Auflösung von audioWorklet.addModule() nicht auf die Auflösung von Promise im AudioWorkletGlobalScope wartet. Das synchrone Laden oder Kompilieren im Hauptthread wird im Allgemeinen nicht empfohlen, da dadurch die anderen Aufgaben im selben Thread blockiert werden. Hier kann die Regel jedoch umgangen werden, da die Kompilierung im AudioWorkletGlobalScope erfolgt, das über den Hauptthread ausgeführt wird. Weitere Informationen finden Sie hier.

Instanziierungsmuster des WASM-Moduls B: Threadübergreifende Übertragung des AudioWorkletNode-Konstruktors verwenden
Instanziierungsmuster des WASM-Moduls B: Cross-Thread-Übertragung des AudioWorkletNode-Konstruktors verwenden

Das Muster B kann nützlich sein, wenn asynchrones Arbeiten mit hohem Aufwand erforderlich ist. Der Hauptthread wird zum Abrufen des Glue-Codes vom Server und zum Kompilieren des Moduls verwendet. Anschließend wird das WASM-Modul über den Konstruktor von AudioWorkletNode übertragen. Dieses Muster ist sogar sinnvoller, wenn das Modul dynamisch geladen werden muss, nachdem AudioWorkletGlobalScope mit dem Rendern des Audiostreams begonnen hat. Je nach Größe des Moduls kann es Störungen im Stream verursachen, wenn es in der Mitte des Renderings kompiliert wird.

WASM-Heap- und Audiodaten

WebAssembly-Code funktioniert nur auf dem Arbeitsspeicher, der in einem dedizierten WASM-Heap zugewiesen ist. Damit dies genutzt werden kann, müssen die Audiodaten zwischen dem WASM-Heap und den Audiodatenarrays geklont werden. Die Klasse HeapAudioBuffer im Beispielcode ermöglicht diesen Vorgang.

HeapAudioBuffer-Klasse zur einfacheren Nutzung von WASM-Heap
Klasse „HeapAudioBuffer“ für die einfachere Nutzung von WASM-Heap

Es wird derzeit ein erster Vorschlag zur direkten Integration des WASM-Heaps in das Audio Worklet-System diskutiert. Das Beseitigen des redundanten Klonens von Daten zwischen dem JS-Arbeitsspeicher und dem WASM-Heap scheint selbstverständlich, aber die spezifischen Details müssen ermittelt werden.

Umgang mit Abweichungen bei der Puffergröße

Ein AudioWorkletNode- und AudioWorkletProcessor-Paar funktioniert wie ein regulärer AudioNode. AudioWorkletNode kümmert sich um die Interaktion mit anderen Codes, während sich AudioWorkletProcessor um die interne Audioverarbeitung kümmert. Da ein regulärer AudioNode 128 Frames gleichzeitig verarbeitet, muss AudioWorkletProcessor dies ebenfalls tun, um zu einer Kernfunktion zu werden. Dies ist einer der Vorteile des Audio Worklet-Designs, die dafür sorgt, dass keine zusätzliche Latenz aufgrund interner Zwischenspeicherung innerhalb des AudioWorkletProcessor eintritt. Es kann jedoch zu einem Problem werden, wenn eine Verarbeitungsfunktion eine andere Puffergröße als 128 Frames erfordert. Die übliche Lösung für einen solchen Fall ist die Verwendung eines Ringpuffers, der auch als kreisförmiger Zwischenspeicher oder FIFO bezeichnet wird.

Hier sehen Sie ein Diagramm von AudioWorkletProcessor, das zwei Ringpuffer für eine WASM-Funktion verwendet, die 512 Frames ein- und ausnimmt. (Die Zahl 512 wird hier willkürlich gewählt.)

RingBuffer in der Methode `process()` von AudioWorkletProcessor verwenden
RingBuffer in der Methode „process()“ von AudioWorkletProcessor verwenden

Der Algorithmus für das Diagramm wäre:

  1. AudioWorkletProcessor überträgt 128 Frames aus der Eingabe in den Input RingBuffer.
  2. Führen Sie die folgenden Schritte nur aus, wenn der Input RingBuffer mehr als oder gleich 512 Frames hat.
    1. Rufen Sie 512 Frames aus dem Input RingBuffer ab.
    2. Verarbeiten Sie 512 Frames mit der angegebenen WASM-Funktion.
    3. Übertragen Sie 512 Frames in den Output RingBuffer.
  3. AudioWorkletProcessor ruft 128 Frames aus dem Output RingBuffer ab, um seine Output zu füllen.

Wie im Diagramm dargestellt, werden Eingabeframes immer im Eingabe-RingBuffer akkumuliert und bewältigt den Pufferüberlauf, indem der älteste Frame-Block im Zwischenspeicher überschrieben wird. Bei einer Echtzeit-Audioanwendung ist dies sinnvoll. Analog wird der Ausgabe-Frame-Block immer vom System abgerufen. Der Pufferunterlauf (nicht genügend Daten) in Output RingBuffer führt zu einer Stummschaltung und einer Störung im Stream.

Dieses Muster ist nützlich, wenn ScriptProcessorNode (SPN) durch AudioWorkletNode ersetzt wird. Da der Entwickler mit SPN eine Puffergröße zwischen 256 und 16.384 Frames auswählen kann, kann die Drop-in-Substitution von SPN mit AudioWorkletNode schwierig sein und die Verwendung eines Ringpuffers bietet eine gute Problemumgehung. Ein Audiorekorder wäre ein gutes Beispiel, das auf diesem Design aufbauen kann.

Es ist jedoch wichtig zu verstehen, dass dieses Design nur die nicht übereinstimmende Puffergröße ausgleicht und nicht mehr Zeit für die Ausführung des angegebenen Scriptcodes gibt. Wenn der Code die Aufgabe nicht innerhalb des Zeitbudgets des Rendering-Quanten (~3 ms bei 44,1 kHz) abschließen kann, wirkt sich dies auf den Beginn der nachfolgenden Callback-Funktion aus und verursacht schließlich Störungen.

Die Kombination dieses Designs mit WebAssembly kann aufgrund der Speicherverwaltung um den WASM-Heap herum kompliziert sein. Beim Schreiben müssen die in den WASM-Heap ein- und ausgehenden Daten geklont werden. Wir können jedoch die Klasse HeapAudioBuffer verwenden, um die Arbeitsspeicherverwaltung etwas einfacher zu machen. Die Idee der Verwendung von vom Nutzer zugewiesenem Arbeitsspeicher zur Reduzierung redundanter Datenklonungen wird in Zukunft besprochen.

Die RingBuffer-Klasse finden Sie hier.

WebAudio Powerhouse: Audio Worklet und SharedArrayBuffer

Das letzte Designmuster in diesem Artikel besteht darin, mehrere innovative APIs an einem Ort zusammenzufassen: Audio Worklet, SharedArrayBuffer, Atomics und Worker. Mit dieser nicht trivialen Einrichtung wird ein Pfad für vorhandene Audiosoftware, die in C/C++ geschrieben ist, freigeschaltet, die in einem Webbrowser ausgeführt werden kann, ohne die Nutzerfreundlichkeit zu beeinträchtigen.

Eine Übersicht über das letzte Designmuster: Audio Worklet, SharedArrayBuffer und Worker
Übersicht über das letzte Designmuster: Audio Worklet, SharedArrayBuffer und Worker

Der größte Vorteil dieses Designs besteht darin, dass ein DedicatedWorkerGlobalScope nur für die Audioverarbeitung verwendet werden kann. In Chrome wird WorkerGlobalScope in einem Thread mit niedrigerer Priorität ausgeführt als der WebAudio-Renderingthread, hat aber mehrere Vorteile gegenüber AudioWorkletGlobalScope. DedicatedWorkerGlobalScope ist in Bezug auf die im Bereich verfügbare API-Oberfläche weniger eingeschränkt. Da die Worker API bereits seit einigen Jahren existiert, erhalten Sie eine bessere Unterstützung von Emscripten.

SharedArrayBuffer spielt eine entscheidende Rolle für die Effizienz dieses Designs. Obwohl sowohl der Worker als auch der AudioWorkletProcessor mit asynchroner Nachricht (MessagePort) ausgestattet sind, ist er aufgrund der sich wiederholenden Speicherzuweisung und der Nachrichtenlatenz für die Audioverarbeitung in Echtzeit nicht optimal. Daher weisen wir im Voraus einen Speicherblock zu, auf den von beiden Threads aus zugegriffen werden kann, um eine schnelle bidirektionale Datenübertragung zu ermöglichen.

Aus puristischer Sicht der Web Audio API sieht dieses Design möglicherweise suboptimal aus, da es das Audio Worklet als einfache "Audiosenke" verwendet und alles im Worker übernimmt. Da jedoch die Kosten für das Umschreiben von C/C++-Projekten in JavaScript erschwinglich oder sogar unmöglich sein können, kann dieses Design der effizienteste Implementierungspfad für solche Projekte sein.

Gemeinsame Status und Atomics

Wenn Sie einen gemeinsamen Speicher für Audiodaten verwenden, muss der Zugriff von beiden Seiten sorgfältig koordiniert werden. Eine Lösung für ein solches Problem ist die Freigabe atomar zugänglicher Status. Für diesen Zweck können wir die Vorteile von Int32Array nutzen, die von einem SAB unterstützt werden.

Synchronisierungsmechanismus: SharedArrayBuffer und Atomics
Synchronisierungsmechanismus: SharedArrayBuffer und Atomics

Synchronisierungsmechanismus: SharedArrayBuffer und Atomics

Jedes Feld des „states“-Arrays repräsentiert wichtige Informationen zu den gemeinsamen Puffern. Das wichtigste ist ein Feld für die Synchronisierung (REQUEST_RENDER). Der Worker wartet darauf, dass dieses Feld vom AudioWorkletProcessor bearbeitet wird und das Audio nach seiner Aktivierung verarbeitet. Zusammen mit SharedArrayBuffer (SAB) ermöglicht die Atomics API diesen Mechanismus.

Beachten Sie, dass die Synchronisierung zweier Threads ziemlich locker ist. Der Beginn von Worker.process() wird durch die Methode AudioWorkletProcessor.process() ausgelöst, aber der AudioWorkletProcessor wartet nicht, bis Worker.process() beendet ist. Dies ist so konzipiert, dass der AudioWorkletProcessor vom Audio-Callback gesteuert wird und daher nicht synchron blockiert werden darf. Im schlimmsten Fall kann es zu einem Duplikat oder Ausfall des Audiostreams kommen. Er wird aber schließlich wiederhergestellt, sobald sich die Renderingleistung stabilisiert hat.

Einrichten und Ausführen

Wie im obigen Diagramm dargestellt, besteht dieses Design aus mehreren Komponenten, die angeordnet werden müssen: DedicatedWorkerGlobalScope (DWGS), AudioWorkletGlobalScope (AWGS), SharedArrayBuffer und der Hauptthread. In den folgenden Schritten wird beschrieben, was in der Initialisierungsphase geschehen sollte.

Initialisierung
  1. [Main] Der AudioWorkletNode-Konstruktor wird aufgerufen.
    1. Worker erstellen.
    2. Der zugehörige AudioWorkletProcessor wird erstellt.
  2. [DWGS] Worker erstellt 2 SharedArrayBuffers (einen für gemeinsame Zustände und einen für Audiodaten)
  3. [DWGS] Der Worker sendet SharedArrayBuffer-Referenzen an AudioWorkletNode.
  4. [Main] AudioWorkletNode sendet SharedArrayBuffer-Referenzen an AudioWorkletProcessor.
  5. [AWGS] AudioWorkletProcessor benachrichtigt AudioWorkletNode, dass die Einrichtung abgeschlossen ist.

Nach Abschluss der Initialisierung wird AudioWorkletProcessor.process() aufgerufen. Folgendes sollte bei jeder Iteration der Rendering-Schleife passieren.

Rendering-Schleife
Multithread-Rendering mit SharedArrayBuffers
Multithread-Rendering mit „SharedArrayBuffers“
  1. [AWGS] AudioWorkletProcessor.process(inputs, outputs) wird für jedes Rendering-Quanten aufgerufen.
    1. inputs wird in Input SAB übertragen.
    2. outputs wird durch die Nutzung von Audiodaten in der Ausgabe-SAB gefüllt.
    3. Aktualisiert SAB entsprechend mit neuen Pufferindexen.
    4. Wenn sich der Ausgabe-SAB dem Unterlauf-Grenzwert nähert, startet der Wake-Worker, um mehr Audiodaten zu rendern.
  2. [DWGS] Der Worker wartet (ruht) auf das Wake-Signal von AudioWorkletProcessor.process(). Nach dem Aufwachen:
    1. Ruft Pufferindexe von States SAB ab.
    2. Führen Sie die Verarbeitungsfunktion mit Daten aus Eingabe-SAB aus, um Ausgabe-SAB zu füllen.
    3. Aktualisiert SAB entsprechend mit Pufferindexen.
    4. Schlaft in den Schlaf und wartet auf das nächste Signal.

Den Beispielcode finden Sie hier. Allerdings muss das experimentelle Flag „SharedArrayBuffer“ aktiviert sein, damit diese Demo funktioniert. Der Code wurde der Einfachheit halber mit reinem JS-Code geschrieben, er kann jedoch bei Bedarf durch WebAssembly-Code ersetzt werden. Seien Sie mit besonderer Sorgfalt gehandhabt, indem Sie die Speicherverwaltung in die Klasse HeapAudioBuffer umschließen.

Fazit

Das ultimative Ziel des Audio Worklet besteht darin, die Web Audio API wirklich "erweiterbar" zu machen. Die Entwicklung des Designs dauerte mehrere Jahre. Es war möglich, den Rest der Web Audio API mit dem Audio Worklet zu implementieren. Das Design ist jetzt wiederum komplexer, was zu einer unerwarteten Herausforderung führen kann.

Glücklicherweise liegt der Grund für diese Komplexität allein darin, die Entwickler:innen zu unterstützen. Durch die Ausführung von WebAssembly auf AudioWorkletGlobalScope erschließt sich ein enormes Potenzial für die leistungsstarke Audioverarbeitung im Web. Für umfangreiche Audioanwendungen, die in C oder C++ geschrieben sind, kann die Verwendung eines Audio-Worklets mit SharedArrayBuffers und Workers eine attraktive Option sein, diese zu untersuchen.

Guthaben

Besonderen Dank an Chris Wilson, Jason Miller, Joshua Bell und Raymond Toy für die Überprüfung eines Entwurfs dieses Artikels und für das aufschlussreiche Feedback.