
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.
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étrenew AudioContext(...)-et, ha még nincs egy, vagy a meglévőclosed. Ha létezik, desuspended, akkorawait audioContext.resume()-t hívj, ahelyett hogy újat készítenél. - A
cleanup-ban cseréld le azaudioContext.close()-taudioContext.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
AudioWorkletmodul is túléli vele — a kód már nem nullázza a „worklet loaded" flagjét cleanupkor, így azaudioWorklet.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 OSsuspended-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ívniclose()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
- Egy
AudioContext-nek van egy állapotgépe — kezeld astate-et teherhordóként. Arunning,suspendedésclosednem 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őScriptProcessorNodetovább megy; a hardver-órázott célpont nem). Ha nem figyeled azonstatechange-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. - A
close()nemfree(). 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. - 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.