
Тишина была несущей
Два последних релиза GeekBye v1 — об одной и той же неудобной правде: транскрипция в реальном времени по настоящей сети не бесстратна, и честный ход — перестать притворяться, что это так. v1.8.20 хранил копию каждого фрагмента звука на диске, прежде чем отбросить его при переподключении, и начал вслух отмечать пропуски в транскрипте. v1.9.0 перестал отправлять тишину, чтобы сэкономить трафик — и обнаружил, что тишина была ровно тем сигналом, по которому транскрайбер понимал, что предложение закончилось. Два релиза о цене выбрасывания вещей.
Два последних релиза GeekBye v1 — v1.8.20 и v1.9.0, те, что прямо перед вехой второй версии — объединяет тема, которая становится очевидной, только когда читаешь их вместе. Оба про вещи, которые приложение тихо выбрасывало, и оба приходят к одному выводу: вещь, которую ты отбрасываешь ради эффективности, делала больше работы, чем тебе казалось. Один релиз выбрасывал звук во время сетевых переподключений. Другой выбрасывал тишину, чтобы сэкономить трафик. В обоих случаях выброшенное оказалось несущим.
Транскрипция в реальном времени не бесстратна, и v1.8.20 это признаёт
Вот факт о потоковом преобразовании речи в текст по WebSocket, от которого легко отвести взгляд: когда соединение рвётся и переподключается — или когда приложение аварийно переключается с одного провайдера транскрипции на другого, с Deepgram на ElevenLabs — клиент намеренно выбрасывает фрагменты звука, захваченные во время этого разрыва. Иначе нельзя. Свежеподключённый провайдер, который вдруг получает накопившуюся пачку несвежего звука, поперхнётся или, что хуже, зацепит свой детектор голосовой активности не за то. Так что клиент выбрасывает эти фрагменты.
Проблема не в выбрасывании. Проблема в том, что выбрасывание раньше значило: эти слова просто никогда не появлялись в транскрипте, и ничто не говорило тебе, что их не хватает. Запись выглядела полной. Она не была.
v1.8.20 бьёт по этому на двух фронтах, и ни один из них — не «сделать так, чтобы переподключения никогда не теряли звук», потому что нельзя, не в реальном времени. Вместо этого он делает потерю восстановимой и честной.
Восстановимая половина — это надёжный PCM-отвод. В обработчике захвата в главном процессе каждый захваченный фрагмент теперь записывается в файл на диске прежде, чем достигнет ворот отбрасывания при переподключении и в льготный период:
every captured chunk is written to
<userData>/sessions/session-<ts>.wavbefore realtime drop gates so reconnect-lost audio is preserved for a future batch-STT reconciliation pass
Механизм приятно незамысловат: teeChunkToDisk открывает WriteStream, резервирует заранее 44-байтный заголовок WAV, дописывает сырые 16-битные моно-сэмплы по мере их поступления и патчит заголовок финальными размерами при остановке (fs.openSync(..., 'r+'), запись 44 байт по смещению 0). Результат — воспроизводимый WAV с полным звуком, включая части, которых транскрипт реального времени никогда не видел. Когда запись заканчивается, он испускает событие session-audio-saved с путём к файлу и числом байт.
Две заметки о честности тут важны, потому что это легко переоценить. Первое: отвод не заполняет пропуск в этих релизах. Он сохраняет сырой звук для будущего сверочного прохода — пакетной ретранскрипции, которая сверяет файл с живым транскриптом — а это зацепка, которую эти релизы закладывают, а не фича, которую они поставляют. Следующий коммит даже снабжает session-audio-saved комментарием TODO, указывающим на задачи по сверке, чтобы никто не принял это за мёртвый код. Второе: это стоит диска. Команда прикинула примерно 86 МБ на 30-минутную запись, поэтому тот же релиз добавляет StartupCleanupService, который удаляет сохранённые сессии старше 7 дней. Страховочная сетка, которая растёт без границ, — это утечка; именно уборка делает отвод пригодным к выпуску.
Честная половина ещё проще. При первой попытке переподключения клиент записывает настоящую, видимую запись в транскрипт:
[reconnecting — audio may be missing]
Эта одна строка — вся философия релиза в миниатюре. Приложение не может гарантировать, что услышало всё, поэтому перестаёт намекать, что услышало. Пропуск, который ты видишь, можно обойти; пропуск, который ты не видишь, — это баг-репорт, ждущий своего часа.
v1.9.0: тишина, которую ты оптимизировал, была сигналом
v1.9.0 — последний релиз v1, и именно в нём бэкенд передал клиенту набор обязанностей по живому транскрипту — выбрасывать тишину, приписывать каждую строку транскрипта микрофону или системному звуку по локальной шкале голосовой активности, владеть циклом переподключения, следить за лимитами записи. Большая часть этого — фундамент для архитектуры второй версии, и о ней рассказано в другом месте. Но одна нить в нём — лучшая маленькая история из обоих релизов, потому что это фикс, которому пришлось откатить ущерб от оптимизации, совершенно правильной в изоляции.
Начинается с очевидно хорошего изменения: перестань слать тишину. Клиент начал отфильтровывать фрагменты, доминирующим источником которых была тишина, прежде чем отправлять их через сокет — меньше трафика, меньше бесполезного звука на обработку бэкенду, и это чтило явный клиентский контракт бэкенда, гласивший «не слать тишину». Каждая причина сделать так была здравой.
И это полностью сломало финализацию высказываний. Коммит, который это чинит, выкладывает причинную цепочку с необычной прямотой:
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.
Это платоновская форма протекающей абстракции, которая кусает в ответ. У детектора голосовой активности транскрайбера нет отдельного входа «говорящий закончил» — он выводит конец предложения из того, что слышит около секунды тишины в звуке. Тишина была не шумом на проводе; она была сообщением. Отфильтровав её ради экономии трафика, клиент случайно перестал слать тот единственный сигнал, что говорил транскрайберу: предложение закончилось. Так что каждый частичный транскрипт просто продолжал расти, хвостом уходя в ..., которое никогда не разрешалось.
Можно починить это, вернув тишину обратно — но тогда ты отменил оптимизацию. Настоящий фикс лучше: пошли смысл тишины, не посылая тишину. Клиент теперь отслеживает локально, как долго звук молчит, и когда это переходит SILENCE_COMMIT_THRESHOLD_MS = 1000 — намеренно совпадающий с собственным порогом тишины VAD бэкенда — он испускает явный сигнал commit вне полосы: sendMixedChunk замечает устойчивую тишину, шлёт realtime-audio:commit через IPC, главный процесс пересылает { type: 'commit' } по сокету, а бэкенд велит ElevenLabs финализировать частичный результат. Два маленьких кусочка состояния, silenceStartedAt и silenceCommitFired, не дают ему срабатывать многократно. Выигрыш по трафику уцелел; финализация снова работает; хвостовое ... разрешается.
К этому релизу есть ещё и маленькая честная сноска: неограниченная логика переподключения, которую ввёл v1.9.0, сначала была построена в рендерере как WsReconnectController, а затем удалена в рамках того же релиза, когда настоящая реализация приземлилась в обработчике главного процесса, где ей и место. Сама история переподключения — неограниченный backoff, чередование провайдеров каждые пять неудач — рассказана в отдельной главе; что добавляет v1.9.0 — так это то, что именно здесь родилась та архитектура и где клиент впервые взял её на себя.
Три вещи, которым научили эти релизы
- Когда не можешь предотвратить потерю, сделай её восстановимой и сделай её видимой. v1.8.20 не мешает переподключениям терять звук — это физически невозможно в реальном времени. Он хранит копию сырого звука на потом и помечает пропуск в транскрипте. Ни то, ни другое не эффектно; вместе они превращают тихую потерю данных в известное, ограниченное, восстановимое состояние.
- Страховочной сетке нужны собственные границы. Надёжный отвод тихо заполнил бы диск, оставь его в покое — фикс, который становится багом в замедленной съёмке. Семидневная уборка, поставленная в том же релизе, — не запоздалая мысль; это именно то, что вообще делает отвод безопасным для выпуска. Если ты добавляешь механизм, который накапливает, добавь тем же вдохом механизм, который его подрезает.
- Вещь, которую ты отбрасываешь ради эффективности, может быть сигналом. Самый острый урок из обоих релизов: отфильтровать тишину было правильно по каждой оси, которую ты обычно проверяешь — трафик, нагрузка, соблюдение контракта — и это всё равно сломало продукт, потому что нижестоящая система вычитывала смысл из тишины. Когда убираешь данные ради эффективности, спроси, что выводило что-то из этих данных. Иногда приходится слать смысл явно именно потому, что ты перестал слать то, из чего он раньше выводился.
И это конец истории первой версии — последнее закаливание перед переписыванием. Про предыдущую главу — три глагола, что держат Web Audio живым (v1.8.16 + v2.0.2); про то, куда всё это шло — чего на самом деле стоит вторая версия: 206 коммитов (v2.0.0); а про всю дугу — анатомию доведения софта до совершенства.