Steven
Steven5 хв читання

Як транслювати звіт у реальному часі без мерехтіння

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

Інженерія
Фронтенд
Стрімінг
Релізи GeekBye
Як транслювати звіт у реальному часі без мерехтіння

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

GeekBye v1.6.13–v1.6.15 перетворив це на звіт, що вливається наживо, поле за полем, у міру генерації. Цікава не сама потокова передача — а все, що нам довелося зробити, щоб стрімінг не виглядав смикано. Спокійний стрімінг і сокирний стрімінг — це одна й та сама функція з дуже різним виконанням.

Мерехтіння, частина перша: не дай кожній порції трясти React

Підсумок — це не один блок, а структурні поля (загальна оцінка, ключові моменти, задачі і так далі), які бекенд видає по одному через server-sent events. Наївний спосіб це відрендерити — оновлювати стан React на кожній події в міру надходження.

Зроби так — і отримаєш мерехтіння. Кожне поле, що приходить, запускає власний повторний рендер; компоненти, що показували «порожньо», монтуються, потім демонтуються, потім монтуються знову як «заповнено»; верстка смикається на кожному пакеті. Звіт збирає себе сам у вас на очах у найгірший можливий спосіб — помітно, нервово.

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

// accumulate in a ref, publish once per chunk
partialRef.current = { ...partialRef.current, [field]: value }
setPartial({ ...partialRef.current })

// one tree, never swapped; missing fields show a placeholder in place
const report = isStreaming ? partial : saved
<Score>{report.overallScore ?? '…'}</Score>

Компонент, що показує оцінку, ніколи не демонтується — він просто показує , поки число не приземлиться, а потім число. Ніщо не смикається. А повністю зібраний об'єкт наприкінці все одно кешується в локальну базу даних, тож потоковий інтерфейс і надійне сховище співіснують без боротьби.

Мерехтіння, частина друга: рендерер, який ми вже мали

Друге мерехтіння тонше й живе в самому markdown. Звіт рендерить багатий markdown — заголовки, жирний текст, списки, блоки коду. Але markdown, що приходить посеред потоку, у будь-яку дану мить недописаний. Список з одним пунктом поки що. Блок коду, який відкрили, але не закрили. Маркер жирного без пари.

Звичайний рендерер markdown повторно парсить увесь рядок на кожному токені, і цей недописаний стан щоразу парситься в щось інше — тож список мерехтить, блок коду спалахує відкриттям і закриттям, верстка стрибає, поки токени дописуються. Це те саме нервове збирання, що й раніше, шаром нижче.

Виправлення було майже ніяково доступним: ми вже використовували рендерер markdown з урахуванням стрімінгу для живого чату з ШІ. Рендерер, побудований терпіти неповні токени — рендерити недописаний markdown стабільно й усталювати його лише в міру дописування токенів — замість того щоб щоразу парсити з нуля. Звіту треба було просто використати той самий. Ми розв'язали рівно цю задачу для чату місяцями раніше; «виправленням» для звіту було усвідомлення, що інструмент у нас уже є, і скерування його на другу поверхню. Повторне використання побило перебудову.

Бонус: форматоване копіювання — це задача формату буфера обміну

Коли звіт став гарно виглядати, люди захотіли його вставляти — у документ, у лист — і зберігати форматування та скриншоти. Інстинкт — серіалізувати звіт у рядок markdown і сподіватися, що ціль його відрендерить. Зазвичай ні.

Справжня відповідь у тому, що буфер обміну тримає кілька представлень одразу. Тож ми записуємо два: HTML-версію зі збереженим форматуванням і скриншотами, вкладеними як inline-зображення, і текстовий запасний варіант для цілей, які не приймають HTML. Встав у форматований редактор — отримаєш відформатований звіт із картинками; встав у просте текстове поле — отримаєш чистий текст. «Форматоване копіювання» ніколи не було задачею серіалізації — це була задача обери-правильний-формат-буфера-обміну.

Той самий реліз ще дав звіту мітки Я / Вони у стилі чату з двома мовцями, вирівняними по протилежних сторонах, щоб транскрипція читалася як розмова, якою й була, і переніс ваші власні скриншоти на їхній бік, щоб збігалося. (Один реліз у цьому вікні, v1.6.14, був чистою перезбіркою — жодної історії тут немає, і чесно так і сказати.)

Три речі, яких нас навчив потоковий інтерфейс

  1. Буферизуй потокові поля, публікуй раз на порцію. Дати кожній server-sent event гнати власне оновлення стану — це і є мерехтіння. Акумулятор у ref плюс єдина публікація на порцію, у дерево, яке ніколи не демонтується, перетворює нервове збирання на спокійне заповнення.
  2. Усе, що стрімиться, потребує рендерера з урахуванням стрімінгу. Рендерер, що повторно парсить увесь рядок на кожному токені, мерехтітиме на недописаному markdown. Використовуй побудований під неповний ввід — а якщо в тебе вже є один для іншої поверхні, переви́користай його, перш ніж будувати другий.
  3. Форматоване копіювання — про формати буфера обміну, а не про серіалізацію. Запиши text/html із вкладеними зображеннями та запасний text/plain. Буфер обміну спроєктовано нести обидва; користуйся цим.

Це п'ятий розділ історії про надійність і оздоблення, що стає GeekBye v2. За попереднім розділом дивись баг із навушниками-вкладишами (v1.6.12); за тим, де та сама ідея server-sent events пізніше стала цілим запасним транспортом, транскрипція наживо, коли фаєрвол блокує WebSockets (v2.0.8); а за всією дугою — анатомію доведення софту до досконалості.

Схожі статті

Тиша була несуча
Steven
Steven7 хв читання

Тиша була несуча

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

Інженерія
Аудіо
Надійність
Три дієслова, що тримають Web Audio живим
Steven
Steven8 хв читання

Три дієслова, що тримають Web Audio живим

Два точкові релізи GeekBye, розділені двома місяцями і в двох різних файлах, дали нашому аудіокоду той самий урок із протилежних кінців: перестань поводитися з браузерним AudioContext як з одноразовим. Один реліз навчився робити resume() контексту, який macOS тихо призупинив посеред запису; інший навчився suspend() замість close(), щоб сесії одна за одною перестали врізатися в накинуту Chromium стелю приблизно в шість контекстів. Resume, suspend, close — ось і весь сюжет.

Інженерія
Аудіо
Десктоп
Відрізнити дзвінок від відкритого застосунку
Steven
Steven8 хв читання

Відрізнити дзвінок від відкритого застосунку

GeekBye вміє помітити, що ти приєднався до відеозустрічі, і запропонувати її записати. Виявлення виявляється легшою половиною — нативний Swift-бінарник читає заголовки вікон кожні десять секунд. Важка половина — точність: не спрацьовувати, коли Zoom просто відкритий, не пропонувати записати зустріч, яку ти вже записуєш, і не глушити мікрофон у дзвінку, в якому ти насправді сидиш. Три релізи, і кожен із них — запобіжник, якому довелося навчитися не перемагати самого себе.

Інженерія
macOS
Десктоп