Steven
Steven6 분 소요

침묵은 하중을 견디고 있었다

GeekBye v1의 마지막 두 릴리스는 같은 불편한 진실에 관한 것입니다: 진짜 네트워크 위의 실시간 전사는 무손실이 아니며, 정직한 수는 그런 척하기를 멈추는 것이라는 진실. v1.8.20은 재연결 도중 오디오 청크를 드롭하기 전에 그 모든 사본을 디스크에 간직했고, 전사의 간극을 소리 내어 표시하기 시작했습니다. v1.9.0은 대역폭을 아끼려고 침묵을 보내기를 멈췄고 — 그 침묵이야말로 문장이 끝났음을 알기 위해 전사 엔진이 쓰던 바로 그 신호였음을 발견했습니다. 무언가를 던져 버리는 일의 대가에 관한 두 릴리스입니다.

엔지니어링
Audio
안정성
GeekBye 릴리스
침묵은 하중을 견디고 있었다

GeekBye v1의 마지막 두 릴리스 — v1.8.20과 v1.9.0, 버전 2 이정표 바로 직전의 것들 — 은 둘을 함께 읽었을 때에만 드러나는 주제를 공유합니다. 둘 다 앱이 조용히 던져 버리고 있던 것들에 관한 것이고, 둘 다 같은 결론에 다다릅니다: 효율을 위해 당신이 드롭하는 것은 당신이 생각한 것보다 더 많은 일을 하고 있었다. 한 릴리스는 네트워크 재연결 도중 오디오를 드롭하고 있었습니다. 다른 하나는 대역폭을 아끼려고 침묵을 드롭하고 있었습니다. 두 경우 모두, 드롭된 것이 하중을 견디고 있었음이 드러났습니다.

실시간 전사는 무손실이 아니고, v1.8.20은 그것을 인정한다

WebSocket 위의 스트리밍 음성 인식에 관해, 눈을 돌리기 쉬운 한 가지 사실이 있습니다: 연결이 끊겼다 다시 이어질 때 — 혹은 앱이 한 전사 제공자에서 다른 제공자로, Deepgram에서 ElevenLabs로 페일오버할 때 — 클라이언트는 그 간극 동안 붙잡은 오디오 청크를 일부러 드롭합니다. 그럴 수밖에 없습니다. 새로 연결된 제공자가 오래된 오디오의 밀린 뭉치를 갑자기 받으면, 목이 메거나, 더 나쁘게는 그 음성 활동 감지기를 엉뚱한 것에 걸어 버립니다. 그래서 클라이언트는 그 청크들을 던져 버립니다.

문제는 드롭하는 것이 아닙니다. 문제는 드롭이 예전에 _의미_했던 것입니다: 그 말들은 그저 전사에 한 번도 나타나지 않았고, 그것이 빠졌다고 아무것도 당신에게 말해 주지 않았습니다. 녹음은 완전해 보였습니다. 완전하지 않았습니다.

v1.8.20은 그것을 두 전선에서 공격하며, 그 어느 쪽도 「재연결이 결코 오디오를 잃지 않게 만들기」가 아닙니다 — 왜냐하면 그것은 불가능하기 때문입니다, 실시간에서는. 대신 그것은 손실을 복구 가능하고 정직하게 만듭니다.

복구 가능한 절반은 내구성 있는 PCM tee입니다. 메인 프로세스의 캡처 핸들러에서, 붙잡은 모든 청크는 이제 재연결과 유예 기간의 드롭 게이트에 닿기 전에 디스크 위 파일에 쓰입니다:

every captured chunk is written to <userData>/sessions/session-<ts>.wav before realtime drop gates so reconnect-lost audio is preserved for a future batch-STT reconciliation pass

그 메커니즘은 기분 좋게 로우테크입니다: teeChunkToDiskWriteStream을 열고, 44바이트 WAV 헤더를 앞서 예약하고, 원본 16비트 모노 샘플을 도착하는 대로 덧붙이고, 정지 시 최종 크기로 헤더를 패치합니다(fs.openSync(..., 'r+'), 오프셋 0에 44바이트 쓰기). 결과는 완전한 오디오의 재생 가능한 WAV입니다 — 실시간 전사가 결코 보지 못한 부분들까지 포함해서. 녹음이 끝나면 파일 경로와 바이트 수를 담은 session-audio-saved 이벤트를 냅니다.

여기서 두 가지 정직함의 메모가 중요합니다, 왜냐하면 이것을 과하게 주장하기가 쉽기 때문입니다. 첫째: tee는 이 릴리스들에서 간극을 채우지 않습니다. 그것은 원본 오디오를 나중의 대조 패스를 위해 보존합니다 — 파일을 라이브 전사와 대조하는 배치 재전사 — 이며, 이는 이 릴리스들이 놓아 두는 갈고리이지 그것들이 출하하는 기능이 아닙니다. 후속 커밋은 아무도 그것을 죽은 코드로 오해하지 않도록 session-audio-saved에 대조 이슈를 가리키는 TODO로 주석까지 답니다. 둘째: 이것은 디스크를 먹습니다. 팀은 30분 녹음당 대략 86 MB로 추산했고, 그래서 같은 릴리스가 7일보다 오래된 저장 세션을 쳐내는 StartupCleanupService를 더합니다. 한계 없이 자라는 안전망은 누수입니다; 그 클린업이야말로 tee를 출하 가능하게 만드는 것입니다.

정직한 절반은 훨씬 더 단순합니다. 첫 재연결 시도에서, 클라이언트는 진짜, 보이는 항목을 전사에 씁니다:

[reconnecting — audio may be missing]

그 한 줄은 이 릴리스의 철학 전부를 축소해 담은 것입니다. 앱은 모든 것을 들었다고 보장할 수 없으므로, 들었다고 넌지시 내비치기를 멈춥니다. 당신이 볼 수 있는 간극은 당신이 우회할 수 있는 간극입니다; 당신이 볼 수 없는 간극은 일어나기를 기다리는 버그 리포트입니다.

v1.9.0: 당신이 최적화로 없애 버린 침묵이 곧 신호였다

v1.9.0은 마지막 v1 릴리스이고, 백엔드가 라이브 전사의 한 묶음의 책임을 클라이언트로 넘겨준 곳입니다 — 침묵을 드롭하기, 각 전사 줄을 로컬 음성 활동 타임라인으로부터 마이크나 시스템 오디오에 귀속시키기, 재연결 루프를 소유하기, 녹음 상한을 강제하기. 그 대부분은 버전 2 아키텍처를 위한 밑작업이고 다른 데서 이야기되었습니다. 하지만 그 안의 한 가닥은 두 릴리스를 통틀어 가장 좋은 작은 이야기입니다, 왜냐하면 그것은 홀로 놓고 보면 완전히 옳았던 최적화가 낸 피해를 되돌려야 했던 수정이기 때문입니다.

그것은 명백히 좋은 변경에서 시작합니다: 침묵을 보내기를 멈추기. 클라이언트는 지배적 소스가 침묵인 청크를 소켓 위로 내보내기 전에 걸러 내기 시작했습니다 — 대역폭이 줄고, 백엔드가 처리할 쓸모없는 오디오가 줄고, 그것은 「침묵을 보내지 마라」고 말하는 명시적인 백엔드 클라이언트 계약을 지켰습니다. 그것을 할 이유는 하나같이 온전했습니다.

그리고 그것은 발화 확정을 완전히 깨뜨렸습니다. 그것을 고치는 커밋은 그 인과의 사슬을 유별난 솔직함으로 펼쳐 놓습니다:

After the client began filtering silence chunks … ElevenLabs Scribe's VAD-based commit strategy no longer fires — EL needs ~1s of silence in the audio stream to commit a partial, and that silence never reaches it. Result: partials accumulate forever, UI shows trailing "...", utterances never finalize.

이것은 새는 추상이 되받아 무는 것의 이데아적 형태입니다. 전사 엔진의 음성 활동 감지기는 별도의 「화자가 끝났다」는 입력을 갖지 않습니다 — 그것은 오디오 속 약 1초의 침묵을 듣는 것으로부터 문장의 끝을 _추론_합니다. 침묵은 회선 위의 잡음이 아니었습니다; 그것은 하나의 메시지였습니다. 대역폭을 아끼려고 그것을 걸러 냄으로써, 클라이언트는 문장이 끝났다고 전사 엔진에 말해 주던 유일한 신호를 얼결에 보내기를 멈췄습니다. 그래서 모든 partial 전사는 그저 계속 자랐고, 꼬리는 결코 해소되지 않는 ...로 흐려졌습니다.

침묵을 도로 넣어 이것을 고칠 수도 있습니다 — 하지만 그러면 당신은 최적화를 도로 무른 것입니다. 실제 수정은 더 낫습니다: 침묵을 보내지 않고 침묵의 의미를 보내기. 클라이언트는 이제 오디오가 로컬에서 얼마나 오래 무음이었는지를 추적하고, 그것이 SILENCE_COMMIT_THRESHOLD_MS = 1000 — 백엔드 자신의 VAD 침묵 임계값에 일부러 맞춘 — 을 넘으면, 명시적인 commit 신호를 대역외로 발화합니다: sendMixedChunk이 지속된 침묵을 알아채고, realtime-audio:commit을 IPC 위로 보내고, 메인 프로세스가 소켓 위에서 { type: 'commit' }을 넘기고, 백엔드가 ElevenLabs에 partial을 확정하라고 말합니다. 두 개의 작은 상태, silenceStartedAtsilenceCommitFired가 그것이 반복 발화하는 것을 막습니다. 대역폭의 승리는 살아남습니다; 확정은 다시 작동합니다; 끝의 ...는 해소됩니다.

이 릴리스에도 작고 정직한 각주가 하나 있습니다: v1.9.0이 도입한 무한 재연결 로직은 처음에 렌더러 안에서 WsReconnectController로 만들어졌다가, 진짜 구현이 그것이 속하는 메인 프로세스 핸들러에 착지하자 같은 릴리스 안에서 삭제되었습니다. 재연결 이야기 그 자체 — 무한 백오프, 다섯 번의 실패마다 번갈아 드는 제공자 — 는 자기만의 장에서 이야기됩니다; v1.9.0이 더하는 것은, 그 아키텍처가 태어난 곳이자 클라이언트가 처음으로 그것을 소유한 곳이라는 점입니다.

이 릴리스들이 가르친 세 가지

  1. 손실을 막을 수 없을 때는, 그것을 복구 가능하게 만들고 보이게 만들어라. v1.8.20은 재연결이 오디오를 드롭하는 것을 멈추지 않습니다 — 그것은 실시간에서 물리적으로 불가능합니다. 그것은 원본 오디오의 사본을 나중을 위해 간직하고, 전사 속 간극에 라벨을 붙입니다. 어느 쪽도 화려하지 않습니다; 둘이 함께, 그것들은 조용한 데이터 손실을 알려진, 경계 있는, 복구 가능한 상태로 바꿉니다.
  2. 안전망은 자기만의 경계가 필요하다. 내구성 있는 tee는 내버려 두면 조용히 디스크를 가득 채울 것입니다 — 슬로모션 버그가 되는 수정입니다. 같은 릴리스에서 출하된 7일 클린업은 나중에 덧붙인 생각이 아닙니다; 그것이야말로 tee를 애초에 안전하게 출하 가능하게 만드는 것입니다. 쌓이는 메커니즘을 더한다면, 그것을 쳐내는 메커니즘을 같은 숨결에 더하세요.
  3. 효율을 위해 당신이 드롭하는 것은 신호일 수 있다. 두 릴리스를 통틀어 가장 날카로운 한 교훈: 침묵을 걸러 내는 것은 당신이 보통 확인하는 모든 축에서 옳았고 — 대역폭, 부하, 계약 준수 — 그럼에도 제품을 깨뜨렸습니다, 왜냐하면 하류의 시스템이 그 침묵에 의미를 읽어 넣고 있었기 때문입니다. 효율을 위해 데이터를 없앨 때, 그 데이터로부터 무언가를 _추론_하고 있던 것이 무엇인지 물으세요. 때로는 의미를 명시적으로 보내야 합니다, 바로 그것이 추론되던 원래의 것을 당신이 보내기를 멈췄기 때문에.

그리고 그것이 버전 1 이야기의 끝입니다 — 다시 쓰기 전의 마지막 굳히기입니다. 이전 장은 Web Audio를 살아 있게 하는 세 개의 동사(v1.8.16 + v2.0.2)를; 이 모든 것이 향하던 곳은 버전 2에 정말로 필요한 것: 206개의 커밋(v2.0.0)을; 그리고 전체 궤적은 소프트웨어를 완벽하게 출시하는 것의 해부학을 보세요.

관련 글

Web Audio를 살아 있게 하는 세 개의 동사
Steven
Steven7 분 소요

Web Audio를 살아 있게 하는 세 개의 동사

두 달 간격으로 서로 다른 파일에서 나온 두 번의 GeekBye 포인트 릴리스가, 우리의 오디오 코드에 같은 교훈을 정반대의 끝에서 가르쳤습니다: 브라우저의 AudioContext를 일회용처럼 다루기를 멈춰라. 한 릴리스는 macOS가 녹화 도중 조용히 suspend해 버린 컨텍스트를 resume()하는 법을 배웠고; 다른 하나는 연이은 세션들이 Chromium의 대략 여섯 컨텍스트 한계에 들이받는 것을 멈추도록 close() 대신 suspend()하는 법을 배웠습니다. resume, suspend, close — 그것이 줄거리 전부입니다.

엔지니어링
Audio
데스크톱
연결이 끊겼다고 앱 전체가 죽어서는 안 된다 — 그런데 우리 앱은 죽었다
Steven
Steven5 분 소요

연결이 끊겼다고 앱 전체가 죽어서는 안 된다 — 그런데 우리 앱은 죽었다

백엔드가 회의 도중에 오프라인이 됐을 때, 그것은 단지 전사를 멈춘 게 아니었습니다 — 앱 전체를 죽였습니다. 원인은 처리되지 않은 단 하나의 이벤트였고, 수정은 열 줄이었습니다. 이것은 GeekBye가 예전에 자신을 죽이던 상황들을 통과하면서도 로그인을 유지하고 연결을 유지하도록 만든 릴리스 묶음의 이야기입니다.

엔지니어링
안정성
Electron
우리 앱을 종료할 수 없게 만든 안전장치
Steven
Steven5 분 소요

우리 앱을 종료할 수 없게 만든 안전장치

자동 업데이트는 우리가 출시한 기능 중 가장 어려웠다 — 앱을 망가뜨리는 것을 막으려고 나흘 동안 여섯 번을 릴리스했다. 최악의 버그는 신중하려다 우리 손으로 만든 것이었다: 실패한 업데이트를 문자 그대로 종료할 수 없는 프로세스로 바꿔버린 500밀리초짜리 "안전" 검사.

엔지니어링
Electron
안정성