Steven
Steven10 min de lectura

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.

Ingeniería
Audio
Desktop
Lanzamientos de GeekBye
Los tres verbos que mantienen vivo el Web Audio

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 un new AudioContext(...) solo si todavía no hay ninguno o el existente está closed. Si existe pero está suspended, hacer await audioContext.resume() en vez de crear uno nuevo.
  • En cleanup, reemplazar audioContext.close() por audioContext.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 AudioWorklet cargado sobrevive con él — el código ya no reinicia su flag de «worklet cargado» en la limpieza, así que audioWorklet.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 contexto suspended por 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 de close(). 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

  1. Un AudioContext tiene una máquina de estados — trata state como algo estructural. running, suspended y closed no son trivialidades. El sistema operativo puede mover un contexto entre ellos sin pedirte permiso, y distintas partes de tu grafo reaccionan de forma distinta (un ScriptProcessorNode en el hilo principal sigue funcionando; el destino gobernado por el reloj del hardware, no). Si no vigilas onstatechange, 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á.
  2. close() no es free(). 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.
  3. 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.

Artículos Relacionados

El silencio era estructural
Steven
Steven8 min de lectura

El silencio era estructural

Las dos últimas releases de GeekBye v1 giran en torno a la misma verdad incómoda: la transcripción en tiempo real sobre una red real no es sin pérdidas, y lo honesto es dejar de fingir que lo es. La v1.8.20 guardaba en disco una copia de cada chunk de audio antes de descartarlo durante una reconexión, y empezó a marcar en voz alta los huecos en la transcripción. La v1.9.0 dejó de enviar silencio para ahorrar ancho de banda — y descubrió que el silencio era justo la señal que el transcriptor usaba para saber que una frase había terminado. Dos releases sobre el coste de tirar cosas.

Ingeniería
Audio
Fiabilidad
Distinguir una llamada de una app abierta
Steven
Steven10 min de lectura

Distinguir una llamada de una app abierta

GeekBye puede notar que te has unido a una reunión por video y ofrecerte grabarla. La detección resulta ser la mitad fácil — un binario Swift que lee títulos de ventana cada diez segundos. La mitad difícil es la precisión: no dispararse cuando Zoom está meramente abierto, no ofrecerte grabar una reunión que ya estás grabando, y no silenciar el micrófono en la llamada en la que realmente estás. Tres releases, y cada una es una salvaguarda que tuvo que aprender a no derrotarse a sí misma.

Ingeniería
macOS
Desktop
Sacar el backend de la ruta de subida
Steven
Steven9 min de lectura

Sacar el backend de la ruta de subida

GeekBye graba tu pantalla y guarda el video en tu Google Drive. La primera versión enviaba cada grabación a través de los propios servidores de GeekBye en el camino; una release después, el archivo iba directo desde tu máquina a Drive, y el backend quedó degradado a sostener un único puntero. Lo interesante es lo poco código que en realidad contiene la versión «directa y reanudable» — porque la reanudabilidad vino de borrar un proxy, no de escribir uno.

Ingeniería
Arquitectura
Desktop