Steven
Steven8 min čtení

Tři slovesa, která udržují Web Audio naživu

Dvě bodová vydání GeekBye, dva měsíce od sebe a ve dvou různých souborech, naučila náš audio kód stejné lekci z opačných konců: přestaň zacházet s AudioContext prohlížeče jako s jednorázovým. Jedno vydání se naučilo volat resume() na kontextu, který macOS tiše suspendoval uprostřed nahrávání; druhé se naučilo volat suspend() místo close(), aby po sobě jdoucí sesiony přestaly narážet do stropu Chromia zhruba šesti kontextů. Resume, suspend, close — to je celá zápletka.

Inženýrství
Audio
Desktop
Vydání GeekBye
Tři slovesa, která udržují Web Audio naživu

Většinu času se Web Audio AudioContext chová jako obyčejný objekt: zkonstruuješ jeden, propojíš pár node a zapomeneš na něj. Dvě vydání GeekBye jsou to, co se stalo, když se ten předpoklad zlomil — dvakrát, ve dvou různých částech aplikace, dva měsíce od sebe. Ani jedno nebylo plánováno jako pokračování druhého. Ale postav je vedle sebe a vyprávějí jeden příběh: AudioContext prohlížeče není jednorázový objekt. Je to vzácný zdroj vlastněný operačním systémem se skutečným stavovým automatem, a tři slovesa toho stavového automatu — resume, suspend, close — jsou celá zápletka obou vydání.

GeekBye má dvě samostatné audio pipeline, což je důvod, proč se stejná lekce musela naučit dvakrát. Jedna je cesta nahrávání videa (ScreenRecordingService), která staví Web Audio graf, aby smíchala mikrofon a systémové audio do nahraného video souboru. Druhá je cesta přepisu v reálném čase (RealtimeAudioCaptureService), která staví vlastní graf, aby napájela speech-to-text. Každá má svůj vlastní AudioContext. Každé vydání níže opravuje jednu z nich.

v1.8.16: video oněmělo, ale přepis ne

První bug je rozštěpený mozek. Nahráváš schůzku, na chvíli přepneš na jinou aplikaci a stane se něco nenápadného: video soubor ztratí tvůj mikrofon, zatímco živý přepis dál běží, jako by se nic nezměnilo. Přepis přísahá, že jsi mluvil. Video je němé. To je šílený druh bugu, protože dvě věci, které by měly souhlasit — „slyšel tě mikrofon" — spolu naprosto nesouhlasí.

Commit, který ho opravuje, pojmenovává příčinu přesně:

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.

Tady je mechanika. ScreenRecordingService.setupMonoMixingPipeline() staví jeden AudioContext, MediaStreamDestination, který se stane audio trackem videa, GainNode pro ztlumení a pár ScriptProcessorNode (přes createScriptProcessor), které pumpují audio framy do VAD/STT logiky. Když macOS suspenduje kontext — což může udělat, když aplikace ztratí fokus během nahrávání obrazovky — části grafu poháněné hodinami audio hardwaru se zastaví: MediaStreamDestination přestane přijímat audio z mikrofonu, takže nahrané video ztichne. Ale callback onaudioprocess uzlu ScriptProcessorNode běží na hlavním JavaScript vlákně, a to vlákno dál tiká. Takže strana STT dál produkuje záznamy přepisu z toho, co měla naposledy, a dva konzumenti stejného mikrofonu se rozejdou.

Oprava je všimnout si suspendování a vrátit ho:

audioContext.onstatechange = () => {
  if (audioContext.state === 'suspended' && this.status === 'recording') {
    logger.info('AudioContext suspended during recording, resuming')
    audioContext.resume()
  }
}

To je celá změna kvůli správnosti: sleduj state kontextu, a pokud spadne na suspended, zatímco bychom stále měli nahrávat, zavolej resume(). Vydání také zapojuje listenery onmute, onunmute a onended na MediaStreamTrack mikrofonu — ne aby změnilo chování, ale aby se ztlumení na úrovni operačního systému nebo odpojení zařízení objevilo v lozích, místo aby bylo neviditelnou příčinou ticha. Když celá obtíž bugu je „proč se audio zastavilo", udělat zastavení audia pozorovatelným je polovina opravy.

Následný commit přistál o pár minut později, aby uklidil po těch nových listenerech: cleanup() teď nuluje micTrack.onmute/onunmute/onended a nastaví audioContext.onstatechange = null, než graf strhne. Poznámka commitu říká, že „odpovídá cleanup vzoru RealtimeAudioCaptureService" — druhé audio službě aplikace. Podrž si ten detail, protože o dva měsíce později přesně tenhle vzor musel příští vydání přepsat.

v2.0.2: audio a přepisy umírají po zhruba šesti sesionech

Druhý bug žije v druhé pipeline, RealtimeAudioCaptureService, a má nádherný tvar: všechno funguje perfektně, a pak, poté co jsi v jednom sesionu aplikace párkrát spustil a zastavil nahrávání, audio prostě... umře. Nejdřív ztichne systémové audio, pak se zastaví přepis. Restartuj aplikaci a je zase v pořádku — do dalších pár sesionů.

Poznámka o hlavní příčině si zaslouží přečíst celá, protože je to učebnicový bug-za-bugem:

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'.

Past je v tom, že starý kód dělal tu „zodpovědnou" věc. Každý start() vytvořil čerstvý AudioContext; každý cleanup() na něm zavolal close(). Zavřít zdroj, když jsi s ním hotový, je přesně to, co tě učí. Byl tam dokonce inline komentář, který zdůvodňoval to plýtvání — že opětovné načítání modulu AudioWorklet na každý čerstvý kontext bylo v pořádku, protože „čerstvý kontext na každý start() znamená, že tuhle cenu vždy zaplatíme jednou".

Ale Chromium omezuje, kolik hardwarově podložených AudioContext může jedna stránka držet — zhruba šest — a, což je zásadní, close() nevrátí slot ve chvíli, kdy ho zavoláš. Zavřený kontext dál obsazuje svůj slot, dokud není garbage-collected, což se stane, kdykoli se k tomu JS engine dostane. Takže svědomitá politika zavírej-pokaždé je přesně to, co tě dovede ke stropu: o šest cyklů vytvoření/zavření později je šest slotů drženo ještě neposbíranými mrtvolami, sedmý new AudioContext() se vrátí mrtvý a každý node, který k němu připojíš, tiše nedělá nic.

Oprava je přestat zacházet s kontextem jako s jednorázovým. Vytvoř ho jednou a použij ho znovu po celou životnost služby:

  • V setupMonoMixingPipeline zkonstruuj new AudioContext(...) jen tehdy, pokud ještě žádný není nebo je existující closed. Pokud existuje, ale je suspended, udělej await audioContext.resume() místo vytváření nového.
  • V cleanup nahraď audioContext.close() za audioContext.suspend() (ošetřené tak, aby se nikdy nevolalo na již zavřeném kontextu). Kontext přežije sesion.
  • Protože kontext přežije, načtený modul AudioWorklet přežije s ním — kód už neresetuje svůj flag „worklet načten" při cleanup, takže audioWorklet.addModule(...) se zaplatí jednou za celou životnost služby, místo každý sesion.

Kusy na sesion — createMediaStreamSource node pro mikrofon a systémové audio, mixer worklet a němý passthrough, který udržuje naživu loopback odbočku systémového audia Chromia — se stále staví a strhávají každý cyklus. Přetrvá jen samotný AudioContext, takže v jednu chvíli existuje nanejvýš jeden. Strop je nedosažitelný, protože proti němu nikdy nestohuješ kontexty.

Jedna poctivá poznámka, kterou commit dělá sám o sobě: tohle bylo ověřeno ručně — „ověřeno ručně: 8+ po sobě jdoucích sesionů udrží systémové audio slyšitelné a přepisy tekoucí (byl mrtvý u sesionu 7)" — a autor je upřímný, že životní cyklus Web Audio „není unit-testovatelný v jsdom bez velkého nového mock harnessu". To je ten správný druh poctivosti. Selhání se objeví jen proti skutečnému audio zařízení napříč skutečnými sesiony; zelená sada unit testů by nedokázala nic. „Zhruba šest" a „mrtvý u sesionu 7" jsou pozorování týmu o tom, kde strop Chromia kousl, ne dokumentovaná konstanta — sám kód se jistí vlnovkou, a tak by měl i každý, kdo to opakuje.

Resume, suspend, close

Postav ta dvě vydání vedle sebe a symetrie je téměř až příliš úhledná. Obě jsou o stejných třech metodách na stejném druhu objektu, přistoupené z opačných směrů:

  • v1.8.16 se naučila dělat resume(). Kontext byl suspended zpod ní operačním systémem, a oprava je zachytit to a obnovit — udrž kontext, který má běžet, běžící.
  • v2.0.2 se naučila dělat suspend() místo close(). Starý kód byl příliš dychtivý zničit kontext, a oprava je suspendovat ho a použít znovu — udrž naživu kontext, který je drahé znovu vytvořit.

Jsou to dvě poloviny jediného principu: přestaň zacházet s AudioContext jako s něčím na vyhození. Jedna polovina říká nenech ho umřít, když nemá; druhá říká nezabíjej ho, když nemusíš. A v časové ose je malá ironie — cleanup v1.8.16 byl napsán, aby „odpovídal cleanup vzoru RealtimeAudioCaptureService", a pak v2.0.2 přepsala přesně ten vzor v RealtimeAudioCaptureService, protože zvyk toho vzoru dělat close() pokaždé se ukázal být bugem. Ty dvě služby se dál učí jedna z toho, jak druhá zachází s audio životním cyklem, jedna oprava po druhé.

Tři věci, které nás tato vydání naučila

  1. AudioContext má stavový automat — ber state jako nosný. running, suspended a closed nejsou drobnosti. OS může kontext mezi nimi přesunout, aniž by se tě zeptal, a různé části tvého grafu reagují různě (ScriptProcessorNode na hlavním vlákně jede dál; cíl taktovaný hardwarem ne). Pokud nesleduješ onstatechange, věříš, že kontext zůstane tam, kde jsi ho nechal — a na skutečném desktopu, pod změnami fokusu a nahráváním obrazovky, nezůstane.
  2. close() není free(). Intuice, že rychlé zavření zdroje je vždy správné, je přesně to, co způsobilo druhý bug. Když je zdrojem slot v tvrdém, prohlížečem vynuceném poolu, který se uvolní jen v čase GC, agresivní zavírání je způsob, jak pool vyčerpáš. Někdy je správný životní cyklus vytvoř-jednou-suspenduj-často, ne vytvoř-a-znič-při-každém-použití.
  3. Některé bugy jsou dosažitelné jen dveřmi skutečného hardwaru a skutečného času. „Umírá po zhruba šesti sesionech" a „ztichne, když přepneš" nelze chytit unit testem, který mockuje audio zařízení — a tým to řekl na rovinu, místo aby to zamázl falešně procházejícím testem. Pojmenuj meze svého testovacího harnessu; bug, který potřebuje osm skutečných sesionů, aby se objevil, si zaslouží poctivé „ověřeno ručně", ne zelené zaškrtnutí, které neznamená nic.

Předchozí kapitolu najdeš v rozeznat hovor od otevřené aplikace (v1.8.15–v1.8.19); a celý oblouk v anatomii dodávání softwaru k dokonalosti.

Související články

Ticho bylo nosné
Steven
Steven7 min čtení

Ticho bylo nosné

Poslední dvě vydání GeekBye v1 jsou o téže nepříjemné pravdě: přepis v reálném čase přes skutečnou síť není bezeztrátový a poctivý krok je přestat předstírat, že je. v1.8.20 uchovávalo kopii každého audio chunku na disku, než ho během reconnectu zahodilo, a začalo mezery v přepisu označovat nahlas. v1.9.0 přestalo posílat ticho kvůli úspoře šířky pásma — a zjistilo, že ticho bylo přesně ten signál, podle kterého přepisovač poznal, že věta skončila. Dvě vydání o ceně zahazování věcí.

Inženýrství
Audio
Spolehlivost
Rozeznat hovor od otevřené aplikace
Steven
Steven8 min čtení

Rozeznat hovor od otevřené aplikace

GeekBye si umí všimnout, že ses připojil k videoschůzce, a nabídnout, že ji nahraje. Detekce se ukazuje být tou snadnou polovinou — Swift binary čtoucí titulky oken každých deset sekund. Tvrdá polovina je přesnost: nespustit se, když je Zoom jen otevřený, neptat se na schůzku, kterou už nahráváš, a neztlumit mikrofon v hovoru, ve kterém opravdu jsi. Tři vydání, a každé je pojistka, která se musela naučit neporazit sama sebe.

Inženýrství
macOS
Desktop
Vyjmutí backendu z cesty nahrávání
Steven
Steven7 min čtení

Vyjmutí backendu z cesty nahrávání

GeekBye nahrává tvou obrazovku a ukládá video na tvůj Google Drive. První verze posílala každou nahrávku cestou tam přes vlastní servery GeekBye; o vydání později šel soubor přímo z tvého stroje na Drive a backend byl degradován na držení jediného ukazatele. Zajímavé je, jak málo kódu vlastně obsahuje „přímá, resumable" verze — protože resumabilita přišla ze smazání proxy, ne z jeho napsání.

Inženýrství
Architektura
Desktop