Steven
Steven6 min read

We Deleted 5,000 Lines of Audio Code — and Transcripts Started Showing Up Twice

GeekBye v1.6 tore out two on-device Swift transcribers and replaced them with one unified pipeline — a net deletion of over 5,000 lines, done while people were mid-meeting on the app. Then transcripts began appearing twice, because the bug moved to the one layer that still thought there were two engines.

Engineering
Transcription
Architecture
GeekBye Releases
We Deleted 5,000 Lines of Audio Code — and Transcripts Started Showing Up Twice

There's a particular kind of terror in refactoring the code that's running right now, for people who are mid-meeting. You can't take the feature down for maintenance — someone is relying on it to transcribe a conversation they can't repeat. GeekBye v1.6 did exactly that to the most critical path in the app: the audio pipeline. This is the story of what we deleted, what broke, and the two reliability lessons that came out of it.

Two engines, one of them in Swift

Before v1.6, GeekBye's speech-to-text ran on-device in Swift binaries. There were two of them: one wrapping Apple's Speech framework, and a larger one (over 1,500 lines) that captured dual audio — your mic plus system audio — via ScreenCaptureKit and opened its own WebSocket straight to our backend to stream to Deepgram. Two transcribers, two capture paths, two ways to talk to the network, all in a compiled language that meant every change required recompiling a binary.

v1.6.0 collapsed all of it into one pipeline. The single unifying commit deleted the Apple Speech transcriber (~1,145 lines), the Deepgram Swift capturer (~1,523 lines), both TypeScript bridges, the old orchestrator, and the dual-audio IPC handlers — a net deletion of over 5,200 lines. In their place: the renderer captures audio through standard Web APIs, ships it as PCM over IPC, and the Electron main process owns a single WebSocket path to the backend, with the provider chosen by backend config. One capture path, one socket owner, one place to reason about.

Deleting five thousand lines feels great. For about a day.

The bug moved to the layer that still thought there were two

Then transcripts started showing up twice. A single spoken sentence would be saved as two identical entries — sometimes worse.

The root cause is the most instructive thing in this whole release, because it's a general law of unification. We had unified the providers in the main process — one socket, one handler. But the React layer hadn't gotten the memo: it still mounted two provider-specific event hooks at once, one built for Deepgram and one for ElevenLabs. Both were listening. Both were saving. Every transcript got persisted by whichever hooks were alive — and they both were.

And there was a second, sneakier doubling underneath it: ElevenLabs emits every finalized line twice — once as a committed event and again as committed_with_timestamps. So even with a single hook, one sentence could arrive as two events.

The fix (in the v1.6 line) is a tidy two-parter that maps exactly onto the two causes: gate each event hook behind an enabled flag driven by the backend's active-provider config, so only one listener is ever live; and add per-source text de-duplication (remember the last saved line for mic and for system audio) to swallow ElevenLabs' double-emit.

The lesson is bigger than this bug: when you unify two backends behind one interface, the duplication doesn't disappear — it migrates to the layer that still assumes there are two. We collapsed the plumbing in the main process and the leak surfaced two layers up, in the UI's event wiring. Unifying a pipeline isn't done when the producer is unified; it's done when every consumer has stopped believing in the old shape.

The first reconnect: "give up after five tries"

v1.6.0 also shipped GeekBye's very first WebSocket auto-reconnect with exponential backoff — and it's worth showing precisely because it's the primitive ancestor of the bulletproof version we run today.

The original was bounded. Five attempts, delays of 1s, 2s, 4s, 8s, 16s — and then it surrendered, emitting a fatal error: "Connection lost — please restart transcription." At the time that felt responsible: don't retry forever, tell the user honestly. In practice it was a UX bug in disguise. A sixteen-second network blip — a Wi-Fi handoff, a VPN reconnect, a tunnel — is transient, but a bounded retry treats it as terminal. The user did nothing wrong and got told to restart a live meeting.

That's the exact failure the current design eliminated. Today GeekBye reconnects with unbounded backoff and even cycles providers, and only ever stops for a genuinely fatal reason — auth, quota, billing. The whole journey from "give up after five" to "never give up on a transient error" is told in why your AI notetaker stops on bad Wi-Fi. v1.6 is where that road started, with the naive version that had to exist first.

Stale connections haunt you unless they have names

One more reliability lesson landed in v1.6.3, fixing a subtle class of bug that anyone doing reconnection over a stateful backend will eventually meet.

When you reconnect — or switch transcription language mid-session, which reconnects under the hood — the old connection doesn't always die quietly. Its death-rattle messages (CONNECTION_LOST, disconnected) can arrive after the new connection is already healthy, and tear down the perfectly good replacement. The fix gives every connection attempt an identity — a per-connection session ID stamped on the WebSocket URL — plus a short grace window after reconnect during which stale disconnect messages from a prior attempt are ignored. It also sends an explicit stop message before closing a socket so the backend can tear the old session down cleanly.

The principle: any reconnect logic over a stateful backend needs to tag each attempt with an identity and treat late messages from prior attempts as noise — otherwise a successful reconnect gets killed by its predecessor's dying breath.

Three things v1.6 taught

  1. Unification relocates bugs; it doesn't delete them. Collapse two engines into one and audit every consumer that still thinks there are two. Ours was the UI, mounting two event hooks after the plumbing had already merged.
  2. Your first reconnect will be bounded, and bounded is wrong for live streams. "Give up after N tries" turns a transient blip into a terminal failure the user is blamed for. Distinguish fatal (auth, quota) from transient (a dropped socket) from the very first version.
  3. Reconnections need identity. Tag each attempt; ignore the late messages from dead attempts. A connection without a name can be murdered by its own ghost.

This is the second chapter of the reliability story that becomes GeekBye v2. For the first, see the auto-update saga that took six releases in four days (v1.5.x); for the next — staying signed in and surviving a dropped connection without crashing — a dropped connection shouldn't crash your whole app (v1.6.8); for where the reconnect work eventually arrived, why your AI notetaker stops on bad Wi-Fi; and for the whole arc, the anatomy of shipping software to perfection.