
Apagámos 5,000 Linhas de Código de Áudio — e as Transcrições Começaram a Aparecer em Duplicado
O GeekBye v1.6 removeu dois transcritores Swift on-device e substituiu-os por um único pipeline unificado — uma eliminação líquida de mais de 5,000 linhas, feita enquanto as pessoas estavam a meio de reuniões na app. Depois as transcrições começaram a aparecer em duplicado, porque o bug migrou para a única camada que ainda pensava existirem dois motores.
Há um tipo específico de terror em refatorar código que está a correr agora mesmo, para pessoas que estão a meio de uma reunião. Não se pode colocar a funcionalidade em manutenção — há alguém a depender dela para transcrever uma conversa que não pode repetir. O GeekBye v1.6 fez exatamente isso ao caminho mais crítico da app: o pipeline de áudio. Esta é a história do que apagámos, do que quebrou, e das duas lições de fiabilidade que daí resultaram.
Dois motores, um deles em Swift
Antes da v1.6, o speech-to-text do GeekBye corria on-device em binários Swift. Havia dois: um que envolvia o framework Apple Speech, e um maior (com mais de 1.500 linhas) que capturava áudio dual — o microfone mais o áudio do sistema — via ScreenCaptureKit e abria o seu próprio WebSocket diretamente para o nosso backend para transmitir ao Deepgram. Dois transcritores, dois caminhos de captura, duas formas de comunicar com a rede — tudo numa linguagem compilada que significava que cada alteração exigia recompilar um binário.
O v1.6.0 colapsou tudo isso num único pipeline. O commit de unificação apagou o transcritor Apple Speech (~1,145 linhas), o capturador Swift Deepgram (~1,523 linhas), ambas as bridges TypeScript, o orquestrador antigo e os handlers IPC de áudio dual — uma eliminação líquida de mais de 5,200 linhas. Em vez disso: o renderer captura áudio através das Web APIs padrão, envia-o como PCM via IPC, e o processo principal Electron detém um único caminho WebSocket para o backend, com o fornecedor escolhido pela configuração do backend. Um caminho de captura, um dono do socket, um único lugar para raciocinar.
Apagar cinco mil linhas é uma sensação ótima. Durante cerca de um dia.
O bug migrou para a camada que ainda pensava existirem dois
Depois as transcrições começaram a aparecer em duplicado. Uma única frase dita era guardada como duas entradas idênticas — às vezes pior.
A causa raiz é a lição mais valiosa de toda esta versão, porque é uma lei geral da unificação. Tínhamos unificado os fornecedores no processo principal — um socket, um handler. Mas a camada React não recebeu o memorando: ainda montava dois hooks de eventos específicos por fornecedor em simultâneo, um para o Deepgram e outro para o ElevenLabs. Ambos estavam a escutar. Ambos estavam a guardar. Cada transcrição era persistida por qualquer hook que estivesse ativo — e estavam os dois.
E havia uma segunda duplicação mais traiçoeira por baixo: o ElevenLabs emite cada linha finalizada duas vezes — uma como evento committed e outra como committed_with_timestamps. Por isso, mesmo com um único hook, uma frase podia chegar como dois eventos.
A correção (na linha v1.6) é uma solução a dois tempos que mapeia exatamente para as duas causas: bloquear cada hook de evento atrás de uma flag enabled controlada pela configuração do fornecedor ativo no backend, para que apenas um listener esteja ativo de cada vez; e adicionar desduplicação de texto por fonte (guardar a última linha gravada para microfone e para áudio do sistema) para absorver a dupla emissão do ElevenLabs.
A lição é maior do que este bug: quando se unificam dois backends por detrás de uma única interface, a duplicação não desaparece — migra para a camada que ainda assume que existem dois. Colápsámos a tubagem no processo principal e a fuga surgiu duas camadas acima, na ligação de eventos da interface. Unificar um pipeline não está feito quando o produtor é unificado; está feito quando cada consumidor deixou de acreditar na forma antiga.
A primeira reconexão: "desistir após cinco tentativas"
A v1.6.0 também lançou a primeira reconexão automática WebSocket do GeekBye com backoff exponencial — e vale a pena mostrar precisamente porque é o ancestral primitivo da versão à prova de balas que usamos hoje.
A versão original era limitada. Cinco tentativas, com atrasos de 1s, 2s, 4s, 8s, 16s — e depois capitulava, emitindo um erro fatal: "Ligação perdida — por favor reinicia a transcrição." Na altura parecia responsável: não tentar para sempre, dizer ao utilizador com honestidade. Na prática era um bug de UX disfarçado. Um problema de rede de dezasseis segundos — uma mudança de Wi-Fi, uma reconexão VPN, um túnel — é transitório, mas uma retry limitada trata-o como terminal. O utilizador não fez nada de errado e foi mandado reiniciar uma reunião em curso.
É essa a falha exata que o design atual eliminou. Hoje o GeekBye reconecta com backoff ilimitado e até alterna fornecedores, e só para por uma razão genuinamente fatal — autenticação, quota, faturação. Toda a jornada de "desistir após cinco" para "nunca desistir de um erro transitório" está contada em porque é que o teu notetaker com IA para com mau Wi-Fi. A v1.6 é onde esse caminho começou, com a versão ingénua que tinha de existir primeiro.
Ligações antigas assombram-te se não tiverem nomes
Mais uma lição de fiabilidade chegou com o v1.6.3, corrigindo uma classe subtil de bugs que qualquer pessoa que faça reconexão sobre um backend stateful acabará por encontrar.
Quando se reconecta — ou se muda o idioma de transcrição a meio da sessão, o que reconecta por baixo — a ligação antiga nem sempre morre em silêncio. As suas mensagens de agonia (CONNECTION_LOST, disconnected) podem chegar depois de a nova ligação já estar saudável, e destruir o substituto em perfeito estado. A correção dá a cada tentativa de ligação uma identidade — um ID de sessão por ligação carimbado no URL do WebSocket — mais uma curta janela de tolerância após a reconexão durante a qual as mensagens de desconexão obsoletas de uma tentativa anterior são ignoradas. Também envia uma mensagem explícita stop antes de fechar um socket para que o backend possa encerrar a sessão antiga de forma limpa.
O princípio: qualquer lógica de reconexão sobre um backend stateful precisa de marcar cada tentativa com uma identidade e tratar as mensagens tardias de tentativas anteriores como ruído — caso contrário, uma reconexão bem-sucedida é destruída pelo último suspiro do seu predecessor.
Três coisas que a v1.6 nos ensinou
- A unificação migra os bugs; não os apaga. Colapsa dois motores num e audita cada consumidor que ainda pensa que são dois. O nosso era a interface, a montar dois hooks de eventos depois de a tubagem já ter sido unificada.
- A tua primeira reconexão vai ser limitada, e limitada está errado para streams em direto. "Desistir após N tentativas" transforma um problema transitório numa falha terminal pela qual o utilizador é culpado. Distingue o fatal (autenticação, quota) do transitório (um socket largado) desde a primeira versão.
- As reconexões precisam de identidade. Marca cada tentativa; ignora as mensagens tardias de tentativas mortas. Uma ligação sem nome pode ser assassinada pelo seu próprio fantasma.
Este é o segundo capítulo da história de fiabilidade que se torna o GeekBye v2. Para o primeiro, vê a saga do auto-update que demorou seis versões em quatro dias (v1.5.x); para onde o trabalho de reconexão acabou por chegar, porque é que o teu notetaker com IA para com mau Wi-Fi; e para todo o arco, a anatomia de lançar software para a perfeição.