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
Десктоп