Designmuster für Audio-Worklet

Hongchan Choi

Im vorherigen Artikel zu Audio-Worklets wurden die grundlegenden Konzepte und die Verwendung erläutert. Seit der Einführung in Chrome 66 haben wir viele Anfragen nach weiteren Beispielen erhalten, wie sie in tatsächlichen Anwendungen verwendet werden kann. Das Audio-Worklet bietet das volle Potenzial von WebAudio. Es kann jedoch schwierig sein, es zu nutzen, da es ein Verständnis für die parallele Programmierung mit mehreren JS-APIs erfordert. Selbst für Entwickler, die mit WebAudio vertraut sind, kann die Einbindung des Audio-Worklets in andere APIs (z.B. WebAssembly) schwierig sein.

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

Zusammenfassung: Audio-Worklet

Bevor wir uns näher mit dem Thema befassen, lassen Sie uns kurz die Begriffe und Fakten zum Audio-Worklet-System wiederholen, das in diesem Beitrag vorgestellt wurde.

  • BaseAudioContext: Das primäre Objekt der Web Audio API.
  • Audio-Worklet: Ein spezieller Scriptdatei-Lademechanismus für den Audio-Worklet-Vorgang. Gehört zu BaseAudioContext. Ein BaseAudioContext kann ein Audio-Worklet haben. Die geladene Scriptdatei 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 speziellen Rendering-Thread für WebAudio ausgeführt. Ein BaseAudioContext kann einen AudioWorkletGlobalScope haben.
  • AudioWorkletNode: Ein AudioNode, der für die Ausführung von Audio-Worklets entwickelt wurde. Wird von einem BaseAudioContext instanziiert. Ein BaseAudioContext kann ähnlich wie native AudioNodes mehrere AudioWorkletNodes haben.
  • AudioWorkletProcessor: Ein Gegenstück zum AudioWorkletNode. Der eigentliche AudioWorkletNode, der den Audiostream mit dem vom Nutzer bereitgestellten Code verarbeitet. Sie wird im AudioWorkletGlobalScope erstellt, wenn ein AudioWorkletNode erstellt wird. Ein AudioWorkletNode kann einen übereinstimmenden AudioWorkletProcessor haben.

Designmuster

Audio-Worklet mit WebAssembly verwenden

WebAssembly ist die perfekte Ergänzung für AudioWorkletProcessor. Die Kombination dieser beiden Funktionen bietet eine Vielzahl von Vorteilen für die Audioverarbeitung im Web. Die beiden größten Vorteile sind: a) die Einbindung vorhandenen C/C++-Audioverarbeitungscodes in das WebAudio-System und b) die Vermeidung des Overheads der JS-JIT-Kompilierung und der Garbage Collection im Audioverarbeitungscode.

Ersteres ist für Entwickler wichtig, die bereits Code und Bibliotheken zur Audioverarbeitung verwenden. Letzteres ist für fast alle Nutzer der API entscheidend. Bei WebAudio ist das Zeitbudget für einen stabilen Audiostream recht knapp: Bei einer Abtastrate von 44, 1 kHz sind es nur 3 ms. Selbst ein kleiner Fehler im Code zur Audioverarbeitung kann zu Störungen führen. Der Entwickler muss den Code für eine schnellere Verarbeitung optimieren, aber auch die Menge an generiertem JS-Müll minimieren. WebAssembly kann eine Lösung sein, die beide Probleme gleichzeitig angeht: Es ist schneller und generiert keinen Garbage-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 Emscripten-Bindungscode) finden Sie in diesem Artikel.

Einrichten

Das klingt alles gut, aber wir brauchen ein wenig Struktur, um alles richtig einzurichten. Die erste Designfrage, die Sie stellen sollten, ist, wie und wo ein WebAssembly-Modul instanziiert werden soll. Nachdem der Emscripten-Bindungscode abgerufen wurde, gibt es zwei Pfade für die Modulinstanziierung:

  1. Erstelle ein WebAssembly-Modul, indem du den Glue-Code über audioContext.audioWorklet.addModule() in den AudioWorkletGlobalScope lädst.
  2. Instanziere ein WebAssembly-Modul im Hauptbereich und übertrage es dann über die Konstruktoroptionen des AudioWorkletNode.

Die Entscheidung hängt weitgehend von Ihrem Design und Ihren Vorlieben ab. Das WebAssembly-Modul kann jedoch eine WebAssembly-Instanz im AudioWorkletGlobalScope generieren, die zu einem Audioverarbeitungskern innerhalb einer AudioWorkletProcessor-Instanz wird.

WebAssembly-Modulinstanziierungsmuster A: .addModule()-Aufruf verwenden
Muster für die Instanziierung von WebAssembly-Modulen A: .addModule()-Aufruf verwenden

Damit Muster A ordnungsgemäß 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 AudioWorklet-Prozessors in mycode.js angehängt, damit sie nach der Initialisierung des Moduls geladen werden kann. Der Hauptgrund für die Verwendung der synchronen Kompilierung ist, dass die Promise-Auflösung von audioWorklet.addModule() nicht auf die Auflösung von Promises 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 können wir die Regel jedoch umgehen, da die Kompilierung im AudioWorkletGlobalScope erfolgt, der vom Hauptthread ausgeführt wird. Weitere Informationen finden Sie in diesem Artikel.

WASM-Modulinstanzierungsmuster B: Threadübergreifende Übertragung des AudioWorkletNode-Konstruktors verwenden
WASM-Modulinstanziierungsmuster B: Threadübergreifende Übertragung des AudioWorkletNode-Konstruktors verwenden

Muster B kann nützlich sein, wenn asynchrone, leistungsintensive Aufgaben erforderlich sind. 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 macht noch mehr Sinn, wenn Sie das Modul dynamisch laden müssen, nachdem der AudioWorkletGlobalScope mit dem Rendern des Audiostreams begonnen hat. Je nach Größe des Moduls kann das Kompilieren in der Mitte des Renderings zu Störungen im Stream führen.

WASM-Heap und Audiodaten

WebAssembly-Code funktioniert nur im Arbeitsspeicher, der in einem speziellen WASM-Heap zugewiesen ist. Damit dies möglich ist, müssen die Audiodaten zwischen dem WASM-Heap und den Audiodatenarrays hin- und hergeklont werden. Die Klasse HeapAudioBuffer im Beispielcode kümmert sich um diesen Vorgang.

HeapAudioBuffer-Klasse für die einfachere Nutzung des WASM-Heaps
HeapAudioBuffer-Klasse für die einfachere Verwendung des WASM-Heaps

Es gibt einen ersten Vorschlag, den WASM-Heap direkt in das Audio-Worklet-System zu integrieren. Das Entfernen dieses redundanten Datenklonens zwischen dem JS-Speicher und dem WASM-Heap erscheint sinnvoll, aber die Details müssen noch ausgearbeitet werden.

Umgang mit nicht übereinstimmender Puffergröße

Ein AudioWorkletNode und ein AudioWorkletProcessor funktionieren zusammen wie ein normaler AudioNode. AudioWorkletNode kümmert sich um die Interaktion mit anderen Codes, während AudioWorkletProcessor die interne Audioverarbeitung übernimmt. Da ein normaler AudioNode jeweils 128 Frames verarbeitet, muss AudioWorkletProcessor dasselbe tun, um eine Hauptfunktion zu werden. Dies ist einer der Vorteile des Audio-Worklet-Designs, da dadurch keine zusätzliche Latenz durch interne Pufferung im AudioWorkletProcessor entsteht. Es kann jedoch ein Problem darstellen, wenn für eine Verarbeitungsfunktion eine andere Puffergröße als 128 Frames erforderlich ist. Die gängige Lösung für diesen Fall ist die Verwendung eines Ringpuffers, auch als zyklischer Puffer oder FIFO bezeichnet.

Hier ist ein Diagramm von AudioWorkletProcessor zu sehen, das zwei Ringbuffer enthält, um eine WASM-Funktion aufzunehmen, die 512 Frames ein- und ausgibt. (Die Zahl 512 ist hier willkürlich ausgewä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 schiebt 128 Frames aus seinem Input in den Input-Ringbuffer.
  2. Führen Sie die folgenden Schritte nur aus, wenn der Eingabe-Ringbuffer mindestens 512 Frames hat.
    1. 512 Frames aus dem Eingabe-Ringbuffer abrufen.
    2. 512 Frames mit der angegebenen WASM-Funktion verarbeiten.
    3. 512 Frames in den Output-Ringbuffer schieben.
  3. AudioWorkletProcessor ruft 128 Frames aus dem Output-Ringbuffer ab, um seinen Output zu füllen.

Wie im Diagramm dargestellt, werden Eingabeframes immer im Eingabe-Ringbuffer gesammelt. Der Ringbuffer kümmert sich um einen Pufferüberlauf, indem er den ältesten Frameblock im Puffer überschreibt. Das ist für eine Echtzeit-Audioanwendung durchaus sinnvoll. Ebenso wird der Block „Output frame“ immer vom System abgerufen. Ein Buffer Underflow (nicht genügend Daten) im Output-Ringbuffer führt zu Stille und damit zu einem Ruckler im Stream.

Dieses Muster ist nützlich, wenn du ScriptProcessorNode (SPN) durch AudioWorkletNode ersetzt. Da der Entwickler mit SPN eine Puffergröße zwischen 256 und 16.384 Frames auswählen kann, ist die Einsetzung von SPN durch AudioWorkletNode schwierig. Die Verwendung eines Ringpuffers ist eine gute Lösung. Ein Audiorekorder wäre ein gutes Beispiel für ein Gerät, das auf diesem Design basieren könnte.

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

Die Kombination dieses Designs mit WebAssembly kann aufgrund der Speicherverwaltung rund um den WASM-Heap kompliziert sein. Zum Zeitpunkt der Erstellung dieses Artikels müssen die Daten, die in den WASM-Heap eingehen und aus ihm herausgehen, geklont werden. Wir können jedoch die HeapAudioBuffer-Klasse verwenden, um die Speicherverwaltung etwas zu vereinfachen. Die Verwendung von vom Nutzer zugewiesenem Arbeitsspeicher, um redundantes Klonen von Daten zu reduzieren, 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 zu kombinieren: Audio Worklet, SharedArrayBuffer, Atomics und Worker. Mit dieser nicht trivialen Einrichtung können bestehende Audiosoftware, die in C/C++ geschrieben wurde, in einem Webbrowser ausgeführt werden, während die Nutzerfreundlichkeit erhalten bleibt.

Ü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 ausschließlich für die Audioverarbeitung verwendet werden kann. In Chrome wird WorkerGlobalScope in einem Thread mit niedrigerer Priorität als der WebAudio-Rendering-Thread ausgeführt. Es hat jedoch mehrere Vorteile gegenüber AudioWorkletGlobalScope. DedicatedWorkerGlobalScope ist in Bezug auf die im Umfang verfügbare API-Oberfläche weniger eingeschränkt. Außerdem können Sie von Emscripten eine bessere Unterstützung erwarten, da die Worker API schon seit einigen Jahren existiert.

SharedArrayBuffer spielt eine wichtige Rolle für die effiziente Funktion dieses Designs. Obwohl sowohl Worker als auch AudioWorkletProcessor mit asynchronem Messaging (MessagePort) ausgestattet sind, ist dies aufgrund der wiederholten Speicherzuweisung und der Messaging-Latenz nicht optimal für die Echtzeit-Audioverarbeitung. Daher weisen wir vorab einen Speicherblock zu, auf den beide Threads zugreifen können, um eine schnelle bidirektionale Datenübertragung zu ermöglichen.

Aus Sicht eines Web Audio API-Puristen ist dieses Design möglicherweise suboptimal, da das Audio-Worklet als einfacher „Audio-Sink“ verwendet wird und alle Verarbeitung im Worker erfolgt. Angesichts der Kosten, die durch das Umschreiben von C/C++-Projekten in JavaScript entstehen können, kann dies jedoch unerschwinglich oder sogar unmöglich sein. In diesem Fall ist dieses Design möglicherweise der effizienteste Implementierungspfad für solche Projekte.

Gemeinsame Status und Atome

Wenn Sie einen gemeinsamen Speicher für Audiodaten verwenden, muss der Zugriff von beiden Seiten sorgfältig koordiniert werden. Die Freigabe atomar zugänglicher Zustände ist eine Lösung für dieses Problem. Wir können Int32Array mit einer SAB für diesen Zweck nutzen.

Synchronisierungsmechanismus: SharedArrayBuffer und Atomics
Synchronisierungsmechanismus: SharedArrayBuffer und Atomics

Synchronisierungsmechanismus: SharedArrayBuffer und Atomics

Jedes Feld des States-Arrays enthält wichtige Informationen zu den freigegebenen Puffern. Das wichtigste ist ein Feld für die Synchronisierung (REQUEST_RENDER). Der Worker wartet darauf, dass dieses Feld von AudioWorkletProcessor berührt wird, und verarbeitet das Audio, wenn er wieder aktiv wird. Zusammen mit SharedArrayBuffer (SAB) ermöglicht die Atomics API diesen Mechanismus.

Die Synchronisierung der beiden Threads ist eher locker. Der Beginn von Worker.process() wird durch die AudioWorkletProcessor.process()-Methode ausgelöst, aber der AudioWorkletProcessor wartet nicht, bis Worker.process() abgeschlossen ist. Das ist beabsichtigt. Der AudioWorkletProcessor wird vom Audio-Callback gesteuert und darf daher nicht synchron blockiert werden. Im schlimmsten Fall kann es zu doppelten Audiostreams oder Aussetzern kommen. Sobald sich die Renderingleistung jedoch stabilisiert hat, sollte das Problem behoben sein.

Einrichten und Ausführen

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

Initialisierung
  1. [Haupt] Der Konstruktor von AudioWorkletNode wird aufgerufen.
    1. Erstellen Sie einen Worker.
    2. Der zugehörige AudioWorkletProcessor wird erstellt.
  2. [DWGS] Worker erstellt zwei SharedArrayBuffers. (eine für freigegebene Status und die andere für Audiodaten)
  3. [DWGS] 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. In jeder Iteration der Renderingschleife sollte Folgendes passieren:

Rendering-Schleife
Mehrfach gerenderte Frames mit SharedArrayBuffers
Multi-Thread-Rendering mit SharedArrayBuffers
  1. [AWGS] AudioWorkletProcessor.process(inputs, outputs) wird für jedes Rendering-Quantum aufgerufen.
    1. inputs wird in Input SAB gesendet.
    2. outputs wird durch das Verbrauchen von Audiodaten in Output SAB ausgefüllt.
    3. Aktualisiert den States SAB entsprechend mit neuen Pufferindexen.
    4. Wenn Output SAB den Unterlaufgrenzwert erreicht, wird der Worker geweckt, um weitere Audiodaten zu rendern.
  2. [DWGS] Der Worker wartet (schläft) auf das Wecksignal von AudioWorkletProcessor.process(). Beim Aufwachen:
    1. Ruft Pufferindexe aus States SAB ab.
    2. Führen Sie die Verarbeitungsfunktion mit Daten aus Input SAB aus, um Output SAB zu füllen.
    3. Aktualisiert States SAB mit Buffer-Indexen entsprechend.
    4. Er wechselt in den Ruhemodus und wartet auf das nächste Signal.

Den Beispielcode finden Sie hier. Beachten Sie jedoch, dass das experimentelle Flag „SharedArrayBuffer“ aktiviert sein muss, damit diese Demo funktioniert. Der Code wurde aus Gründen der Einfachheit in reinem JS-Code geschrieben, kann aber bei Bedarf durch WebAssembly-Code ersetzt werden. In diesem Fall sollte die Speicherverwaltung mit der Klasse HeapAudioBuffer umhüllt werden.

Fazit

Das ultimative Ziel des Audio-Worklets besteht darin, die Web Audio API wirklich „erweiterbar“ zu machen. Die Entwicklung des Audio-Worklets hat mehrere Jahre gedauert, damit der Rest der Web Audio API damit implementiert werden kann. Das Design ist jetzt komplexer und das kann eine unerwartete Herausforderung sein.

Glücklicherweise liegt der Grund für diese Komplexität darin, Entwicklern mehr Möglichkeiten zu bieten. Die Möglichkeit, WebAssembly auf AudioWorkletGlobalScope auszuführen, eröffnet ein enormes Potenzial für die leistungsstarke Audioverarbeitung im Web. Für groß angelegte Audioanwendungen, die in C oder C++ geschrieben sind, kann die Verwendung eines Audio-Worklets mit SharedArrayBuffers und Workern eine attraktive Option sein.

Gutschriften

Ein besonderer Dank geht an Chris Wilson, Jason Miller, Joshua Bell und Raymond Toy, die einen Entwurf dieses Artikels gelesen und hilfreiches Feedback gegeben haben.