Steven
Steven9 min di lettura

I tre verbi che tengono vivo il Web Audio

Due release puntuali di GeekBye, a due mesi di distanza e in due file diversi, hanno insegnato al nostro codice audio la stessa lezione da estremità opposte: smettere di trattare l'AudioContext del browser come usa e getta. Una release ha imparato a fare resume() di un contesto che macOS aveva silenziosamente sospeso a metà registrazione; l'altra ha imparato a fare suspend() invece di close() così che sessioni consecutive smettano di schiantarsi contro il tetto di circa sei contesti di Chromium. Resume, suspend, close — è tutta qui la trama.

Ingegneria
Audio
Desktop
Release di GeekBye
I tre verbi che tengono vivo il Web Audio

La maggior parte del tempo, un AudioContext di Web Audio si comporta come un oggetto qualunque: ne costruisci uno, colleghi qualche nodo, e te ne dimentichi. Due release di GeekBye sono ciò che è successo quando quell'assunzione si è rotta — due volte, in due parti diverse dell'app, a due mesi di distanza. Nessuna era pianificata come seguito dell'altra. Ma mettile fianco a fianco e raccontano una sola storia: l'AudioContext del browser non è un oggetto usa e getta. È una risorsa scarsa, di proprietà del sistema operativo, con una vera macchina a stati, e i tre verbi di quella macchina a stati — resume, suspend, close — sono l'intera trama di entrambe le release.

GeekBye ha due pipeline audio separati, ed è per questo che la stessa lezione è dovuta essere imparata due volte. Uno è il percorso di registrazione video (ScreenRecordingService), che costruisce un grafo Web Audio per mixare microfono e audio di sistema in un file video registrato. L'altro è il percorso di trascrizione in tempo reale (RealtimeAudioCaptureService), che costruisce il proprio grafo per alimentare il riconoscimento vocale. Ciascuno ha il proprio AudioContext. Ogni release qui sotto ne corregge uno.

v1.8.16: il video è diventato muto, ma la trascrizione no

Il primo bug è un cervello diviso. Stai registrando una riunione, passi per un attimo a un'altra app, e succede qualcosa di sottile: il file video perde il tuo microfono mentre la trascrizione dal vivo continua a scorrere come se nulla fosse cambiato. La trascrizione giura che stavi parlando. Il video è muto. È un tipo di bug esasperante perché le due cose che dovrebbero concordare — «il microfono ti ha sentito?» — sono in totale disaccordo.

Il commit che lo corregge nomina la causa con precisione:

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.

Ecco la meccanica. ScreenRecordingService.setupMonoMixingPipeline() costruisce un AudioContext, un MediaStreamDestination che diventa la traccia audio del video, un GainNode per il silenziamento, e una coppia di ScriptProcessorNode (via createScriptProcessor) che pompano frame audio verso la logica VAD/STT. Quando macOS sospende il contesto — cosa che può fare quando l'app perde il focus durante una registrazione dello schermo — le parti del grafo governate dal clock dell'hardware audio si fermano: il MediaStreamDestination smette di ricevere audio dal microfono, così il video registrato diventa muto. Ma il callback onaudioprocess di un ScriptProcessorNode gira sul thread principale di JavaScript, e quel thread continua a battere. Così il lato STT continua a produrre voci di trascrizione da quel che aveva per ultimo, e i due consumatori dello stesso microfono divergono.

Il fix è accorgersi della sospensione e annullarla:

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

È tutto qui il cambiamento di correttezza: sorvegliare lo state del contesto, e se scende a suspended mentre dovremmo ancora registrare, chiamare resume(). La release collega anche listener onmute, onunmute e onended sul MediaStreamTrack del microfono — non per cambiare il comportamento, ma perché un silenziamento a livello di OS o una disconnessione del dispositivo compaia nei log invece di essere una causa invisibile di silenzio. Quando tutta la difficoltà di un bug è «perché l'audio si è fermato», rendere l'arresto dell'audio osservabile è metà del fix.

Un commit di seguito è atterrato un paio di minuti dopo per pulire dopo quei nuovi listener: cleanup() ora azzera micTrack.onmute/onunmute/onended e imposta audioContext.onstatechange = null prima di smontare il grafo. La nota del commit dice che «corrisponde al pattern di pulizia di RealtimeAudioCaptureService» — l'altro servizio audio dell'app. Tieni a mente questo dettaglio, perché due mesi dopo proprio quel pattern è ciò che la release successiva ha dovuto riscrivere.

v2.0.2: audio e trascrizioni muoiono dopo circa sei sessioni

Il secondo bug vive nell'altro pipeline, RealtimeAudioCaptureService, e ha una forma meravigliosa: tutto funziona alla perfezione, e poi dopo aver avviato e fermato la registrazione una manciata di volte in una stessa sessione dell'app, l'audio semplicemente... muore. Prima l'audio di sistema diventa muto, poi la trascrizione si ferma. Riavvia l'app e va di nuovo tutto bene — fino a un'altra manciata di sessioni.

La nota di causa radice vale la pena leggerla per intero, perché è un bug-dietro-al-bug da manuale:

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

La trappola è che il vecchio codice stava facendo la cosa «responsabile». Ogni start() creava un AudioContext nuovo; ogni cleanup() gli chiamava close(). Chiudere una risorsa quando hai finito con essa è esattamente ciò che ti insegnano a fare. C'era perfino un commento inline che razionalizzava il viavai — che ricaricare il modulo AudioWorklet per ogni contesto nuovo andava bene perché «un contesto nuovo per start() significa che paghiamo sempre questo costo una volta».

Ma Chromium limita quanti AudioContext supportati dall'hardware una singola pagina può contenere — circa sei — e, cosa cruciale, close() non restituisce lo slot nell'istante in cui lo chiami. Un contesto chiuso continua a occupare il suo slot finché non viene raccolto dal garbage collector, cosa che accade quando al motore JS gira di farlo. Così una scrupolosa politica di chiudere-ogni-volta è proprio ciò che ti porta dritto nel tetto: sei cicli di crea/chiudi dopo, i sei slot sono tutti occupati da cadaveri non ancora raccolti, il settimo new AudioContext() torna morto, e ogni nodo che gli attacchi silenziosamente non fa nulla.

Il fix è smettere di trattare il contesto come usa e getta. Crearlo una volta e riutilizzarlo per tutta la vita del servizio:

  • In setupMonoMixingPipeline, costruire un new AudioContext(...) solo se non ce n'è ancora nessuno o quello esistente è closed. Se esiste ma è suspended, fare await audioContext.resume() invece di crearne uno nuovo.
  • In cleanup, sostituire audioContext.close() con audioContext.suspend() (protetto così da non essere mai chiamato su un contesto già chiuso). Il contesto sopravvive alla sessione.
  • Poiché il contesto sopravvive, il modulo AudioWorklet caricato sopravvive con lui — il codice non reimposta più il suo flag «worklet caricato» nella pulizia, così audioWorklet.addModule(...) si paga una volta per tutta la vita del servizio invece che a ogni sessione.

I pezzi per sessione — i nodi createMediaStreamSource per il microfono e l'audio di sistema, il worklet mixer, e il passthrough silenzioso che tiene vivo il punto di prelievo del loopback dell'audio di sistema di Chromium — vengono ancora costruiti e smontati a ogni ciclo. Solo l'AudioContext stesso persiste, così ne esiste al massimo uno alla volta. Il tetto è irraggiungibile perché non impili mai contesti contro di esso.

Una nota onesta che il commit fa su se stesso: questo è stato verificato a mano — «verificato manualmente: 8+ sessioni consecutive mantengono l'audio di sistema udibile e le trascrizioni che scorrono (era morto alla sessione 7)» — e l'autore è franco nel dire che il ciclo di vita di Web Audio «non è testabile a livello unitario in jsdom senza un grande nuovo harness di mock». Questo è il tipo giusto di onestà. Il guasto appare solo di fronte a un vero dispositivo audio attraverso sessioni reali; una suite unitaria in verde non avrebbe provato nulla. Il «circa sei» e il «morto alla sessione 7» sono osservazioni del team di dove il limite di Chromium ha morso, non una costante documentata — il codice stesso si cautela con una tilde, e così dovrebbe fare chiunque lo ripeta.

Resume, suspend, close

Allinea le due release e la simmetria è quasi troppo pulita. Entrambe riguardano gli stessi tre metodi sullo stesso tipo di oggetto, affrontati da direzioni opposte:

  • La v1.8.16 ha imparato a fare resume(). L'OS ha lasciato il contesto suspended da sotto i piedi, e il fix è intercettarlo e riprendere — tenere in esecuzione un contesto che dovrebbe essere in esecuzione.
  • La v2.0.2 ha imparato a fare suspend() invece di close(). Il vecchio codice aveva troppa fretta di distruggere il contesto, e il fix è sospenderlo e riutilizzarlo — tenere vivo un contesto costoso da ricreare.

Sono le due metà di un unico principio: smettere di trattare l'AudioContext come qualcosa di usa e getta. Una metà dice: non lasciarlo morire quando non dovrebbe; l'altra dice: non ucciderlo quando non è necessario. E c'è una piccola ironia nella cronologia — la pulizia della v1.8.16 è stata scritta per «corrispondere al pattern di pulizia di RealtimeAudioCaptureService», e poi la v2.0.2 ha riscritto proprio quel pattern in RealtimeAudioCaptureService, perché l'abitudine di close()-ogni-volta del pattern si è rivelata essere il bug. I due servizi continuano a imparare l'uno dalla gestione del ciclo di vita audio dell'altro, un fix alla volta.

Tre cose che queste release ci hanno insegnato

  1. Un AudioContext ha una macchina a stati — tratta state come portante. running, suspended e closed non sono banalità. L'OS può spostare un contesto tra di essi senza chiedertelo, e parti diverse del tuo grafo reagiscono in modo diverso (un ScriptProcessorNode sul thread principale continua; la destinazione cadenzata dall'hardware no). Se non sorvegli onstatechange, ti stai fidando che il contesto resti dove l'hai lasciato — e su un vero desktop, sotto cambi di focus e registrazione dello schermo, non lo farà.
  2. close() non è free(). L'intuizione che chiudere prontamente una risorsa sia sempre corretto è esattamente ciò che ha causato il secondo bug. Quando la risorsa è uno slot in un pool duro, imposto dal browser, che viene recuperato solo al momento del GC, chiudere in modo aggressivo è come esaurisci il pool. A volte il ciclo di vita giusto è crea-una-volta-sospendi-spesso, non crea-e-distruggi-a-ogni-uso.
  3. Alcuni bug sono raggiungibili solo dalla porta dell'hardware reale e del tempo reale. «Muore dopo circa sei sessioni» e «diventa muto quando passi a un'altra scheda» non possono essere catturati da un test unitario che simula il dispositivo audio — e il team l'ha detto chiaramente invece di coprirlo con un test che passa in modo fasullo. Nomina i limiti del tuo harness di test; un bug che ha bisogno di otto sessioni reali per apparire merita un onesto «verificato manualmente», non una spunta verde che non significa nulla.

Per il capitolo precedente, distinguere una chiamata da un'app aperta (v1.8.15–v1.8.19); e per tutto l'arco, l'anatomia del pubblicare software fino alla perfezione.

Articoli Correlati

Il silenzio era portante
Steven
Steven8 min di lettura

Il silenzio era portante

Le ultime due release di GeekBye v1 ruotano attorno alla stessa verità scomoda: la trascrizione in tempo reale su una rete vera non è senza perdite, e la mossa onesta è smettere di fingere che lo sia. La v1.8.20 teneva su disco una copia di ogni chunk audio prima di scartarlo durante una riconnessione, e ha iniziato a segnare ad alta voce i buchi nella trascrizione. La v1.9.0 ha smesso di inviare silenzio per risparmiare banda — e ha scoperto che il silenzio era proprio il segnale che il trascrittore usava per sapere che una frase era finita. Due release sul costo di buttare via le cose.

Ingegneria
Audio
Affidabilità
Distinguere una chiamata da un'app aperta
Steven
Steven9 min di lettura

Distinguere una chiamata da un'app aperta

GeekBye può accorgersi che ti sei unito a una riunione video e offrirti di registrarla. La rilevazione si rivela la metà facile — un binario Swift che legge i titoli delle finestre ogni dieci secondi. La metà difficile è la precisione: non scattare quando Zoom è semplicemente aperto, non proporti di registrare una riunione che stai già registrando, e non silenziare il microfono nella chiamata in cui sei davvero. Tre release, e ognuna è una salvaguardia che ha dovuto imparare a non sabotare se stessa.

Ingegneria
macOS
Desktop
Togliere il backend dal percorso di upload
Steven
Steven9 min di lettura

Togliere il backend dal percorso di upload

GeekBye registra il tuo schermo e salva il video sul tuo Google Drive. La prima versione faceva passare ogni registrazione attraverso i server di GeekBye lungo il tragitto; una release più tardi, il file andava direttamente dalla tua macchina a Drive, e il backend veniva retrocesso a tenere un singolo puntatore. La parte interessante è quanto poco codice contenga davvero la versione «diretta e riprendibile» — perché la riprendibilità è venuta dal cancellare un proxy, non dallo scriverne uno.

Ingegneria
Architettura
Desktop