
Cele trei verbe care mențin Web Audio în viață
Două lansări punctuale GeekBye, la două luni distanță și în două fișiere diferite, au învățat codul nostru audio aceeași lecție din capete opuse: nu mai trata AudioContext-ul browserului ca pe ceva de unică folosință. O lansare a învățat să apeleze resume() pe un context pe care macOS îl suspendase în tăcere în mijlocul înregistrării; cealaltă a învățat să apeleze suspend() în loc de close(), ca sesiunile consecutive să nu mai izbească plafonul Chromium de aproximativ șase contexte. Resume, suspend, close — asta e toată intriga.
De cele mai multe ori, un AudioContext Web Audio se comportă ca un obiect obișnuit: construiești unul, conectezi câteva noduri și uiți de el. Două lansări GeekBye sunt ceea ce s-a întâmplat când acea presupunere s-a rupt — de două ori, în două părți diferite ale aplicației, la două luni distanță. Niciuna nu a fost planificată ca o continuare a celeilalte. Dar pune-le una lângă alta și spun o singură poveste: AudioContext-ul browserului nu e un obiect de unică folosință. E o resursă rară, deținută de sistemul de operare, cu o mașină de stări reală, iar cele trei verbe ale acelei mașini de stări — resume, suspend, close — sunt toată intriga ambelor lansări.
GeekBye are două pipeline-uri audio separate, motiv pentru care aceeași lecție a trebuit învățată de două ori. Unul e calea de înregistrare video (ScreenRecordingService), care construiește un graf Web Audio ca să amestece microfonul și audio-ul sistemului într-un fișier video înregistrat. Celălalt e calea de transcriere în timp real (RealtimeAudioCaptureService), care își construiește propriul graf ca să alimenteze speech-to-text. Fiecare are propriul AudioContext. Fiecare lansare de mai jos o repară pe una dintre ele.
v1.8.16: video-ul a amuțit, dar transcrierea nu
Primul bug e un creier scindat. Înregistrezi o ședință, treci pentru o clipă la altă aplicație și se întâmplă ceva subtil: fișierul video îți pierde microfonul, în timp ce transcrierea live continuă să deruleze ca și cum nimic nu s-ar fi schimbat. Transcrierea jură că vorbeai. Video-ul e mut. E un gen de bug înnebunitor, pentru că cele două lucruri care ar trebui să fie de acord — „te-a auzit microfonul" — sunt în total dezacord.
Commit-ul care îl repară numește cauza cu precizie:
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.
Iată mecanica. ScreenRecordingService.setupMonoMixingPipeline() construiește un AudioContext, un MediaStreamDestination care devine pista audio a video-ului, un GainNode pentru mut și o pereche de ScriptProcessorNode-uri (prin createScriptProcessor) care pompează cadre audio către logica VAD/STT. Când macOS suspendă contextul — ceea ce poate face când aplicația pierde focusul în timpul unei înregistrări de ecran — părțile grafului conduse de ceasul hardware-ului audio se opresc: MediaStreamDestination nu mai primește audio de la microfon, așa că video-ul înregistrat amuțește. Dar callback-ul onaudioprocess al unui ScriptProcessorNode rulează pe thread-ul principal JavaScript, iar acel thread continuă să ticăie. Așa că partea STT continuă să producă intrări de transcriere din ce a avut ultima dată, iar cei doi consumatori ai aceluiași microfon diverg.
Reparația e să observi suspendarea și s-o anulezi:
audioContext.onstatechange = () => {
if (audioContext.state === 'suspended' && this.status === 'recording') {
logger.info('AudioContext suspended during recording, resuming')
audioContext.resume()
}
}
Asta e toată schimbarea de corectitudine: urmărește state-ul contextului, iar dacă scade la suspended cât timp încă ar trebui să înregistrăm, apelează resume(). Lansarea conectează și ascultători onmute, onunmute și onended pe MediaStreamTrack-ul microfonului — nu ca să schimbe comportamentul, ci pentru ca un mut la nivel de sistem de operare sau o deconectare de dispozitiv să apară în loguri, în loc să fie o cauză invizibilă de tăcere. Când toată dificultatea unui bug e „de ce s-a oprit audio-ul", a face oprirea audio-ului observabilă e jumătate din reparație.
Un commit ulterior a apărut câteva minute mai târziu ca să facă curat după acei noi ascultători: cleanup() acum anulează micTrack.onmute/onunmute/onended și setează audioContext.onstatechange = null înainte să dărâme graful. Nota commit-ului spune că „potrivește tiparul de cleanup al RealtimeAudioCaptureService" — celălalt serviciu audio al aplicației. Reține acest detaliu, pentru că două luni mai târziu exact acel tipar e ceea ce a trebuit rescris de următoarea lansare.
v2.0.2: audio-ul și transcrierile mor după aproximativ șase sesiuni
Al doilea bug trăiește în celălalt pipeline, RealtimeAudioCaptureService, și are o formă minunată: totul funcționează perfect, iar apoi, după ce ai pornit și oprit înregistrarea de câteva ori într-o singură sesiune a aplicației, audio-ul pur și simplu... moare. Întâi audio-ul sistemului amuțește, apoi transcrierea se oprește. Repornești aplicația și e din nou în regulă — până la încă un pumn de sesiuni.
Nota despre cauza principală merită citită în întregime, pentru că e un bug-din-spatele-bug-ului de manual:
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'.
Capcana e că vechiul cod făcea lucrul „responsabil". Fiecare start() crea un AudioContext proaspăt; fiecare cleanup() apela close() pe el. Să închizi o resursă când ai terminat cu ea e exact ce ești învățat să faci. Exista chiar și un comentariu inline care raționaliza risipa — că reîncărcarea modulului AudioWorklet per context proaspăt era în regulă, pentru că „un context proaspăt per start() înseamnă că plătim mereu acest cost o singură dată".
Dar Chromium limitează câte AudioContext-uri susținute de hardware poate ține o singură pagină — aproximativ șase — și, esențial, close() nu înapoiază slotul în clipa în care îl apelezi. Un context închis continuă să-și ocupe slotul până când e colectat de garbage collector, ceea ce se întâmplă când apucă motorul JS. Așa că o politică scrupuloasă de închide-de-fiecare-dată e exact lucrul care te duce în plafon: șase cicluri de creare/închidere mai târziu, cele șase sloturi sunt toate ocupate de cadavre încă necolectate, al șaptelea new AudioContext() se întoarce mort, iar fiecare nod pe care i-l atașezi nu face nimic în tăcere.
Reparația e să încetezi să tratezi contextul ca pe ceva de unică folosință. Creează-l o dată și refolosește-l pe toată durata de viață a serviciului:
- În
setupMonoMixingPipeline, construiește unnew AudioContext(...)doar dacă nu există încă unul sau dacă cel existent eclosed. Dacă există, dar esuspended, făawait audioContext.resume()în loc să creezi unul nou. - În
cleanup, înlocuieșteaudioContext.close()cuaudioContext.suspend()(păzit ca să nu fie apelat niciodată pe un context deja închis). Contextul supraviețuiește sesiunii. - Pentru că contextul supraviețuiește, modulul
AudioWorkletîncărcat supraviețuiește odată cu el — codul nu mai resetează flag-ul său „worklet încărcat" la cleanup, așa căaudioWorklet.addModule(...)e plătit o singură dată pentru toată durata de viață a serviciului, în loc de fiecare sesiune.
Piesele per-sesiune — nodurile createMediaStreamSource pentru microfon și audio-ul sistemului, worklet-ul de mixare și passthrough-ul silențios care menține în viață priza de loopback a audio-ului de sistem din Chromium — sunt încă construite și dărâmate la fiecare ciclu. Doar AudioContext-ul în sine persistă, așa că există cel mult unul la un moment dat. Plafonul e de neatins pentru că nu stivuiești niciodată contexte spre el.
O notă onestă pe care commit-ul o face despre sine: aceasta a fost verificată manual — „verificat manual: 8+ sesiuni consecutive mențin audio-ul sistemului audibil și transcrierile curgând (era mort la sesiunea 7)" — iar autorul e sincer că ciclul de viață Web Audio „nu e testabil unitar în jsdom fără un mare harness de mock nou". Ăsta e genul potrivit de onestitate. Eșecul apare doar cu un dispozitiv audio real, de-a lungul unor sesiuni reale; o suită de teste unitare verde n-ar fi dovedit nimic. „Aproximativ șase" și „mort la sesiunea 7" sunt observațiile echipei despre unde a mușcat plafonul Chromium, nu o constantă documentată — codul însuși ezită cu o tildă, și la fel ar trebui oricine îl repetă.
Resume, suspend, close
Așază cele două lansări una lângă alta și simetria e aproape prea curată. Ambele sunt despre aceleași trei metode pe același gen de obiect, abordate din direcții opuse:
- v1.8.16 a învățat să facă
resume(). Contextul a fostsuspendedpe sub el de către sistemul de operare, iar reparația e să prinzi asta și să reiei — menține un context care ar trebui să ruleze, rulând. - v2.0.2 a învățat să facă
suspend()în loc declose(). Vechiul cod era prea nerăbdător să distrugă contextul, iar reparația e să-l suspenzi și să-l refolosești — menține în viață un context care e costisitor de recreat.
Sunt cele două jumătăți ale unui singur principiu: nu mai trata AudioContext-ul ca pe ceva de aruncat. O jumătate spune să nu-l lași să moară când n-ar trebui; cealaltă spune să nu-l omori când nu e nevoie. Și există o mică ironie în cronologie — cleanup-ul din v1.8.16 a fost scris ca să „potrivească tiparul de cleanup al RealtimeAudioCaptureService", iar apoi v2.0.2 a rescris exact acel tipar în RealtimeAudioCaptureService, pentru că obiceiul tiparului de a apela close() de fiecare dată s-a dovedit a fi bug-ul. Cele două servicii continuă să învețe unul din gestionarea ciclului de viață audio al celuilalt, câte o reparație pe rând.
Trei lucruri pe care ni le-au învățat aceste lansări
- Un
AudioContextare o mașină de stări — trateazăstateca fiind portant.running,suspendedșiclosednu sunt fleacuri. Sistemul de operare poate muta un context între ele fără să te întrebe, iar diferite părți ale grafului tău reacționează diferit (unScriptProcessorNodepe thread-ul principal continuă; destinația conectată la ceasul hardware nu). Dacă nu urmăreștionstatechange, te încrezi că un context rămâne unde l-ai lăsat — iar pe un desktop real, sub schimbări de focus și înregistrare de ecran, nu va rămâne. close()nu efree(). Intuiția că a închide prompt o resursă e mereu corect e exact ce a cauzat al doilea bug. Când resursa e un slot într-un bazin dur, impus de browser, care e recuperat doar la momentul GC, închiderea agresivă e felul în care epuizezi bazinul. Uneori ciclul de viață corect e creează-o-dată-suspendă-des, nu creează-și-distruge-la-fiecare-utilizare.- Unele bug-uri sunt accesibile doar prin ușa hardware-ului real și a timpului real. „Moare după aproximativ șase sesiuni" și „amuțește când treci la altă filă" nu pot fi prinse de un test unitar care simulează dispozitivul audio — iar echipa a spus-o clar, în loc s-o mascheze cu un test care trece fals. Numește limitele harness-ului tău de testare; un bug care are nevoie de opt sesiuni reale ca să apară merită un „verificat manual" onest, nu o bifă verde care nu înseamnă nimic.
Pentru capitolul anterior, a deosebi un apel de o aplicație deschisă (v1.8.15–v1.8.19); iar pentru întregul arc, anatomia livrării de software până la perfecțiune.