
Kolme verbiä, jotka pitävät Web Audion elossa
Kaksi GeekBye-julkaisua, kahden kuukauden välein ja kahdessa eri tiedostossa, opettivat ääni-koodillemme saman opetuksen vastakkaisista päistä: lakkaa kohtelemasta selaimen AudioContextia kertakäyttöisenä. Yksi julkaisu oppi kutsumaan resume() kontekstille, jonka macOS oli hiljaa keskeyttänyt kesken tallennuksen; toinen oppi kutsumaan suspend() eikä close(), niin että peräkkäiset istunnot lakkaavat törmäämästä Chromiumin noin-kuuden-kontekstin kattoon. Resume, suspend, close — siinä on koko juoni.
Suurimman osan ajasta Web Audio AudioContext käyttäytyy kuin tavallinen olio: rakennat yhden, kytket muutaman solmun ja unohdat sen. Kaksi GeekBye-julkaisua ovat se, mitä tapahtui kun tuo oletus rikkoutui — kahdesti, sovelluksen kahdessa eri osassa, kahden kuukauden välein. Kumpaakaan ei suunniteltu toisen jatkoksi. Mutta aseta ne rinnakkain ja ne kertovat yhden tarinan: selaimen AudioContext ei ole kertakäyttöinen olio. Se on niukka, käyttöjärjestelmän omistama resurssi jolla on todellinen tilakone, ja tuon tilakoneen kolme verbiä — resume, suspend, close — ovat molempien julkaisujen koko juoni.
GeekByellä on kaksi erillistä ääniputkea, minkä vuoksi sama opetus piti oppia kahdesti. Yksi on videotallennuspolku (ScreenRecordingService), joka rakentaa Web Audio -graafin sekoittaakseen mikrofonin ja järjestelmän äänen tallennettuun videotiedostoon. Toinen on reaaliaikaisen transkription polku (RealtimeAudioCaptureService), joka rakentaa oman graafinsa syöttääkseen puheentunnistusta. Kummallakin on oma AudioContext. Kukin alla oleva julkaisu korjaa niistä yhden.
v1.8.16: video meni hiljaiseksi, mutta transkriptio ei
Ensimmäinen bugi on halkaistu mieli. Tallennat kokousta, vaihdat hetkeksi toiseen sovellukseen, ja jotain hienovaraista tapahtuu: videotiedosto menettää mikrofonisi samalla kun live-transkriptio jatkaa vieritystä kuin mikään ei olisi muuttunut. Transkriptio vannoo että puhuit. Video on hiljainen. Se on raivostuttava bugin laji, koska kaksi asiaa joiden pitäisi olla samaa mieltä — "kuuliko mikrofoni sinut" — ovat suorastaan eri mieltä.
Commit joka korjaa sen nimeää syyn täsmällisesti:
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.
Tässä on mekaniikka. ScreenRecordingService.setupMonoMixingPipeline() rakentaa yhden AudioContextin, MediaStreamDestinationin josta tulee videon ääniraita, GainNode:n mykistykseen ja parin ScriptProcessorNode:ja (createScriptProcessor:n kautta) jotka pumppaavat äänikehyksiä VAD/STT-logiikkaan. Kun macOS keskeyttää kontekstin — minkä se voi tehdä kun sovellus menettää fokuksen näytön tallennuksen aikana — äänilaitteiston kellon ajamat graafin osat pysähtyvät: MediaStreamDestination lakkaa vastaanottamasta mikrofonin ääntä, joten tallennettu video hiljenee. Mutta ScriptProcessorNode:n onaudioprocess-takaisinkutsu ajetaan JavaScriptin pääsäikeessä, ja tuo säie jatkaa tikitystä. Joten STT-puoli jatkaa transkriptiomerkintöjen tuottamista siitä mitä sillä viimeksi oli, ja saman mikrofonin kaksi kuluttajaa erkanevat.
Korjaus on huomata keskeytys ja kumota se:
audioContext.onstatechange = () => {
if (audioContext.state === 'suspended' && this.status === 'recording') {
logger.info('AudioContext suspended during recording, resuming')
audioContext.resume()
}
}
Siinä on koko oikeellisuusmuutos: tarkkaile kontekstin state:a, ja jos se putoaa suspended:iin kun meidän pitäisi vielä tallentaa, kutsu resume(). Julkaisu kytkee myös onmute-, onunmute- ja onended-kuuntelijat mikrofonin MediaStreamTrack:iin — ei muuttaakseen käytöstä, vaan jotta käyttöjärjestelmätason mykistys tai laitteen irtoaminen näkyisi lokeissa sen sijaan että olisi näkymätön hiljaisuuden syy. Kun bugin koko vaikeus on "miksi ääni pysähtyi", äänen pysähtymisen tekeminen havaittavaksi on puolet korjauksesta.
Jatkocommit laskeutui pari minuuttia myöhemmin siivotakseen näiden uusien kuuntelijoiden jäljiltä: cleanup() nollaa nyt micTrack.onmute/onunmute/onended ja asettaa audioContext.onstatechange = null ennen kuin se purkaa graafin. Commitin muistiinpano sanoo sen "vastaavan RealtimeAudioCaptureService-siivouskuviota" — sovelluksen toista äänipalvelua. Pidä tuo yksityiskohta mielessä, koska kaksi kuukautta myöhemmin juuri tuon kuvion seuraava julkaisu joutui kirjoittamaan uudelleen.
v2.0.2: ääni ja transkriptiot kuolevat noin kuuden istunnon jälkeen
Toinen bugi asuu toisessa putkessa, RealtimeAudioCaptureService:ssä, ja sillä on ihana muoto: kaikki toimii moitteettomasti, ja sitten kun olet aloittanut ja pysäyttänyt tallennuksen kourallisen kertoja yhden sovellusistunnon aikana, ääni vain... kuolee. Ensin järjestelmän ääni hiljenee, sitten transkriptio pysähtyy. Käynnistä sovellus uudelleen ja se on taas kunnossa — seuraavaan kourallisen istuntoja asti.
Perussyy-muistiinpano on lukemisen arvoinen kokonaisuudessaan, koska se on oppikirjaesimerkki bugista-bugin-takana:
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'.
Ansa on siinä, että vanha koodi teki "vastuullisen" asian. Jokainen start() loi tuoreen AudioContextin; jokainen cleanup() kutsui sille close(). Resurssin sulkeminen kun olet valmis sen kanssa on juuri sitä mitä sinua opetetaan tekemään. Siinä oli jopa rivinsisäinen kommentti joka perusteli tätä vaihtuvuutta — että AudioWorklet-moduulin uudelleenlataus per tuore konteksti oli ok koska "tuore konteksti per start() tarkoittaa että maksamme tämän kustannuksen aina kerran."
Mutta Chromium rajoittaa kuinka monta laitteistopohjaista AudioContextia yksi sivu voi pitää — noin kuusi — ja, ratkaisevasti, close() ei luovuta paikkaa takaisin sillä hetkellä kun kutsut sitä. Suljettu konteksti jatkaa paikkansa varaamista kunnes se roskienkerätään, mikä tapahtuu kun JS-moottori ennättää siihen. Joten tunnollinen sulje-joka-kerta -politiikka on juuri se asia joka kävelyttää sinut kattoon: kuusi luo/sulje-kiertoa myöhemmin kaikkia kuutta paikkaa pitävät vielä keräämättömät ruumiit, seitsemäs new AudioContext() palaa kuolleena, ja jokainen solmu jonka siihen liität tekee hiljaa ei mitään.
Korjaus on lakata kohtelemasta kontekstia kertakäyttöisenä. Luo se kerran ja käytä sitä uudelleen palvelun eliniän ajan:
setupMonoMixingPipeline:ssä konstruoinew AudioContext(...)vain jos sellaista ei vielä ole tai olemassa oleva onclosed. Jos se on olemassa muttasuspended, kutsuawait audioContext.resume()sen sijaan että tekisit uuden.cleanup:ssa korvaaaudioContext.close()audioContext.suspend():llä (suojattuna niin ettei sitä koskaan kutsuta jo suljetulla kontekstilla). Konteksti selviää istunnosta.- Koska konteksti selviää, ladattu
AudioWorklet-moduuli selviää sen mukana — koodi ei enää nollaa "worklet ladattu" -lippuaan siivouksessa, jotenaudioWorklet.addModule(...)maksetaan kerran koko palvelun eliniän ajan eikä joka istunnolla.
Istuntokohtaiset palaset — createMediaStreamSource-solmut mikrofonille ja järjestelmän äänelle, mikserin worklet, ja hiljainen läpivienti joka pitää Chromiumin järjestelmän äänen loopback-haaran elossa — rakennetaan ja puretaan yhä joka kierrolla. Vain AudioContext itse pysyy, joten kerrallaan on olemassa korkeintaan yksi. Katto on saavuttamaton koska et koskaan pinoa konteksteja sitä vasten.
Yksi rehellinen huomio jonka commit tekee itsestään: tämä varmennettiin käsin — "manuaalisesti varmennettu: 8+ peräkkäistä istuntoa pitävät järjestelmän äänen kuuluvana ja transkriptiot virtaavina (oli kuollut istunnossa 7)" — ja tekijä on suora siitä, että Web Audio -elinkaari "ei ole yksikkötestattavissa jsdom:issa ilman suurta uutta mock-kehikkoa". Se on oikeanlaista rehellisyyttä. Vika ilmenee vain oikeaa äänilaitetta vasten oikeiden istuntojen yli; vihreä yksikkötestisarja ei olisi todistanut mitään. "Noin kuusi" ja "kuollut istunnossa 7" ovat tiimin havaintoja siitä missä Chromiumin raja puri, ei dokumentoitu vakio — koodi itse pehmentää tildellä, ja niin pitäisi kaikkien jotka sitä toistavat.
Resume, suspend, close
Aseta kaksi julkaisua riviin ja symmetria on melkein liian siisti. Molemmat ovat samasta kolmesta metodista samanlaisella oliolla, lähestyttynä vastakkaisista suunnista:
- v1.8.16 oppi kutsumaan
resume(). Käyttöjärjestelmä keskeytti (suspended) kontekstin sen alta, ja korjaus on napata se kiinni ja jatkaa — pitää konteksti jonka pitäisi olla käynnissä, käynnissä. - v2.0.2 oppi kutsumaan
suspend()eikäclose(). Vanha koodi oli liian innokas tuhoamaan kontekstin, ja korjaus on keskeyttää ja käyttää se uudelleen — pitää elossa konteksti jonka uudelleenluonti on kallista.
Ne ovat yhden periaatteen kaksi puolikasta: lakkaa kohtelemasta AudioContextia heitteenä. Toinen puolisko sanoo: älä anna sen kuolla kun ei pitäisi; toinen sanoo: älä tapa sitä kun sinun ei tarvitse. Ja aikajanassa on pieni ironia — v1.8.16:n siivous kirjoitettiin "vastaamaan RealtimeAudioCaptureService:n siivouskuviota", ja sitten v2.0.2 kirjoitti juuri tuon kuvion uudelleen RealtimeAudioCaptureService:ssä, koska kuvion close()-joka-kerta -tapa osoittautui bugiksi. Kaksi palvelua jatkavat toistensa ääni-elinkaaren käsittelystä oppimista, yksi korjaus kerrallaan.
Kolme asiaa jotka nämä julkaisut opettivat
AudioContext:lla on tilakone — kohtelestate:a kantavana.running,suspendedjaclosedeivät ole pikkuseikkoja. Käyttöjärjestelmä voi siirtää kontekstin niiden välillä kysymättä sinulta, ja graafisi eri osat reagoivat eri tavoin (ScriptProcessorNodepääsäikeessä jatkaa; laitteistokellotettu kohde ei). Jos et tarkkaileonstatechange:a, luotat siihen että konteksti pysyy siinä mihin sen jätit — ja oikealla työpöydällä, fokusmuutosten ja näytön tallennuksen alla, se ei pysy.close()ei olefree(). Intuitio että resurssin ripeä sulkeminen on aina oikein on juuri se joka aiheutti toisen bugin. Kun resurssi on paikka kovassa, selaimen määräämässä altaassa joka vapautetaan vasta GC-aikaan, aggressiivinen sulkeminen on tapa jolla loppuunkulutat altaan. Joskus oikea elinkaari on luo-kerran-keskeytä-usein, ei luo-ja-tuhoa-per-käyttö.- Jotkin bugit ovat saavutettavissa vain oikean laitteiston ja oikean ajan oven kautta. "Kuolee noin kuuden istunnon jälkeen" ja "hiljenee kun vaihdat pois" eivät ole napattavissa yksikkötestillä joka jäljittelee äänilaitetta — ja tiimi sanoi sen suoraan sen sijaan että olisi peittänyt sen valheellisesti-läpäisevällä testillä. Nimeä testikehikkosi rajat; bugi joka tarvitsee kahdeksan oikeaa istuntoa ilmestyäkseen ansaitsee rehellisen "manuaalisesti varmennettu", ei vihreää ruksia joka ei tarkoita mitään.
Edellistä lukua varten — puhelun erottaminen avoimesta sovelluksesta (v1.8.15–v1.8.19); ja koko kaarelle — ohjelmiston toimittamisen anatomia täydellisyyteen asti.