
Tri slovesá, ktoré udržujú Web Audio nažive
Dve bodové vydania GeekBye, dva mesiace od seba a v dvoch rôznych súboroch, naučili náš audio kód tú istú lekciu z opačných koncov: prestaň zaobchádzať s prehliadačovým AudioContext ako s jednorazovým. Jedno vydanie sa naučilo resume() kontextu, ktorý macOS potichu pozastavil uprostred nahrávania; druhé sa naučilo suspend() namiesto close(), aby na seba nadväzujúce relácie prestali narážať do Chromiom nastaveného stropu zhruba šiestich kontextov. Resume, suspend, close — to je celá zápletka.
Väčšinu času sa prehliadačový AudioContext správa ako obyčajný objekt: skonštruuješ jeden, zapojíš pár uzlov a zabudneš naň. Dve vydania GeekBye sú to, čo sa stalo, keď sa tento predpoklad zlomil — dvakrát, v dvoch rôznych častiach aplikácie, dva mesiace od seba. Ani jedno nebolo plánované ako nadväznosť na druhé. Ale polož ich vedľa seba a rozpovedia jeden príbeh: prehliadačový AudioContext nie je jednorazový objekt. Je to vzácny, operačným systémom vlastnený zdroj so skutočným stavovým automatom, a tri slovesá toho automatu — resume, suspend, close — sú celou zápletkou oboch vydaní.
GeekBye má dva samostatné audio reťazce, a práve preto sa tú istú lekciu bolo treba naučiť dvakrát. Jeden je cesta nahrávania videa (ScreenRecordingService), ktorá stavia graf Web Audio, aby zmiešala mikrofón a systémový zvuk do nahrávaného video súboru. Druhý je cesta prepisu v reálnom čase (RealtimeAudioCaptureService), ktorá stavia vlastný graf napájajúci prevod reči na text. Každý má svoj AudioContext. Každé vydanie nižšie opravuje jeden z nich.
v1.8.16: video stíchlo, ale prepis nie
Prvá chyba je rozdvojenie mysle. Nahrávaš stretnutie, na chvíľu prepneš do inej aplikácie a stane sa čosi jemné: video súbor stratí tvoj mikrofón, kým živý prepis sa posúva ďalej, akoby sa nič nezmenilo. Prepis prisahá, že si hovoril. Video mlčí. To je na zošalenie druh chyby, lebo dve veci, ktoré by sa mali zhodnúť — „počul ťa mikrofón" — sa priam nezhodujú.
Commit, ktorý to opravuje, presne pomenúva príčinu:
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.
Tu je mechanika. ScreenRecordingService.setupMonoMixingPipeline() stavia jeden AudioContext, MediaStreamDestination, ktorý sa stane audio stopou videa, GainNode na stlmenie a pár uzlov ScriptProcessorNode (cez createScriptProcessor), ktoré pumpujú audio snímky do VAD/STT logiky. Keď macOS pozastaví kontext — čo môže urobiť, keď aplikácia stratí fokus počas nahrávania obrazovky — časti grafu poháňané hardvérovými hodinami zvuku sa zastavia: MediaStreamDestination prestane prijímať zvuk mikrofónu, takže nahrávané video stíchne. Ale callback onaudioprocess uzla ScriptProcessorNode beží na hlavnom vlákne JavaScriptu a to vlákno tiká ďalej. Takže STT strana ďalej produkuje záznamy prepisu z toho, čo mala naposledy, a dvaja konzumenti toho istého mikrofónu sa rozchádzajú.
Oprava je všimnúť si pozastavenie a vrátiť ho späť:
audioContext.onstatechange = () => {
if (audioContext.state === 'suspended' && this.status === 'recording') {
logger.info('AudioContext suspended during recording, resuming')
audioContext.resume()
}
}
To je celá zmena korektnosti: sleduj state kontextu, a ak spadne na suspended, kým máme ešte nahrávať, volaj resume(). Vydanie tiež zapája poslucháčov onmute, onunmute a onended na MediaStreamTrack mikrofónu — nie aby zmenilo správanie, ale aby sa stlmenie na úrovni OS alebo odpojenie zariadenia objavilo v logoch namiesto toho, aby bolo neviditeľnou príčinou ticha. Keď celá ťažkosť chyby je „prečo sa zvuk zastavil", urobiť zastavenie zvuku pozorovateľným je polovica opravy.
Nadväzujúci commit dosadol o pár minút neskôr, aby upratal po tých nových poslucháčoch: cleanup() teraz vynuluje micTrack.onmute/onunmute/onended a nastaví audioContext.onstatechange = null, kým rozoberie graf. Poznámka commitu hovorí, že „dopĺňa vzor čistenia RealtimeAudioCaptureService" — druhej audio služby aplikácie. Podrž si tento detail, lebo o dva mesiace neskôr práve tento vzor muselo ďalšie vydanie prepísať.
v2.0.2: zvuk a prepisy umierajú po zhruba šiestich reláciách
Druhá chyba žije v druhom reťazci, RealtimeAudioCaptureService, a má nádherný tvar: všetko funguje dokonale a potom, keď si spustil a zastavil nahrávanie zopár ráz v jednej relácii aplikácie, zvuk jednoducho... umrie. Najprv stíchne systémový zvuk, potom sa zastaví prepis. Reštartuj aplikáciu a je to zas v poriadku — až do ďalšej hŕstky relácií.
Poznámku o koreňovej príčine sa oplatí prečítať celú, lebo je to učebnicová chyba-za-chybou:
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'.
Pasca je v tom, že starý kód robil „zodpovednú" vec. Každý start() vytvoril čerstvý AudioContext; každý cleanup() naň volal close(). Zatvoriť zdroj, keď si s ním skončil, je presne to, čo ťa učia. Bol tam dokonca komentár v kóde racionalizujúci to plytvanie — že prenačítať modul AudioWorklet na každý čerstvý kontext je v poriadku, lebo „čerstvý kontext na každý start() znamená, že túto cenu vždy platíme raz".
Ale Chromium obmedzuje, koľko hardvérovo podporených AudioContext môže jedna stránka držať — zhruba šesť — a, čo je kľúčové, close() nevráti slot v okamihu, keď ho zavoláš. Zatvorený kontext ďalej obsadzuje svoj slot, kým ho nezozbiera GC, čo sa stane, keď sa k tomu JS engine dostane. Takže úzkostlivá politika zatvárať-zakaždým je presne tá vec, ktorá ťa privedie pod strop: o šesť cyklov vytvor/zatvor neskôr všetkých šesť slotov držia ešte nezozbierané mŕtvoly, siedmy new AudioContext() sa vráti mŕtvy a každý uzol, ktorý naň napojíš, potichu nerobí nič.
Oprava je prestať zaobchádzať s kontextom ako s jednorazovým. Vytvor ho raz a použi znova na celý život služby:
- V
setupMonoMixingPipelinekonštruujnew AudioContext(...)len ak žiadny ešte nie je alebo existujúci jeclosed. Ak existuje, ale jesuspended, zavolajawait audioContext.resume()namiesto tvorby nového. - V
cleanupnahraďaudioContext.close()zaaudioContext.suspend()(so strážou, aby sa nikdy nevolalo na už zatvorenom kontexte). Kontext prežije reláciu. - Keďže kontext prežije, prežije s ním načítaný modul
AudioWorklet— kód už neresetuje príznak „worklet načítaný" pri čistení, takžeaudioWorklet.addModule(...)sa zaplatí raz na celý život služby, nie každú reláciu.
Kúsky na úrovni relácie — uzly createMediaStreamSource pre mikrofón a systémový zvuk, mixér worklet a tichý passthrough, ktorý drží nažive odbočku slučky systémového zvuku Chromium — sa stále stavajú a rozoberajú každý cyklus. Pretrváva len samotný AudioContext, takže v ktoromkoľvek okamihu existuje najviac jeden. Strop je nedosiahnuteľný, lebo proti nemu nikdy nenaskladáš kontexty.
Jedna čestná poznámka, ktorú commit robí o sebe: bolo to overené ručne — „ručne overené: 8+ na seba nadväzujúcich relácií drží systémový zvuk počuteľný a prepisy tečúce (bolo mŕtve pri relácii 7)" — a autor je úprimný, že životný cyklus Web Audio „sa nedá jednotkovo testovať v jsdom bez veľkého nového mock harness". To je správny druh čestnosti. Zlyhanie sa objaví len voči skutočnému audio zariadeniu naprieč skutočnými reláciami; zelená jednotková sada by nedokázala nič. „Zhruba šesť" a „mŕtve pri relácii 7" sú pozorovania tímu, kde uhryzol Chromium limit, nie zdokumentovaná konštanta — samotný kód sa poisťuje vlnovkou a rovnako by mal každý, kto to opakuje.
Resume, suspend, close
Postav dve vydania vedľa seba a symetria je takmer priveľmi úhľadná. Obe sú o tých istých troch metódach na tom istom druhu objektu, prístup z opačných smerov:
- v1.8.16 sa naučilo
resume(). Kontext sa stalsuspendedspod nás zásahom OS a oprava je to zachytiť a obnoviť — udržať v behu kontext, ktorý má bežať. - v2.0.2 sa naučilo
suspend()namiestoclose(). Starý kód bol priveľmi horlivý zničiť kontext a oprava je pozastaviť a použiť ho znova — udržať nažive kontext, ktorý je drahý znovu vytvoriť.
Sú to dve polovice jedného princípu: prestaň zaobchádzať s AudioContext ako s jednorazovkou. Jedna polovica hovorí: nenechaj ho umrieť, keď nemá; druhá hovorí: nezabíjaj ho, keď nemusíš. A je tu malá irónia v časovej osi — čistenie v1.8.16 bolo napísané, aby „dopĺňalo vzor čistenia RealtimeAudioCaptureService", a potom v2.0.2 prepísalo presne ten vzor v RealtimeAudioCaptureService, lebo návyk vzoru „close() zakaždým" sa ukázal byť tou chybou. Obe služby sa ďalej učia z toho, ako tá druhá zaobchádza so životným cyklom audia, jedna oprava po druhej.
Tri veci, ktoré nás tieto vydania naučili
AudioContextmá stavový automat — berstateako nosný.running,suspendedaclosednie sú maličkosti. OS môže presúvať kontext medzi nimi bez toho, aby sa ťa pýtal, a rôzne časti tvojho grafu reagujú rôzne (ScriptProcessorNodena hlavnom vlákne beží ďalej; hardvérovými hodinami poháňaný cieľ nie). Ak nesleduješonstatechange, dôveruješ, že kontext ostane tam, kde si ho nechal — a na skutočnom desktope, pri zmenách fokusu a nahrávaní obrazovky, neostane.close()nie jefree(). Intuícia, že zavrieť zdroj promptne je vždy správne, je presne to, čo spôsobilo druhú chybu. Keď je zdrojom slot v tvrdom, prehliadačom vnútenom fonde, ktorý sa uvoľní až pri GC, agresívne zatváranie je spôsob, ako fond vyčerpať. Niekedy je správnym životným cyklom vytvor-raz-pozastavuj-často, nie vytvor-a-znič-na-každé-použitie.- K niektorým chybám sa dá dostať len cez dvere skutočného hardvéru a skutočného času. „Umiera po zhruba šiestich reláciách" a „stíchne, keď prepneš preč" nedokáže zachytiť jednotkový test, ktorý mockuje audio zariadenie — a tím to povedal na rovinu namiesto toho, aby to prelepil falošne prechádzajúcim testom. Pomenúvaj limity svojho testovacieho harness; chyba, ktorá potrebuje osem skutočných relácií, aby sa objavila, si zaslúži čestné „ručne overené", nie zelenú fajku, ktorá neznamená nič.
Pre predchádzajúcu kapitolu — rozoznať hovor od otvorenej aplikácie (v1.8.15–v1.8.19); a pre celý oblúk — anatómiu dodávania softvéru do dokonalosti.