1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Interactive Liveを並列開発しても状態を壊さない:作業単位・イベント契約・AI停止線をTypeScriptで実装する

1
Last updated at Posted at 2026-10-27

ライブ配信機能を分担すると、UI、RTC接続、AIホスト、運営コンソールを並行して進められます。しかし、各ブランチがそれぞれ「配信中」「終了済み」「AIが発言可能」を判断し始めると、コードはマージできたのに、実行時の状態だけが食い違う問題が起きます。

特にAIホストを加える場合、文章を生成できることと、いま発言してよいことは別問題です。運営者が介入した後に、生成済みの古い案内が流れる設計では、人間が制御を取り戻せません。

この記事では、Interactive Liveの実装を依存関係付きの作業単位へ分けつつ、最後に安全に統合する方法を示します。

結論:ブランチより先に「状態を変更できるイベント」を固定する

並列化の単位は、画面や機能の名前だけで決めません。次の3つを先に固定します。

  1. 配信状態を書き換えられるイベント
  2. 各イベントを生成してよい作業単位
  3. AIの出力を破棄できる人間のオーバーライド経路

今回の構成では、各担当が直接isLiveのようなBooleanを書き換えることを禁止します。すべての状態変更を共通のReducerへ通し、AIホストには状態変更権限を与えません。

AIが担当するのは発言候補の生成までです。配信中か、人間が介入中か、生成後に制御状態が変わっていないかは、決定的なコードで判定します。

前提:Tencent RTCの組み込み方式を最初に選ぶ

Tencent RTCのライブ配信セットアップでは、構築済みUIを利用する方法と、ヘッドレスなAtomicXCoreを利用する方法が案内されています。

  • 標準的な視聴・交流体験を早く組み立てたい:構築済みUI
  • 独自の運営コンソールや状態管理を深く統合したい:AtomicXCore

公式実装ガイド:
https://trtc.io/document/78279

TUILiveKitの公式紹介では、視聴、チャット、いいね、ギフト、視聴者管理、共同配信、PK、弱いネットワークへの対応、Beauty AR連携などが説明されています。

この記事の状態機械はSDKの代替ではありません。採用した組み込み方式の外側に置く、アプリケーション固有の統合境界です。SDK固有のメソッド名は、対象プラットフォームの公式ドキュメントに従ってAdapter内へ閉じ込めます。

完成条件を作業単位へ変換する

まず、実装計画を次のように分けます。

units:
  - name: live-contract
    owns:
      - src/live-contract.ts
      - test/live-contract.test.ts
    depends_on: []

  - name: rtc-adapter
    owns:
      - src/adapters/rtc-adapter.ts
    depends_on:
      - live-contract

  - name: operator-console
    owns:
      - src/operator/**
    depends_on:
      - live-contract

  - name: ai-host
    owns:
      - src/ai-host/**
    depends_on:
      - live-contract

  - name: integration
    owns:
      - src/app.ts
      - test/integration/**
    depends_on:
      - rtc-adapter
      - operator-console
      - ai-host

重要なのは、live-contractを全作業の前提にしていることです。並列作業中に各担当が独自の状態型を作ると、統合ブランチで意味の調整が必要になります。

共有ファイルを触るのはintegrationだけにします。READMEやアプリのComposition Rootも、各機能ブランチから更新しないほうが衝突を減らせます。

ファイル所有権だけでは足りない

ファイルが分かれていても、次のような意味上の競合は残ります。

  • RTC担当は接続開始を「配信中」と解釈する
  • UI担当は配信画面を開いた時点で「配信中」と表示する
  • AI担当はルームIDが存在すれば発言可能と解釈する
  • 運営コンソールは停止ボタンを押した時点でAIが止まったと考える

そこで、配信状態はイベント契約からのみ導出します。

手順1:再現用プロジェクトを作る

Node.js環境で、次のファイルを用意します。

mkdir live-contract-demo
cd live-contract-demo
npm init -y
npm install --save-dev typescript tsx @types/node
mkdir -p src test

package.jsonへテストコマンドを追加します。

{
  "type": "module",
  "scripts": {
    "test": "tsx --test test/*.test.ts"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "tsx": "^4.0.0",
    "typescript": "^5.0.0"
  }
}

バージョン番号は固定要件ではありません。既存プロジェクトでは、利用中のNode.jsとTypeScriptのサポート範囲に合わせてください。

手順2:単一の状態機械とAI配信ゲートを実装する

src/live-contract.tsを作成します。

export type LivePhase = "idle" | "ready" | "live" | "closed";
export type HostControl = "ai" | "human";

export type LiveEvent =
  | {
      type: "ROOM_OPENED";
      roomId: string;
      seq: number;
    }
  | {
      type: "LIVE_STARTED";
      roomId: string;
      seq: number;
    }
  | {
      type: "HUMAN_TAKEOVER";
      roomId: string;
      seq: number;
      operatorId: string;
    }
  | {
      type: "AI_RESUMED";
      roomId: string;
      seq: number;
      operatorId: string;
    }
  | {
      type: "LIVE_STOPPED";
      roomId: string;
      seq: number;
    };

export type LiveState = {
  roomId: string;
  phase: LivePhase;
  control: HostControl;
  lastSeq: number;
  controlRevision: number;
};

export type AiDraft = {
  draftId: string;
  roomId: string;
  basedOnControlRevision: number;
  text: string;
};

export type DeliveryDecision =
  | { ok: true; draftId: string; text: string }
  | {
      ok: false;
      draftId: string;
      reason:
        | "WRONG_ROOM"
        | "NOT_LIVE"
        | "HUMAN_CONTROL"
        | "STALE_CONTROL_REVISION";
    };

export function initialState(roomId: string): LiveState {
  return {
    roomId,
    phase: "idle",
    control: "ai",
    lastSeq: 0,
    controlRevision: 0,
  };
}

function assertEventOrder(state: LiveState, event: LiveEvent): void {
  if (event.roomId !== state.roomId) {
    throw new Error(`room mismatch: ${event.roomId}`);
  }

  if (event.seq !== state.lastSeq + 1) {
    throw new Error(
      `unexpected seq: expected=${state.lastSeq + 1}, actual=${event.seq}`,
    );
  }
}

export function applyEvent(
  state: LiveState,
  event: LiveEvent,
): LiveState {
  assertEventOrder(state, event);

  const changed = {
    lastSeq: event.seq,
    controlRevision: state.controlRevision + 1,
  };

  switch (event.type) {
    case "ROOM_OPENED":
      if (state.phase !== "idle") {
        throw new Error(`ROOM_OPENED is invalid in ${state.phase}`);
      }
      return { ...state, ...changed, phase: "ready" };

    case "LIVE_STARTED":
      if (state.phase !== "ready") {
        throw new Error(`LIVE_STARTED is invalid in ${state.phase}`);
      }
      return { ...state, ...changed, phase: "live" };

    case "HUMAN_TAKEOVER":
      if (state.phase !== "live") {
        throw new Error(`HUMAN_TAKEOVER is invalid in ${state.phase}`);
      }
      return { ...state, ...changed, control: "human" };

    case "AI_RESUMED":
      if (state.phase !== "live") {
        throw new Error(`AI_RESUMED is invalid in ${state.phase}`);
      }
      return { ...state, ...changed, control: "ai" };

    case "LIVE_STOPPED":
      if (state.phase !== "ready" && state.phase !== "live") {
        throw new Error(`LIVE_STOPPED is invalid in ${state.phase}`);
      }
      return {
        ...state,
        ...changed,
        phase: "closed",
        control: "human",
      };
  }
}

export function decideAiDelivery(
  state: LiveState,
  draft: AiDraft,
): DeliveryDecision {
  if (draft.roomId !== state.roomId) {
    return { ok: false, draftId: draft.draftId, reason: "WRONG_ROOM" };
  }

  if (state.phase !== "live") {
    return { ok: false, draftId: draft.draftId, reason: "NOT_LIVE" };
  }

  if (state.control === "human") {
    return { ok: false, draftId: draft.draftId, reason: "HUMAN_CONTROL" };
  }

  if (draft.basedOnControlRevision !== state.controlRevision) {
    return {
      ok: false,
      draftId: draft.draftId,
      reason: "STALE_CONTROL_REVISION",
    };
  }

  return {
    ok: true,
    draftId: draft.draftId,
    text: draft.text,
  };
}

controlRevisionが必要な理由

AIの生成中に運営者が介入すると、生成開始時とは状況が変わっています。

単に現在のcontrolだけを見ると、次の順序で古い候補が復活します。

  1. AIが候補Aを生成開始
  2. 人間が介入
  3. 人間が対応を終えてAIを再開
  4. 介入前の候補Aが遅れて到着

手順4ではcontrolが再びaiなので、Booleanだけでは候補Aを通してしまいます。basedOnControlRevisionを比較すれば、再開前に生成された候補を破棄できます。

これはLLMの賢さでは解決できません。AIに現在状態をプロンプトで説明しても、生成中に起きた変更は反映されないためです。

手順3:境界条件をテストする

test/live-contract.test.tsを作成します。

import assert from "node:assert/strict";
import test from "node:test";

import {
  applyEvent,
  decideAiDelivery,
  initialState,
  type AiDraft,
} from "../src/live-contract.js";

function startLive(roomId = "room-001") {
  let state = initialState(roomId);

  state = applyEvent(state, {
    type: "ROOM_OPENED",
    roomId,
    seq: 1,
  });

  state = applyEvent(state, {
    type: "LIVE_STARTED",
    roomId,
    seq: 2,
  });

  return state;
}

test("配信中かつ制御版が一致すればAI候補を許可する", () => {
  const state = startLive();
  const draft: AiDraft = {
    draftId: "draft-1",
    roomId: state.roomId,
    basedOnControlRevision: state.controlRevision,
    text: "まもなく次のコーナーです",
  };

  assert.deepEqual(decideAiDelivery(state, draft), {
    ok: true,
    draftId: "draft-1",
    text: "まもなく次のコーナーです",
  });
});

test("人間が介入したら生成済み候補を停止する", () => {
  let state = startLive();
  const draft: AiDraft = {
    draftId: "draft-2",
    roomId: state.roomId,
    basedOnControlRevision: state.controlRevision,
    text: "AIからのお知らせです",
  };

  state = applyEvent(state, {
    type: "HUMAN_TAKEOVER",
    roomId: state.roomId,
    seq: 3,
    operatorId: "operator-7",
  });

  assert.equal(decideAiDelivery(state, draft).ok, false);
  assert.deepEqual(decideAiDelivery(state, draft), {
    ok: false,
    draftId: "draft-2",
    reason: "HUMAN_CONTROL",
  });
});

test("AIを再開しても介入前の候補は復活しない", () => {
  let state = startLive();
  const oldDraft: AiDraft = {
    draftId: "draft-old",
    roomId: state.roomId,
    basedOnControlRevision: state.controlRevision,
    text: "介入前に生成した案内",
  };

  state = applyEvent(state, {
    type: "HUMAN_TAKEOVER",
    roomId: state.roomId,
    seq: 3,
    operatorId: "operator-7",
  });

  state = applyEvent(state, {
    type: "AI_RESUMED",
    roomId: state.roomId,
    seq: 4,
    operatorId: "operator-7",
  });

  assert.deepEqual(decideAiDelivery(state, oldDraft), {
    ok: false,
    draftId: "draft-old",
    reason: "STALE_CONTROL_REVISION",
  });
});

test("配信開始前にはAI候補を流さない", () => {
  let state = initialState("room-001");
  state = applyEvent(state, {
    type: "ROOM_OPENED",
    roomId: "room-001",
    seq: 1,
  });

  const draft: AiDraft = {
    draftId: "draft-3",
    roomId: "room-001",
    basedOnControlRevision: state.controlRevision,
    text: "まだ配信前です",
  };

  assert.deepEqual(decideAiDelivery(state, draft), {
    ok: false,
    draftId: "draft-3",
    reason: "NOT_LIVE",
  });
});

test("順序が飛んだイベントを拒否する", () => {
  const state = initialState("room-001");

  assert.throws(() =>
    applyEvent(state, {
      type: "LIVE_STARTED",
      roomId: "room-001",
      seq: 2,
    }),
  );
});

実行します。

npm test

ここで確認しているのはAIの文章品質ではありません。

  • 配信前に発言しない
  • 人間の介入中に発言しない
  • 介入前の古い候補を再開後に流さない
  • イベント欠落を正常状態として扱わない

という、統合時に守るべき契約です。

手順4:依存関係に沿ってworktreeを作る

最初にlive-contractだけを実装・レビューして統合します。

git switch develop
git worktree add ../live-contract -b unit/live-contract

契約をdevelopへマージした後、その時点を基準に独立作業を開始します。

git switch develop
git pull --ff-only

git worktree add ../live-rtc-adapter -b unit/rtc-adapter develop
git worktree add ../live-operator -b unit/operator-console develop
git worktree add ../live-ai-host -b unit/ai-host develop

各作業単位の責任は次のように限定します。

作業単位 入力 出力 禁止事項
RTC Adapter SDK側の確定した状態変化 ROOM_OPENED、LIVE_STARTED、LIVE_STOPPED AI制御状態を変更しない
運営コンソール 運営者の明示操作 HUMAN_TAKEOVER、AI_RESUMED 画面内だけで停止済みにしない
AIホスト コメントや進行情報 AiDraft 配信状態を直接変更しない
Integration 各モジュールの出力 永続化、Reducer適用、配信判断 新しい独自状態を作らない

AIコーディングエージェントを使う場合も、1エージェントへリポジトリ全体を渡すのではなく、この入出力と変更可能ファイルを作業指示へ含めます。ただし、ファイル制限は権限制御そのものではありません。CIでも変更パスと契約テストを検査します。

手順5:Tencent RTCとの接続位置を一つに絞る

RTC Adapterでは、ボタンを押した事実ではなく、採用した組み込み方式で確認できた実際の状態変化をドメインイベントへ変換します。

// SDK固有のAPI名ではなく、接続位置を示す概念コード
async function onRtcLiveConfirmed(roomId: string) {
  await appendLiveEvent({
    type: "LIVE_STARTED",
    roomId,
    seq: await nextSequence(roomId),
  });
}

async function onOperatorTakeover(
  roomId: string,
  operatorId: string,
) {
  await appendLiveEvent({
    type: "HUMAN_TAKEOVER",
    roomId,
    operatorId,
    seq: await nextSequence(roomId),
  });
}

async function onAiDraftReady(draft: AiDraft) {
  const state = await rebuildState(draft.roomId);
  const decision = decideAiDelivery(state, draft);

  if (!decision.ok) {
    await recordRejectedDraft(decision);
    return;
  }

  await deliverApprovedDraft(decision);
}

appendLiveEventはサーバー側で直列化する必要があります。複数クライアントがローカルでlastSeq + 1を計算すると、同じ番号が重複します。データベースのトランザクション、楽観ロック、単一書き込みキューなど、利用中の基盤に合う方法で採番してください。

また、deliverApprovedDraftでチャット表示、画面上の案内、音声化のどれを行うかは別の判断です。RTCのメディア輸送、LLM、音声合成、アプリ状態を一つのモジュールへ混ぜないでください。

確認方法:マージ成功ではなくイベント列を再生する

統合ブランチでは、正常な入室だけでなく次のイベント列を確認します。

1. AI生成中に人間が介入する

ROOM_OPENED
LIVE_STARTED
AI生成開始
HUMAN_TAKEOVER
AI生成完了

期待結果:生成結果はHUMAN_CONTROLで破棄される。

2. 人間の介入後にAIを再開する

AI生成開始 revision=2
HUMAN_TAKEOVER revision=3
AI_RESUMED revision=4
古いAI生成完了 revision=2

期待結果:STALE_CONTROL_REVISIONで破棄される。

3. 配信終了とAI生成完了が競合する

LIVE_STARTED
AI生成開始
LIVE_STOPPED
AI生成完了

期待結果:NOT_LIVEで破棄される。

4. イベントが欠落する

ROOM_OPENED seq=1
LIVE_STARTED seq=3

期待結果:状態を推測して進めず、再取得または復旧処理へ送る。

5. 別ルームの候補が混ざる

期待結果:WRONG_ROOMで拒否し、内容が自然でも配信しない。

AIを入れるかの判断基準

AIホストは、進行案や案内文の候補生成には利用できます。一方、次の判断をLLM出力へ委ねる必要はありません。

  • 現在配信中か
  • 運営者が制御を引き取ったか
  • 生成開始後に状態が変わったか
  • どのルームへ届けるか

これらはイベント台帳から決定的に判断できます。

AIを導入する価値があるのは、文章候補を人手で毎回作る負担が実際にあり、誤生成時に無言・定型文・有人進行へ戻せる場合です。単に「AIホストがいる」デモを見せる目的なら、状態管理と監視のコストが効果を上回る可能性があります。

注意点とトレードオフ

イベント契約の先行実装は初速を遅くする

画面から作り始めるより、最初のデモは遅く見えます。その代わり、各作業単位が独自の状態解釈を持つのを防げます。短期間の試作なら構築済みUIを優先し、独自運営フローが固まってから契約を増やす選択もあります。

seqだけでは分散障害をすべて解決しない

再送、二重書き込み、永続化失敗、接続復帰時の再同期は別途扱う必要があります。本番ではイベントID、冪等性、監査ログ、現在状態との照合を追加してください。

AI停止ボタンはUIだけに置かない

ボタン表示を切り替えるだけでは、別プロセスで完了したAI候補を止められません。HUMAN_TAKEOVERをサーバー側の台帳へ保存し、配信直前のゲートで必ず再確認します。

弱いネットワークでは表示と実状態がずれる

ネットワーク状態が悪いと、操作結果やイベント通知が遅れる可能性があります。UIでは「停止済み」を先に断定せず、要求中・確認済み・復旧必要を区別してください。具体的な測定値やフォールバック条件は、対象端末とネットワークで計測して決めます。

統合前チェックリスト

  • 組み込み方式を構築済みUIまたはAtomicXCoreから選んだ
  • 状態型とイベント型が一つの共有契約に集約されている
  • 作業単位ごとの変更可能ファイルが重複していない
  • RTC Adapter以外がRTC固有処理を直接呼んでいない
  • AIホストが配信状態を書き換えられない
  • 人間の介入がサーバー側イベントとして保存される
  • 配信直前にroomId、phase、controlRevisionを再検証する
  • 配信終了後の遅延AI候補を拒否できる
  • 欠落・重複・順序逆転したイベントの復旧方針がある
  • AIを停止してもライブ配信自体は継続できる

並列開発の目的は、単に複数ブランチを同時に進めることではありません。各作業の判断範囲を狭め、統合時に解釈を一つへ戻せることが重要です。Interactive Liveでは、とくにRTCの実状態、運営者の操作、AIの生成結果を別々の責任として扱うと、説得力のあるデモから運用可能な実装へ進みやすくなります。


関係性の開示: 筆者はTencent RTCのコンテンツ制作に関係しています。本記事はTencent RTCの公式ドキュメントを実装上の参照資料として使用しています。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?