
Trys veiksmažodžiai, kurie palaiko Web Audio gyvą
Du GeekBye taškiniai leidimai, per du mėnesius vienas nuo kito ir dviejuose skirtinguose failuose, išmokė mūsų garso kodą tos pačios pamokos iš priešingų galų: liaukis elgtis su naršyklės AudioContext kaip su vienkartiniu. Vienas leidimas išmoko resume() kontekstą, kurį macOS tyliai pristabdė įrašymo viduryje; kitas išmoko suspend() vietoj close(), kad vienas po kito einantys seansai nustotų atsitrenkti į Chromium primestą maždaug šešių kontekstų lubą. Resume, suspend, close — štai ir visas siužetas.
Didžiąją laiko dalį naršyklės AudioContext elgiasi kaip paprastas objektas: sukonstruoji vieną, prijungi keletą mazgų ir pamiršti apie jį. Du GeekBye leidimai — tai kas nutiko, kai ta prielaida sugriuvo — dukart, dviejose skirtingose programos dalyse, per du mėnesius vienas nuo kito. Nė vienas nebuvo suplanuotas kaip kito tęsinys. Bet padėk juos greta — ir jie papasakoja vieną istoriją: naršyklės AudioContext nėra vienkartinis objektas. Tai ribotas, operacinei sistemai priklausantis išteklius su tikra būsenų mašina, o trys tos mašinos veiksmažodžiai — resume, suspend, close — yra visas abiejų leidimų siužetas.
GeekBye turi du atskirus garso konvejerius, ir būtent todėl tos pačios pamokos teko mokytis dukart. Vienas — tai vaizdo įrašymo kelias (ScreenRecordingService), kuris stato Web Audio grafą, kad sumaišytų mikrofoną ir sistemos garsą į įrašomą vaizdo failą. Kitas — tai transkripcijos realiu laiku kelias (RealtimeAudioCaptureService), kuris stato savo grafą, maitinantį kalbos vertimą į tekstą. Kiekvienas turi savo AudioContext. Kiekvienas leidimas žemiau ištaiso vieną iš jų.
v1.8.16: vaizdas nutilo, o transkripcija — ne
Pirmoji klaida yra sąmonės skilimas. Įrašinėji susitikimą, akimirkai persijungi į kitą programą, ir nutinka kažkas subtilaus: vaizdo failas praranda tavo mikrofoną, o gyva transkripcija slenka toliau, tarsi niekas nepasikeitė. Transkripcija prisiekia, kad kalbėjai. Vaizdas tyli. Tai iš proto varanti klaidos rūšis, nes du dalykai, kurie turėtų sutapti — „ar mikrofonas tave girdėjo" — atvirai nesutampa.
Commit, kuris tai ištaiso, tiksliai įvardija priežastį:
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.
Štai mechanika. ScreenRecordingService.setupMonoMixingPipeline() stato vieną AudioContext, MediaStreamDestination, kuris tampa vaizdo garso takeliu, GainNode nutildymui ir porą ScriptProcessorNode mazgų (per createScriptProcessor), kurie pumpuoja garso kadrus į VAD/STT logiką. Kai macOS pristabdo kontekstą — o gali tai padaryti, kai programa praranda fokusą ekrano įrašymo metu — grafo dalys, varomos aparatinio garso laikrodžio, sustoja: MediaStreamDestination nustoja gauti mikrofono garsą, tad įrašomas vaizdas nutyla. Bet ScriptProcessorNode mazgo onaudioprocess atgalinis iškvietimas veikia pagrindinėje JavaScript gijoje, o ta gija tiksi toliau. Tad STT pusė toliau gamina transkripcijos įrašus iš to, ką turėjo paskutinį kartą, ir du to paties mikrofono vartotojai išsiskiria.
Pataisymas — pastebėti pristabdymą ir jį atšaukti:
audioContext.onstatechange = () => {
if (audioContext.state === 'suspended' && this.status === 'recording') {
logger.info('AudioContext suspended during recording, resuming')
audioContext.resume()
}
}
Tai ir yra visas korektiškumo pakeitimas: stebėk konteksto state, ir jei jis nukrenta į suspended, kol vis dar turėtume įrašinėti, iškviesk resume(). Leidimas taip pat prijungia onmute, onunmute ir onended klausytojus prie mikrofono MediaStreamTrack — ne tam, kad pakeistų elgseną, o kad OS lygmens nutildymas ar įrenginio atjungimas atsirastų žurnaluose, užuot buvęs nematoma tylos priežastimi. Kai visas klaidos sunkumas yra „kodėl garsas sustojo", padaryti garso sustojimą stebimą yra pusė pataisymo.
Tęstinis commit nusileido pora minučių vėliau, kad sutvarkytų po tais naujais klausytojais: cleanup() dabar nunulina micTrack.onmute/onunmute/onended ir nustato audioContext.onstatechange = null, prieš išardydamas grafą. Commit užrašas sako, kad jis „atitinka RealtimeAudioCaptureService valymo šabloną" — kitos programos garso paslaugos. Įsidėmėk šią detalę, nes po dviejų mėnesių būtent šį šabloną kitam leidimui teko perrašyti.
v2.0.2: garsas ir transkripcijos miršta maždaug po šešių seansų
Antroji klaida gyvena kitame konvejeryje, RealtimeAudioCaptureService, ir turi nuostabią formą: viskas veikia tobulai, o paskui, kai kelis kartus paleidai ir sustabdei įrašymą per vieną programos seansą, garsas tiesiog... miršta. Pirma nutyla sistemos garsas, paskui sustoja transkripcija. Perkrauk programą — ir vėl viskas gerai, kol nepraeis dar sauja seansų.
Užrašą apie šakninę priežastį verta perskaityti visą, nes tai vadovėlinė klaida-už-klaidos:
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'.
Spąstai tame, kad senasis kodas darė „atsakingą" dalyką. Kiekvienas start() sukurdavo šviežią AudioContext; kiekvienas cleanup() jam iškviesdavo close(). Uždaryti išteklių, kai su juo baigei, yra būtent tai, ko tave moko. Ten net buvo kodo komentaras, racionalizuojantis tą švaistymą — kad perkrauti AudioWorklet modulį kiekvienam šviežiam kontekstui yra gerai, nes „šviežias kontekstas kiekvienam start() reiškia, kad šią kainą visada sumokame vieną kartą".
Bet Chromium riboja, kiek aparatiškai palaikomų AudioContext gali laikyti vienas puslapis — maždaug šešis — ir, kas svarbiausia, close() negrąžina lizdo tą pačią akimirką, kai jį iškvieti. Uždarytas kontekstas toliau užima savo lizdą, kol jį surenka GC, o tai nutinka, kai tik JS variklis prie to prieina. Tad kruopšti uždaryti-kiekvieną-kartą politika yra būtent tai, kas nuveda tave po lubomis: po šešių sukurti/uždaryti ciklų visi šeši lizdai laiko dar nesurinktus lavonus, septintas new AudioContext() grįžta miręs, o kiekvienas mazgas, kurį prie jo prijungi, tyliai nedaro nieko.
Pataisymas — nustoti elgtis su kontekstu kaip su vienkartiniu. Sukurk jį vieną kartą ir naudok pakartotinai visą paslaugos gyvavimą:
setupMonoMixingPipelinekonstruoknew AudioContext(...)tik jei jo dar nėra arba esamas yraclosed. Jei jis egzistuoja, bet yrasuspended, iškvieskawait audioContext.resume(), o ne kurk naują.cleanuppakeiskaudioContext.close()įaudioContext.suspend()(su apsauga, kad niekada nebūtų iškviestas jau uždarytam kontekstui). Kontekstas išgyvena seansą.- Kadangi kontekstas išgyvena, kartu su juo išgyvena įkeltas
AudioWorkletmodulis — kodas nebeatstato „workletas įkeltas" vėliavėlės valymo metu, tadaudioWorklet.addModule(...)apmokamas vieną kartą per visą paslaugos gyvavimą, o ne kiekvieną seansą.
Seanso lygmens dalys — createMediaStreamSource mazgai mikrofonui ir sistemos garsui, maišytuvo workletas ir tyli passthrough, kuri palaiko gyvą Chromium sistemos garso kilpos atšaką — vis dar statomos ir ardomos kiekvieną ciklą. Išlieka tik pats AudioContext, tad bet kuriuo momentu egzistuoja daugiausiai vienas. Lubos nepasiekiamos, nes niekada nekrauni kontekstų vieno ant kito po jomis.
Vienas sąžiningas užrašas, kurį commit padaro apie save: tai buvo patikrinta rankiniu būdu — „patikrinta rankiniu būdu: 8+ vienas po kito einančių seansų palaiko sistemos garsą girdimą, o transkripcijas tekančias (buvo mirę 7-ame seanse)" — ir autorius atviras, kad Web Audio gyvavimo ciklo „neįmanoma testuoti vienetais jsdom be didelio naujo mock karkaso". Tai teisinga sąžiningumo rūšis. Gedimas pasirodo tik su tikru garso įrenginiu per tikrus seansus; žalias vienetinių testų rinkinys nebūtų nieko įrodęs. „Maždaug šeši" ir „mirę 7-ame seanse" yra komandos pastebėjimai, kur įkando Chromium limitas, o ne dokumentuota konstanta — pats kodas apsidraudžia tilde, ir taip turėtų daryti kiekvienas, kas tai kartoja.
Resume, suspend, close
Išrikiuok du leidimus greta — ir simetrija beveik per daug tvarkinga. Abu apie tuos pačius tris metodus ant to paties objekto rūšies, priėjimai iš priešingų krypčių:
- v1.8.16 išmoko
resume(). OS pavertė kontekstąsuspendedmums iš po kojų, ir pataisymas — tai pagauti ir atnaujinti — palaikyti veikiantį kontekstą, kuris turi veikti. - v2.0.2 išmoko
suspend()vietojclose(). Senasis kodas buvo per daug uolus naikinti kontekstą, ir pataisymas — tai pristabdyti ir naudoti jį pakartotinai — palaikyti gyvą kontekstą, kurį brangu atkurti iš naujo.
Tai dvi vieno principo pusės: liaukis elgtis su AudioContext kaip su vienkarčiu. Viena pusė sako: neleisk jam mirti, kai neturėtų; kita sako: nežudyk jo, kai neprivalai. Ir čia yra maža ironija laiko juostoje — v1.8.16 valymas buvo parašytas, kad „atitiktų RealtimeAudioCaptureService valymo šabloną", o paskui v2.0.2 perrašė būtent tą šabloną RealtimeAudioCaptureService, nes šablono „close() kiekvieną kartą" įprotis pasirodė esąs ta klaida. Abi paslaugos toliau mokosi viena iš kitos garso gyvavimo ciklo tvarkymo, po vieną pataisymą.
Trys dalykai, kurių šie leidimai išmokė
AudioContextturi būsenų mašiną — laikykstatelaikančiuoju.running,suspendedirclosednėra smulkmenos. OS gali stumdyti kontekstą tarp jų tavęs neklausdama, o skirtingos tavo grafo dalys reaguoja skirtingai (ScriptProcessorNodepagrindinėje gijoje veikia toliau; aparatiniu laikrodžiu varoma paskirtis — ne). Jei nestebionstatechange, tu pasitiki, kad kontekstas liks ten, kur jį palikai — o tikrame kompiuteryje, esant fokuso pokyčiams ir ekrano įrašymui, neliks.close()nėrafree(). Intuicija, kad iškart uždaryti išteklių visada teisinga, yra būtent tai, kas sukėlė antrąją klaidą. Kai išteklius yra lizdas kietame, naršyklės primestame telkinyje, kuris atlaisvinamas tik per GC, agresyvus uždarymas yra būdas išsemti telkinį. Kartais teisingas gyvavimo ciklas yra sukurk-vieną-kartą-pristabdyk-dažnai, o ne kurk-ir-naikink-kiekvienam-naudojimui.- Kai kurias klaidas galima pasiekti tik pro tikros aparatūros ir tikro laiko duris. „Miršta maždaug po šešių seansų" ir „nutyla, kai persijungi" negali pagauti vienetinis testas, kuris imituoja garso įrenginį — ir komanda tai pasakė atvirai, o ne užlipdė netikrai praeinančiu testu. Įvardyk savo testų karkaso ribas; klaida, kuriai reikia aštuonių tikrų seansų pasirodyti, nusipelno sąžiningo „patikrinta rankiniu būdu", o ne žalios varnelės, kuri nereiškia nieko.
Ankstesniam skyriui — atskirti skambutį nuo atvertos programos (v1.8.15–v1.8.19); o visai istorijos linijai — programinės įrangos tobulybės pristatymo anatomija.