
Abbiamo cancellato 5,000 righe di codice audio — e le trascrizioni hanno iniziato ad apparire due volte
GeekBye v1.6 ha eliminato due trascrittori Swift on-device e li ha sostituiti con un'unica pipeline — una riduzione netta di oltre 5,000 righe, eseguita mentre le persone erano a metà riunione sull'app. Poi le trascrizioni hanno iniziato ad apparire due volte, perché il bug si è spostato nell'unico livello che credeva ancora di avere due motori.
C'è un tipo particolare di terrore nel fare refactoring del codice che sta girando adesso, per persone che sono nel bel mezzo di una riunione. Non puoi mettere offline la funzionalità per manutenzione — qualcuno ci sta contando per trascrivere una conversazione che non può ripetere. GeekBye v1.6 ha fatto esattamente questo al percorso più critico dell'app: la pipeline audio. Questa è la storia di ciò che abbiamo eliminato, di cosa si è rotto, e delle due lezioni di affidabilità che ne sono emerse.
Due motori, uno dei quali in Swift
Prima di v1.6, il riconoscimento vocale di GeekBye girava on-device in binari Swift. Ce n'erano due: uno che avvolgeva il framework Apple Speech, e uno più grande (oltre 1,500 righe) che acquisiva audio duale — il tuo microfono più l'audio di sistema — tramite ScreenCaptureKit e apriva il proprio WebSocket direttamente al nostro backend per lo streaming verso Deepgram. Due trascrittori, due percorsi di acquisizione, due modi di parlare con la rete, tutto in un linguaggio compilato che significava che ogni modifica richiedeva la ricompilazione di un binario.
v1.6.0 ha condensato tutto in un'unica pipeline. Il singolo commit unificante ha eliminato il trascrittore Apple Speech (~1,145 righe), il capturer Swift di Deepgram (~1,523 righe), entrambi i bridge TypeScript, il vecchio orchestratore e gli handler IPC per l'audio duale — una riduzione netta di oltre 5,200 righe. Al loro posto: il renderer acquisisce l'audio tramite Web APIs standard, lo invia come PCM via IPC, e il processo principale di Electron possiede un singolo percorso WebSocket verso il backend, con il provider scelto dalla configurazione del backend. Un percorso di acquisizione, un proprietario del socket, un solo posto dove ragionare.
Eliminare cinquemila righe fa un gran bel effetto. Per circa un giorno.
Il bug si è spostato nel livello che credeva ancora di averne due
Poi le trascrizioni hanno iniziato ad apparire due volte. Una singola frase pronunciata veniva salvata come due voci identiche — a volte peggio.
La causa principale è la cosa più istruttiva di tutta questa release, perché è una legge generale dell'unificazione. Avevamo unificato i provider nel processo principale — un socket, un handler. Ma il livello React non ne era al corrente: montava ancora due hook di evento specifici per provider contemporaneamente, uno costruito per Deepgram e uno per ElevenLabs. Entrambi ascoltavano. Entrambi salvavano. Ogni trascrizione veniva resa persistente da qualunque hook fosse attivo — ed erano attivi entrambi.
E c'era un secondo raddoppiamento più subdolo al di sotto: ElevenLabs emette ogni riga finalizzata due volte — una volta come evento committed e di nuovo come committed_with_timestamps. Quindi anche con un singolo hook, una frase poteva arrivare come due eventi.
Il fix (nella linea v1.6) è un elegante doppietto che si mappa esattamente sulle due cause: mettere ogni hook di evento dietro un flag enabled guidato dalla configurazione del provider attivo del backend, così solo un listener è mai attivo; e aggiungere la deduplicazione del testo per sorgente (ricordare l'ultima riga salvata per il microfono e per l'audio di sistema) per assorbire il doppio-emit di ElevenLabs.
La lezione è più grande di questo bug: quando unifichi due backend dietro un'unica interfaccia, la duplicazione non scompare — migra nel livello che presuppone ancora ce ne siano due. Abbiamo compresso l'idraulica nel processo principale e la perdita è emersa due livelli più su, nel cablaggio degli eventi dell'interfaccia. Unificare una pipeline non è finito quando il producer è unificato; è finito quando ogni consumer ha smesso di credere nella forma vecchia.
La prima riconnessione: "arrendersi dopo cinque tentativi"
v1.6.0 ha anche spedito la primissima riconnessione automatica WebSocket di GeekBye con backoff esponenziale — e vale la pena mostrarla esattamente perché è l'antenato primitivo della versione a prova di proiettile che usiamo oggi.
L'originale era limitato. Cinque tentativi, ritardi di 1s, 2s, 4s, 8s, 16s — e poi si arrendeva, emettendo un errore fatale: "Connessione persa — per favore riavvia la trascrizione." All'epoca sembrava responsabile: non ritentare all'infinito, dire all'utente onestamente. In pratica era un bug UX mascherato. Un calo di rete di sedici secondi — un passaggio di Wi-Fi, una riconnessione VPN, un tunnel — è transitorio, ma un retry limitato lo tratta come terminale. L'utente non ha fatto nulla di sbagliato e gli è stato detto di riavviare una riunione in corso.
Questo è esattamente il fallimento che il design attuale ha eliminato. Oggi GeekBye si riconnette con backoff illimitato e alterna persino i provider, e si ferma solo per una ragione genuinamente fatale — auth, quota, billing. L'intero percorso da "arrendersi dopo cinque" a "non arrendersi mai su un errore transitorio" è raccontato in perché il tuo notetaker con IA si ferma con un cattivo Wi-Fi. v1.6 è dove quella strada è iniziata, con la versione ingenua che doveva esistere prima.
Le connessioni stantie ti perseguitano se non hanno un nome
Un'altra lezione di affidabilità è arrivata in v1.6.3, risolvendo una sottile classe di bug che chiunque faccia riconnessione su un backend stateful incontrerà prima o poi.
Quando ti riconnetti — o cambi lingua di trascrizione a metà sessione, il che riconnette sotto il cofano — la vecchia connessione non sempre muore in silenzio. I suoi messaggi d'agonia (CONNECTION_LOST, disconnected) possono arrivare dopo che la nuova connessione è già sana, e abbattere il perfettamente buon rimpiazzo. Il fix dà a ogni tentativo di connessione un'identità — un ID di sessione per connessione impresso nell'URL del WebSocket — più una breve finestra di tolleranza dopo la riconnessione durante la quale i messaggi di disconnessione stantii di un tentativo precedente vengono ignorati. Invia anche un messaggio esplicito stop prima di chiudere un socket così il backend può abbattere la vecchia sessione in modo pulito.
Il principio: qualsiasi logica di riconnessione su un backend stateful deve etichettare ogni tentativo con un'identità e trattare i messaggi tardivi di tentativi precedenti come rumore — altrimenti una riconnessione riuscita viene uccisa dall'ultimo respiro del suo predecessore.
Tre cose che la v1.6 ci ha insegnato
- L'unificazione ri-localizza i bug; non li elimina. Comprimi due motori in uno e controlla ogni consumer che crede ancora ce ne siano due. Il nostro era l'interfaccia, che montava due hook di evento dopo che l'idraulica si era già unificata.
- La tua prima riconnessione sarà limitata, e il limitato è sbagliato per gli stream in diretta. "Arrendersi dopo N tentativi" trasforma un calo transitorio in un fallimento terminale di cui l'utente viene incolpato. Distingui fatale (auth, quota) da transitorio (un socket caduto) fin dalla prima versione.
- Le riconnessioni hanno bisogno di un'identità. Etichetta ogni tentativo; ignora i messaggi tardivi di tentativi morti. Una connessione senza nome può essere uccisa dal proprio fantasma.
Questo è il secondo capitolo della storia di affidabilità che diventa GeekBye v2. Per il primo, vedi la saga dell'aggiornamento automatico che ha richiesto sei release in quattro giorni (v1.5.x); per dove il lavoro di riconnessione è eventualmente arrivato, perché il tuo notetaker con IA si ferma con un cattivo Wi-Fi; e per l'intero arco, l'anatomia del rilascio software alla perfezione.