Steven
Steven8 min čítania

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.

Inžinierstvo
Audio
Desktop
Vydania GeekBye
Tri slovesá, ktoré udržujú Web Audio nažive

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 setupMonoMixingPipeline konštruuj new AudioContext(...) len ak žiadny ešte nie je alebo existujúci je closed. Ak existuje, ale je suspended, zavolaj await audioContext.resume() namiesto tvorby nového.
  • V cleanup nahraď audioContext.close() za audioContext.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že audioWorklet.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 stal suspended spod 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() namiesto close(). 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

  1. AudioContext má stavový automat — ber state ako nosný. running, suspended a closed nie 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 (ScriptProcessorNode na 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.
  2. close() nie je free(). 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.
  3. 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.

Súvisiace články

Ticho bolo nosné
Steven
Steven7 min čítania

Ticho bolo nosné

Posledné dve vydania GeekBye v1 sú o tej istej nepríjemnej pravde: prepis v reálnom čase cez skutočnú sieť nie je bezstratový a čestný ťah je prestať predstierať, že je. v1.8.20 si nechalo kópiu každého úseku zvuku na disku, než ho počas opätovného pripojenia zahodilo, a začalo nahlas označovať medzery v prepise. v1.9.0 prestalo posielať ticho, aby ušetrilo šírku pásma — a zistilo, že ticho bolo presne tým signálom, podľa ktorého prepisovač vedel, že veta skončila. Dve vydania o cene zahadzovania vecí.

Inžinierstvo
Audio
Spoľahlivosť
Rozoznať hovor od otvorenej aplikácie
Steven
Steven8 min čítania

Rozoznať hovor od otvorenej aplikácie

GeekBye si vie všimnúť, že si sa pripojil k videohovoru, a ponúknuť jeho nahranie. Detekcia sa ukáže ako ľahšia polovica — natívny Swift binárnik čítajúci tituly okien každých desať sekúnd. Ťažká polovica je presnosť: nespustiť sa, keď je Zoom len otvorený, neponúkať nahrávanie stretnutia, ktoré už nahrávaš, a nestlmiť mikrofón v hovore, v ktorom naozaj sedíš. Tri vydania, a každé z nich je poistka, ktorá sa musela naučiť neporaziť samu seba.

Inžinierstvo
macOS
Desktop
Vyberáme backend z cesty nahrávania
Steven
Steven7 min čítania

Vyberáme backend z cesty nahrávania

GeekBye nahráva tvoju obrazovku a ukladá video na tvoj Google Drive. Prvá verzia posielala každú nahrávku cez vlastné servery GeekBye cestou tam; o vydanie neskôr išiel súbor priamo z tvojho stroja na Drive a backend bol degradovaný na držanie jediného ukazovateľa. Zaujímavá časť je, ako málo kódu naozaj obsahuje „priama, obnoviteľná" verzia — lebo obnoviteľnosť prišla zo zmazania proxy, nie z jeho napísania.

Inžinierstvo
Architektúra
Desktop