Steven
Steven9 min citire

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.

Inginerie
Audio
Desktop
Lansări GeekBye
Cele trei verbe care mențin Web Audio în viață

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 un new AudioContext(...) doar dacă nu există încă unul sau dacă cel existent e closed. Dacă există, dar e suspended, fă await audioContext.resume() în loc să creezi unul nou.
  • În cleanup, înlocuiește audioContext.close() cu audioContext.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 fost suspended pe 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 de close(). 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

  1. Un AudioContext are o mașină de stări — tratează state ca fiind portant. running, suspended și closed nu 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 (un ScriptProcessorNode pe thread-ul principal continuă; destinația conectată la ceasul hardware nu). Dacă nu urmărești onstatechange, 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.
  2. close() nu e free(). 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.
  3. 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.

Articole Similare

Tăcerea era portantă
Steven
Steven8 min citire

Tăcerea era portantă

Ultimele două lansări ale GeekBye v1 sunt despre același adevăr incomod: transcrierea în timp real peste o rețea reală nu e fără pierderi, iar mișcarea onestă e să încetezi să pretinzi că e. v1.8.20 a păstrat pe disc o copie a fiecărui fragment audio înainte să-l arunce în timpul unei reconectări și a început să marcheze cu voce tare golurile din transcriere. v1.9.0 a încetat să trimită tăcere ca să economisească lățime de bandă — și a descoperit că tăcerea era exact semnalul pe care transcriitorul îl folosea ca să știe că o propoziție s-a încheiat. Două lansări despre costul aruncării lucrurilor.

Inginerie
Audio
Fiabilitate
A deosebi un apel de o aplicație deschisă
Steven
Steven9 min citire

A deosebi un apel de o aplicație deschisă

GeekBye poate observa că te-ai alăturat unei ședințe video și îți poate oferi s-o înregistreze. Detecția se dovedește a fi jumătatea ușoară — un binar Swift care citește titlurile ferestrelor la fiecare zece secunde. Jumătatea grea e precizia: să nu se declanșeze când Zoom e doar deschis, să nu întrebe pentru o ședință pe care deja o înregistrezi și să nu silențieze microfonul din apelul în care chiar te afli. Trei lansări, iar fiecare e o gardă care a trebuit să învețe să nu se învingă singură.

Inginerie
macOS
Desktop
Scoaterea backend-ului din calea de upload
Steven
Steven8 min citire

Scoaterea backend-ului din calea de upload

GeekBye îți înregistrează ecranul și salvează videoclipul în Google Drive-ul tău. Prima versiune trimitea fiecare înregistrare prin serverele proprii ale GeekBye pe drumul într-acolo; o lansare mai târziu, fișierul mergea direct de la mașina ta către Drive, iar backend-ul a fost retrogradat la a ține un singur pointer. Partea interesantă e cât de puțin cod conține de fapt versiunea „directă, resumabilă" — pentru că resumabilitatea a venit din ștergerea unui proxy, nu din scrierea unuia.

Inginerie
Arhitectură
Desktop