
Тиша була несуча
Два останні релізи 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); а про всю дугу — анатомію доставки програмного забезпечення до досконалості.