Steven
Steven5 хв читання

Ми видалили 5 000 рядків аудіокоду — і транскрипти почали з'являтися двічі

GeekBye v1.6 вирвав два вбудовані Swift-транскрайбери й замінив їх одним уніфікованим конвеєром — чиста різниця понад 5 000 видалених рядків, зроблено просто тоді, коли люди були посеред зустрічі в застосунку. А потім транскрипти почали з'являтися двічі, бо баг переїхав на той єдиний шар, який досі вважав, що двигунів два.

Інженерія
Транскрипція
Архітектура
Релізи GeekBye
Ми видалили 5 000 рядків аудіокоду — і транскрипти почали з'являтися двічі

Є особливий різновид жаху в рефакторингу коду, який працює просто зараз, для людей, які посеред зустрічі. Не можна вимкнути функцію на обслуговування — хтось покладається на неї, щоб транскрибувати розмову, яку не можна повторити. GeekBye v1.6 зробив саме це з найкритичнішим шляхом у застосунку: аудіоконвеєром. Це історія про те, що ми видалили, що зламалося, і про два уроки надійності, які з цього вийшли.

Два двигуни, один із них на Swift

До v1.6 розпізнавання мовлення в GeekBye працювало на пристрої у Swift-бінарниках. Їх було два: один обгортав фреймворк Apple Speech, а більший (понад 1 500 рядків) захоплював подвійне аудіо — ваш мікрофон плюс системний звук — через ScreenCaptureKit і відкривав власне WebSocket-з'єднання просто до нашого бекенду, щоб стримити в Deepgram. Два транскрайбери, два шляхи захоплення, два способи спілкуватися з мережею, і все це компільованою мовою, а отже кожна зміна вимагала перекомпіляції бінарника.

v1.6.0 згорнув усе це в один конвеєр. Єдиний уніфікуючий коміт видалив транскрайбер Apple Speech (~1 145 рядків), Swift-захоплювач Deepgram (~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 перепідключається з необмеженою затримкою і навіть циклічно перемикає провайдерів, а зупиняється лише з по-справжньому фатальної причини — автентифікація, квота, білінг. Уся дорога від «здатися після п'яти» до «ніколи не здаватися на тимчасовій помилці» описана в чому ваш AI-нотатник зупиняється на поганому Wi-Fi. v1.6 — це те, де почалася ця дорога, з наївної версії, яка мусила існувати першою.

Застарілі з'єднання переслідують вас, доки не матимуть імен

Ще один урок надійності приземлився у v1.6.3, виправивши тонкий клас багів, з яким рано чи пізно зустрінеться кожен, хто робить перепідключення поверх бекенду зі станом.

Коли ви перепідключаєтеся — або перемикаєте мову транскрипції посеред сесії, що під капотом теж перепідключається — старе з'єднання не завжди помирає тихо. Його передсмертні повідомлення (CONNECTION_LOST, disconnected) можуть прийти після того, як нове з'єднання вже здорове, і зруйнувати цілком справну заміну. Виправлення дає кожній спробі з'єднання ідентичність — ідентифікатор сесії на кожне з'єднання, вбитий у WebSocket URL — плюс коротке вікно відстрочки після перепідключення, протягом якого застарілі повідомлення про роз'єднання від попередньої спроби ігноруються. Воно також надсилає явне повідомлення stop перед закриттям сокета, щоб бекенд міг чисто згорнути стару сесію.

Принцип: будь-яка логіка перепідключення поверх бекенду зі станом мусить позначати кожну спробу ідентичністю й трактувати запізнілі повідомлення від попередніх спроб як шум — інакше успішне перепідключення вб'є передсмертний подих його попередника.

Три речі, яких навчив v1.6

  1. Уніфікація переселяє баги; вона їх не видаляє. Згорніть два двигуни в один і перевірте кожного споживача, який досі думає, що їх два. Нашим був інтерфейс, що монтував два обробники подій уже після того, як комунікація об'єдналася.
  2. Ваше перше перепідключення буде обмеженим, а обмежене — неправильне для живих стримів. «Здатися після N спроб» перетворює тимчасовий збій на фінальну помилку, у якій звинувачують користувача. Розрізняйте фатальне (автентифікація, квота), тимчасове (обірваний сокет) із найпершої версії.
  3. Перепідключення потребують ідентичності. Позначайте кожну спробу; ігноруйте запізнілі повідомлення від мертвих спроб. З'єднання без імені може бути вбите власним привидом.

Це другий розділ історії надійності, що стає GeekBye v2. Про перший дивіться сагу про автооновлення, яка забрала шість релізів за чотири дні (v1.5.x); про те, куди зрештою прийшла робота над перепідключенням, — чому ваш AI-нотатник зупиняється на поганому Wi-Fi; а про всю дугу — анатомію доведення програмного забезпечення до досконалості.

Схожі статті

Транскрипція в реальному часі, коли файрвол блокує WebSocket
Steven
Steven4 хв читання

Транскрипція в реальному часі, коли файрвол блокує WebSocket

Корпоративні мережі обожнюють пропускати HTTPS і тихцем убивати upgrade до WebSocket. Це непомітно ламає транскрипцію в реальному часі. GeekBye v2.0.8 автоматично переходить на чисто-HTTPS транспорт — а його випуск викрив баг, який зробив би всю функцію непотрібною.

Транскрипція
Мережі
Інженерія
Чому ШІ-транскрипція перекручує технічні терміни (і як ми це виправили)
Steven
Steven6 хв читання

Чому ШІ-транскрипція перекручує технічні терміни (і як ми це виправили)

Жива сесія почула «what is the pointer in C++» як «what is the point in life». Ось слід розслідування від того транскрипту до GeekBye v2.0.11 — keyterm biasing, гонка, що обривала з'єднання, і день, коли наш власний фікс обернувся проти нас.

Транскрипція
Надійність
Інженерія
Розрив з'єднання не повинен ронити весь застосунок — а наш ронив
Steven
Steven6 хв читання

Розрив з'єднання не повинен ронити весь застосунок — а наш ронив

Коли наш бекенд відвалився посеред зустрічі, він не просто призупинив транскрипцію — він поклав увесь застосунок. Причиною була одна необроблена подія, а виправлення вмістилося в десять рядків. Це кластер релізів, завдяки якому GeekBye лишається в системі й на зв'язку крізь те, що раніше його вбивало.

Інженерія
Надійність
Electron