How a Web Worker Fixed My Dying-Battery Audio (And What I Learned About PWAs the Hard Way)

How a Web Worker Fixed My Dying-Battery Audio (And What I Learned About PWAs the Hard Way)

Wie ein Web Worker meine sterbende-Batterie-Audio fixte (und was ich hart über PWAs gelernt habe)

https://ift.tt/fsdU0ri

Ich habe die letzte Woche damit verbracht, einen Open-Source-NES-Emulator so modifiziert zu haben, dass er im Browser als PWA läuft. Ich bin ausgebildeter Android-Entwickler — Kotlin, Jetpack Compose, Flutter, wenn das Projekt es verlangt. Das war mein erster echter Tauchgang in Web Workers, SharedArrayBuffer und darin, einen Browser-Tab wie eine native App wirken zu lassen.

Hier ist, was ich gelernt habe. Einiges war im Nachhinein offensichtlich. Die meiste war es nicht.

Die das Ganze auslöste: Das Problem
Ich wollte reale Modulationsschieber für das NES-Browser-Emulator-Erlebnis hinzufügen. Geschwindigkeits-Multiplikator, Feuerkraft-Boost, unendliche Leben — so etwas ist trivial, wenn man Zugriff auf den Spielspeicher hat. Der Emulator (JSNES, Open-Source) gibt direkten Zugriff auf den NES-CPU-RAM über JavaScript. Einen Schieber zu schreiben, der alle Frame anpasst, ist vielleicht 10 Zeilen Code.

Ich richtete einen GitHub Codespace ein, brachte den Emulator zum Laufen und testete ihn im Browser. Alles funktionierte wunderbar. Dann öffnete ich die gleiche URL auf einem älteren Android-Smartphone auf meinem Schreibtisch.

Die Spielgrafik war flüssig. Aber der Ton — die ikonische 8-Bit-Musik — klang wie ein Spielzeug mit leerer Batterie. Langsam, hakt, schmerzhaft. Als würde jemand den APU des NES unter Wasser halten.

Warum Einzels Verlauf der Grund war
So lief es: Die NES erzeugt Audio-Samples mit 44.100 Hz, direkt an die CPU-Emulation gebunden. Jedes Emulations-Frame produziert ca. 735 Audio-Samples. Die Web Audio API des Browsers erwartet, dass diese Samples in konstanter Rate geliefert werden.

Auf einem ordentlichen Rechner lief der Haupt-Thread den Emulator glatt mit 60 fps + Canvas-Rendering + Audio-Samples. Keine Konkurrenz. Auf dem langsamen Android-Handy rief das Canvas-Rendering den Haupt-Thread stark aus. Frames brachen auf 30 fps ein. Die Hälfte der Audio-Samples wurde pro Sekunde erstellt. Die Web Audio API spielte sie zwar mit der erwarteten Rate ab, lief aber nach der Hälfte ins Leere — und produzierte dieses Sterben-aus der-Batterie-Audio.

Ich probierte jeden Hack, den mir einfiel:
– Adaptive Sample-Drops — beobachtete FPS und ließ Audio-Samples fallen, wenn das Gerät zu kämpfen hatte. Ergebnis: Verfranste Audio-Ausgabe statt langsames Audio. Nicht besser.
– Dynamische Ratensteuerung — dehnte verfügbare Samples durch Interpolation (Algorithmus wie RetroArch). Ergebnis: Alien-Kommunikationsklänge. Die Tonhöhe stimmte nicht, weil man 22K Samples nicht einfach so auf 44K-Slots strecken kann, ohne die Grundfrequenz zu verändern.
– Multi-Frame-Aufhol-Strategie — lief 2 NES-Frames pro requestAnimationFrame, wenn das Gerät hinterherhinkte. Ergebnis: noch langsamer, weil das Gerät 2 Frames nicht verarbeiten konnte, wenn es bereits mit 1 frame kämpfte.

Nichts davon funktionierte, weil ich das Symptom statt der Krankheit behandelte. Die Krankheit war: Audiogeneration und Canvas-Rendering konkurrierten um denselben Thread.

Die Lösung: Web Worker
Die architektonisch einfache Lösung war, die NES-Emulation (CPU + Audiogeneration) in einen Web Worker auszulagern. Der Haupt-Thread kümmert sich nur um Canvas-Rendering, Benutzereingaben und UI.

Prozess-Layout (Diagramm in Textform)
Worker-Thread (setInterval @ 60fps)
– JSNES-Emulation (CPU, PPU, APU)
– Audiosample-Erzeugung → SharedArrayBuffer
– Spiel-Mod-Logik (Geschwindigkeit, Feuerkraft, Leben)
– Frame-Pixel-Konvertierung → postMessage (Transferable)

Main-Thread (requestAnimationFrame)
– Canvas-Rendering (empfängt Pixel vom Worker)
– Audio-Wiedergabe (liest aus SharedArrayBuffer)
– Tastatur-/Touch-Eingabe → postMessage an Worker
– UI (Schieber, Schalter, Speichern/Laden, Vollbild)

Der Schlüssel: setInterval in einem Web Worker wird nicht throttled, wenn der Tab im Hintergrund ist. Das tut requestAnimationFrame im Hauptthread. Das bedeutet, der Worker generiert Audio konstant weiter, unabhängig davon, was der Renderer macht. Der Audio-Puffer verhungert nie.

SharedArrayBuffer: Die Zero-Copy-Audio-Brücke
Das war der spannendste Teil für mich, da ich aus einem mobilen Hintergrund komme, in dem Inter-Thread-Kommunikation üblicherweise über Handler.post() oder Kotlin-Koroutinen-Kanäle läuft.

Der Worker erzeugt ca. 735 Audio-Samples pro Frame. Diese Samples müssen mit minimaler Latenz den Hauptthread’s ScriptProcessorNode erreichen. postMessage fügt Serialisierungs-Overhead und Scheduling-Jitter hinzu — gut für Input-Ereignisse, nicht so gut für 44.100 Samples pro Sekunde.

SharedArrayBuffer gibt beiden Threads Zugriff auf denselben Speicher. Der Worker schreibt Audio-Samples in einen Ringpuffer. Der Audio-Prozessor des Haupt-Threads liest aus demselben Puffer. Null Copy, Null Serialisierung, Mikrosekunden-Zugriff.

Das Layout ist einfach:
SharedArrayBuffer:
[0-3] Int32: Write-Index (Worker schreibt via Atomics.store)
[4-7] Int32: Read-Index (Main liest via Atomics.load)
[8+] Float32[]: Interleaved L/R-Audio-Samples

Der Worker schreibt Samples nach jedem nes.frame()-Aufruf. Der ScriptProcessorNode im Hauptthread liest sie in seinem onaudioprocess-Callback. Die Atomics-Operationen geben Speichervorgaben — keine Sperren nötig für einen Single-Producer, Single-Consumer Ringpuffer.

Ein Fettnäpfchen, das mich eine Stunde gekostet hat: Interleaved Audio-Samples müssen immer paarweise geschrieben werden (links + rechts). Wenn der verfügbare Pufferraum ungerade ist, schreibt man ein L-Sample ohne sein R, und jede nachfolgende Leseoperation verschiebt sich um einen Kanal. Die Lösung ist eine Zeile: samplesToWrite = available & ~1 — erzwingt eine gerade Anzahl.

SharedArrayBuffer benötigt spezifische HTTP-Header:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Ohne diese header ist typeof SharedArrayBuffer === ‘undefined’ in jedem Browser. Ich habe einen Fallback-Pfad gebaut, der postMessage mit Transferable Float32Array nutzt, für Umgebungen, wo die Header nicht gesetzt werden können.

Frame Transfer: Transferable Objects
Die NES-Ausgabe beträgt 256×240 Pixel pro Frame. Das sind ca. 245KB Pixeldaten bei 60fps. Das Kopieren via postMessage wäre teuer. Transferable Objects lösen das — das ArrayBuffer wird zwischen Threads verschoben, nicht kopiert. Die sendende Seite verliert den Zugriff darauf (wird „neutered“), aber der Transfer ist im Wesentlichen kostenlos.

Ich nutzte Double-Buffering: Zwei Pixel-Arrays im Worker, abwechselnd welches gefüllt und übertragen wird. In der Praxis stellte sich heraus, dass es einfacher ist, einfach nach jedem Transfer ein neues Uint32Array(61440) zuzuweisen — 245KB Allokation bei 60fps liegt im komfortablen Bereich von V8.

Der PWA-Teil
Aus dem Ganzen eine Progressive Web App zu machen, war eine eigene Lektion. Folgendes lernte ich:

IOS-Safari hat keine Vollbild-API. Weder requestFullscreen noch webkitRequestFullscreen oder andere Varianten. Das stellte sich heraus, als der Vollbild-Button auf dem iPhone einfach nichts tat. Der einzige Weg, „Vollbild“ auf dem iPhone zu bekommen, ist display: standalone im Webmanifest + Home-Bildschirm hinzufügen. Selbst dann bleibt die Statusleiste sichtbar.

Ich baute schließlich ein CSS-simuliertes Vollbild: eine Klasse am Body, die alles außer der Spiel-Kachel und den Touch-Steuerungen versteckt. Doch der Exit-Button funktionierte dann nicht. Unter iOS führt ein position: fixed Button außerhalb des Haupt-Touch-Containers still dazu, dass Touch-Ereignisse nicht registriert werden. Der Button wird gerendert, man sieht ihn, tippt darauf — es passiert nichts. Ich musste die Exit-Steuerung in denselben Overlay-Bereich legen, der die Spiel-Eingaben verarbeitet. Das kostete mich einige Stunden verwirrendes Debugging.

PWA-Icons auf iOS müssen PNGs sein, nicht SVG, und RGB statt RGBA. Safari ignoriert SVG apple-touch-icon-Links vollständig. Und wenn dein PNG einen Alpha-Kanal hat, rendert iOS manchmal ein leeres Symbol oder verwendet sein Standard-Icon. Mein benutzerdefiniertes Pixelkunst-Symbol erschien erst, nachdem ich es von RGBA zu RGB mit Pillow konvertiert hatte.

Service Worker-Caching ist aggressiv und getrennt vom Safari-Cache. Das Löschen von Safari-Daten leert nicht den Cache einer PWA. Du musst zuerst das App-Icon vom Home-Bildschirm löschen, dann Safari-Daten löschen und erneut hinzufügen. Diese harte Lektion lernte ich, als Tester immer wieder alte Versionen sahen.

Der viewport-fit: cover Meta-Tag ist das, womit deine App unter die iPhone-Notch reichen kann. Ohne ihn bekommst du schwarze Balken.

Bonus: Hintergrund-Ausführungskontrolle
Eine Sache, die ich nicht erwartet hatte — die Worker-Architektur gibt dir einfache Kontrolle über das Verhalten im Hintergrund. Da die Emulations-Schleife in einem Web Worker mit setInterval läuft (von Browsern in Hintergrund-Tabs nicht gedrosselt), läuft das Spiel weiter, auch wenn der Benutzer Apps oder Tabs wechselt. Das ist toll für Audio-Kontinuität, aber schrecklich für die Batterielaufzeit.

Die Lösung ist trivial: Auf dem Haupt-Thread on visibilitychange hören und eine Pause/Weiter-Fortsetzen-Nachricht an den Worker senden. Die Emulation stoppt vollständig, wenn die App in den Hintergrund geht, und setzt exakt dort fort, wo sie aufgehört hat, wenn der Benutzer zurückkehrt. Kein Statusverlust, kein Audio-Glitch beim Fortsetzen. Falls du jemals Hintergrundausführung brauchst (z. B. für einen Musik-Player oder eine lang laufende Berechnung), sende einfach nicht die Pause — der Worker läuft weiter, egal was der Haupt-Thread macht. Das bewusste Treffen dieser Entscheidung statt einer browserseitigen Einschränkung ist ein schöner Nebeneffekt der Architektur.

Das Ergebnis
Auf dem gleichen langsamen Android-Handy, das mit der ein-Thread-Architektur sterbendes Audio produzierte: flüssiges, konsistentes, korrektes Tempo bei Audio. Der Web Worker erzeugt Samples in stabilen 60fps via setInterval, völlig unabhängig von der Rendering-Frame-Rate des Haupt-Threads. Die SharedArrayBuffer-Verbindung hat effektiv Null Latenz.

Die visuellen Frames können auf einem langsamen Gerät auf 30fps fallen — das Spiel wirkt etwas weniger flüssig — aber das Audio bleibt unverändert. Das ist der richtige Kompromiss. Menschen tolerieren stockende Video wesentlich besser als stockendes Audio.

Takeaways
– Thread-Architektur ist eine Tag-1-Entscheidung, kein Optimierungsziel. Ich baute zuerst die Ein-Thread-Version, weil sie schneller zu prototypisieren war. Dann verbrachte ich mehr Zeit damit, Audio-Hacks zu patchen, als die Worker-Migration letztlich benötigte. Wenn deine App Echtzeit-Audio/Video-Verarbeitung macht, setze den Produzenten von Anfang an auf einen separaten Thread.
– SharedArrayBuffer ist das richtige Werkzeug für hochfrequente Inter-Thread-Daten. Für Audio mit 44.100 Samples/Sekunde fügt postMessage zu viel Jitter hinzu. Für Eingabeereignisse bei 10–30/Sekunde ist postMessage völlig in Ordnung. Pass das Werkzeug an die Frequenz an.
– Transferable Objects sind kostenlos. Wenn du große Arrays zwischen Threads via postMessage übergibst, markiere sie als transferable. Null Copy, Null Overhead. Beachte nur, dass der Sender den Zugriff verliert.
– PWAs auf iOS sind eine ganz andere Plattform. Geh nicht davon aus, dass Web-APIs identisch funktionieren. Vollbild-API existiert nicht. Touch-Ereignisse verhalten sich anders bei fixed-Position-Elementen. Icons benötigen bestimmte Formate. Teste auf einem echten iPhone, nicht nur in der mobilen Emulation von Chrome DevTools.
– Teste zuerst auf dem langsamsten Gerät. Hättest du das am ersten Tag am alten Android-Handy getestet, hättest du von Anfang an für Worker entworfen. Nur Tests auf schneller Hardware verstecken architektonische Probleme, die später sehr teuer zu beheben sind.
– Ich bin Mobil-Entwickler (Android/Kotlin, Flutter) und erkunde den Browser als Plattform für Echtzeit-Anwendungen. Wenn du dich mit Web Workern, SharedArrayBuffer oder PWA-Quirkiness auf iOS auskennst, würde ich gern von deinen Erfahrungen in den Kommentaren hören.

HI-FI News

via DEV Community https://dev.to

13. April 2026 um 14:18 Uhr

April 13, 2026 at 02:18PM