Steven
Steven9 min leestijd

De drie werkwoorden die Web Audio in leven houden

Twee GeekBye-point-releases, twee maanden uit elkaar en in twee verschillende bestanden, leerden onze audiocode dezelfde les vanuit tegengestelde kanten: behandel de AudioContext van de browser niet langer als wegwerpbaar. De ene release leerde resume() aan te roepen op een context die macOS stilletjes midden in een opname had gesuspend; de andere leerde suspend() te gebruiken in plaats van close() zodat opeenvolgende sessies niet langer tegen Chromiums plafond van ongeveer zes contexten aan knallen. Resume, suspend, close — dat is het hele plot.

Engineering
Audio
Desktop
GeekBye Releases
De drie werkwoorden die Web Audio in leven houden

Meestal gedraagt een Web Audio AudioContext zich als een gewoon object: je maakt er een, koppelt er wat nodes aan, en vergeet hem verder. Twee GeekBye-releases zijn wat er gebeurde toen die aanname het begaf — twee keer, in twee verschillende delen van de app, twee maanden uit elkaar. Geen van beide was bedoeld als vervolg op de ander. Maar zet ze naast elkaar en ze vertellen één verhaal: de AudioContext van de browser is geen wegwerpobject. Het is een schaarse, door het besturingssysteem bezeten resource met een echte toestandsmachine, en de drie werkwoorden van die toestandsmachine — resume, suspend, close — zijn het hele plot van beide releases.

GeekBye heeft twee aparte audiopijplijnen, en daarom moest dezelfde les twee keer geleerd worden. De ene is het video-opname-pad (ScreenRecordingService), dat een Web Audio-graaf bouwt om mic- en systeemaudio in een opgenomen videobestand te mengen. De andere is het real-time transcriptie-pad (RealtimeAudioCaptureService), dat zijn eigen graaf bouwt om spraak-naar-tekst te voeden. Elk heeft zijn eigen AudioContext. Elke release hieronder repareert er één.

v1.8.16: de video viel stil, maar het transcript niet

De eerste bug is een split-brain. Je neemt een vergadering op, je tabt even weg naar een andere app, en er gebeurt iets subtiels: het videobestand verliest je microfoon terwijl het live transcript blijft doorlopen alsof er niets veranderd is. Het transcript zweert dat je aan het praten was. De video is stil. Dat is een tot waanzin drijvend soort bug, want de twee dingen die het eens zouden moeten zijn — "heeft de mic je gehoord" — zijn het botweg oneens.

De commit die het repareert benoemt de oorzaak precies:

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.

Zo werkt het mechanisch. ScreenRecordingService.setupMonoMixingPipeline() bouwt één AudioContext, een MediaStreamDestination die de audiotrack van de video wordt, een GainNode voor dempen, en een paar ScriptProcessorNodes (via createScriptProcessor) die audioframes naar de VAD/STT-logica pompen. Wanneer macOS de context suspend — wat het kan doen wanneer de app focus verliest tijdens een schermopname — stoppen de delen van de graaf die door de audiohardwareklok worden aangedreven: de MediaStreamDestination ontvangt geen mic-audio meer, dus de opgenomen video valt stil. Maar de onaudioprocess-callback van een ScriptProcessorNode draait op de JavaScript-hoofdthread, en die thread blijft doortikken. Dus de STT-kant blijft transcriptregels produceren op basis van wat het als laatste had, en de twee consumenten van dezelfde microfoon lopen uiteen.

De fix is de suspensie opmerken en ongedaan maken:

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

Dat is de hele correctheidswijziging: hou de state van de context in de gaten, en als die naar suspended zakt terwijl we nog geacht worden op te nemen, roep resume() aan. De release koppelt ook onmute-, onunmute- en onended-listeners aan de MediaStreamTrack van de microfoon — niet om gedrag te veranderen, maar zodat een dempactie op OS-niveau of een apparaatontkoppeling in de logs verschijnt in plaats van een onzichtbare oorzaak van stilte te zijn. Wanneer de hele moeilijkheid van een bug "waarom stopte de audio" is, is het waarneembaar maken van het stoppen van de audio de helft van de fix.

Een vervolgcommit landde een paar minuten later om op te ruimen na die nieuwe listeners: cleanup() zet nu micTrack.onmute/onunmute/onended op null en zet audioContext.onstatechange = null voordat het de graaf afbreekt. De commit-notitie zegt dat het "matching RealtimeAudioCaptureService cleanup pattern" is — de andere audioservice van de app. Onthoud dat detail, want twee maanden later is precies dat patroon wat de volgende release moest herschrijven.

v2.0.2: audio en transcripts sterven na ongeveer zes sessies

De tweede bug leeft in de andere pijplijn, RealtimeAudioCaptureService, en heeft een prachtige vorm: alles werkt perfect, en dan, nadat je een handvol keren binnen één app-sessie de opname bent gestart en gestopt, sterft de audio gewoon... weg. Eerst valt de systeemaudio stil, dan stopt het transcript. Herstart de app en het is weer in orde — tot nog een handvol sessies.

De root-cause-notitie is het waard om volledig te lezen, want het is een schoolvoorbeeld van een bug-achter-de-bug:

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'.

De valstrik is dat de oude code het "verantwoorde" deed. Elke start() maakte een verse AudioContext; elke cleanup() riep er close() op aan. Een resource sluiten als je ermee klaar bent is precies wat je wordt aangeleerd. Er stond zelfs een inline-commentaar dat het geschuif rechtvaardigde — dat het herladen van de AudioWorklet-module per verse context prima was omdat "a fresh context per start() means we always pay this cost once."

Maar Chromium beperkt hoeveel hardware-gedragen AudioContexts een enkele pagina kan bevatten — ongeveer zes — en, cruciaal, close() geeft het slot niet terug op het moment dat je het aanroept. Een gesloten context blijft zijn slot bezetten tot het door de garbage collector is opgeruimd, wat gebeurt wanneer de JS-engine er toevallig aan toekomt. Dus een nauwgezet sluit-elke-keer-beleid is juist het ding dat je tegen het plafond aan laat lopen: zes create/close-cycli later worden alle zes slots vastgehouden door nog-niet-opgeruimde lijken, de zevende new AudioContext() komt dood terug, en elke node die je eraan koppelt doet stilletjes niets.

De fix is de context niet langer als wegwerpbaar behandelen. Maak hem één keer en hergebruik hem voor de levensduur van de service:

  • In setupMonoMixingPipeline, construeer alleen een new AudioContext(...) als er nog geen is of de bestaande closed is. Als hij bestaat maar suspended is, await audioContext.resume() in plaats van een nieuwe te maken.
  • In cleanup, vervang audioContext.close() door audioContext.suspend() (afgeschermd zodat het nooit op een al-gesloten context wordt aangeroepen). De context overleeft de sessie.
  • Omdat de context overleeft, overleeft de geladen AudioWorklet-module mee — de code reset zijn "worklet loaded"-vlag niet meer bij cleanup, dus audioWorklet.addModule(...) wordt één keer voor de hele levensduur van de service betaald in plaats van elke sessie.

De per-sessie-onderdelen — de createMediaStreamSource-nodes voor mic- en systeemaudio, de mixer-worklet, en de stille passthrough die Chromiums systeemaudio-loopback-tap in leven houdt — worden nog steeds elke cyclus opgebouwd en afgebroken. Alleen de AudioContext zelf blijft bestaan, dus er is er hooguit één op enig moment. Het plafond is onbereikbaar omdat je er nooit contexten tegenaan stapelt.

Eén eerlijke noot die de commit over zichzelf maakt: dit is met de hand geverifieerd — "manually verified: 8+ back-to-back sessions keep system audio audible and transcripts flowing (was dead at session 7)" — en de auteur is openhartig dat de Web Audio-levenscyclus "isn't unit-testable in jsdom without a large new mock harness." Dat is het juiste soort eerlijkheid. De fout verschijnt alleen tegen een echt audioapparaat over echte sessies heen; een groene unit-suite zou niets hebben bewezen. De "ongeveer zes" en "dood bij sessie 7" zijn de waarnemingen van het team van waar Chromiums plafond toesloeg, geen gedocumenteerde constante — de code zelf slaat een slag om de arm met een tilde, en dat zou iedereen die het napraat ook moeten doen.

Resume, suspend, close

Zet de twee releases op een rij en de symmetrie is bijna te netjes. Beide gaan over dezelfde drie methodes op hetzelfde soort object, benaderd vanuit tegengestelde richtingen:

  • v1.8.16 leerde resume() aan te roepen. De context werd door het OS onder hem vandaan suspended, en de fix is dat opvangen en hem resumen — hou een context die geacht wordt te draaien, draaiend.
  • v2.0.2 leerde suspend() te gebruiken in plaats van close(). De oude code was te gretig om de context te vernietigen, en de fix is hem suspenden en hergebruiken — hou een context die duur is om opnieuw te maken, in leven.

Het zijn de twee helften van één principe: behandel de AudioContext niet als wegwerp. De ene helft zegt: laat hem niet sterven wanneer dat niet zou moeten; de andere zegt: dood hem niet wanneer het niet hoeft. En er zit een kleine ironie in de tijdlijn — de cleanup van v1.8.16 was geschreven om "match RealtimeAudioCaptureService's cleanup pattern," en toen herschreef v2.0.2 precies dat patroon in RealtimeAudioCaptureService, omdat de close()-elke-keer-gewoonte van het patroon de bug bleek te zijn. De twee services blijven leren van elkaars audio-levenscyclusafhandeling, één fix per keer.

Drie dingen die deze releases leerden

  1. Een AudioContext heeft een toestandsmachine — behandel state als dragend. running, suspended en closed zijn geen trivia. Het OS kan een context ertussen verplaatsen zonder het je te vragen, en verschillende delen van je graaf reageren verschillend (een ScriptProcessorNode op de hoofdthread gaat door; de hardwaregeklokte bestemming niet). Als je onstatechange niet in de gaten houdt, vertrouw je erop dat de context blijft waar je hem liet — en op een echte desktop, onder focuswisselingen en schermopname, doet hij dat niet.
  2. close() is niet free(). De intuïtie dat een resource meteen sluiten altijd juist is, is precies wat de tweede bug veroorzaakte. Wanneer de resource een slot is in een harde, door de browser opgelegde pool die pas bij GC wordt vrijgegeven, is agressief sluiten juist hoe je de pool uitput. Soms is de juiste levenscyclus één-keer-maken-vaak-suspenden, niet maken-en-vernietigen-per-gebruik.
  3. Sommige bugs zijn alleen bereikbaar via de deur van echte hardware en echte tijd. "Sterft na ongeveer zes sessies" en "valt stil als je wegtabt" kunnen niet worden gevangen door een unittest die het audioapparaat mockt — en het team zei dat gewoon ronduit in plaats van het te verdoezelen met een nep-slagende test. Benoem de grenzen van je testharnas; een bug die acht echte sessies nodig heeft om te verschijnen verdient een eerlijk "handmatig geverifieerd," geen groen vinkje dat niets betekent.

Voor het vorige hoofdstuk, een call onderscheiden van een open app (v1.8.15–v1.8.19); en voor de hele boog, de anatomie van software tot in de perfectie uitbrengen.

Gerelateerde Artikelen

De stilte was dragend
Steven
Steven8 min leestijd

De stilte was dragend

De laatste twee releases van GeekBye v1 gaan over dezelfde ongemakkelijke waarheid: real-time transcriptie over een echt netwerk is niet verliesvrij, en de eerlijke zet is te stoppen met doen alsof het dat wel is. v1.8.20 bewaarde een kopie van elke audiochunk op schijf voordat het die tijdens een reconnect weggooide, en begon de gaten in het transcript hardop te markeren. v1.9.0 stopte met het versturen van stilte om bandbreedte te besparen — en ontdekte dat de stilte precies het signaal was dat de transcriber gebruikte om te weten dat een zin was afgelopen. Twee releases over de kosten van dingen weggooien.

Engineering
Audio
Betrouwbaarheid
Een call onderscheiden van een open app
Steven
Steven9 min leestijd

Een call onderscheiden van een open app

GeekBye kan merken dat je aan een videovergadering bent gaan deelnemen en aanbieden om die op te nemen. De detectie blijkt de makkelijke helft — een Swift-binary die elke tien seconden vensternamen leest. De moeilijke helft is precisie: niet afgaan wanneer Zoom slechts openstaat, niet vragen om een vergadering die je al opneemt, en niet de microfoon dempen in het gesprek waar je daadwerkelijk in zit. Drie releases, en elk ervan is een waarborg die moest leren zichzelf niet te verslaan.

Engineering
macOS
Desktop
De backend uit het uploadpad halen
Steven
Steven9 min leestijd

De backend uit het uploadpad halen

GeekBye neemt je scherm op en bewaart de video in je Google Drive. De eerste versie stuurde elke opname onderweg via de eigen servers van GeekBye; een release later ging het bestand rechtstreeks van je machine naar Drive, en de backend werd gedegradeerd tot het vasthouden van één enkele pointer. Het interessante is hoe weinig code de 'directe, hervatbare' versie eigenlijk bevat — want de hervatbaarheid kwam voort uit het verwijderen van een proxy, niet uit het schrijven ervan.

Engineering
Architecture
Desktop