
De tre verber, der holder Web Audio i live
To GeekBye-punktreleases, to måneder fra hinanden og i to forskellige filer, lærte vores lydkode den samme lektie fra modsatte ender: hold op med at behandle browserens AudioContext som engangsbrug. Den ene release lærte at kalde resume() på en kontekst, macOS stille havde suspenderet midt i optagelsen; den anden lærte at kalde suspend() i stedet for close(), så optagelser i træk holder op med at buldre ind i Chromiums loft på cirka seks kontekster. Resume, suspend, close — det er hele handlingen.
Det meste af tiden opfører en Web Audio-AudioContext sig som et almindeligt objekt: du konstruerer én, kobler nogle noder sammen og glemmer den. To GeekBye-releases er, hvad der skete, da den antagelse brast — to gange, i to forskellige dele af appen, to måneder fra hinanden. Ingen af dem var planlagt som en opfølger til den anden. Men stil dem side om side, og de fortæller én historie: browserens AudioContext er ikke et engangsobjekt. Det er en knap, operativsystemsejet ressource med en rigtig tilstandsmaskine, og den tilstandsmaskines tre verber — resume, suspend, close — er hele handlingen i begge releases.
GeekBye har to separate lydpipelines, hvilket er grunden til, at den samme lektie måtte læres to gange. Den ene er videooptagelsesvejen (ScreenRecordingService), der bygger en Web Audio-graf til at mixe mikrofon- og systemlyd sammen til en optaget videofil. Den anden er realtidstransskriptionsvejen (RealtimeAudioCaptureService), der bygger sin egen graf til at fodre tale-til-tekst. Hver har sin egen AudioContext. Hver af releases nedenfor fikser en af dem.
v1.8.16: videoen blev tavs, men transskriptionen gjorde ikke
Den første bug er et spaltet sind. Du optager et møde, du skifter væk til en anden app et øjeblik, og noget subtilt sker: videofilen mister din mikrofon, mens live-transskriptionen bliver ved med at rulle, som om intet var ændret. Transskriptionen sværger, at du talte. Videoen er tavs. Det er en vanvidsfremkaldende slags bug, fordi de to ting, der burde være enige — "hørte mikrofonen dig" — er direkte uenige.
Committen, der fikser det, navngiver årsagen præcist:
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.
Her er mekanikken. ScreenRecordingService.setupMonoMixingPipeline() bygger én AudioContext, en MediaStreamDestination, der bliver videoens lydspor, en GainNode til mute, og et par ScriptProcessorNode:r (via createScriptProcessor), der pumper lydrammer til VAD/STT-logikken. Når macOS suspenderer konteksten — hvilket den kan gøre, når appen mister fokus under en skærmoptagelse — stopper de dele af grafen, der drives af lydhardwarens ur: MediaStreamDestination holder op med at modtage mikrofonlyd, så den optagede video bliver stille. Men en ScriptProcessorNodes onaudioprocess-callback kører på JavaScript-hovedtråden, og den tråd bliver ved med at tikke. Så STT-siden bliver ved med at producere transskriptionsposter fra det, den sidst havde, og de to forbrugere af den samme mikrofon divergerer.
Fixet er at bemærke suspenderingen og fortryde den:
audioContext.onstatechange = () => {
if (audioContext.state === 'suspended' && this.status === 'recording') {
logger.info('AudioContext suspended during recording, resuming')
audioContext.resume()
}
}
Det er hele korrekthedsændringen: hold øje med kontekstens state, og hvis den falder til suspended, mens vi stadig antages at optage, kald resume(). Releasen kobler også onmute-, onunmute- og onended-lyttere på mikrofonens MediaStreamTrack — ikke for at ændre adfærd, men så en mute på OS-niveau eller en enhedsfrakobling dukker op i loggene i stedet for at være en usynlig årsag til tavshed. Når en bugs hele vanskelighed er "hvorfor stoppede lyden", er det halvdelen af fixet at gøre lydens ophør observerbart.
En opfølgende commit landede et par minutter senere for at rydde op efter de nye lyttere: cleanup() nuller nu micTrack.onmute/onunmute/onended ud og sætter audioContext.onstatechange = null, før den river grafen ned. Commit-noten siger, at den "matcher RealtimeAudioCaptureService oprydningsmønster" — appens anden lydtjeneste. Hold fast i den detalje, for to måneder senere er det præcis det mønster, den næste release måtte skrive om.
v2.0.2: lyd og transskriptioner dør efter cirka seks sessioner
Den anden bug bor i den anden pipeline, RealtimeAudioCaptureService, og den har en vidunderlig form: alt fungerer perfekt, og så efter at du har startet og stoppet optagelse en håndfuld gange i én app-session, bare... dør lyden. Først bliver systemlyden stille, så stopper transskriptionen. Genstart appen, og den er fin igen — indtil endnu en håndfuld sessioner.
Rodårsagsnoten er værd at læse i sin helhed, for den er en lærebogs-bug-bag-buggen:
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'.
Fælden er, at den gamle kode gjorde det "ansvarlige". Hvert start() oprettede en frisk AudioContext; hvert cleanup() kaldte close() på den. At lukke en ressource, når du er færdig med den, er præcis, hvad du læres at gøre. Der var endda en inline-kommentar, der rationaliserede udskiftningen — at genindlæse AudioWorklet-modulet per frisk kontekst var fint, fordi "en frisk kontekst per start() betyder, at vi altid betaler denne omkostning én gang."
Men Chromium begrænser, hvor mange hardwarestøttede AudioContext:er en enkelt side kan holde — cirka seks — og, afgørende, close() giver ikke pladsen tilbage i det øjeblik, du kalder den. En lukket kontekst bliver ved med at optage sin plads, indtil den bliver garbage-collected, hvilket sker, når JS-motoren når til det. Så en samvittighedsfuld luk-hver-gang-politik er præcis det, der fører dig ind i loftet: seks opret/luk-cyklusser senere holdes alle seks pladser af endnu ikke-indsamlede lig, den syvende new AudioContext() kommer tilbage død, og hver node, du fæstner til den, gør stille ingenting.
Fixet er at holde op med at behandle konteksten som engangsbrug. Opret den én gang, og genbrug den i tjenestens levetid:
- I
setupMonoMixingPipeline, konstruer kun ennew AudioContext(...), hvis der ikke allerede er en, eller den eksisterende erclosed. Hvis den findes, men ersuspended, kaldawait audioContext.resume()i stedet for at lave en ny. - I
cleanup, erstataudioContext.close()medaudioContext.suspend()(afskærmet, så det aldrig kaldes på en allerede lukket kontekst). Konteksten overlever sessionen. - Fordi konteksten overlever, overlever det indlæste
AudioWorklet-modul med den — koden nulstiller ikke længere sit "worklet indlæst"-flag ved oprydning, såaudioWorklet.addModule(...)betales én gang for hele tjenestens levetid frem for hver session.
De sessionsspecifikke stykker — createMediaStreamSource-noderne til mikrofon og systemlyd, mixer-workleten, og den tavse passthrough, der holder Chromiums systemlyds-loopback-aftapning i live — bygges og rives stadig ned hver cyklus. Kun AudioContext selv består, så der findes højst én ad gangen. Loftet er uopnåeligt, fordi du aldrig stabler kontekster op mod det.
En ærlig bemærkning, committen gør om sig selv: dette blev verificeret i hånden — "manuelt verificeret: 8+ sessioner i træk holder systemlyden hørbar og transskriptioner flydende (var død ved session 7)" — og forfatteren er ærlig om, at Web Audio-livscyklussen "ikke er unit-testbar i jsdom uden en stor ny mock-rig". Det er den rigtige slags ærlighed. Fejlen viser sig kun mod en rigtig lydenhed på tværs af rigtige sessioner; en grøn unit-suite ville ikke have bevist noget. "Cirka seks" og "død ved session 7" er teamets observationer af, hvor Chromiums loft bed, ikke en dokumenteret konstant — koden selv tager forbehold med en tilde, og det bør enhver, der gentager den, også.
Resume, suspend, close
Stil de to releases op, og symmetrien er næsten for pæn. Begge handler om de samme tre metoder på den samme slags objekt, grebet an fra modsatte retninger:
- v1.8.16 lærte at kalde
resume(). Konteksten blevsuspendedunder fødderne af OS'et, og fixet er at fange det og genoptage — holde en kontekst, der skal køre, kørende. - v2.0.2 lærte at kalde
suspend()i stedet forclose(). Den gamle kode var for ivrig efter at ødelægge konteksten, og fixet er at suspendere og genbruge den — holde en kontekst, der er dyr at genskabe, i live.
De er de to halvdele af ét enkelt princip: hold op med at behandle AudioContext som noget, der kan smides væk. Den ene halvdel siger, lad den ikke dø, når den ikke burde; den anden siger, dræb den ikke, når du ikke er nødt til det. Og der er en lille ironi i tidslinjen — v1.8.16's oprydning blev skrevet for at "matche RealtimeAudioCaptureService's oprydningsmønster", og så skrev v2.0.2 præcis det mønster om i RealtimeAudioCaptureService, fordi mønstrets close()-hver-gang-vane viste sig at være buggen. De to tjenester bliver ved med at lære af hinandens håndtering af lydlivscyklussen, ét fix ad gangen.
Tre ting, disse releases lærte os
- En
AudioContexthar en tilstandsmaskine — behandlstatesom bærende.running,suspendedogcloseder ikke trivialiteter. OS'et kan flytte en kontekst mellem dem uden at spørge dig, og forskellige dele af din graf reagerer forskelligt (enScriptProcessorNodepå hovedtråden kører videre; den hardware-urstyrede destination gør ikke). Hvis du ikke holder øje medonstatechange, stoler du på, at konteksten bliver, hvor du efterlod den — og på et rigtigt skrivebord, under fokusskift og skærmoptagelse, gør den ikke. close()er ikkefree(). Intuitionen om, at det altid er korrekt at lukke en ressource straks, er præcis det, der forårsagede den anden bug. Når ressourcen er en plads i en hård, browserpålagt pulje, der kun genvindes ved GC-tid, er aggressiv lukning måden, du udtømmer puljen på. Nogle gange er den rigtige livscyklus opret-én-gang-suspender-ofte, ikke opret-og-ødelæg-per-brug.- Nogle bugs er kun nåbare gennem døren af rigtig hardware og rigtig tid. "Dør efter cirka seks sessioner" og "bliver tavs, når du skifter væk" kan ikke fanges af en unit-test, der mocker lydenheden — og teamet sagde det ligeud i stedet for at dække over det med en falsk-bestået test. Navngiv grænserne for din testrig; en bug, der kræver otte rigtige sessioner for at dukke op, fortjener et ærligt "manuelt verificeret", ikke et grønt flueben, der ikke betyder noget.
For det forrige kapitel, at skelne et opkald fra en åben app (v1.8.15–v1.8.19); og for hele buen, anatomien i at shippe software til perfektion.