
De tre verben som håller Web Audio vid liv
Två GeekBye-punktreleaser, två månader isär och i två olika filer, lärde vår ljudkod samma läxa från motsatta håll: sluta behandla webbläsarens AudioContext som slit-och-släng. Den ena releasen lärde sig att anropa resume() på en kontext som macOS tyst hade suspenderat mitt i inspelningen; den andra lärde sig att anropa suspend() i stället för close() så att inspelningar i följd slutar dundra in i Chromiums tak på ungefär sex kontexter. Resume, suspend, close — det är hela handlingen.
För det mesta beter sig en Web Audio-AudioContext som ett vanligt objekt: du konstruerar en, kopplar ihop några noder och glömmer bort den. Två GeekBye-releaser är vad som hände när det antagandet brast — två gånger, i två olika delar av appen, två månader isär. Ingendera planerades som en uppföljare till den andra. Men ställ dem sida vid sida och de berättar en historia: webbläsarens AudioContext är inte ett slit-och-släng-objekt. Det är en knapp, operativsystemsägd resurs med en riktig tillståndsmaskin, och den tillståndsmaskinens tre verb — resume, suspend, close — är hela handlingen i båda releaserna.
GeekBye har två separata ljudpipelines, vilket är varför samma läxa fick läras två gånger. Den ena är videoinspelningsvägen (ScreenRecordingService), som bygger en Web Audio-graf för att mixa mikrofon- och systemljud till en inspelad videofil. Den andra är realtidstranskriberingsvägen (RealtimeAudioCaptureService), som bygger sin egen graf för att mata tal-till-text. Var och en har sin egen AudioContext. Var och en av releaserna nedan fixar en av dem.
v1.8.16: videon blev tyst, men transkriptet gjorde det inte
Den första buggen är ett kluvet sinne. Du spelar in ett möte, du växlar bort till en annan app ett ögonblick, och något subtilt händer: videofilen tappar din mikrofon medan livetranskriptet fortsätter rulla som om ingenting förändrats. Transkriptet svär på att du talade. Videon är tyst. Det är en vansinnesframkallande sorts bugg eftersom de två saker som borde vara överens — ”hörde mikrofonen dig” — är rakt av oense.
Committen som fixar det namnger orsaken exakt:
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.
Här är mekaniken. ScreenRecordingService.setupMonoMixingPipeline() bygger en AudioContext, en MediaStreamDestination som blir videons ljudspår, en GainNode för mute, och ett par ScriptProcessorNode:er (via createScriptProcessor) som pumpar ljudrutor till VAD/STT-logiken. När macOS suspenderar kontexten — vilket den kan göra när appen tappar fokus under en skärminspelning — stannar de delar av grafen som drivs av ljudhårdvarans klocka: MediaStreamDestination slutar ta emot mikrofonljud, så den inspelade videon tystnar. Men en ScriptProcessorNode:s onaudioprocess-callback körs på JavaScript-huvudtråden, och den tråden fortsätter ticka. Så STT-sidan fortsätter producera transkriptposter från vad den senast hade, och de två konsumenterna av samma mikrofon divergerar.
Fixen är att märka suspenderingen och ångra den:
audioContext.onstatechange = () => {
if (audioContext.state === 'suspended' && this.status === 'recording') {
logger.info('AudioContext suspended during recording, resuming')
audioContext.resume()
}
}
Det är hela korrekthetsändringen: bevaka kontextens state, och om den faller till suspended medan vi fortfarande antas spela in, anropa resume(). Releasen kopplar också in onmute-, onunmute- och onended-lyssnare på mikrofonens MediaStreamTrack — inte för att ändra beteende, utan så att en mute på OS-nivå eller en enhetsfrånkoppling dyker upp i loggarna i stället för att vara en osynlig orsak till tystnad. När en buggs hela svårighet är ”varför slutade ljudet” är det halva fixen att göra ljudets upphörande observerbart.
En uppföljande commit landade ett par minuter senare för att städa efter de nya lyssnarna: cleanup() nollar nu micTrack.onmute/onunmute/onended och sätter audioContext.onstatechange = null innan den river ner grafen. Commit-noten säger att den ”matchar RealtimeAudioCaptureService städmönster” — appens andra ljudtjänst. Håll fast vid den detaljen, för två månader senare är det exakt det mönstret som nästa release fick skriva om.
v2.0.2: ljud och transkript dör efter ungefär sex sessioner
Den andra buggen bor i den andra pipelinen, RealtimeAudioCaptureService, och den har en underbar form: allt fungerar perfekt, och sedan efter att du startat och stoppat inspelning en handfull gånger under en appsession bara... dör ljudet. Först tystnar systemljudet, sedan slutar transkriptet. Starta om appen och det är bra igen — tills ännu en handfull sessioner.
Rotorsaksnoten är värd att läsa i sin helhet, för den är en lärobok-bugg-bakom-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ällan är att den gamla koden gjorde det ”ansvarsfulla”. Varje start() skapade en färsk AudioContext; varje cleanup() anropade close() på den. Att stänga en resurs när du är klar med den är precis vad du lärs att göra. Det fanns till och med en inline-kommentar som rationaliserade omsättningen — att ladda om AudioWorklet-modulen per färsk kontext var okej eftersom ”en färsk kontext per start() betyder att vi alltid betalar denna kostnad en gång.”
Men Chromium begränsar hur många hårdvarustödda AudioContext:er en enda sida kan hålla — ungefär sex — och, avgörande nog, close() lämnar inte tillbaka platsen i samma ögonblick som du anropar den. En stängd kontext fortsätter uppta sin plats tills den skräpsamlas, vilket sker närhelst JS-motorn kommer runt till det. Så en samvetsgrann stäng-varje-gång-policy är precis det som går rakt in i taket: sex skapa/stäng-cykler senare hålls alla sex platser av ännu icke-insamlade lik, den sjunde new AudioContext() kommer tillbaka död, och varje nod du fäster vid den gör tyst ingenting.
Fixen är att sluta behandla kontexten som slit-och-släng. Skapa den en gång och återanvänd den under tjänstens livstid:
- I
setupMonoMixingPipeline, konstruera bara ennew AudioContext(...)om det inte redan finns en eller den befintliga ärclosed. Om den finns men ärsuspended, anropaawait audioContext.resume()i stället för att göra en ny. - I
cleanup, ersättaudioContext.close()medaudioContext.suspend()(skyddat så att det aldrig anropas på en redan stängd kontext). Kontexten överlever sessionen. - Eftersom kontexten överlever, överlever den laddade
AudioWorklet-modulen med den — koden återställer inte längre sin ”worklet laddad”-flagga vid städning, såaudioWorklet.addModule(...)betalas en gång för hela tjänstens livstid i stället för varje session.
De sessionsspecifika bitarna — createMediaStreamSource-noderna för mikrofon och systemljud, mixer-workleten, och den tysta passthrough som håller Chromiums systemljuds-loopback-tapp vid liv — byggs och rivs fortfarande varje cykel. Bara AudioContext själv består, så det finns som mest en i taget. Taket är onåbart eftersom du aldrig staplar kontexter mot det.
En ärlig notering committen gör om sig själv: detta verifierades för hand — ”manuellt verifierat: 8+ sessioner i följd håller systemljudet hörbart och transkript flödande (var dött vid session 7)” — och författaren är uppriktig med att Web Audio-livscykeln ”inte är enhetstestbar i jsdom utan en stor ny mock-rigg”. Det är rätt sorts ärlighet. Felet uppträder bara mot en riktig ljudenhet över riktiga sessioner; en grön enhetssvit hade inte bevisat någonting. ”Ungefär sex” och ”dött vid session 7” är teamets observationer av var Chromiums tak bet, inte en dokumenterad konstant — koden själv garderar sig med ett tildetecken, och det bör var och en som upprepar den också.
Resume, suspend, close
Ställ upp de två releaserna och symmetrin är nästan för prydlig. Båda handlar om samma tre metoder på samma sorts objekt, angripna från motsatta riktningar:
- v1.8.16 lärde sig att anropa
resume(). Kontexten blevsuspendedunder fötterna av OS:et, och fixen är att fånga det och återuppta — hålla en kontext som ska köra, körande. - v2.0.2 lärde sig att anropa
suspend()i stället förclose(). Den gamla koden var för ivrig att förstöra kontexten, och fixen är att suspendera och återanvända den — hålla en kontext som är dyr att återskapa, vid liv.
De är de två halvorna av en enda princip: sluta behandla AudioContext som slit-och-släng. Den ena halvan säger låt den inte dö när den inte borde; den andra säger döda den inte när du inte måste. Och det finns en liten ironi i tidslinjen — v1.8.16:s städning skrevs för att ”matcha RealtimeAudioCaptureService:s städmönster”, och sedan skrev v2.0.2 om exakt det mönstret i RealtimeAudioCaptureService, eftersom mönstrets close()-varje-gång-vana visade sig vara buggen. De två tjänsterna fortsätter lära sig av varandras hantering av ljudlivscykeln, en fix i taget.
Tre saker dessa releaser lärde ut
- En
AudioContexthar en tillståndsmaskin — behandlastatesom bärande.running,suspendedochclosedär inte trivialiteter. OS:et kan flytta en kontext mellan dem utan att fråga dig, och olika delar av din graf reagerar olika (enScriptProcessorNodepå huvudtråden fortsätter; den hårdvaruklockade destinationen gör det inte). Om du inte bevakaronstatechangelitar du på att kontexten stannar där du lämnade den — och på ett riktigt skrivbord, under fokusbyten och skärminspelning, gör den inte det. close()är intefree(). Intuitionen att prompt stänga en resurs alltid är korrekt är precis det som orsakade den andra buggen. När resursen är en plats i en hård, webbläsarpåtvingad pool som bara återvinns vid GC-tid är aggressiv stängning hur du förbrukar poolen. Ibland är rätt livscykel skapa-en-gång-suspendera-ofta, inte skapa-och-förstör-per-användning.- Vissa buggar är bara nåbara genom dörren av riktig hårdvara och riktig tid. ”Dör efter ungefär sex sessioner” och ”blir tyst när du växlar bort” kan inte fångas av ett enhetstest som mockar ljudenheten — och teamet sa det rakt ut i stället för att dölja det med ett falskt-godkänt test. Namnge gränserna för din testrigg; en bugg som behöver åtta riktiga sessioner för att dyka upp förtjänar ett ärligt ”manuellt verifierat”, inte en grön bock som inte betyder någonting.
För det föregående kapitlet, att skilja ett samtal från en öppen app (v1.8.15–v1.8.19); och för hela bågen, anatomin i att skeppa mjukvara till perfektion.