Steven
Steven1 分で読める

ライブレポートをちらつきなしでストリーミングする方法

会議が終わると、GeekByeのサマリーはスピナーを見つめさせる代わりに、いまやライブで埋まっていきます。ストリーミングUIをそわそわではなく落ち着いた感触にするには、ちらつきを二度解決する必要がありました — 一度は構造化フィールドのために、もう一度はmarkdownのために。そして二番目の修正は、すでに自分たちが持っていたレンダラーでした。

エンジニアリング
フロントエンド
ストリーミング
GeekByeリリース
ライブレポートをちらつきなしでストリーミングする方法

このリリース以前、GeekByeの会議を終えるということはスピナーを見つめることを意味しました。アプリは文字起こし全体を分析へ送り、サマリーのすべて — スコア、キーポイント、アクションアイテム、その一切 — を待ち、それから一度に画面へ落とすのです。機能はしますが、遅く感じました。動いている間、あなたは何もない画面を見つめていたからです。

GeekBye v1.6.13〜v1.6.15は、それを生成されるそばからフィールドごとにライブで流れ込むレポートへと変えました。おもしろいのはストリーミングそのものではありません — ストリーミングがそわそわして見えないようにするために私たちがやらねばならなかったすべてです。落ち着いたストリーミングとぎくしゃくしたストリーミングは、実装がまるで違うだけで同じ機能なのです。

ちらつき、その一: すべてのチャンクにReactを揺さぶらせない

サマリーは一つの塊ではありません — バックエンドがserver-sent eventsで一つずつ送り出す構造化フィールド(全体スコア、キーポイント、アクションアイテムなど)です。それを描画する素朴なやり方は、イベントが届くたびにReactのstateを更新することです。

そうすると、ちらつきが出ます。各フィールドの到着はそれぞれの再レンダリングを引き起こし、「空」を表示していたコンポーネントがマウントされ、アンマウントされ、そして「読み込み済み」として再マウントされる。レイアウトはパケットごとにびくつきます。レポートは最悪のやり方で目の前で自らを組み立てていく — 目に見えて、神経質に。

修正は二部構成の規律です。第一に、届いてくるフィールドをref — レンダリングを引き起こさない可変のホルダー — に蓄積し、チャンクごとに一度だけ新しいコピーでReactのstateへ公開する。そうすればツリーはあらゆる微小イベントごとにではなく、意図的に更新されます。第二に、ストリーミング中でも完了後でも、まだ届いていないフィールドにはインラインのプレースホルダーを添えて、終始一つの安定したコンポーネントツリーを描画するのです:

// 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>

スコアを表示するコンポーネントは決してアンマウントされません — 数字が届くまでただ を表示し、それから数字を表示します。何もびくつきません。そして完全に組み上がったオブジェクトは最後にローカルデータベースへキャッシュされるので、ストリーミングUIと永続的なストレージはぶつかることなく共存します。

ちらつき、その二: すでに自分たちが持っていたレンダラー

二番目のちらつきはもっと微妙で、markdownそのものの中に潜んでいます。レポートはリッチなmarkdown — 見出し、太字、リスト、コードブロック — を描画します。しかしストリームの途中で届くmarkdownは、任意の瞬間においては中途半端です。今のところ箇条書きが一つだけのリスト。開かれたが閉じられていないコードフェンス。まだ相方のいない太字マーカー。

従来のmarkdownレンダラーはトークンごとに文字列全体を再パースし、その中途半端な状態は毎回違うものへパースされます — だからリストはちらつき、コードブロックは開いたり閉じたりを繰り返し、トークンが完成するにつれてレイアウトが跳ねます。一つ下の層で起きる、先ほどと同じ神経質な組み立てです。

修正はほとんど恥ずかしいほど手近にありました: 私たちはすでにライブAIチャットのためにストリーミング対応のmarkdownレンダラーを使っていたのです。不完全なトークンに耐えるよう作られたレンダラー — 毎回ゼロから再パースするのではなく、中途半端なmarkdownを安定して描画し、トークンが完成したときにだけ落ち着かせる。レポートは同じものを使えばよいだけでした。私たちはこのまさに同じ問題を数か月前にチャットで解決していたのです。レポートの「修正」とは、すでにその道具を持っていると気づき、それを二つ目の面に向けることでした。作り直しより再利用が勝ったのです。

おまけ: リッチコピーはクリップボード形式の問題

レポートが見栄えよくなると、人々はそれを貼り付けたがりました — ドキュメントへ、メールへ — そして書式とスクリーンショットを保ったままで。とっさの発想は、レポートをmarkdown文字列にシリアライズして、貼り付け先がそれを描画してくれると願うことです。たいてい描画してくれません。

本当の答えは、クリップボードは複数の表現を同時に保持できるということです。そこで私たちは二つ書き込みます: 書式をそのまま保ち、スクリーンショットをインライン画像として埋め込んだHTML版と、HTMLを受け付けない貼り付け先のためのプレーンテキストのフォールバックです。リッチエディタに貼れば書式付きのレポートが画像付きで得られ、プレーンテキストのボックスに貼ればきれいなテキストが得られます。「リッチコピー」はけっしてシリアライズの問題ではなかったのです — それは正しいクリップボード形式を選ぶ問題だったのです。

同じリリースはまた、レポートにチャット風のMe / Themラベルを与え、二人の話者を左右反対の側へ揃えました。おかげで文字起こしは、それが本来そうであった会話のように読めます。そしてあなた自身のスクリーンショットも、それに合わせて自分の側へ移しました。(この期間のうち一つのリリース、v1.6.14は純粋な再ビルドでした — 語るべき物語はなく、そう正直に言っておきます。)

ストリーミングUIが教えた三つのこと

  1. ストリーミングされるフィールドはバッファし、チャンクごとに一度だけ公開せよ。 あらゆるserver-sent eventにそれぞれのstate更新を駆動させることこそがちらつきだ。refのアキュムレータに、チャンクごとの単一の公開を加え、決してアンマウントしないツリーへ流し込むことが、神経質な組み立てを落ち着いた埋め込みへ変える。
  2. ストリーミングするものはすべて、ストリーミング対応のレンダラーを必要とする。 トークンごとに文字列全体を再パースするレンダラーは、中途半端なmarkdownでちらつく。不完全な入力のために作られたものを使え — そしてもし別の面のためにすでに持っているなら、二つ目を作る前に再利用せよ。
  3. リッチコピーはシリアライズではなくクリップボード形式の話だ。 画像を埋め込んだ text/html text/plain のフォールバックを書き込め。クリップボードは両方を運ぶよう設計されている。それを使え。

これは、GeekBye v2になる信頼性と磨き上げの物語の第五章です。前の章はイヤホンのバグ(v1.6.12)を。同じserver-sent-eventsのアイデアが後にまるごとのフォールバック伝送路になった話は、ファイアウォールがWebSocketをブロックするときのライブ文字起こし(v2.0.8)を。そして全体の弧については、ソフトウェアを完璧に出荷することの解剖学をご覧ください。

関連記事

沈黙は耐荷重だった
Steven
Steven1 分で読める

沈黙は耐荷重だった

GeekBye v1の最後の二つのリリースは、同じ居心地の悪い真実についてのものです: 現実のネットワーク越しのリアルタイム文字起こしはロスレスではなく、誠実な一手はそのふりをやめることだ、と。v1.8.20は、再接続中にオーディオチャンクをドロップする前に、そのすべてのコピーをディスクに保ち、そして文字起こしのギャップを声に出して印づけ始めました。v1.9.0は帯域幅を節約するために沈黙を送るのをやめ — そして、その沈黙こそが、文の終わりを知るために文字起こしエンジンが使っていたまさにその信号だったと気づきました。物を投げ捨てることの代償についての、二つのリリースです。

エンジニアリング
Audio
信頼性
Web Audioを生かし続ける三つの動詞
Steven
Steven2 分で読める

Web Audioを生かし続ける三つの動詞

二ヶ月を隔てて別々のファイルで出た二つのGeekByeポイントリリースが、私たちのオーディオコードに同じ教訓を正反対の端から教えました: ブラウザのAudioContextを使い捨てとして扱うのをやめよ、と。一方のリリースは、macOSが録画中にこっそりsuspendしたコンテキストをresume()することを学びました; もう一方は、連続するセッションがChromiumのおよそ六コンテキストの上限に激突するのをやめさせるべく、close()する代わりにsuspend()することを学びました。resume、suspend、close — それが筋の全部です。

エンジニアリング
Audio
デスクトップ
開いているアプリと通話中を見分ける
Steven
Steven2 分で読める

開いているアプリと通話中を見分ける

GeekByeは、あなたがビデオ会議に参加したことに気づき、ワンクリックでの録画を差し出せます。検出のほうは、実のところ簡単な半分でした — 10秒ごとにウィンドウタイトルを読むSwiftバイナリです。難しい半分は精度です: Zoomがただ開いているだけのときに発火しないこと、すでに録画している会議についてうながさないこと、そしてあなたが実際にいる通話のマイクをミュートしないこと。三つのリリース、そのどれもが、自分自身を打ち負かさないことを学ばねばならなかったガードなのです。

エンジニアリング
macOS
デスクトップ