Steven
Steven8 perc olvasás

A három ige, amely életben tartja a Web Audiót

Két GeekBye pontkiadás, két hónap eltéréssel és két különböző fájlban, ugyanarra a leckére tanította meg az audio kódunkat ellentétes végekről: ne kezeld a böngésző AudioContext-jét eldobhatóként. Az egyik kiadás megtanult resume()-t hívni egy contexten, amelyet a macOS csendben suspendelt a felvétel közepén; a másik megtanult suspend()-et hívni close() helyett, hogy az egymást követő munkamenetek ne csapódjanak bele a Chromium nagyjából hat contextes plafonjába. Resume, suspend, close — ez az egész cselekmény.

Mérnöki munka
Audio
Desktop
GeekBye kiadások
A három ige, amely életben tartja a Web Audiót

Az esetek többségében egy Web Audio AudioContext úgy viselkedik, mint egy egyszerű objektum: létrehozol egyet, összekötsz néhány node-ot, és elfelejted. Két GeekBye kiadás az, ami akkor történt, amikor ez a feltevés eltört — kétszer, az alkalmazás két különböző részében, két hónap eltéréssel. Egyiket sem tervezték a másik folytatásaként. De tedd őket egymás mellé, és egy történetet mesélnek: a böngésző AudioContext-je nem egy eldobható objektum. Egy szűkös, az operációs rendszer által birtokolt erőforrás valódi állapotgéppel, és annak az állapotgépnek a három igéje — resume, suspend, close — mindkét kiadás teljes cselekménye.

A GeekBye-nak két külön audio pipeline-ja van, ezért kellett ugyanazt a leckét kétszer megtanulni. Az egyik a videórögzítés útvonala (ScreenRecordingService), amely egy Web Audio grafikont épít, hogy a mikrofont és a rendszerhangot egy rögzített videófájlba keverje. A másik a valós idejű átírás útvonala (RealtimeAudioCaptureService), amely saját grafikont épít, hogy a speech-to-textet táplálja. Mindegyiknek saját AudioContext-je van. Az alábbi kiadások mindegyike egyet javít közülük.

v1.8.16: a videó elnémult, de az átirat nem

Az első bug egy hasadt agy. Rögzítesz egy megbeszélést, egy pillanatra átváltasz egy másik alkalmazásra, és valami finom történik: a videófájl elveszíti a mikrofonodat, miközben az élő átirat tovább gördül, mintha semmi sem változott volna. Az átirat esküszik, hogy beszéltél. A videó néma. Ez egy őrjítő fajta bug, mert a két dolog, amelynek egyet kellene értenie — „hallott-e a mikrofon" — kereken ellentmond egymásnak.

A commit, amely javítja, pontosan megnevezi az okot:

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.

Íme a mechanika. A ScreenRecordingService.setupMonoMixingPipeline() épít egy AudioContext-et, egy MediaStreamDestination-t, amely a videó audio trackjévé válik, egy GainNode-ot a némításhoz, és egy pár ScriptProcessorNode-ot (a createScriptProcessor-on át), amelyek audio frame-eket pumpálnak a VAD/STT logikába. Amikor a macOS suspendeli a contextet — amit megtehet, ha az alkalmazás elveszti a fókuszt egy képernyőfelvétel közben — a grafikon audio hardver órája által hajtott részei leállnak: a MediaStreamDestination abbahagyja a mikrofonhang fogadását, így a rögzített videó elcsendesedik. De egy ScriptProcessorNode onaudioprocess callbackje a JavaScript fő szálon fut, és az a szál tovább ketyeg. Így az STT oldal tovább termel átirat-bejegyzéseket abból, ami utoljára volt neki, és ugyanannak a mikrofonnak a két fogyasztója szétválik.

A javítás az, hogy észreveszed a suspendelést, és visszavonod:

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

Ez az egész helyességi változás: figyeld a context state-jét, és ha suspended-re esik, miközben még rögzítenünk kellene, hívj resume()-t. A kiadás beköt onmute, onunmute és onended listenereket is a mikrofon MediaStreamTrack-jére — nem azért, hogy megváltoztassa a viselkedést, hanem hogy egy OS-szintű némítás vagy egy eszközleválás megjelenjen a logokban, ahelyett hogy a csend láthatatlan oka lenne. Amikor egy bug egész nehézsége az, hogy „miért állt le a hang", a hang leállásának megfigyelhetővé tétele a javítás fele.

Egy követő commit néhány perccel később érkezett, hogy feltakarítson azok után az új listenerek után: a cleanup() most nullázza a micTrack.onmute/onunmute/onended-et, és audioContext.onstatechange = null-ra állítja, mielőtt lebontaná a grafikont. A commit jegyzete szerint „megfelel a RealtimeAudioCaptureService cleanup mintájának" — az alkalmazás másik audio szolgáltatásának. Tartsd meg ezt a részletet, mert két hónappal később pontosan ezt a mintát kellett a következő kiadásnak átírnia.

v2.0.2: a hang és az átiratok körülbelül hat munkamenet után elhalnak

A második bug a másik pipeline-ban, a RealtimeAudioCaptureService-ben él, és csodálatos formája van: minden tökéletesen működik, majd miután egy alkalmazás-munkamenetben néhányszor elindítottad és leállítottad a felvételt, a hang egyszerűen... elhal. Előbb a rendszerhang elnémul, aztán az átirat leáll. Indítsd újra az alkalmazást, és megint rendben van — újabb maroknyi munkamenetig.

A gyökérok-jegyzet megérdemli, hogy teljes egészében elolvasd, mert egy tankönyvi bug-a-bug-mögött:

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

A csapda az, hogy a régi kód a „felelős" dolgot csinálta. Minden start() friss AudioContext-et hozott létre; minden cleanup() close()-t hívott rajta. Egy erőforrás bezárása, amikor végeztél vele, pontosan az, amire tanítanak. Volt még egy inline megjegyzés is, amely racionalizálta a pazarlást — hogy az AudioWorklet modul újratöltése friss contextenként rendben volt, mert „egy friss context start()-onként azt jelenti, hogy ezt a költséget mindig egyszer fizetjük meg".

De a Chromium korlátozza, hány hardveresen támogatott AudioContext-et tarthat egyetlen oldal — nagyjából hatot — és, ami döntő, a close() nem adja vissza a helyet abban a pillanatban, amikor meghívod. Egy lezárt context tovább foglalja a helyét, amíg garbage-collected nem lesz, ami akkor történik, amikor a JS motor rákerül. Így egy aprólékos zárj-be-mindig politika pontosan az a dolog, amely belesétáltat a plafonba: hat létrehozás/lezárás ciklussal később a hat helyet mind még össze nem gyűjtött hullák tartják, a hetedik new AudioContext() halottan tér vissza, és minden node, amit hozzácsatolsz, csendben nem csinál semmit.

A javítás az, hogy abbahagyod a context eldobhatóként kezelését. Hozd létre egyszer, és használd újra a szolgáltatás élettartamára:

  • A setupMonoMixingPipeline-ban csak akkor hozz létre new AudioContext(...)-et, ha még nincs egy, vagy a meglévő closed. Ha létezik, de suspended, akkor await audioContext.resume()-t hívj, ahelyett hogy újat készítenél.
  • A cleanup-ban cseréld le az audioContext.close()-t audioContext.suspend()-re (védetten, hogy soha ne hívódjon meg egy már lezárt contexten). A context túléli a munkamenetet.
  • Mivel a context túléli, a betöltött AudioWorklet modul is túléli vele — a kód már nem nullázza a „worklet loaded" flagjét cleanupkor, így az audioWorklet.addModule(...) egyszer fizetődik ki a teljes szolgáltatás-élettartamra, nem pedig minden munkamenetben.

A munkamenetenkénti darabok — a createMediaStreamSource node-ok a mikrofonhoz és a rendszerhanghoz, a mixer worklet, és a néma passthrough, amely életben tartja a Chromium rendszerhang-loopback csapját — továbbra is minden ciklusban felépülnek és lebomlanak. Csak maga az AudioContext marad fenn, így egyszerre legfeljebb egy létezik. A plafon elérhetetlen, mert soha nem halmozol ellene contexteket.

Egy őszinte megjegyzés, amit a commit tesz önmagáról: ezt kézzel ellenőrizték — „kézzel ellenőrizve: 8+ egymást követő munkamenet fenntartja a rendszerhang hallhatóságát és az átiratok áramlását (a 7. munkamenetnél halott volt)" — és a szerző őszinte azzal, hogy a Web Audio életciklusa „nem unit-tesztelhető jsdomban egy nagy új mock harness nélkül". Ez a jó fajta őszinteség. A hiba csak egy valódi audio eszközzel jelenik meg valódi munkameneteken át; egy zöld unit csomag semmit sem bizonyított volna. A „nagyjából hat" és a „a 7. munkamenetnél halott" a csapat megfigyelései arról, hol harapott a Chromium plafonja, nem egy dokumentált konstans — maga a kód tildével óvatoskodik, és így kellene tennie bárkinek, aki megismétli.

Resume, suspend, close

Állítsd a két kiadást egymás mellé, és a szimmetria szinte túl takaros. Mindkettő ugyanarról a három metódusról szól ugyanolyan fajta objektumon, ellentétes irányokból megközelítve:

  • A v1.8.16 megtanult resume()-t hívni. A contextet az OS suspended-elte ki alóla, és a javítás az, hogy elkapod ezt és folytatod — tarts futásban egy contextet, amelynek futnia kellene.
  • A v2.0.2 megtanult suspend()-et hívni close() helyett. A régi kód túl mohó volt a context elpusztításában, és a javítás az, hogy suspendeled és újrahasznosítod — tarts életben egy contextet, amelyet drága újra létrehozni.

Ezek egyetlen elv két fele: hagyd abba az AudioContext eldobhatóként kezelését. Az egyik fele azt mondja, ne hagyd meghalni, amikor nem kellene; a másik azt mondja, ne öld meg, amikor nem muszáj. És van egy kis irónia az idővonalban — a v1.8.16 cleanupját azért írták, hogy „megfeleljen a RealtimeAudioCaptureService cleanup mintájának", majd a v2.0.2 pontosan azt a mintát írta át a RealtimeAudioCaptureService-ben, mert a minta minden-alkalommal-close() szokása bizonyult a bugnak. A két szolgáltatás tovább tanul egymás audio-életciklus kezeléséből, egy javítás egyszerre.

Három dolog, amit ezek a kiadások megtanítottak

  1. Egy AudioContext-nek van egy állapotgépe — kezeld a state-et teherhordóként. A running, suspended és closed nem apróságok. Az OS anélkül mozgathat egy contextet közöttük, hogy megkérdezné, és a grafikonod különböző részei másképp reagálnak (egy fő szálon lévő ScriptProcessorNode tovább megy; a hardver-órázott célpont nem). Ha nem figyeled az onstatechange-et, azt bízod meg, hogy a context ott marad, ahol hagytad — és egy valódi desktopon, fókuszváltások és képernyőfelvétel alatt, nem fog.
  2. A close() nem free(). Az az intuíció, hogy egy erőforrás azonnali bezárása mindig helyes, pontosan az, ami a második bugot okozta. Amikor az erőforrás egy hely egy kemény, böngésző által kényszerített poolban, amely csak GC-időben szabadul fel, az agresszív bezárás az, ahogy kimeríted a poolt. Néha a helyes életciklus a hozd-létre-egyszer-suspendeld-gyakran, nem a hozd-létre-és-pusztítsd-el-használatonként.
  3. Néhány bug csak a valódi hardver és a valódi idő ajtaján keresztül érhető el. A „körülbelül hat munkamenet után elhal" és az „elnémul, amikor átváltasz" nem kapható el egy unit teszttel, amely mockolja az audio eszközt — és a csapat ezt nyíltan kimondta, ahelyett hogy egy hamisan-átmenő teszttel takargatta volna. Nevezd meg a teszt harnessed határait; egy bug, amelynek nyolc valódi munkamenetre van szüksége, hogy megjelenjen, egy őszinte „kézzel ellenőrizve"-t érdemel, nem egy zöld pipát, amely semmit sem jelent.

Az előző fejezetért, megkülönböztetni egy hívást egy nyitott alkalmazástól (v1.8.15–v1.8.19); a teljes ívért pedig a szoftver tökéletesre szállításának anatómiája.

Kapcsolódó cikkek

A csend teherhordó volt
Steven
Steven7 perc olvasás

A csend teherhordó volt

A GeekBye v1 utolsó két kiadása ugyanarról a kényelmetlen igazságról szól: a valós idejű átírás egy valódi hálózaton nem veszteségmentes, és az őszinte lépés az, hogy abbahagyod az úgy tenni, mintha az lenne. A v1.8.20 minden audio chunk másolatát a lemezen tartotta, mielőtt egy újracsatlakozás során eldobta volna, és elkezdte hangosan megjelölni a réseket az átiratban. A v1.9.0 abbahagyta a csend küldését, hogy sávszélességet spóroljon — és felfedezte, hogy a csend pontosan az a jel volt, amelyet az átíró használt annak megállapítására, hogy egy mondat véget ért. Két kiadás a dolgok eldobásának költségéről.

Mérnöki munka
Audio
Megbízhatóság
Megkülönböztetni egy hívást egy nyitott alkalmazástól
Steven
Steven8 perc olvasás

Megkülönböztetni egy hívást egy nyitott alkalmazástól

A GeekBye észreveheti, hogy csatlakoztál egy videohíváshoz, és felajánlhatja, hogy rögzíti. Az észlelés a könnyebbik felének bizonyul — egy Swift bináris, amely tízmásodpercenként ablakcímeket olvas. A nehezebbik fél a pontosság: nem elsülni, amikor a Zoom csak nyitva van, nem rákérdezni egy megbeszélésre, amelyet már rögzítesz, és nem némítani a mikrofont abban a hívásban, amelyben valójában benne vagy. Három kiadás, és mindegyik egy védelem, amelynek meg kellett tanulnia, hogy ne győzze le önmagát.

Mérnöki munka
macOS
Desktop
A backend kivétele a feltöltési útvonalból
Steven
Steven8 perc olvasás

A backend kivétele a feltöltési útvonalból

A GeekBye rögzíti a képernyődet, és elmenti a videót a Google Drive-odba. Az első verzió minden felvételt a GeekBye saját szerverein keresztül továbbított odafelé; egy kiadással később a fájl egyenesen a gépedről ment a Drive-ba, a backendet pedig lefokozták egyetlen mutató tartására. Az érdekes rész az, hogy milyen kevés kódot tartalmaz valójában a „közvetlen, folytatható" verzió — mert a folytathatóság egy proxy törléséből jött, nem a megírásából.

Mérnöki munka
Architektúra
Desktop