
오디오 코드 5,000줄을 지웠더니 — 회의록이 두 번씩 나타나기 시작했다
GeekBye v1.6은 온디바이스 Swift 전사기 두 개를 뜯어내고 하나의 통합 파이프라인으로 교체했습니다 — 순삭 5,000줄이 넘는 작업을, 사람들이 앱에서 한창 회의 중일 때 해냈습니다. 그러자 회의록이 두 번씩 나타나기 시작했습니다. 버그가 여전히 엔진이 두 개라고 믿고 있던 그 한 층으로 옮겨 갔기 때문입니다.
지금 이 순간 실행 중인 코드를, 한창 회의 중인 사람들을 위해 리팩터링하는 데에는 특별한 종류의 공포가 있습니다. 유지보수를 위해 기능을 내릴 수가 없습니다 — 누군가는 다시 반복할 수 없는 대화를 전사하려고 그것에 의지하고 있으니까요. GeekBye v1.6은 앱에서 가장 중요한 경로에 정확히 그 일을 했습니다: 오디오 파이프라인 말입니다. 이것은 우리가 무엇을 지웠고, 무엇이 망가졌으며, 거기서 나온 두 가지 신뢰성 교훈에 관한 이야기입니다.
두 개의 엔진, 그중 하나는 Swift로
v1.6 이전, GeekBye의 음성 인식은 온디바이스 Swift 바이너리에서 돌아갔습니다. 그것은 두 개였습니다: 하나는 Apple의 Speech 프레임워크를 감쌌고, 더 큰 다른 하나(1,500줄이 넘는)는 ScreenCaptureKit을 통해 듀얼 오디오 — 당신의 마이크 더하기 시스템 오디오 — 를 캡처하고, Deepgram으로 스트리밍하기 위해 우리 백엔드로 곧장 이어지는 자기 자신의 WebSocket을 열었습니다. 전사기 둘, 캡처 경로 둘, 네트워크와 대화하는 방식 둘, 그 모두가 컴파일 언어 안에 있어서 변경할 때마다 바이너리를 다시 컴파일해야 했습니다.
v1.6.0은 그 모든 것을 하나의 파이프라인으로 압축했습니다. 통합하는 단 하나의 커밋이 Apple Speech 전사기(~1,145줄), Deepgram Swift 캡처기(~1,523줄), TypeScript 브리지 두 개, 옛 오케스트레이터, 그리고 듀얼 오디오 IPC 핸들러를 지웠습니다 — 순삭 5,200줄이 넘습니다. 그 자리에는: 렌더러가 표준 Web APIs를 통해 오디오를 캡처하고, 그것을 PCM으로 IPC를 넘어 실어 보내며, Electron 메인 프로세스가 백엔드로 이어지는 단일 WebSocket 경로를 소유하되 공급자는 백엔드 설정으로 선택됩니다. 캡처 경로 하나, 소켓 소유자 하나, 추론할 곳 하나.
오천 줄을 지우는 건 기분이 끝내줍니다. 하루 정도는요.
버그는 여전히 둘이 있다고 믿던 층으로 옮겨 갔다
그러자 회의록이 두 번씩 나타나기 시작했습니다. 입으로 말한 문장 하나가 동일한 항목 두 개로 저장되곤 했습니다 — 때로는 더 나빴습니다.
근본 원인은 이 릴리스 전체에서 가장 교훈적인 것입니다. 그것이 통합의 일반 법칙이기 때문입니다. 우리는 공급자를 메인 프로세스에서 통합했습니다 — 소켓 하나, 핸들러 하나. 하지만 React 층은 그 소식을 받지 못했습니다: 그것은 여전히 공급자별 이벤트 훅 두 개를 동시에 마운트하고 있었습니다, 하나는 Deepgram용, 하나는 ElevenLabs용. 둘 다 듣고 있었습니다. 둘 다 저장하고 있었습니다. 모든 회의록은 살아있는 훅이 무엇이든 그것에 의해 저장됐는데 — 둘 다 살아있었습니다.
그리고 그 아래에는 두 번째, 더 은근한 이중화가 있었습니다: ElevenLabs는 확정된 모든 줄을 두 번 방출합니다 — 한 번은 committed 이벤트로, 다시 한 번은 committed_with_timestamps로. 그래서 훅이 하나뿐이어도, 한 문장이 두 이벤트로 도착할 수 있었습니다.
수정(v1.6 라인에서)은 두 원인에 정확히 대응하는 깔끔한 2부작입니다: 백엔드의 활성 공급자 설정으로 구동되는 enabled 플래그 뒤에 각 이벤트 훅을 두어, 오직 하나의 리스너만 살아있게 하는 것; 그리고 소스별 텍스트 중복 제거(마이크와 시스템 오디오 각각에 대해 마지막으로 저장한 줄을 기억)를 추가해 ElevenLabs의 이중 방출을 삼키는 것.
교훈은 이 버그보다 큽니다: 두 백엔드를 하나의 인터페이스 뒤로 통합하면, 이중화는 사라지지 않는다 — 그것은 여전히 둘이 있다고 가정하는 층으로 이주한다. 우리는 메인 프로세스에서 배관을 압축했고 누수는 두 층 위, UI의 이벤트 배선에서 드러났습니다. 파이프라인 통합은 생산자가 통합됐다고 끝나는 게 아닙니다; 모든 소비자 가 옛 형태를 믿기를 멈춰야 끝납니다.
첫 재연결: "다섯 번 시도하고 포기하라"
v1.6.0은 또한 GeekBye의 아주 첫 WebSocket 자동 재연결을 지수 백오프와 함께 출시했습니다 — 그리고 이것은 보여줄 가치가 있습니다. 바로 그것이 오늘 우리가 돌리는 완벽에 가까운 버전의 원시 조상 이기 때문입니다.
원본은 제한적이었습니다. 다섯 번의 시도, 1s, 2s, 4s, 8s, 16s의 지연 — 그런 다음 그것은 항복했습니다, 치명적 오류를 방출하며: "연결이 끊겼습니다 — 전사를 다시 시작해 주세요." 당시에는 그게 책임감 있게 느껴졌습니다: 영원히 재시도하지 말고, 사용자에게 정직하게 말하라. 실제로는 그것은 UX 버그를 감춘 것이었습니다. 16초짜리 네트워크 끊김 — Wi-Fi 핸드오프, VPN 재연결, 터널 — 은 일시적 이지만, 제한된 재시도는 그것을 종료적 으로 취급합니다. 사용자는 아무 잘못도 하지 않았는데 실시간 회의를 다시 시작하라는 말을 들었습니다.
그것이 현재 설계가 없앤 바로 그 실패입니다. 오늘 GeekBye는 무제한 백오프로 재연결하고 심지어 공급자를 순환시키며, 오직 진짜로 치명적인 이유 — 인증, 할당량, 청구 — 때문에만 멈춥니다. "다섯 번 하고 포기"에서 "일시적 오류에는 결코 포기하지 않기"까지의 여정 전체는 당신의 AI 노트테이커가 나쁜 Wi-Fi에서 멈추는 이유에 담겨 있습니다. v1.6은 그 길이 시작된 곳입니다, 먼저 존재해야 했던 순진한 버전과 함께.
오래된 연결은 이름이 없으면 당신을 따라다니며 괴롭힌다
한 가지 신뢰성 교훈이 더 v1.6.3에 도착했습니다. 상태를 가진 백엔드 위에서 재연결을 하는 누구든 결국 마주치게 될, 미묘한 부류의 버그를 고친 것입니다.
당신이 재연결할 때 — 혹은 세션 도중에 전사 언어를 전환할 때, 이것은 내부적으로 재연결을 합니다 — 옛 연결이 항상 조용히 죽지는 않습니다. 그것의 임종의 메시지(CONNECTION_LOST, disconnected)가 새 연결이 이미 건강해진 후에 도착해, 멀쩡히 좋은 대체 연결을 무너뜨릴 수 있습니다. 수정은 모든 연결 시도에 정체성을 부여합니다 — WebSocket URL에 찍힌 연결별 세션 ID — 더하기 재연결 후의 짧은 유예 창, 그 동안에는 이전 시도에서 온 오래된 끊김 메시지가 무시됩니다. 또한 소켓을 닫기 전에 명시적인 stop 메시지를 보내, 백엔드가 옛 세션을 깔끔하게 정리할 수 있게 합니다.
원칙은 이렇습니다: 상태를 가진 백엔드 위의 어떤 재연결 로직이든 각 시도에 정체성을 태그하고 이전 시도에서 온 늦은 메시지를 소음으로 취급해야 한다 — 그러지 않으면 성공한 재연결이 그 선행 연결의 죽어가는 숨결에 살해당합니다.
v1.6이 가르쳐 준 세 가지
- 통합은 버그를 재배치하지, 삭제하지 않는다. 두 엔진을 하나로 압축하고, 여전히 둘이 있다고 믿는 모든 소비자를 감사하세요. 우리의 경우는 UI였습니다, 배관이 이미 병합된 후에도 이벤트 훅 두 개를 마운트하던.
- 당신의 첫 재연결은 제한적일 것이고, 제한적인 것은 실시간 스트림에 틀렸다. "N번 시도하고 포기"는 일시적 끊김을, 사용자가 비난받는 종료적 실패로 바꿉니다. 아주 첫 버전부터 치명적(인증, 할당량)을 일시적(끊어진 소켓)과 구별하세요.
- 재연결에는 정체성이 필요하다. 각 시도를 태그하고; 죽은 시도에서 온 늦은 메시지를 무시하세요. 이름 없는 연결은 자기 자신의 유령에게 살해당할 수 있습니다.
이것은 GeekBye v2가 되는 신뢰성 이야기의 두 번째 장입니다. 첫 번째는 나흘간 여섯 번의 릴리스가 걸린 자동 업데이트 무용담(v1.5.x)을 보세요; 재연결 작업이 결국 도착한 곳은 당신의 AI 노트테이커가 나쁜 Wi-Fi에서 멈추는 이유를; 그리고 그 전체 궤적은 완벽하게 소프트웨어를 출시하는 일의 해부학을 보세요.