Automergeでマルチプレイヤー・ポッドキャストエディタを作る
(adamsolove.com)- 数年前までは、リアルタイムのマルチプレイヤーデータ同期は、専門人材と企業レベルの投資を要する最難関の問題の1つだったが、今では**
npm installを一度実行するだけ**で、趣味プロジェクトにもマルチプレイヤーUIを実装できる - Automergeは、ローカルファースト・マルチプレイヤー安全・バージョン管理を備えたデータモデル構築ツールで、Reactの
useStateパターンに似た方法で、データ永続化・履歴管理・共同編集者へのブロードキャスト・競合解消を、UIが意識しなくても自動で処理する - ブラウザベースのマルチプレイヤー音声エディタDuckingの事例では、データモデルをCRDTの操作に自然にマッピングできるよう設計することが核心となる
- リストの並べ替えのようにAutomergeが保証しないケースでは、アプリケーション層のコードでより強い不変条件を自前で実装する
- かつては業務用レベルの魔法だったリアルタイム共同編集が、今では少人数向けの小さなアプリにも自由に適用できるようになったことが最大の意味だ
背景 — Duckingプロジェクト
- この数か月、パートナーのポッドキャスト向けにブラウザベースのマルチプレイヤー音声エディタDuckingを制作してきた
- 音声編集が、20年前から続く単一ユーザー向けデスクトップアプリとファイルの受け渡し方式にとどまっている現状は不自然だった
- 1人がクリップを編集している間に、別の1人がトランスクリプトを直したりEQ設定を調整したりできる、Google DocsやFigmaのような協調ワークフローが必要だった
- コメント、履歴、変更追跡のような現代的な協業ツールも同時に求められた
- 以前の記事で扱った独特なUIデザインや音声レイアウトモデルは、単一エディタをより有効にしたが、本当に求めていたのはより協調的なワークフローだった
Automergeの動作方式
- 音声blobを除くDuckingのすべてのデータはAutomergeドキュメントに保存される
- 中核となるパターンはReact開発者に馴染みのある形で、フックでデータを取得してレンダリングし、非同期の変更リクエストをdispatchすると、データ変更後にフックが再レンダリングをトリガーする
useDocumentフックの使用例:const [doc, changeDoc] = useDocument<Episode>(docUrl)の形でドキュメントを受け取り、入力値変更時にchangeDoc((d) => { d.title = e.target.value })で更新する
- データ更新操作は命令的に見えるが、ネイティブのJSオブジェクト・配列とは異なる
- メソッドは少なく、即座に変更(mutate)されず、内部で変更を横取りしてドキュメント履歴のchangelist項目へ変換する
- Automergeは単純な用途では必要な処理をこなすが、魔法ではなく、不変条件が望む意味と常に一致するわけではないため、慎重なデータモデル設計が重要になる
- ほとんどの意味単位のユーザー操作が、Automergeが提供する単一の操作に対応するようにする
- 関連データに対する別々のユーザー操作が、そのAutomerge操作の不変条件の観点から自然に解消されるようにする
- 保存される正規データ(canonical data)と、計算で導かれる派生データ(derived data)を明確に分離する
マルチプレイヤー向けのデータモデリング
- Duckingのデータモデルにおける**clip(クリップ)**は、変更不可能な基底音声ソースの一部を再生するためのウィンドウで、再生区間・エフェクト適用・タイムライン上の占有領域を担う
- 最も一般的なエフェクトは、クリップが基底音声の音量を時間に応じて調整し、クロスフェードしたりノイズを除去したりすることだ
- 当初は各クリップがクリップ開始基準の時間インデックス付き音量レベルの一覧を持っていたが、音量変更の多くはクリップではなく基底音声に関するものだったため問題が起きた
- クリップ開始時刻を少し前にずらすと、すべての音量変更が音声の別の部分に適用されてしまう
- クリップ開始時刻が変わるたびに、すべての音量タイムスタンプを更新するコードを書くのは悪い選択だった
- 2人の共同編集者が同時にクリップ開始時刻を編集すると、それぞれの編集は開始時刻とすべての音量オートメーションのタイムスタンプ変更をまとめて含むことになる
- Automergeはそれらの変更間の**因果関係(causal relationship)**を把握できないため、マージ時にめちゃくちゃな形で解消されうる
- これは、1つの意味的な操作が、CRDTが理解できない因果的な形で複数の永続データを更新しようとするときに起きる典型例だ
- 解決策は、音声エフェクトのデータをクリップではなく、基底音声のタイムフレーム基準へ移すことだった
- クリップの開始や長さを変更しても更新が不要になり、複数の編集者が開始時刻・音量オートメーション・その他のエフェクトを変更しても互いに独立するため、正しくマージされる可能性が高くなる
- 単一ユーザーUIとマルチプレイヤーUIの違い
- 単一ユーザーUIでは、既存のデータモデルのまま、書き込み時に追加計算を加えて動かすこともある
- マルチプレイヤーUIでは、すべての永続データを**直交(orthogonal)**した状態に保つため、データモデルの移行がはるかに一般的になる
- 書き込み時の単純化と読み取り時の計算を強く優先することで、Automergeの自動マージを最大限活用できる
- データ形式の移行(migration)に関する助言
- ビルド過程でデータ形式を移行する必要があることを受け入れ、最初の大きな移行を恐れないよう、初期に時間をかけて練習しておく
- クライアントの読み取り時処理、サーバー側の一括アップグレードなど、さまざまなパターンがある
- 移行前後が同じであることを確認できる便利な不変条件を見つけると、作業はずっと楽になる
- Duckingでは、移行前後で全プロジェクトの音声を書き出し、**オーディオフィンガープリント(audio fingerprint)**で差分の有無を確認することで、大きなスキーマ変更も怖がらずにデプロイできた
リストの並べ替え実装
- ときには、Automergeが提供しない保証のために、アプリケーション層のコードでより強い不変条件を自前で書く必要がある
- Duckingのmagnetic timeline(再生するクリップの整列済みリスト)を実装する際に問題が起きた
- Automergeはインデックスによる削除・挿入の配列操作は提供するが、既存項目を**原子的に並べ替える(atomic re-order)**操作は提供していない
- 既知の解法はある
- Martin Kleppmannは原子的なリスト並べ替え操作に関する論文を発表している
- Liangrun Daとともに"Extending JSON CRDTs with Move Operations"という論文も発表している
- Automergeに追加するdraft PRもあるが、まだマージされていない
- 単純な並べ替え方式の問題
- 現在のインデックスからオブジェクトを削除し、目的のインデックスに再追加する方式
- この2操作の不変条件を組み合わせても、「同時並べ替えが多いときでも、オブジェクトがリストに正確に1回だけ存在する」という望ましい不変条件は保証されない
- 同時の削除・追加が複数あると、オブジェクトがリスト内の複数位置に存在しうる(AliceとBobがそれぞれBをdelete+insertで移動すると、2つの削除は1つのtombstoneに統合される一方、2つの挿入はそれぞれ新要素を作るため、両方が残ってBが2回現れる)
- 「正確に1回」不変条件をアプリケーション層で満たす直接実装
- クリップがタイムラインに挿入される際にsemantic idを付与する
- 並べ替え時には上記のような削除・挿入操作を発生させる
- 読み取り時にアプリケーションが同じsemantic idの重複を走査し、削除されていない最初の項目を任意に選び、それ以外を無視する
- これにより、オブジェクトはリストに1回だけ存在し、複数の読者も常に同じ最終状態へ到達する
- リスト並べ替えは、DuckingでAutomergeが提供していない唯一の操作であり、PRがマージされればアプリケーションレベルのロジックは不要になる見込みだ
ドキュメント履歴(Document history)
- 優れたマルチプレイヤーUIには履歴管理ツールが必要で、共同編集者は不在中の変更確認・diffへのコメント・旧バージョン比較やロールバックを望む
- Automergeはドキュメントのバージョン履歴を追跡し、履歴や比較を扱う優れた基本要素(primitive)を提供する
- ただし、その情報をどう公開し、どんな概念をユーザーに提示するかはアプリケーション開発者が決める
- Ink & SwitchのPatchwork lab notesが推奨されている
- ユーザーにブランチを露出する取り組みと、universal commentsの取り組みが特に興味深い
- Duckingが落ち着いた比較的シンプルな協業・履歴モデル
- ユーザー定義名のcheckpointを持つ線形バージョン履歴で、checkpointが変更のグループ化単位であり、議論・diff・ロールバックの単位でもある
- 音声の特定箇所、トランスクリプトの領域、バージョンcheckpointに結びつけられるコメントスレッド(comment thread)
- まだブランチ導入の十分な理由はなかったが、将来的には有用かもしれないと述べている
テキストとmarks
- リッチテキスト作業は、編集可能なテキストの上にカスタムロジックを載せようとすると特に厄介だ
- リッチテキストとマルチプレイヤーソフトウェア全般の難しさを説明するPeritext論文が推奨されている
- Automergeのリッチテキストスキーマにはmarks(テキスト範囲に適用され、テキスト編集中でも一貫性を保つ注釈)が含まれる
- もっとも一般的には太字や斜体のような書式に使われるが、アプリケーション固有のカスタムmarkも作成できる
- Duckingにおけるカスタムmarkの活用は2つある
- コメントスレッドの対象となったトランスクリプト領域の追跡
- トランスクリプト内の単語のタイムスタンプを追跡しつつ、編集はそのまま許可すること
- 文字起こしサービスが、各単語にタイミング情報のmarkを付けたrichtextオブジェクトとしてトランスクリプトをAutomergeに保存する
- 小さなタイプミスで1語だけ修正した場合、markが維持され、すべてのタイミング情報が保たれる
- 文全体を書き換えると、中間のmarkの一部は失われるが、文頭と文末のmarkは残るため、少なくとも大まかなタイミング情報は確保できる
- marksの1つの制約は、datumが単純な値(一般には文字列)でなければならず、マルチプレイヤーのマージ対象にならないことだ
- トランスクリプトのタイミング情報のように小さく不変なデータは、JSONを文字列としてシリアライズする
- コメントスレッドのようにより複雑または可変なデータは、markにはidだけを保存し、実データはドキュメント内の別の場所に保持する
- marksは、マルチプレイヤーリッチテキストの上にアプリケーション機能を積み上げる優れた土台を提供する
次の記事 — シリーズ構成
- 本記事はDucking制作に関する3部作の第2部
- 第1部: ソフトウェアの独特なUIデザインを説明
- 第2部(本記事): Automergeを検討する価値を勧め、趣味向けマルチプレイヤープロジェクトを構築できる可能性を示す
- 最終第3部(予定): Ducking制作経験の振り返り
- 最終第3部に関する言及
- LLM支援は、作業を強化するためではなく、より多くのスケッチ時間やハンモックの時間を確保する目的で使っている
- 少人数だけを満足させればよいnarrowcast softwareを作る楽しさ
想定される質問
音声データは?
- すべてのマルチプレイヤーデータはAutomergeに保存されるが、基底のaudio blobは高速再生のためAutomargeには置かず、別処理が必要だ
- 目標は、新しい共同編集者がページ読み込み後4秒以内に視聴・編集を始められることで、デスクトップアプリ起動より速く、プロジェクト全体ファイルのダウンロードよりもはるかに速い
- 1時間のエピソードは、高品質スタジオ録音4時間分に効果音やBGMを加えた約1ギガバイトの音声に依存する場合がある
- 高速コールドスタートのため、アップロード時に音声サービスが行う処理
- 元音声のバックアップ
- トランスクリプト表示用に音声を文字起こし(transcribe)
- タイムライン表示用の波形(waveform)を生成
- 40分録音のうち1分しか使わないなら、大半のクライアントは小さな断片を1つか2つ受け取るだけで済むよう、短いウィンドウに分割(slice)
- 断片を圧縮形式にトランスコードし、高品質音声がバックグラウンドでダウンロードされている間も即時再生できるlossy版を提供
- UIデータ層は、ユーザーの意図に従って、直ちに必要な高速版データと、実際に使用された全音声の高品質版の読み込みを管理する
- ブラウザのIndexedDB APIは、多段キャッシュとcontent-addressable保存に有用で、自動evictionに任せれば、使っている間は残り、使わなければ消える
- これらの処理とローカルキャッシュが済めば、残るUIは音声への高速ランダムアクセスを前提に、編集ワークフローへ集中できる
なぜローカルファーストアプリではなくサーバー+ブラウザUIを作ったのか
- サーバーなしで完全に動作するObsidianのようなlocal-firstアプリを好んでおり、特に信頼できる脱出経路を提供しつつ、クラウドベースの有料体験も併せ持つ形が好みだ
- 初期には、ローカルファイルシステム保存と任意のサーバー同期を備えたTauriアプリという選択肢で始めた
- サーバーでもローカルアプリでも供給できるデータインターフェース基準でUIを構築した
- 将来どんな資金が入っても、囲い込みによってアプリをより収益化する誘惑に流されないための安全装置でもあった
- その後、これはSaaSではなく、パートナーや少数の友人と使いたいものだと判断した
- 誤った扱いをするインセンティブが消え、恒久的な運用コストも低いため、最も簡単な方法で作ることにした
- 約3秒のコールドスタートを達成すると、誰もネイティブアプリをダウンロード・インストールするために時間を無駄にしたがらなくなった
- 音声アプリが、現在のデスクトップ専用世界から、同期オプションを備えたlocal-firstの世界へ一気に飛び越え、途中の10〜20年にわたるSaaS囲い込みを回避できることを願っている
Automergeは安全でweb-scaleか? スタートアップで使うべきか?
- 楽しく「わからない」と答えられる。それは拒絶ではなく、本当にわからないという意味だ
- 入社当時、競合のないリアルタイムマルチプレイヤー編集は魔法であり、10年前には特定問題の既知の解法はあったが、資金のあるチームと複数分野の専門性が必要だった
- 今日では、依存関係を1つ追加し、おおむね直感的にUIを作るだけで、友人たちとリアルタイム協業できる
- セキュリティ面では、現在のDuckingは限定的なネットワークアクセスと、Automergeサーバーへのwebsocket接続作成時の認可(authorization)段階によって保護されている
- ユーザーは招待されていないプロジェクトを見つけたり編集したりできない
- 編集やコメントのユーザー帰属は一部しか安全ではなく、友人たちが悪さをしないという前提に依存している
- コメントのみ可/編集不可、プロジェクトの一部だけ編集可、発見可能性の制御といった細粒度の権限には慎重な設計が必要だ
- Ink & Switchが開発中のKeyhiveは、暗号学的に安全なcapabilityベースのアクセス制御モデルを提供する
- 信頼できないユーザーにもAutomergeアプリを公開共有しやすくするが、まだ準備は整っていない
Automergeのほうが良いのか?
- この分野の別解としてYjsもあるが、どちらが適切かを代わりに評価することはできない
- 変わらない助言
- 問題を深く考え、遭遇する限界について大まかな見積もり(back-of-the-napkin)を行い、複数の代替案でプロトタイプを作り、自分の問題は実はそれほど難しくなく、最新・最高級の解法が不要かもしれないと正直に認めること
- Duckingでは、素早いプロトタイプとドキュメント調査により、Automergeがその用途に十分成熟しており、性能も良いことを確認した
- さらに重要なのは、Ink & Switchエコシステムに美学的な魅力を感じたことだ
- Automergeが単なる同期・バージョン管理エンジンではなく、ソフトウェアをより安全で、協調的で、柔軟で、楽しく、個人的なものにするという大きなビジョンの一部であること
- Keyhiveなどの成功を願いつつ、少人数向けの小さいが魔法のようなソフトウェアが広がることを期待している
まだコメントはありません。