
Los tres verbos que mantienen vivo el Web Audio
Dos releases puntuales de GeekBye, con dos meses de diferencia y en dos archivos distintos, le enseñaron a nuestro código de audio la misma lección desde extremos opuestos: dejar de tratar el AudioContext del navegador como desechable. Una release aprendió a hacer resume() de un contexto que macOS había suspendido silenciosamente a mitad de grabación; la otra aprendió a hacer suspend() en vez de close() para que las sesiones consecutivas dejen de estrellarse contra el techo de aproximadamente seis contextos de Chromium. Resume, suspend, close — ese es todo el argumento.
La mayor parte del tiempo, un AudioContext de Web Audio se comporta como un objeto corriente: construyes uno, conectas algunos nodos, y te olvidas de él. Dos releases de GeekBye son lo que pasó cuando esa suposición se rompió — dos veces, en dos partes distintas de la app, con dos meses de diferencia. Ninguna se planeó como continuación de la otra. Pero ponlas una al lado de la otra y cuentan una sola historia: el AudioContext del navegador no es un objeto desechable. Es un recurso escaso, propiedad del sistema operativo, con una máquina de estados real, y los tres verbos de esa máquina de estados — resume, suspend, close — son todo el argumento de ambas releases.
GeekBye tiene dos pipelines de audio separados, y por eso la misma lección tuvo que aprenderse dos veces. Uno es la ruta de grabación de video (ScreenRecordingService), que construye un grafo de Web Audio para mezclar el micrófono y el audio del sistema en un archivo de video grabado. El otro es la ruta de transcripción en tiempo real (RealtimeAudioCaptureService), que construye su propio grafo para alimentar el reconocimiento de voz. Cada uno tiene su propio AudioContext. Cada release de abajo arregla uno de ellos.
v1.8.16: el video se quedó en silencio, pero la transcripción no
El primer bug es un cerebro escindido. Estás grabando una reunión, te cambias un momento a otra app, y ocurre algo sutil: el archivo de video pierde tu micrófono mientras la transcripción en vivo sigue desplazándose como si nada hubiera cambiado. La transcripción jura que estabas hablando. El video está en silencio. Es un tipo de bug enloquecedor porque las dos cosas que deberían coincidir — «¿te oyó el micrófono?» — discrepan de plano.
El commit que lo arregla nombra la causa con precisión:
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.
Esta es la mecánica. ScreenRecordingService.setupMonoMixingPipeline() construye un AudioContext, un MediaStreamDestination que se convierte en la pista de audio del video, un GainNode para el silenciado, y un par de ScriptProcessorNode (vía createScriptProcessor) que bombean frames de audio a la lógica de VAD/STT. Cuando macOS suspende el contexto — cosa que puede hacer cuando la app pierde el foco durante una grabación de pantalla — las partes del grafo gobernadas por el reloj del hardware de audio se detienen: el MediaStreamDestination deja de recibir audio del micrófono, así que el video grabado se queda mudo. Pero el callback onaudioprocess de un ScriptProcessorNode corre en el hilo principal de JavaScript, y ese hilo sigue latiendo. Así que el lado de STT sigue produciendo entradas de transcripción a partir de lo último que tenía, y los dos consumidores del mismo micrófono divergen.
El arreglo es notar la suspensión y deshacerla:
audioContext.onstatechange = () => {
if (audioContext.state === 'suspended' && this.status === 'recording') {
logger.info('AudioContext suspended during recording, resuming')
audioContext.resume()
}
}
Ese es todo el cambio de corrección: vigilar el state del contexto, y si cae a suspended mientras se supone que seguimos grabando, llamar a resume(). La release también conecta listeners onmute, onunmute y onended en el MediaStreamTrack del micrófono — no para cambiar el comportamiento, sino para que un silenciado a nivel del sistema operativo o una desconexión de dispositivo aparezca en los logs en vez de ser una causa invisible de silencio. Cuando toda la dificultad de un bug es «por qué se detuvo el audio», hacer que la detención del audio sea observable es la mitad del arreglo.
Un commit de seguimiento llegó un par de minutos después para limpiar tras esos nuevos listeners: cleanup() ahora pone a null micTrack.onmute/onunmute/onended y establece audioContext.onstatechange = null antes de desmontar el grafo. La nota del commit dice que «coincide con el patrón de limpieza de RealtimeAudioCaptureService» — el otro servicio de audio de la app. Guárdate ese detalle, porque dos meses después ese mismísimo patrón es lo que la siguiente release tuvo que reescribir.
v2.0.2: el audio y las transcripciones mueren tras unas seis sesiones
El segundo bug vive en el otro pipeline, RealtimeAudioCaptureService, y tiene una forma maravillosa: todo funciona a la perfección, y luego, después de haber iniciado y detenido la grabación un puñado de veces en una misma sesión de la app, el audio simplemente... muere. Primero el audio del sistema se queda en silencio, luego la transcripción se detiene. Reinicia la app y vuelve a estar bien — hasta otro puñado de sesiones.
La nota de causa raíz vale la pena leerla entera, porque es un bug-detrás-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'.
La trampa es que el código antiguo estaba haciendo lo «responsable». Cada start() creaba un AudioContext nuevo; cada cleanup() le llamaba close(). Cerrar un recurso cuando terminas con él es exactamente lo que te enseñan a hacer. Había incluso un comentario en línea que racionalizaba el trajín — que recargar el módulo AudioWorklet por cada contexto nuevo estaba bien porque «un contexto nuevo por start() significa que siempre pagamos este coste una vez».
Pero Chromium limita cuántos AudioContext respaldados por hardware puede tener una sola página — aproximadamente seis — y, algo crucial, close() no devuelve el slot en el instante en que lo llamas. Un contexto cerrado sigue ocupando su slot hasta que es recolectado por el GC, lo cual ocurre cuando al motor de JS le da por hacerlo. Así que una política escrupulosa de cerrar-cada-vez es precisamente lo que te lleva de cabeza al techo: seis ciclos de crear/cerrar después, los seis slots están todos ocupados por cadáveres aún no recolectados, el séptimo new AudioContext() vuelve muerto, y cada nodo que le enganches no hace nada en silencio.
El arreglo es dejar de tratar el contexto como desechable. Crearlo una vez y reutilizarlo durante toda la vida del servicio:
- En
setupMonoMixingPipeline, construir unnew AudioContext(...)solo si todavía no hay ninguno o el existente estáclosed. Si existe pero estásuspended, hacerawait audioContext.resume()en vez de crear uno nuevo. - En
cleanup, reemplazaraudioContext.close()poraudioContext.suspend()(con una guarda para que nunca se llame sobre un contexto ya cerrado). El contexto sobrevive a la sesión. - Como el contexto sobrevive, el módulo
AudioWorkletcargado sobrevive con él — el código ya no reinicia su flag de «worklet cargado» en la limpieza, así queaudioWorklet.addModule(...)se paga una vez para toda la vida del servicio en lugar de en cada sesión.
Las piezas por sesión — los nodos createMediaStreamSource para el micrófono y el audio del sistema, el worklet del mezclador, y el passthrough silencioso que mantiene vivo el punto de captura del loopback de audio del sistema de Chromium — se siguen construyendo y desmontando en cada ciclo. Solo el propio AudioContext persiste, así que como mucho existe uno a la vez. El techo es inalcanzable porque nunca apilas contextos contra él.
Una nota honesta que el commit hace sobre sí mismo: esto se verificó a mano — «verificado manualmente: 8+ sesiones consecutivas mantienen el audio del sistema audible y las transcripciones fluyendo (estaba muerto en la sesión 7)» — y el autor es franco en que el ciclo de vida de Web Audio «no es testeable con tests unitarios en jsdom sin un gran arnés de mocks nuevo». Ese es el tipo correcto de honestidad. El fallo solo aparece frente a un dispositivo de audio real a lo largo de sesiones reales; una suite unitaria en verde no habría demostrado nada. El «aproximadamente seis» y el «muerto en la sesión 7» son observaciones del equipo de dónde mordió el límite de Chromium, no una constante documentada — el propio código se cura en salud con una tilde, y también debería hacerlo cualquiera que lo repita.
Resume, suspend, close
Pon las dos releases en fila y la simetría es casi demasiado pulcra. Ambas tratan de los mismos tres métodos sobre el mismo tipo de objeto, abordados desde direcciones opuestas:
- La v1.8.16 aprendió a hacer
resume(). El sistema operativo dejó el contextosuspendedpor debajo, y el arreglo es atrapar eso y reanudar — mantener corriendo un contexto que se supone que debe estar corriendo. - La v2.0.2 aprendió a hacer
suspend()en vez declose(). El código antiguo tenía demasiada prisa por destruir el contexto, y el arreglo es suspenderlo y reutilizarlo — mantener vivo un contexto que es caro de recrear.
Son las dos mitades de un solo principio: dejar de tratar el AudioContext como algo de usar y tirar. Una mitad dice: no lo dejes morir cuando no debería; la otra dice: no lo mates cuando no hace falta. Y hay una pequeña ironía en la cronología — la limpieza de la v1.8.16 se escribió para «coincidir con el patrón de limpieza de RealtimeAudioCaptureService», y luego la v2.0.2 reescribió exactamente ese patrón en RealtimeAudioCaptureService, porque el hábito de close()-cada-vez del patrón resultó ser el bug. Los dos servicios siguen aprendiendo el uno del otro sobre el manejo del ciclo de vida del audio, un arreglo a la vez.
Tres cosas que estas releases enseñaron
- Un
AudioContexttiene una máquina de estados — tratastatecomo algo estructural.running,suspendedyclosedno son trivialidades. El sistema operativo puede mover un contexto entre ellos sin pedirte permiso, y distintas partes de tu grafo reaccionan de forma distinta (unScriptProcessorNodeen el hilo principal sigue funcionando; el destino gobernado por el reloj del hardware, no). Si no vigilasonstatechange, estás confiando en que el contexto se queda donde lo dejaste — y en un escritorio real, con cambios de foco y grabación de pantalla, no lo hará. close()no esfree(). La intuición de que cerrar un recurso enseguida siempre es correcto es exactamente lo que causó el segundo bug. Cuando el recurso es un slot en un pool duro, impuesto por el navegador, que solo se recupera en el momento del GC, cerrar de forma agresiva es como agotas el pool. A veces el ciclo de vida correcto es crear-una-vez-suspender-a-menudo, no crear-y-destruir-por-uso.- Algunos bugs solo son alcanzables por la puerta del hardware real y el tiempo real. «Muere tras unas seis sesiones» y «se queda en silencio cuando te vas a otra pestaña» no pueden atraparse con un test unitario que simula el dispositivo de audio — y el equipo lo dijo con claridad en vez de taparlo con un test que pasa en falso. Nombra los límites de tu arnés de pruebas; un bug que necesita ocho sesiones reales para aparecer merece un honesto «verificado manualmente», no una marca verde que no significa nada.
Para el capítulo anterior, distinguir una llamada de una app abierta (v1.8.15–v1.8.19); y para todo el arco, la anatomía de publicar software hasta la perfección.