
Мы удалили 5 000 строк аудиокода — и транскрипты начали появляться дважды
GeekBye v1.6 выдрал два транскрайбера на устройстве, написанных на Swift, и заменил их одним унифицированным конвейером — чистое удаление более 5 000 строк, сделанное прямо во время того, как люди сидели на встречах в приложении. А потом транскрипты стали появляться дважды, потому что баг переехал в единственный слой, который всё ещё думал, что движков два.
Есть особый вид ужаса в рефакторинге кода, который работает прямо сейчас, для людей, которые прямо сейчас на встрече. Нельзя отключить функцию на техобслуживание — кто-то полагается на неё, чтобы записать разговор, который нельзя повторить. GeekBye v1.6 сделал ровно это с самым критичным путём в приложении: с аудиоконвейером. Это история о том, что мы удалили, что сломалось и какие два урока о надёжности из этого вышли.
Два движка, один из них на Swift
До v1.6 распознавание речи в GeekBye работало на устройстве в Swift-бинарниках. Их было два: один оборачивал фреймворк Apple Speech, а второй, побольше (более 1 500 строк), захватывал двойное аудио — ваш микрофон плюс системный звук — через ScreenCaptureKit и открывал собственное WebSocket-соединение прямо к нашему бэкенду, чтобы стримить в Deepgram. Два транскрайбера, два пути захвата, два способа общаться с сетью — и всё это на компилируемом языке, из-за которого каждое изменение требовало перекомпиляции бинарника.
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) — аккуратный двусоставный ответ, который точно ложится на две причины: закрыть каждый хук событий за флагом enabled, управляемым конфигом активного провайдера на бэкенде, чтобы жив был только один слушатель; и добавить дедупликацию текста по источнику (запоминать последнюю сохранённую строку для микрофона и для системного звука), чтобы проглотить двойную выдачу ElevenLabs.
Урок больше, чем этот баг: когда вы объединяете два бэкенда за одним интерфейсом, дублирование не исчезает — оно мигрирует в слой, который всё ещё считает, что их два. Мы свернули трубопровод в главном процессе, а протечка всплыла двумя слоями выше, в проводке событий интерфейса. Объединение конвейера не закончено, когда объединён производитель; оно закончено, когда каждый потребитель перестал верить в старую форму.
Первое переподключение: «сдаться после пяти попыток»
v1.6.0 также отгрузил самое первое автоматическое переподключение WebSocket в GeekBye с экспоненциальной задержкой — и его стоит показать именно потому, что это примитивный предок пуленепробиваемой версии, которую мы гоняем сегодня.
Оригинал был ограниченным. Пять попыток, задержки в 1s, 2s, 4s, 8s, 16s — а затем оно капитулировало, выдавая фатальную ошибку: «Соединение потеряно — пожалуйста, перезапустите транскрипцию.» Тогда это казалось ответственным: не повторять вечно, честно сказать пользователю. На практике это был замаскированный UX-баг. Шестнадцатисекундный сетевой сбой — переключение Wi-Fi, переподключение VPN, туннель — кратковременный, но ограниченный повтор трактует его как окончательный. Пользователь ничего не сделал неправильно и получил указание перезапустить живую встречу.
Именно этот сбой устранил текущий дизайн. Сегодня GeekBye переподключается с неограниченной задержкой и даже перебирает провайдеров, а останавливается только по по-настоящему фатальной причине — аутентификация, квота, биллинг. Весь путь от «сдаться после пяти» до «никогда не сдаваться при кратковременной ошибке» рассказан в статье почему ваш ИИ-конспектировщик останавливается на плохом Wi-Fi. v1.6 — это то место, где эта дорога началась, с наивной версией, которая должна была существовать первой.
Устаревшие соединения преследуют вас, если у них нет имён
Ещё один урок о надёжности приземлился в v1.6.3, исправив тонкий класс багов, с которым в итоге столкнётся каждый, кто делает переподключение поверх бэкенда с состоянием.
Когда вы переподключаетесь — или меняете язык транскрипции посреди сессии, что переподключается под капотом, — старое соединение не всегда умирает тихо. Его предсмертные сообщения (CONNECTION_LOST, disconnected) могут прийти после того, как новое соединение уже здорово, и снести совершенно исправную замену. Исправление даёт каждой попытке соединения идентичность — идентификатор сессии на каждое соединение, прописанный в URL WebSocket, — плюс короткое окно снисхождения после переподключения, в течение которого устаревшие сообщения об отключении от предыдущей попытки игнорируются. Оно также отправляет явное сообщение stop перед закрытием сокета, чтобы бэкенд мог чисто свернуть старую сессию.
Принцип: любой логике переподключения поверх бэкенда с состоянием нужно помечать каждую попытку идентичностью и трактовать запоздалые сообщения от предыдущих попыток как шум — иначе успешное переподключение убивает предсмертный вздох его предшественника.
Три вещи, которым научил v1.6
- Объединение перемещает баги; оно их не удаляет. Сверните два движка в один и проаудируйте каждого потребителя, который всё ещё думает, что их два. Нашим был интерфейс, монтировавший два хука событий после того, как трубопровод уже слился.
- Ваше первое переподключение будет ограниченным, а ограниченное — неправильно для живых потоков. «Сдаться после N попыток» превращает кратковременный сбой в окончательный отказ, в котором винят пользователя. Различайте фатальное (аутентификация, квота) от кратковременного (оборванный сокет) с самой первой версии.
- Переподключениям нужна идентичность. Помечайте каждую попытку; игнорируйте запоздалые сообщения от мёртвых попыток. Соединение без имени может быть убито собственным призраком.
Это вторая глава истории о надёжности, которая становится GeekBye v2. О первой смотрите сагу об автообновлении, занявшую шесть релизов за четыре дня (v1.5.x); о том, куда в итоге пришла работа над переподключением, — почему ваш ИИ-конспектировщик останавливается на плохом Wi-Fi; а о всей дуге — анатомию доведения ПО до совершенства.