Steven
Steven9 Min. Lesezeit

Die drei Verben, die Web Audio am Leben halten

Zwei punktuelle GeekBye-Releases, zwei Monate auseinander und in zwei verschiedenen Dateien, brachten unserem Audio-Code dieselbe Lektion von entgegengesetzten Enden bei: den AudioContext des Browsers nicht länger als Wegwerfobjekt zu behandeln. Ein Release lernte, einen Kontext per resume() wiederaufzunehmen, den macOS mitten in der Aufnahme still suspendiert hatte; das andere lernte, suspend() statt close() aufzurufen, damit aufeinanderfolgende Sitzungen nicht länger gegen Chromiums Decke von etwa sechs Kontexten krachen. Resume, suspend, close — das ist die ganze Handlung.

Technik
Audio
Desktop
GeekBye-Releases
Die drei Verben, die Web Audio am Leben halten

Die meiste Zeit verhält sich ein Web-Audio-AudioContext wie ein schlichtes Objekt: du konstruierst einen, verdrahtest ein paar Knoten und vergisst ihn. Zwei GeekBye-Releases sind das, was passierte, als diese Annahme brach — zweimal, in zwei verschiedenen Teilen der App, zwei Monate auseinander. Keines war als Folge des anderen geplant. Aber stell sie nebeneinander, und sie erzählen eine Geschichte: der AudioContext des Browsers ist kein Wegwerfobjekt. Er ist eine knappe, dem Betriebssystem gehörende Ressource mit einer echten Zustandsmaschine, und die drei Verben dieser Zustandsmaschine — resume, suspend, close — sind die gesamte Handlung beider Releases.

GeekBye hat zwei getrennte Audio-Pipelines, weshalb dieselbe Lektion zweimal gelernt werden musste. Die eine ist der Pfad der Videoaufnahme (ScreenRecordingService), der einen Web-Audio-Graphen baut, um Mikro und System-Audio in eine aufgezeichnete Videodatei zu mischen. Die andere ist der Pfad der Echtzeit-Transkription (RealtimeAudioCaptureService), der seinen eigenen Graphen baut, um die Spracherkennung zu speisen. Jede hat ihren eigenen AudioContext. Jedes Release unten behebt eine von ihnen.

v1.8.16: das Video wurde still, aber das Transkript nicht

Der erste Bug ist eine gespaltene Persönlichkeit. Du nimmst ein Meeting auf, wechselst kurz zu einer anderen App, und etwas Subtiles passiert: die Videodatei verliert dein Mikrofon, während das Live-Transkript weiterläuft, als hätte sich nichts geändert. Das Transkript schwört, dass du geredet hast. Das Video ist still. Das ist eine zum Wahnsinn treibende Art von Bug, weil die beiden Dinge, die übereinstimmen sollten — „hat dich das Mikro gehört“ — sich rundheraus widersprechen.

Der Commit, der ihn behebt, benennt die Ursache präzise:

AudioContext can be suspended by macOS when the app loses focus during screen recording. This silences the mic in the video file while the ScriptProcessorNode (STT path) keeps running on the JS main thread, causing a split where transcripts continue but the video has no mic.

Hier ist die Mechanik. ScreenRecordingService.setupMonoMixingPipeline() baut einen AudioContext, ein MediaStreamDestination, das zur Audiospur des Videos wird, einen GainNode zum Stummschalten und ein Paar ScriptProcessorNode (via createScriptProcessor), die Audio-Frames an die VAD/STT-Logik pumpen. Wenn macOS den Kontext suspendiert — was es tun kann, wenn die App während einer Bildschirmaufnahme den Fokus verliert — stoppen die Teile des Graphen, die von der Audio-Hardware-Uhr getrieben werden: das MediaStreamDestination empfängt kein Mikro-Audio mehr, sodass das aufgezeichnete Video verstummt. Aber der onaudioprocess-Callback eines ScriptProcessorNode läuft auf dem JavaScript-Hauptthread, und dieser Thread tickt weiter. Also produziert die STT-Seite weiter Transkript-Einträge aus dem, was sie zuletzt hatte, und die beiden Konsumenten desselben Mikrofons driften auseinander.

Der Fix ist, die Suspendierung zu bemerken und rückgängig zu machen:

audioContext.onstatechange = () => {
  if (audioContext.state === 'suspended' && this.status === 'recording') {
    logger.info('AudioContext suspended during recording, resuming')
    audioContext.resume()
  }
}

Das ist die gesamte Korrektheitsänderung: den state des Kontexts beobachten, und wenn er auf suspended fällt, während wir noch aufnehmen sollten, resume() aufrufen. Das Release verdrahtet außerdem onmute-, onunmute- und onended-Listener auf dem MediaStreamTrack des Mikrofons — nicht um das Verhalten zu ändern, sondern damit eine Stummschaltung auf OS-Ebene oder eine Geräte-Trennung in den Logs auftaucht, statt eine unsichtbare Ursache von Stille zu sein. Wenn die ganze Schwierigkeit eines Bugs „warum hörte das Audio auf“ ist, macht das Audio-Aufhören beobachtbar zu machen den halben Fix aus.

Ein Folge-Commit landete ein paar Minuten später, um nach diesen neuen Listenern aufzuräumen: cleanup() nullt jetzt micTrack.onmute/onunmute/onended und setzt audioContext.onstatechange = null, bevor es den Graphen abbaut. Die Commit-Notiz sagt, es „entspricht dem Aufräum-Pattern von RealtimeAudioCaptureService“ — dem anderen Audio-Dienst der App. Merk dir dieses Detail, denn zwei Monate später ist genau dieses Pattern das, was das nächste Release umschreiben musste.

v2.0.2: Audio und Transkripte sterben nach etwa sechs Sitzungen

Der zweite Bug lebt in der anderen Pipeline, RealtimeAudioCaptureService, und hat eine wunderbare Form: alles funktioniert perfekt, und dann, nachdem du in einer App-Sitzung eine Handvoll Mal die Aufnahme gestartet und gestoppt hast, stirbt das Audio einfach... ab. Zuerst wird das System-Audio still, dann stoppt das Transkript. Starte die App neu, und alles ist wieder in Ordnung — bis zur nächsten Handvoll Sitzungen.

Die Grundursachen-Notiz lohnt es sich ganz zu lesen, denn sie ist ein Bug-hinter-dem-Bug wie aus dem Lehrbuch:

Root cause: setupMonoMixingPipeline created a new AudioContext on every start() and cleanup() closed it. Chromium hard-caps hardware AudioContexts at ~6 per page and closed-but-not-yet-GC'd contexts keep their slot, so ~6 back-to-back sessions exhaust the cap. The next context is dead → the silent-monitor passthrough can't play (no system audio) AND the mixer worklet can't capture (no transcript) — the reported 'after 6+ sessions audio dies, then transcript dies'.

Die Falle ist, dass der alte Code das „Verantwortungsvolle“ tat. Jedes start() erzeugte einen frischen AudioContext; jedes cleanup() rief close() darauf auf. Eine Ressource zu schließen, wenn man mit ihr fertig ist, ist genau das, was man dir beibringt. Es gab sogar einen Inline-Kommentar, der das Herumwirtschaften rechtfertigte — dass das Neuladen des AudioWorklet-Moduls pro frischem Kontext in Ordnung sei, weil „ein frischer Kontext pro start() bedeutet, dass wir diese Kosten immer einmal bezahlen“.

Aber Chromium begrenzt, wie viele hardwaregestützte AudioContext eine einzelne Seite halten kann — etwa sechs — und, entscheidend, close() gibt den Slot nicht in dem Moment zurück, in dem du es aufrufst. Ein geschlossener Kontext belegt seinen Slot weiter, bis er per Garbage Collection eingesammelt wird, was passiert, wann immer die JS-Engine dazu kommt. Also ist eine gewissenhafte Jedes-Mal-schließen-Politik genau das, was dich in die Decke laufen lässt: sechs Erzeugen/Schließen-Zyklen später werden alle sechs Slots von noch nicht eingesammelten Leichen gehalten, das siebte new AudioContext() kommt tot zurück, und jeder Knoten, den du daran hängst, tut still nichts.

Der Fix ist, den Kontext nicht mehr als Wegwerfware zu behandeln. Ihn einmal erzeugen und für die Lebensdauer des Dienstes wiederverwenden:

  • In setupMonoMixingPipeline nur dann einen new AudioContext(...) konstruieren, wenn es noch keinen gibt oder der vorhandene closed ist. Existiert er, ist aber suspended, await audioContext.resume(), statt einen neuen zu machen.
  • In cleanup audioContext.close() durch audioContext.suspend() ersetzen (abgesichert, sodass es nie auf einem bereits geschlossenen Kontext aufgerufen wird). Der Kontext überlebt die Sitzung.
  • Weil der Kontext überlebt, überlebt das geladene AudioWorklet-Modul mit ihm — der Code setzt sein „Worklet geladen“-Flag beim Aufräumen nicht mehr zurück, sodass audioWorklet.addModule(...) einmal für die gesamte Lebensdauer des Dienstes bezahlt wird statt jede Sitzung.

Die Pro-Sitzung-Teile — die createMediaStreamSource-Knoten für Mikro und System-Audio, das Mixer-Worklet und der stille Passthrough, der Chromiums System-Audio-Loopback-Abgriff am Leben hält — werden weiterhin in jedem Zyklus gebaut und abgebaut. Nur der AudioContext selbst bleibt bestehen, sodass zu jedem Zeitpunkt höchstens einer existiert. Die Decke ist unerreichbar, weil du nie Kontexte gegen sie stapelst.

Eine ehrliche Notiz, die der Commit über sich selbst macht: dies wurde von Hand verifiziert — „manuell verifiziert: 8+ aufeinanderfolgende Sitzungen halten System-Audio hörbar und Transkripte fließend (war bei Sitzung 7 tot)“ — und der Autor gibt freimütig zu, dass der Web-Audio-Lebenszyklus „in jsdom ohne ein großes neues Mock-Harness nicht unit-testbar ist“. Das ist die richtige Art von Ehrlichkeit. Der Fehler zeigt sich nur an einem echten Audiogerät über echte Sitzungen hinweg; eine grüne Unit-Suite hätte nichts bewiesen. Die „etwa sechs“ und „bei Sitzung 7 tot“ sind die Beobachtungen des Teams, wo Chromiums Deckel zubiss, keine dokumentierte Konstante — der Code selbst sichert sich mit einer Tilde ab, und das sollte jeder tun, der es wiederholt.

Resume, suspend, close

Reihe die beiden Releases auf, und die Symmetrie ist fast zu sauber. Beide drehen sich um dieselben drei Methoden am selben Objekttyp, angegangen aus entgegengesetzten Richtungen:

  • v1.8.16 lernte resume(). Der Kontext wurde vom OS unter ihm weg suspended, und der Fix ist, das abzufangen und wiederaufzunehmen — einen Kontext, der laufen soll, am Laufen halten.
  • v2.0.2 lernte suspend() statt close(). Der alte Code war zu eifrig, den Kontext zu zerstören, und der Fix ist, ihn zu suspendieren und wiederzuverwenden — einen Kontext, der teuer neu zu erzeugen ist, am Leben halten.

Sie sind die zwei Hälften eines einzigen Prinzips: den AudioContext nicht mehr als Wegwerfware behandeln. Die eine Hälfte sagt: lass ihn nicht sterben, wenn er nicht sollte; die andere sagt: töte ihn nicht, wenn du nicht musst. Und es gibt eine kleine Ironie in der Chronologie — das Aufräumen der v1.8.16 wurde geschrieben, um „dem Aufräum-Pattern von RealtimeAudioCaptureService zu entsprechen“, und dann schrieb v2.0.2 genau dieses Pattern in RealtimeAudioCaptureService um, weil sich die close()-jedes-Mal-Gewohnheit des Patterns als der Bug herausstellte. Die beiden Dienste lernen weiter voneinander über die Handhabung des Audio-Lebenszyklus, ein Fix nach dem anderen.

Drei Dinge, die uns diese Releases lehrten

  1. Ein AudioContext hat eine Zustandsmaschine — behandle state als tragend. running, suspended und closed sind keine Belanglosigkeiten. Das OS kann einen Kontext zwischen ihnen bewegen, ohne dich zu fragen, und verschiedene Teile deines Graphen reagieren unterschiedlich (ein ScriptProcessorNode auf dem Hauptthread läuft weiter; das hardware-getaktete Ziel nicht). Wenn du onstatechange nicht beobachtest, vertraust du darauf, dass der Kontext dort bleibt, wo du ihn gelassen hast — und auf einem echten Desktop, unter Fokuswechseln und Bildschirmaufnahme, wird er das nicht.
  2. close() ist nicht free(). Die Intuition, dass eine Ressource prompt zu schließen immer richtig ist, ist genau das, was den zweiten Bug verursachte. Wenn die Ressource ein Slot in einem harten, vom Browser vorgegebenen Pool ist, der erst zur GC-Zeit zurückgewonnen wird, ist aggressives Schließen die Art, wie du den Pool erschöpfst. Manchmal ist der richtige Lebenszyklus einmal-erzeugen-oft-suspendieren, nicht erzeugen-und-pro-Nutzung-zerstören.
  3. Manche Bugs sind nur durch die Tür echter Hardware und echter Zeit erreichbar. „Stirbt nach etwa sechs Sitzungen“ und „wird still, wenn du wegwechselst“ können von einem Unit-Test, der das Audiogerät mockt, nicht gefangen werden — und das Team sagte das offen, statt es mit einem falsch-bestandenen Test zu übertünchen. Benenne die Grenzen deines Test-Harness; ein Bug, der acht echte Sitzungen braucht, um aufzutauchen, verdient ein ehrliches „manuell verifiziert“, kein grünes Häkchen, das nichts bedeutet.

Für das vorige Kapitel, einen Anruf von einer offenen App unterscheiden (v1.8.15–v1.8.19); und für den ganzen Bogen, die Anatomie, Software bis zur Perfektion auszuliefern.

Ähnliche Artikel

Die Stille war tragend
Steven
Steven7 Min. Lesezeit

Die Stille war tragend

Die letzten beiden Releases von GeekBye v1 drehen sich um dieselbe unbequeme Wahrheit: Echtzeit-Transkription über ein echtes Netzwerk ist nicht verlustfrei, und der ehrliche Schritt ist, aufzuhören, so zu tun, als wäre sie es. v1.8.20 behielt eine Kopie jedes Audio-Chunks auf der Festplatte, bevor es ihn während eines Reconnects verwarf, und begann, die Lücken im Transkript laut zu markieren. v1.9.0 hörte auf, Stille zu senden, um Bandbreite zu sparen — und entdeckte, dass die Stille genau das Signal war, mit dem der Transkriber erkannte, dass ein Satz zu Ende war. Zwei Releases über die Kosten des Wegwerfens.

Technik
Audio
Zuverlässigkeit
Einen Anruf von einer offenen App unterscheiden
Steven
Steven8 Min. Lesezeit

Einen Anruf von einer offenen App unterscheiden

GeekBye kann bemerken, dass du einem Videomeeting beigetreten bist, und anbieten, es aufzunehmen. Die Erkennung erweist sich als die einfache Hälfte — ein Swift-Binary, das alle zehn Sekunden Fenstertitel liest. Die harte Hälfte ist Präzision: nicht auszulösen, wenn Zoom bloß offen ist, nicht für ein Meeting zu fragen, das du bereits aufnimmst, und nicht das Mikrofon in dem Anruf stummzuschalten, in dem du tatsächlich bist. Drei Releases, und jedes ist ein Wächter, der lernen musste, sich nicht selbst zu besiegen.

Technik
macOS
Desktop
Das Backend aus dem Upload-Pfad nehmen
Steven
Steven8 Min. Lesezeit

Das Backend aus dem Upload-Pfad nehmen

GeekBye nimmt deinen Bildschirm auf und speichert das Video auf deinem Google Drive. Die erste Version schickte jede Aufnahme unterwegs durch GeekByes eigene Server; eine Version später ging die Datei direkt von deiner Maschine zu Drive, und das Backend wurde zum Halten eines einzigen Zeigers degradiert. Das Interessante ist, wie wenig Code die „direkte, fortsetzbare“ Version tatsächlich enthält — denn die Fortsetzbarkeit kam vom Löschen eines Proxys, nicht vom Schreiben eines.

Technik
Architektur
Desktop