Steven
Steven7 min de lectura

Borramos 5,000 líneas de código de audio — y las transcripciones empezaron a aparecer dos veces

GeekBye v1.6 arrancó dos transcriptores Swift en local y los reemplazó con un único pipeline unificado — una eliminación neta de más de 5,000 líneas, hecha mientras los usuarios estaban en mitad de una reunión. Entonces las transcripciones empezaron a aparecer dos veces, porque el bug se trasladó a la única capa que seguía creyendo que había dos motores.

Ingeniería
Transcripción
Arquitectura
Lanzamientos de GeekBye
Borramos 5,000 líneas de código de audio — y las transcripciones empezaron a aparecer dos veces

Hay un terror particular en refactorizar el código que está funcionando ahora mismo, para personas que están en mitad de una reunión. No puedes bajar la funcionalidad por mantenimiento — alguien depende de ella para transcribir una conversación que no puede repetir. GeekBye v1.6 hizo exactamente eso en el camino más crítico de la app: el pipeline de audio. Esta es la historia de lo que borramos, lo que se rompió, y las dos lecciones de fiabilidad que surgieron de ello.

Dos motores, uno de ellos en Swift

Antes de v1.6, el reconocimiento de voz de GeekBye corría en local en binarios Swift. Había dos: uno envolviendo el framework Speech de Apple, y otro más grande (más de 1,500 líneas) que capturaba audio dual — tu micrófono más el audio del sistema — vía ScreenCaptureKit y abría su propio WebSocket directamente a nuestro backend para hacer streaming a Deepgram. Dos transcriptores, dos rutas de captura, dos formas de hablar con la red, todo en un lenguaje compilado que significaba que cada cambio requería recompilar un binario.

v1.6.0 lo colapsó todo en un único pipeline. El commit unificador eliminó el transcriptor Apple Speech (~1,145 líneas), el capturador Swift de Deepgram (~1,523 líneas), ambos puentes TypeScript, el orquestador antiguo y los manejadores IPC de audio dual — una eliminación neta de más de 5,200 líneas. En su lugar: el renderer captura audio a través de Web APIs estándar, lo envía como PCM por IPC, y el proceso principal de Electron posee una única ruta WebSocket hacia el backend, con el proveedor elegido por la configuración del backend. Una ruta de captura, un propietario del socket, un único lugar donde razonar.

Borrar cinco mil líneas se siente genial. Durante más o menos un día.

El bug se trasladó a la capa que seguía creyendo que había dos

Entonces las transcripciones empezaron a aparecer dos veces. Una única frase pronunciada se guardaba como dos entradas idénticas — a veces peor.

La causa raíz es lo más instructivo de todo este lanzamiento, porque es una ley general de la unificación. Habíamos unificado los proveedores en el proceso principal — un socket, un manejador. Pero la capa React no se había enterado: seguía montando dos hooks de eventos específicos de cada proveedor a la vez, uno construido para Deepgram y otro para ElevenLabs. Ambos escuchaban. Ambos guardaban. Cada transcripción quedaba persistida por los hooks que estuvieran vivos — y los dos lo estaban.

Y había una segunda duplicación más sigilosa debajo: ElevenLabs emite cada línea finalizada dos veces — una como evento committed y otra como committed_with_timestamps. Así que incluso con un único hook, una frase podía llegar como dos eventos.

El arreglo (en la línea v1.6) es un ordenado trabajo en dos partes que se mapea exactamente sobre las dos causas: proteger cada hook de eventos tras un flag enabled controlado por la configuración del proveedor activo del backend, para que solo haya un listener activo en todo momento; y añadir deduplicación de texto por fuente (recordar la última línea guardada para el micrófono y para el audio del sistema) para absorber la doble emisión de ElevenLabs.

La lección es más grande que este bug: cuando unificas dos backends detrás de una interfaz, la duplicación no desaparece — migra a la capa que sigue creyendo que hay dos. Colapsamos la fontanería en el proceso principal y la fuga apareció dos capas más arriba, en el cableado de eventos de la interfaz. Unificar un pipeline no está terminado cuando el productor está unificado; está terminado cuando cada consumidor ha dejado de creer en la forma antigua.

La primera reconexión: "rendirse después de cinco intentos"

v1.6.0 también estrenó la primera reconexión automática WebSocket de GeekBye con backoff exponencial — y merece mostrarse precisamente porque es el ancestro primitivo de la versión a prueba de balas que ejecutamos hoy.

El original era limitado. Cinco intentos, con retardos de 1s, 2s, 4s, 8s, 16s — y entonces se rendía, emitiendo un error fatal: "Conexión perdida — reinicia la transcripción." En ese momento parecía responsable: no reintentar para siempre, decirle al usuario la verdad. En la práctica era un bug de UX disfrazado. Un parpadeo de red de dieciséis segundos — un cambio de Wi-Fi, una reconexión de VPN, un túnel — es transitorio, pero un reintento limitado lo trata como terminal. El usuario no hizo nada mal y le dijeron que reiniciara una reunión en directo.

Ese es exactamente el fallo que eliminó el diseño actual. Hoy GeekBye reconecta con backoff ilimitado e incluso cicla proveedores, y solo se detiene por una razón genuinamente fatal — auth, cuota, facturación. Todo el camino desde "rendirse después de cinco" hasta "nunca rendirse ante un error transitorio" se cuenta en por qué tu notetaker con IA se detiene con mal Wi-Fi. v1.6 es donde empezó ese camino, con la versión ingenua que tenía que existir primero.

Las conexiones obsoletas te persiguen si no tienen nombre

Una lección más de fiabilidad aterrizó en v1.6.3, arreglando una clase sutil de bug que cualquiera que haga reconexión sobre un backend con estado acabará encontrándose.

Cuando reconectas — o cambias el idioma de transcripción en mitad de una sesión, lo que reconecta internamente — la conexión antigua no siempre muere en silencio. Sus mensajes de agonía (CONNECTION_LOST, disconnected) pueden llegar después de que la nueva conexión ya esté sana, y destruir el reemplazo que funcionaba perfectamente. El arreglo le da a cada intento de conexión una identidad — un ID de sesión por conexión estampado en la URL del WebSocket — más una breve ventana de gracia tras la reconexión durante la cual los mensajes de desconexión obsoletos de un intento anterior se ignoran. También envía un mensaje stop explícito antes de cerrar un socket para que el backend pueda derribar la sesión antigua limpiamente.

El principio: cualquier lógica de reconexión sobre un backend con estado necesita etiquetar cada intento con una identidad y tratar los mensajes tardíos de intentos anteriores como ruido — de lo contrario, una reconexión exitosa queda destruida por el último aliento de su predecesora.

Tres cosas que enseñó v1.6

  1. La unificación reubica los bugs; no los elimina. Colapsa dos motores en uno y audita cada consumidor que siga creyendo que hay dos. El nuestro era la interfaz, montando dos hooks de eventos después de que la fontanería ya se había fusionado.
  2. Tu primera reconexión será limitada, y limitada está mal para streams en vivo. "Rendirse después de N intentos" convierte un parpadeo transitorio en un fallo terminal del que se culpa al usuario. Distingue fatal (auth, cuota) de transitorio (un socket caído) desde la primera versión.
  3. Las reconexiones necesitan identidad. Etiqueta cada intento; ignora los mensajes tardíos de intentos muertos. Una conexión sin nombre puede ser asesinada por su propio fantasma.

Este es el segundo capítulo de la historia de fiabilidad que se convierte en GeekBye v2. Para el primero, mira la saga del auto-update que tardó seis lanzamientos en cuatro días (v1.5.x); para donde llegó finalmente el trabajo de reconexión, por qué tu notetaker con IA se detiene con mal Wi-Fi; y para todo el arco, la anatomía de publicar software hasta la perfección.