Steven
Steven6 min leestijd

We schrapten 5.000 regels audiocode — en transcripten begonnen dubbel te verschijnen

GeekBye v1.6 rukte twee on-device Swift-transcribers eruit en verving ze door één verenigde pijplijn — netto meer dan 5.000 regels geschrapt, terwijl mensen midden in een meeting op de app zaten. Toen begonnen transcripten dubbel te verschijnen, omdat de bug verhuisde naar de ene laag die nog dacht dat er twee engines waren.

Engineering
Transcriptie
Architectuur
GeekBye Releases
We schrapten 5.000 regels audiocode — en transcripten begonnen dubbel te verschijnen

Er zit een bijzonder soort angst in het refactoren van code die op dit moment draait, voor mensen die midden in een meeting zitten. Je kunt de functie niet voor onderhoud offline halen — iemand vertrouwt erop om een gesprek te transcriberen dat ze niet kunnen herhalen. GeekBye v1.6 deed precies dat met het meest kritieke pad in de app: de audiopijplijn. Dit is het verhaal van wat we schrapten, wat brak, en de twee betrouwbaarheidslessen die eruit voortkwamen.

Twee engines, één ervan in Swift

Vóór v1.6 draaide GeekBye's spraak-naar-tekst on-device in Swift-binaries. Er waren er twee: één die Apple's Speech-framework omhulde, en een grotere (meer dan 1.500 regels) die dubbele audio opving — je microfoon plus systeemaudio — via ScreenCaptureKit en zijn eigen WebSocket rechtstreeks naar onze backend opende om naar Deepgram te streamen. Twee transcribers, twee opnamepaden, twee manieren om met het netwerk te praten, allemaal in een gecompileerde taal die betekende dat elke wijziging het hercompileren van een binary vereiste.

v1.6.0 klapte het allemaal samen tot één pijplijn. De enkele verenigende commit verwijderde de Apple Speech-transcriber (~1.145 regels), de Deepgram Swift-capturer (~1.523 regels), beide TypeScript-bridges, de oude orchestrator en de dubbele-audio-IPC-handlers — netto een verwijdering van meer dan 5.200 regels. In hun plaats: de renderer vangt audio op via standaard Web APIs, verstuurt die als PCM over IPC, en het Electron main-proces bezit één WebSocket-pad naar de backend, waarbij de provider door backend-config wordt gekozen. Eén opnamepad, één socket-eigenaar, één plek om over te redeneren.

Vijfduizend regels schrappen voelt geweldig. Ongeveer een dag lang.

De bug verhuisde naar de laag die nog dacht dat er twee waren

Toen begonnen transcripten dubbel te verschijnen. Eén uitgesproken zin werd opgeslagen als twee identieke vermeldingen — soms erger.

De grondoorzaak is het meest leerzame in deze hele release, want het is een algemene wet van unificatie. We hadden de providers verenigd in het main-proces — één socket, één handler. Maar de React-laag had het memo niet gekregen: het koppelde nog steeds twee providerspecifieke event-hooks tegelijk aan, één gebouwd voor Deepgram en één voor ElevenLabs. Beide luisterden. Beide sloegen op. Elk transcript werd bewaard door welke hooks er ook leefden — en dat deden ze allebei.

En er zat een tweede, sluwere verdubbeling onder: ElevenLabs zendt elke afgeronde regel twee keer uit — één keer als een committed-event en nogmaals als committed_with_timestamps. Dus zelfs met een enkele hook kon één zin als twee events binnenkomen.

De fix (in de v1.6-lijn) is een nette tweeluik dat precies op de twee oorzaken past: gate elke event-hook achter een enabled-vlag aangedreven door de active-provider-config van de backend, zodat er altijd maar één luisteraar leeft; en voeg per-bron tekstontdubbeling toe (onthoud de laatst opgeslagen regel voor microfoon en voor systeemaudio) om ElevenLabs' dubbele uitzending te slikken.

De les is groter dan deze bug: wanneer je twee backends achter één interface verenigt, verdwijnt de duplicatie niet — die migreert naar de laag die nog steeds aanneemt dat er twee zijn. We klapten het leidingwerk in het main-proces samen en het lek dook twee lagen hoger op, in de event-bedrading van de UI. Een pijplijn verenigen is niet klaar wanneer de producent verenigd is; het is klaar wanneer elke consument is gestopt met geloven in de oude vorm.

De eerste herverbinding: "geef op na vijf pogingen"

v1.6.0 bracht ook GeekBye's allereerste WebSocket-auto-herverbinding met exponentiële backoff — en het is de moeite waard om te tonen, juist omdat het de primitieve voorouder is van de kogelvrije versie die we vandaag draaien.

Het origineel was begrensd. Vijf pogingen, met vertragingen van 1s, 2s, 4s, 8s, 16s — en dan gaf het zich over, met een fatale fout: "Verbinding verbroken — start de transcriptie opnieuw." Op dat moment voelde dat verantwoord: probeer het niet eeuwig, vertel de gebruiker eerlijk. In de praktijk was het een UX-bug in vermomming. Een netwerkhapering van zestien seconden — een wifi-overdracht, een VPN-herverbinding, een tunnel — is tijdelijk, maar een begrensde retry behandelt het als fataal. De gebruiker deed niets verkeerd en kreeg te horen een live meeting opnieuw te starten.

Dat is precies de fout die het huidige ontwerp elimineerde. Vandaag herverbindt GeekBye met onbegrensde backoff en wisselt zelfs van provider, en stopt alleen ooit om een werkelijk fatale reden — auth, quota, facturering. De hele reis van "geef op na vijf" naar "geef nooit op bij een tijdelijke fout" wordt verteld in waarom je AI-notulist stopt bij slechte wifi. v1.6 is waar die weg begon, met de naïeve versie die eerst moest bestaan.

Verouderde verbindingen achtervolgen je tenzij ze namen hebben

Nog één betrouwbaarheidsles landde in v1.6.3, die een subtiele klasse bugs oploste die iedereen die herverbindt over een stateful backend uiteindelijk zal tegenkomen.

Wanneer je herverbindt — of de transcriptietaal midden in een sessie wisselt, wat onderhuids herverbindt — sterft de oude verbinding niet altijd stilletjes. Zijn stervensrochels (CONNECTION_LOST, disconnected) kunnen aankomen nadat de nieuwe verbinding al gezond is, en de perfect goede vervanger neersabelen. De fix geeft elke verbindingspoging een identiteit — een sessie-ID per verbinding gestempeld op de WebSocket-URL — plus een kort respijtvenster na herverbinding waarin verouderde disconnect-berichten van een eerdere poging worden genegeerd. Het stuurt ook een expliciet stop-bericht vóór het sluiten van een socket zodat de backend de oude sessie netjes kan afbreken.

Het principe: elke herverbindingslogica over een stateful backend moet elke poging taggen met een identiteit en late berichten van eerdere pogingen als ruis behandelen — anders wordt een geslaagde herverbinding gedood door de laatste adem van zijn voorganger.

Drie dingen die v1.6 ons leerde

  1. Unificatie verplaatst bugs; het verwijdert ze niet. Klap twee engines samen tot één en controleer elke consument die nog steeds denkt dat er twee zijn. De onze was de UI, die twee event-hooks aankoppelde nadat het leidingwerk al was samengevoegd.
  2. Je eerste herverbinding zal begrensd zijn, en begrensd is verkeerd voor live streams. "Geef op na N pogingen" verandert een tijdelijke hapering in een fatale fout waar de gebruiker de schuld van krijgt. Onderscheid fataal (auth, quota) van tijdelijk (een verbroken socket) vanaf de allereerste versie.
  3. Herverbindingen hebben identiteit nodig. Tag elke poging; negeer de late berichten van dode pogingen. Een verbinding zonder naam kan door zijn eigen spook worden vermoord.

Dit is het tweede hoofdstuk van het betrouwbaarheidsverhaal dat GeekBye v2 wordt. Voor het eerste, zie de auto-update-saga die zes releases in vier dagen kostte (v1.5.x); voor waar het herverbindingswerk uiteindelijk aankwam, waarom je AI-notulist stopt bij slechte wifi; en voor de hele boog, de anatomie van software tot in de perfectie uitbrengen.