Steven
Steven7 min lukuaika

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.

Ohjelmistokehitys
Audio
Desktop
GeekBye-julkaisut
Kolme verbiä, jotka pitävät Web Audion elossa

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ä konstruoi new AudioContext(...) vain jos sellaista ei vielä ole tai olemassa oleva on closed. Jos se on olemassa mutta suspended, kutsu await audioContext.resume() sen sijaan että tekisit uuden.
  • cleanup:ssa korvaa audioContext.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, joten audioWorklet.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

  1. AudioContext:lla on tilakone — kohtele state:a kantavana. running, suspended ja closed eivät ole pikkuseikkoja. Käyttöjärjestelmä voi siirtää kontekstin niiden välillä kysymättä sinulta, ja graafisi eri osat reagoivat eri tavoin (ScriptProcessorNode pääsäikeessä jatkaa; laitteistokellotettu kohde ei). Jos et tarkkaile onstatechange: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.
  2. close() ei ole free(). 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ö.
  3. 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.

Aiheeseen liittyvät artikkelit

Hiljaisuus oli kantava
Steven
Steven6 min lukuaika

Hiljaisuus oli kantava

GeekBye v1:n kaksi viimeistä julkaisua kertovat samasta epämukavasta totuudesta: reaaliaikainen transkriptio oikean verkon yli ei ole häviötöntä, ja rehellinen liike on lakata teeskentelemästä että se on. v1.8.20 säilytti jokaisesta äänipalasta kopion levyllä ennen kuin pudotti sen uudelleenyhdistämisen aikana, ja alkoi merkitä transkription aukot ääneen. v1.9.0 lakkasi lähettämästä hiljaisuutta kaistanleveyden säästämiseksi — ja huomasi, että hiljaisuus oli juuri se signaali, jolla transkriboija tiesi lauseen päättyneen. Kaksi julkaisua asioiden pois heittämisen hinnasta.

Ohjelmistokehitys
Audio
Luotettavuus
Puhelun erottaminen avoimesta sovelluksesta
Steven
Steven7 min lukuaika

Puhelun erottaminen avoimesta sovelluksesta

GeekBye voi huomata, että olet liittynyt videokokoukseen, ja tarjoutua tallentamaan sen. Tunnistus osoittautuu helpommaksi puoliskoksi — Swift-binääri, joka lukee ikkunoiden otsikoita joka kymmenes sekunti. Vaikea puolisko on tarkkuus: olla laukeamatta kun Zoom on vain auki, olla tarjoamatta kokousta jota jo tallennat, ja olla mykistämättä mikrofonia puhelussa jossa oikeasti olet. Kolme julkaisua, ja jokainen niistä on vartija jonka oli opittava olemaan kukistamatta itseään.

Ohjelmistokehitys
macOS
Desktop
Backendin poistaminen latauspolusta
Steven
Steven6 min lukuaika

Backendin poistaminen latauspolusta

GeekBye tallentaa näyttösi ja tallentaa videon Google Driveesi. Ensimmäinen versio toimitti jokaisen tallenteen GeekByen omien palvelimien kautta matkalla sinne; yksi julkaisu myöhemmin tiedosto meni suoraan koneeltasi Driveen, ja backend alennettiin yhden osoittimen pitämiseen. Kiinnostava osa on se, miten vähän koodia 'suora, jatkettava' versio oikeasti sisältää — koska jatkettavuus tuli välityspalvelimen poistamisesta, ei sen kirjoittamisesta.

Ohjelmistokehitys
Arkkitehtuuri
Desktop