
De tre verbene som holder Web Audio i live
To GeekBye-punktreleaser, to måneder fra hverandre og i to ulike filer, lærte lydkoden vår den samme lærdommen fra motsatte ender: slutt å behandle nettleserens AudioContext som engangsbruk. Den ene releasen lærte å kalle resume() på en kontekst macOS stille hadde suspendert midt i opptaket; den andre lærte å kalle suspend() i stedet for close(), så opptak på rad slutter å dundre inn i Chromiums tak på omtrent seks kontekster. Resume, suspend, close — det er hele handlingen.
Mesteparten av tiden oppfører en Web Audio-AudioContext seg som et vanlig objekt: du konstruerer én, kobler sammen noen noder og glemmer den. To GeekBye-releaser er hva som skjedde da den antakelsen brast — to ganger, i to ulike deler av appen, to måneder fra hverandre. Ingen av dem var planlagt som en oppfølger til den andre. Men still dem side om side, og de forteller én historie: nettleserens AudioContext er ikke et engangsobjekt. Det er en knapp, operativsystemeid ressurs med en ekte tilstandsmaskin, og den tilstandsmaskinens tre verb — resume, suspend, close — er hele handlingen i begge releasene.
GeekBye har to separate lydpipelines, og det er derfor den samme lærdommen måtte læres to ganger. Den ene er videoopptaksveien (ScreenRecordingService), som bygger en Web Audio-graf for å mikse mikrofon- og systemlyd sammen til en opptatt videofil. Den andre er sanntidstranskripsjonsveien (RealtimeAudioCaptureService), som bygger sin egen graf for å mate tale-til-tekst. Hver har sin egen AudioContext. Hver av releasene nedenfor fikser en av dem.
v1.8.16: videoen ble stille, men transkripsjonen ikke
Den første buggen er et splittet sinn. Du tar opp et møte, du bytter bort til en annen app et øyeblikk, og noe subtilt skjer: videofilen mister mikrofonen din mens live-transkripsjonen fortsetter å rulle som om ingenting hadde endret seg. Transkripsjonen sverger på at du snakket. Videoen er stille. Det er en vanviddsfremkallende slags bug, fordi de to tingene som burde være enige — "hørte mikrofonen deg" — er rett og slett uenige.
Committen som fikser det navngir årsaken presist:
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 som blir videoens lydspor, en GainNode for mute, og et par ScriptProcessorNode-er (via createScriptProcessor) som pumper lydrammer til VAD/STT-logikken. Når macOS suspenderer konteksten — noe den kan gjøre når appen mister fokus under et skjermopptak — stopper de delene av grafen som drives av lydmaskinvarens klokke: MediaStreamDestination slutter å motta mikrofonlyd, så den opptatte videoen blir stille. Men en ScriptProcessorNodes onaudioprocess-callback kjører på JavaScript-hovedtråden, og den tråden fortsetter å tikke. Så STT-siden fortsetter å produsere transkripsjonsoppføringer fra det den sist hadde, og de to forbrukerne av den samme mikrofonen divergerer.
Fiksen er å legge merke til suspenderingen og angre den:
audioContext.onstatechange = () => {
if (audioContext.state === 'suspended' && this.status === 'recording') {
logger.info('AudioContext suspended during recording, resuming')
audioContext.resume()
}
}
Det er hele korrekthetsendringen: overvåk kontekstens state, og hvis den faller til suspended mens vi fortsatt antas å ta opp, kall resume(). Releasen kobler også på onmute-, onunmute- og onended-lyttere på mikrofonens MediaStreamTrack — ikke for å endre atferd, men slik at en mute på OS-nivå eller en enhetsfrakobling dukker opp i loggene i stedet for å være en usynlig årsak til stillhet. Når en bugs hele vanskelighet er "hvorfor stoppet lyden", er det halve fiksen å gjøre lydens opphør observerbart.
En oppfølgende commit landet et par minutter senere for å rydde opp etter de nye lytterne: cleanup() nuller nå ut micTrack.onmute/onunmute/onended og setter audioContext.onstatechange = null før den river ned grafen. Commit-notatet sier at den "matcher RealtimeAudioCaptureService oppryddingsmønster" — appens andre lydtjeneste. Hold fast på den detaljen, for to måneder senere er det nøyaktig det mønsteret den neste releasen måtte skrive om.
v2.0.2: lyd og transkripsjoner dør etter omtrent seks økter
Den andre buggen bor i den andre pipelinen, RealtimeAudioCaptureService, og den har en herlig form: alt fungerer perfekt, og så etter at du har startet og stoppet opptak en håndfull ganger i én app-økt, bare... dør lyden. Først blir systemlyden stille, så stopper transkripsjonen. Start appen på nytt, og den er fin igjen — til enda en håndfull økter.
Rotårsaksnotatet er verdt å lese i sin helhet, for det er en lærebok-bug-bak-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'.
Fellen er at den gamle koden gjorde det "ansvarlige". Hvert start() opprettet en fersk AudioContext; hvert cleanup() kalte close() på den. Å lukke en ressurs når du er ferdig med den er nøyaktig det du blir lært å gjøre. Det var til og med en inline-kommentar som rasjonaliserte utskiftingen — at å laste AudioWorklet-modulen på nytt per ferske kontekst var greit fordi "en fersk kontekst per start() betyr at vi alltid betaler denne kostnaden én gang."
Men Chromium begrenser hvor mange maskinvarestøttede AudioContext-er en enkelt side kan holde — omtrent seks — og, avgjørende, close() gir ikke plassen tilbake i det øyeblikket du kaller den. En lukket kontekst fortsetter å oppta plassen sin til den blir garbage-collected, noe som skjer når JS-motoren kommer til det. Så en samvittighetsfull lukk-hver-gang-policy er nøyaktig det som fører deg inn i taket: seks opprett/lukk-sykluser senere holdes alle seks plasser av ennå ikke-innsamlede lik, den syvende new AudioContext() kommer tilbake død, og hver node du fester til den gjør stille ingenting.
Fiksen er å slutte å behandle konteksten som engangsbruk. Opprett den én gang og gjenbruk den i tjenestens levetid:
- I
setupMonoMixingPipeline, konstruer bare ennew AudioContext(...)hvis det ikke allerede finnes en eller den eksisterende erclosed. Hvis den finnes, men ersuspended, kallawait audioContext.resume()i stedet for å lage en ny. - I
cleanup, erstattaudioContext.close()medaudioContext.suspend()(skjermet så den aldri kalles på en allerede lukket kontekst). Konteksten overlever økten. - Fordi konteksten overlever, overlever den lastede
AudioWorklet-modulen med den — koden nullstiller ikke lenger sitt "worklet lastet"-flagg ved opprydding, såaudioWorklet.addModule(...)betales én gang for hele tjenestens levetid i stedet for hver økt.
De øktspesifikke bitene — createMediaStreamSource-nodene for mikrofon og systemlyd, mikser-workleten, og den stille passthrough-en som holder Chromiums systemlyd-loopback-avtapping i live — bygges og rives fortsatt ned hver syklus. Bare AudioContext selv består, så det finnes høyst én om gangen. Taket er uoppnåelig fordi du aldri stabler kontekster mot det.
Ett ærlig notat committen gjør om seg selv: dette ble verifisert for hånd — "manuelt verifisert: 8+ økter på rad holder systemlyden hørbar og transkripsjoner flytende (var død ved økt 7)" — og forfatteren er åpen om at Web Audio-livssyklusen "ikke er enhetstestbar i jsdom uten en stor ny mock-rigg". Det er den rette slags ærlighet. Feilen dukker bare opp mot en ekte lydenhet over ekte økter; en grønn enhetstestpakke ville ikke ha bevist noe. "Omtrent seks" og "død ved økt 7" er teamets observasjoner av hvor Chromiums tak bet, ikke en dokumentert konstant — koden selv tar forbehold med en tilde, og det bør enhver som gjentar den også.
Resume, suspend, close
Still de to releasene opp, og symmetrien er nesten for pen. Begge handler om de samme tre metodene på den samme typen objekt, angrepet fra motsatte retninger:
- v1.8.16 lærte å kalle
resume(). Konteksten blesuspendedunder føttene på OS-et, og fiksen er å fange det og gjenoppta — holde en kontekst som skal kjøre, kjørende. - v2.0.2 lærte å kalle
suspend()i stedet forclose(). Den gamle koden var for ivrig etter å ødelegge konteksten, og fiksen er å suspendere og gjenbruke den — holde en kontekst som er dyr å gjenskape, i live.
De er de to halvdelene av ett enkelt prinsipp: slutt å behandle AudioContext som noe du kaster. Den ene halvdelen sier: ikke la den dø når den ikke burde; den andre sier: ikke drep den når du ikke må. Og det er en liten ironi i tidslinjen — v1.8.16s opprydding ble skrevet for å "matche RealtimeAudioCaptureServices oppryddingsmønster", og så skrev v2.0.2 om nøyaktig det mønsteret i RealtimeAudioCaptureService, fordi mønsterets close()-hver-gang-vane viste seg å være buggen. De to tjenestene fortsetter å lære av hverandres håndtering av lydlivssyklusen, én fiks om gangen.
Tre ting disse releasene lærte oss
- En
AudioContexthar en tilstandsmaskin — behandlestatesom bærende.running,suspendedogcloseder ikke bagateller. OS-et kan flytte en kontekst mellom dem uten å spørre deg, og ulike deler av grafen din reagerer ulikt (enScriptProcessorNodepå hovedtråden fortsetter; det maskinvareklokkede målet gjør ikke). Hvis du ikke overvåkeronstatechange, stoler du på at konteksten blir der du forlot den — og på et ekte skrivebord, under fokusbytter og skjermopptak, gjør den ikke det. close()er ikkefree(). Intuisjonen om at det alltid er riktig å lukke en ressurs raskt, er nøyaktig det som forårsaket den andre buggen. Når ressursen er en plass i et hardt, nettleserpålagt basseng som bare gjenvinnes ved GC-tid, er aggressiv lukking måten du tømmer bassenget på. Noen ganger er den riktige livssyklusen opprett-én-gang-suspender-ofte, ikke opprett-og-ødelegg-per-bruk.- Noen bugs er bare nåbare gjennom døren av ekte maskinvare og ekte tid. "Dør etter omtrent seks økter" og "blir stille når du bytter bort" kan ikke fanges av en enhetstest som mocker lydenheten — og teamet sa det rett ut i stedet for å dekke over det med en falsk-bestått test. Navngi grensene for testriggen din; en bug som trenger åtte ekte økter for å dukke opp, fortjener et ærlig "manuelt verifisert", ikke en grønn hake som ikke betyr noe.
For det forrige kapittelet, å skille en samtale fra en åpen app (v1.8.15–v1.8.19); og for hele buen, anatomien i å shippe programvare til perfeksjon.