Steven
Steven10 min de lectura

Els tres verbs que mantenen viu el Web Audio

Dues point releases de GeekBye, amb dos mesos de diferència i en dos fitxers diferents, van ensenyar al nostre codi d'àudio la mateixa lliçó des d'extrems oposats: deixa de tractar l'AudioContext del navegador com a rebutjable. Una versió va aprendre a fer resume() d'un context que macOS havia suspès silenciosament enmig d'una gravació; l'altra va aprendre a fer suspend() en comptes de close() perquè les sessions consecutives deixin d'estavellar-se contra el sostre de prop de sis contextos de Chromium. Resume, suspend, close — aquest és tot l'argument.

Engineering
Audio
Desktop
GeekBye Releases
Els tres verbs que mantenen viu el Web Audio

La major part del temps, un Web Audio AudioContext es comporta com un objecte planer: en construeixes un, hi connectes uns quants nodes, i te n'oblides. Dues versions de GeekBye són el que va passar quan aquella suposició es va trencar — dues vegades, en dues parts diferents de l'app, amb dos mesos de diferència. Cap de les dues estava planificada com a continuació de l'altra. Però posa-les de costat i expliquen una sola història: l'AudioContext del navegador no és un objecte rebutjable. És un recurs escàs, propietat del sistema operatiu, amb una màquina d'estats real, i els tres verbs d'aquella màquina d'estats — resume, suspend, close — són tot l'argument de totes dues versions.

GeekBye té dos pipelines d'àudio separats, i és per això que la mateixa lliçó s'havia d'aprendre dues vegades. L'un és el camí de gravació de vídeo (ScreenRecordingService), que construeix un graf de Web Audio per mesclar àudio de micròfon i de sistema en un fitxer de vídeo gravat. L'altre és el camí de transcripció en temps real (RealtimeAudioCaptureService), que construeix el seu propi graf per alimentar la conversió de veu a text. Cadascun té el seu propi AudioContext. Cada versió de sota n'arregla un.

v1.8.16: el vídeo va emmudir, però la transcripció no

El primer bug és un cervell dividit. Estàs gravant una reunió, canvies un moment de pestanya a una altra app, i passa una cosa subtil: el fitxer de vídeo perd el teu micròfon mentre la transcripció en directe segueix desplaçant-se com si res no hagués canviat. La transcripció jura que estaves parlant. El vídeo és mut. És un tipus de bug per tornar-te boig, perquè les dues coses que haurien d'estar d'acord — "t'ha sentit el micròfon" — estan rotundament en desacord.

El commit que ho arregla anomena la causa amb precisió:

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.

Aquí tens la mecànica. ScreenRecordingService.setupMonoMixingPipeline() construeix un AudioContext, un MediaStreamDestination que esdevé la pista d'àudio del vídeo, un GainNode per silenciar, i un parell de ScriptProcessorNodes (via createScriptProcessor) que bombegen fotogrames d'àudio cap a la lògica de VAD/STT. Quan macOS suspèn el context — cosa que pot fer quan l'app perd el focus durant una gravació de pantalla — les parts del graf impulsades pel rellotge del maquinari d'àudio s'aturen: el MediaStreamDestination deixa de rebre àudio del micròfon, així que el vídeo gravat es queda en silenci. Però la devolució de trucada onaudioprocess d'un ScriptProcessorNode s'executa al fil principal de JavaScript, i aquell fil segueix funcionant. Així que la banda de STT segueix produint entrades de transcripció a partir del que tenia per últim, i els dos consumidors del mateix micròfon divergeixen.

La solució és adonar-se de la suspensió i desfer-la:

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

Aquesta és tota la correcció: vigila l'state del context, i si cau a suspended mentre encara se suposa que hem de gravar, crida resume(). La versió també connecta escoltadors onmute, onunmute i onended al MediaStreamTrack del micròfon — no per canviar el comportament, sinó perquè un silenciament a nivell d'OS o una desconnexió de dispositiu apareguin als registres en comptes de ser una causa invisible de silenci. Quan tota la dificultat d'un bug és "per què s'ha aturat l'àudio," fer que l'aturada de l'àudio sigui observable és mitja solució.

Un commit posterior va arribar un parell de minuts després per netejar després d'aquells nous escoltadors: cleanup() ara posa a null micTrack.onmute/onunmute/onended i estableix audioContext.onstatechange = null abans de desmuntar el graf. La nota del commit diu que és "matching RealtimeAudioCaptureService cleanup pattern" — l'altre servei d'àudio de l'app. Recorda aquest detall, perquè dos mesos després aquell mateix patró és el que la versió següent va haver de reescriure.

v2.0.2: l'àudio i les transcripcions moren després d'unes sis sessions

El segon bug viu a l'altre pipeline, RealtimeAudioCaptureService, i té una forma meravellosa: tot funciona perfectament, i llavors, després d'haver iniciat i aturat la gravació un grapat de vegades en una sola sessió de l'app, l'àudio simplement... es mor. Primer emmudeix l'àudio del sistema, després s'atura la transcripció. Reinicia l'app i torna a anar bé — fins a un altre grapat de sessions.

La nota de causa arrel val la pena de llegir sencera, perquè és un bug-darrere-del-bug 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'.

El parany és que el codi antic feia la cosa "responsable." Cada start() creava un AudioContext fresc; cada cleanup() hi cridava close(). Tancar un recurs quan has acabat amb ell és exactament el que t'ensenyen a fer. Fins i tot hi havia un comentari en línia que racionalitzava el trasbals — que recarregar el mòdul AudioWorklet per cada context fresc estava bé perquè "a fresh context per start() means we always pay this cost once."

Però Chromium limita quants AudioContexts amb suport de maquinari pot contenir una sola pàgina — prop de sis — i, crucialment, close() no torna la ranura l'instant que el crides. Un context tancat segueix ocupant la seva ranura fins que la recull el recol·lector d'escombraries, cosa que passa quan el motor de JS s'hi posa. Així que una política escrupolosa de tancar-cada-vegada és precisament el que et fa entrar al sostre: sis cicles de creació/tancament després, les sis ranures estan totes ocupades per cadàvers encara-no-recollits, el setè new AudioContext() torna mort, i cada node que hi connectes silenciosament no fa res.

La solució és deixar de tractar el context com a rebutjable. Crea'l un cop i reutilitza'l durant tota la vida del servei:

  • A setupMonoMixingPipeline, construeix un new AudioContext(...) només si encara no n'hi ha cap o el que existeix és closed. Si existeix però és suspended, fes await audioContext.resume() en comptes de fer-ne un de nou.
  • A cleanup, substitueix audioContext.close() per audioContext.suspend() (protegit perquè mai es cridi sobre un context ja tancat). El context sobreviu a la sessió.
  • Com que el context sobreviu, el mòdul AudioWorklet carregat sobreviu amb ell — el codi ja no reinicia la seva bandera "worklet loaded" a la neteja, així que audioWorklet.addModule(...) es paga un cop per a tota la vida del servei en comptes de cada sessió.

Les peces per sessió — els nodes createMediaStreamSource per a l'àudio de micròfon i de sistema, el worklet mesclador, i el pas silenciós que manté viva la presa de bucle d'àudio del sistema de Chromium — encara es construeixen i es desmunten cada cicle. Només l'AudioContext mateix persisteix, així que n'hi ha com a màxim un en existència en tot moment. El sostre és inabastable perquè mai hi apiles contextos a sobre.

Una nota honesta que el commit fa sobre si mateix: això es va verificar a mà — "manually verified: 8+ back-to-back sessions keep system audio audible and transcripts flowing (was dead at session 7)" — i l'autor és franc que el cicle de vida de Web Audio "isn't unit-testable in jsdom without a large new mock harness." Aquell és el tipus correcte d'honestedat. La fallada només apareix contra un dispositiu d'àudio real al llarg de sessions reals; una suite d'unitats verda no hauria demostrat res. El "prop de sis" i "mort a la sessió 7" són les observacions de l'equip d'on va mossegar el límit de Chromium, no una constant documentada — el mateix codi es cobreix les espatlles amb una titlla, i també ho hauria de fer qualsevol que ho repeteixi.

Resume, suspend, close

Alinea les dues versions i la simetria és gairebé massa polida. Totes dues tracten dels mateixos tres mètodes sobre el mateix tipus d'objecte, abordats des de direccions oposades:

  • La v1.8.16 va aprendre a fer resume(). El context va quedar suspended per sota seu pel SO, i la solució és detectar-ho i reprendre — mantén funcionant un context que se suposa que ha d'estar funcionant.
  • La v2.0.2 va aprendre a fer suspend() en comptes de close(). El codi antic era massa àvid de destruir el context, i la solució és suspendre'l i reutilitzar-lo — mantén viu un context que és car de recrear.

Són les dues meitats d'un sol principi: deixa de tractar l'AudioContext com a d'un sol ús. Una meitat diu que no el deixis morir quan no hauria de fer-ho; l'altra diu que no el matis quan no cal. I hi ha una petita ironia a la cronologia — la neteja de la v1.8.16 es va escriure per "match RealtimeAudioCaptureService's cleanup pattern," i llavors la v2.0.2 va reescriure exactament aquell patró a RealtimeAudioCaptureService, perquè l'hàbit de close()-cada-vegada del patró va resultar ser el bug. Els dos serveis segueixen aprenent l'un de la gestió del cicle de vida d'àudio de l'altre, una solució cada cop.

Tres coses que ens van ensenyar aquestes versions

  1. Un AudioContext té una màquina d'estats — tracta state com a portant. running, suspended i closed no són detalls trivials. El SO pot moure un context entre ells sense demanar-t'ho, i diferents parts del teu graf reaccionen de maneres diferents (un ScriptProcessorNode al fil principal segueix funcionant; la destinació regida pel rellotge del maquinari no). Si no vigiles onstatechange, confies que el context es quedi on el vas deixar — i en un escriptori real, sota canvis de focus i gravació de pantalla, no ho farà.
  2. close() no és free(). La intuïció que tancar un recurs de seguida és sempre correcte és exactament el que va causar el segon bug. Quan el recurs és una ranura en un conjunt dur, imposat pel navegador, que només es recupera en el moment de la recol·lecció d'escombraries, tancar de manera agressiva és com esgotes el conjunt. De vegades el cicle de vida correcte és crear-un-cop-suspendre-sovint, no crear-i-destruir-per-ús.
  3. Alguns bugs només són abastables a través de la porta del maquinari real i el temps real. "Mor després d'unes sis sessions" i "emmudeix quan canvies de pestanya" no es poden atrapar amb una prova d'unitat que simula el dispositiu d'àudio — i l'equip ho va dir clarament en comptes de tapar-ho amb una prova que passa falsament. Anomena els límits del teu arnès de proves; un bug que necessita vuit sessions reals per aparèixer mereix un honest "verificat manualment," no una marca verda que no significa res.

Per al capítol anterior, distingir una trucada d'una app oberta (v1.8.15–v1.8.19); i per a tot l'arc, l'anatomia de publicar programari fins a la perfecció.

Articles Relacionats

El silenci era portant
Steven
Steven8 min de lectura

El silenci era portant

Les dues últimes releases de GeekBye v1 tracten de la mateixa veritat incòmoda: la transcripció en temps real sobre una xarxa real no és sense pèrdues, i el moviment honest és deixar de fer veure que ho és. v1.8.20 va guardar una còpia de cada chunk d'àudio al disc abans de descartar-lo durant una reconnexió, i va començar a marcar en veu alta els buits de la transcripció. v1.9.0 va deixar d'enviar silenci per estalviar amplada de banda — i va descobrir que el silenci era exactament el senyal que el transcriptor feia servir per saber que una frase havia acabat. Dues releases sobre el cost de llençar coses.

Engineering
Audio
Fiabilitat
Distingir una trucada d'una app oberta
Steven
Steven9 min de lectura

Distingir una trucada d'una app oberta

GeekBye pot notar que t'has unit a una videotrucada i oferir-se a gravar-la. La detecció resulta ser la meitat fàcil — un binari de Swift que llegeix títols de finestra cada deu segons. La meitat difícil és la precisió: no disparar-se quan Zoom només està obert, no preguntar per una reunió que ja estàs gravant, i no silenciar el micròfon de la trucada on realment ets. Tres versions, i cadascuna és una salvaguarda que va haver d'aprendre a no derrotar-se a si mateixa.

Engineering
macOS
Desktop
Treure el backend del camí de pujada
Steven
Steven9 min de lectura

Treure el backend del camí de pujada

GeekBye enregistra la teva pantalla i desa el vídeo al teu Google Drive. La primera versió enviava cada enregistrament a través dels servidors del propi GeekBye pel camí; una versió després, el fitxer anava directament de la teva màquina a Drive, i el backend va quedar degradat a sostenir un únic punter. La part interessant és com poc codi conté realment la versió 'directa i resumible' — perquè la resumibilitat va venir d'esborrar un proxy, no d'escriure'n un.

Engineering
Architecture
Desktop