Amazon IVSのリアルタイムストリーミングでインカム付き送り返しWebアプリを作ってみた

IVS_02_rev

Amazon Interactive Video Service Real-Time Streaming

Amazon Interactive Video Service (IVS)は、ライブ配信を簡単に実装できるAWSのマネージドライブストリーミングソリューションです。

前回の記事では超基礎編として簡単に0.3秒のレイテンシーで配信ができることが分かりました。

送り返しWebアプリを作成

今回は生放送での使用を想定し、実際にこの機能を使って送り返しWebアプリを作ってみました。

送り返しとは、生放送において本社や中継車から中継現場などの遠隔地に向けて、本番映像や音声をリアルタイムで送る技術のことを指します。
この送り返しの映像や音声があることで、中継現場ではスタジオから自分たちの映像に切り替わったタイミングを把握したり、切り替わったインサート映像に合わせてコメントしたりと、柔軟な対応が可能となります。

従来のSaaSでも送り返しの要件は十分に満たせていますが、送り返し専用のサービスであるため、部署ごとの運用や番組制作のケースによっては課題が生じることもあります。例えばライセンス数の制約があったり、痒いところに手が届く細かいカスタマイズをしたくても難しかったりします。

そこでIVSを使って送り返し機能を実装できれば、自分たちで開発するWebアプリとして、必要な要件に応じて柔軟にカスタマイズできるのではないかと考えています。

そういった観点から、今回は番組制作の現場で実際に使いそうな以下の2つの要件を考慮した送り返しアプリを作ってみます。

  • 送り返し映像は本社(Host)からの1映像のみとする
  • 音声はインカムを想定し、それぞれの端末(Viewer)からも話せるようにする

実際に作ったもの

百聞は一見にしかず、ということで実際に作ってみたものを最初に紹介します!

構成図はこのような形。SDIの映像をWeb Presenterで取り込んでPCで配信。
それをスマホで視聴するという形でデモをしてみました。
もう1台のPCではマネージメントコンソールからサブスクライブで監視をしていました。

配信側(Host)

  1. ステージ(test-01)を選択し『配信者として参加』をします。
  2. カメラ/マイクの使用許可をしたのちにWeb Presenterを選択。
  3. 『配信開始』を押すと配信が始まりました。なお、参加者一覧で見ている人が確認できました。

視聴側(Viewer)

  1. ステージ(test-01)を選択し『視聴者として参加』をします。
  2. インカムとして話す予定のあるViewerはマイクの許可をして『視聴開始』を選択します。
  3. すると送り返しを見ることができました。なお、何かしゃべりたい人はマイクをONにすることで話すことができます。

マネージメントコンソール

またマネージメントコンソール上からもサブスクライブをしてみると接続している参加者がそれぞれ表示されました。
一応、Viewerも技術的には映像を送信できる設定になっているため、オンライン会議システムのカメラOFF状態のように参加者のメールアドレスのみが表示されています。

ちなみに、Viewer同士でしゃべっているのは各デバイスでももちろん確認できましたが、マネージメントコンソール上でもオレンジの枠のように、しゃべっている人を確認することができました。

実装(使ったものを簡単に)

さて、続いて実装編として使ったコードを簡単に紹介します。

フロントエンドに関しては、初手としてAmplify Docsの中にあるAmplify Gen 2 のReact Viteテンプレートを使って、Cognito認証など基本的なところまでのデプロイを簡単に作ってから必要なコンポーネントなどを足していきました。
こちらのReactでほぼほぼ実装をするのですが、どのステージで処理をするか選ぶためにステージのリストを取得するところと、stageArnとuserIdを送り参加トークンを取得するところは、Amplifyとは別にLambda関数を作成し、Lambda関数URLを発行してフロントエンドから直接呼び出す構成にしました。

全体の流れ

実装の大きな流れは以下の3ステップです。

  • バックエンド:Lambda関数を2つ作成(ステージ一覧取得・参加トークン発行)
  • フロントエンド:IVS Web Broadcast SDKを使ってStageに参加
  • Host(配信者)とViewer(視聴者)でStrategyを分けて、送り返しの内容を整理

バックエンド(Lambda関数)

IVSのリアルタイムストリーミングでは、参加者がStageに入るために「参加トークン」が必要です。
このトークンの発行はサーバーサイドで行う必要があるため、Lambda関数を用意しました。
また、どのステージに参加するかを選べるように、ステージの一覧を取得するLambda関数も別途作成しています。

こちらの2つのLambdaについては @aws-sdk/client-ivs-realtime を使ってIVSのAPIを呼び出しました。

ステージ一覧取得

ListStagesCommandでIVSに作成済みのステージ一覧を取得し、ARNと名前を返します。

import { IVSRealTimeClient, ListStagesCommand } from '@aws-sdk/client-ivs-realtime';

const client = new IVSRealTimeClient();

export const handler = async (event) => {
  const command = new ListStagesCommand({});
  const response = await client.send(command);

  return {
    statusCode: 200,
    headers: {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Headers': 'Content-Type',
    },
    body: JSON.stringify({
      stages: (response.stages || []).map(stage => ({
        arn: stage.arn,
        name: stage.name,
      })),
    }),
  };
};

参加トークン発行

CreateParticipantTokenCommand で指定されたステージに参加トークンを発行します。
パラメータは以下のようにしました。

  • stageArn:該当するステージのARN
  • userId:Cognitoで認証されているユーザのメールアドレス
  • capabilities: ['PUBLISH', 'SUBSCRIBE'] :HostもViewerも配信と受信の両方を許可
  • attributes: { role }:HostかViewerかをトークンに埋め込む(フロントエンドの判別に使用)
※今回は社内での活用を目的としているため、メールアドレスを使っていますが、本番利用の際には何のデータを入れるか十分検討してください。
(ドキュメントにも「このフィールドはすべてのステージ参加者に公開されるため、個人を特定できる情報、機密情報、または機密性の高い情報には使用しないでください。」と記載されています。)
import { IVSRealTimeClient, CreateParticipantTokenCommand } from '@aws-sdk/client-ivs-realtime';

const client = new IVSRealTimeClient();

export const handler = async (event) => {
  const body = typeof event.body === 'string'
    ? JSON.parse(event.body)
    : (event.body || event);
  const { userId, stageArn, role } = body;

  const command = new CreateParticipantTokenCommand({
    stageArn: stageArn || process.env.STAGE_ARN,
    userId: userId || 'anonymous',
    capabilities: ['PUBLISH', 'SUBSCRIBE'],
    attributes: {
      role: role || 'viewer',
    },
  });

  const response = await client.send(command);

  return {
    statusCode: 200,
    headers: {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Headers': 'Content-Type',
    },
    body: JSON.stringify({
      token: response.participantToken?.token ?? '',
      participantId: response.participantToken?.participantId ?? '',
    }),
  };
};

なお、これらのLambda関数はAmplifyのテンプレートとは別に作成し、Lambda関数URLを発行してフロントエンドから直接呼び出す構成にしました。今回はデモのために割愛していますが誰でもトークンが発行できてしまうことを防ぐために、本番環境ではAmplifyのバックエンド統合やCORSの設定が必要です。

フロントエンド(IVS Web Broadcast SDK)

https://docs.aws.amazon.com/ja_jp/ivs/latest/RealTimeUserGuide/web-publish-subscribe.html
まずSDKをインストールします。

npm install amazon-ivs-web-broadcast

IVSのリアルタイムストリーミングユーザーガイドとIVS Web Broadcast SDKリファレンスを参考に実装していきます。

Stageに参加する

IVSのリアルタイムストリーミングでは、Stageクラスをメインで使います。
参加トークンと「Strategy」を渡してインスタンスを作り、join() で参加します。

import { Stage } from 'amazon-ivs-web-broadcast';

const stage = new Stage(token, strategy);
await stage.join();

このコードだけでStageに参加でき、退出も stage.leave() を呼ぶだけとなります。

Strategyとは

急にStrategyというのが出てきました。

Strategyとは

  • 誰の映像を受信するか (Subscribe)
  • 自分を配信するか (Publish)
  • 何を配信するか (映像/音声/画面共有)

を制御するためのルール設定となります。

例えば、全員の映像を受信すると帯域が足りないとき、必要な人だけ受信したいとなるとStrategyでそのように定義すれば必要な人だけ受信することができます。
IVSのSDKがこのオブジェクトのメソッドを呼び出して、配信・受信の挙動を決定します。

そのため、今回の送り返しアプリでは、HostとViewerでStrategyを分けているのがポイントです。

import { LocalStageStream, SubscribeType } from 'amazon-ivs-web-broadcast';

// Host用Strategy:映像と音声の両方を配信
function createHostStrategy(audioTrack, videoTrack) {
  return {
	  // 音声と映像をPublish
    stageStreamsToPublish() {
      return [
        new LocalStageStream(audioTrack),
        new LocalStageStream(videoTrack),
      ];
    },
    shouldPublishParticipant() {
      return true;
    },
    shouldSubscribeToParticipant() {
      return SubscribeType.AUDIO_VIDEO;
    },
  };
}

// Viewer用Strategy:音声のみ配信(インカム用)
function createViewerStrategy(audioTrack) {
  return {
	  // 音声のみをPublish
    stageStreamsToPublish() {
      return audioTrack ? [new LocalStageStream(audioTrack)] : [];	// インカムで発しない人のための処理を追加
    },
    shouldPublishParticipant() {
      return true;
    },
    shouldSubscribeToParticipant() {
      return SubscribeType.AUDIO_VIDEO;
    },
  };
}

使用しているメソッドは以下の通りです。

  • stageStreamsToPublish():自分が配信するストリームを返す。Hostは映像+音声、Viewerは音声のみ
  • shouldPublishParticipant():自分が配信するかどうか。両方ともtrue
  • shouldSubscribeToParticipant():相手のストリームをどう受信するか。AUDIO_VIDEOで映像と音声の両方を受信
  • LocalStageStream:ブラウザのMediaStreamTrackをIVS SDKに渡すためのラッパー

createHostStrategycreateViewerStrategyで分けることにより、「Hostからの映像をViewerが受け取り、Viewerからの音声をHostが受け取る」という送り返しの要件を実現しました。

Stageイベントで相手のストリームを受信する

Stageに参加した後、相手の映像や音声を受け取るにはイベントリスナーを設定します。
簡単にいうとStageは「何か起きたら教えてくれる」仕組みを持っているため、UIの更新としてはこのイベントを聞いているだけでOKということみたいです。

import { StageEvents } from 'amazon-ivs-web-broadcast';

// 相手のストリームが届いたとき
stage.on(StageEvents.STAGE_PARTICIPANT_STREAMS_ADDED, (participant, streams) => {
  // ローカル参加者(自分自身)は除外
  if (participant.isLocal) return;

  const mediaStream = new MediaStream();
  streams.forEach(stream => {
    if (stream.mediaStreamTrack) {
      mediaStream.addTrack(stream.mediaStreamTrack);
    }
  });

  // <video>要素にセットして再生
  videoElement.srcObject = mediaStream;
});

主に使用したイベントは以下の通りです。

イベント 用途
STAGE_CONNECTION_STATE_CHANGED 接続状態の変化を監視
STAGE_PARTICIPANT_JOINED 参加者の入室を検知
STAGE_PARTICIPANT_LEFT 参加者の退出を検知
STAGE_PARTICIPANT_STREAMS_ADDED リモート参加者のストリーム受信
STAGE_PARTICIPANT_STREAMS_REMOVED リモート参加者のストリーム削除

Viewer側での映像と音声の振り分け

Viewer側では、受信したストリームの中身を見て処理を分けています。
具体的には、Viewerからの映像は不要なので、トークンを作る時に設定したattributesを見て、そのユーザーの映像はスキップをするという処理をしています。
ここで先ほどのトークン発行時に埋め込んだ attributes.role が活用されます。participant.attributes?.role === 'viewer' で判定することで、Viewer同士の映像トラックを除外し、音声のみを受け取るようにしています。

stage.on(StageEvents.STAGE_PARTICIPANT_STREAMS_ADDED, (participant, streams) => {
  if (participant.isLocal) return;

  const mediaStream = new MediaStream();
  streams.forEach(stream => {
    if (!stream.mediaStreamTrack) return;

    // Viewer同士の映像は不要なのでスキップ
    if (participant.attributes?.role === 'viewer' && stream.mediaStreamTrack.kind === 'video') {
      return;
    }
    mediaStream.addTrack(stream.mediaStreamTrack);
  });

  // 映像トラックがあれば<video>で表示、音声のみなら<audio>で再生
  if (mediaStream.getVideoTracks().length > 0) {
    // Hostからの映像 → <video>要素にセット
  } else if (mediaStream.getAudioTracks().length > 0) {
    // 他のViewerからの音声 → <audio>要素にセット
  }
});

他にも細かい部分などを書きながら、デプロイすると無事、配信を行うことができました。

実際に送り返しアプリとして使えるのか?

今回の記事は『IVSのアプリを作ってみた』ではなく『送り返しアプリを作ってみた』ということでした。

なので実際にどうなん?ってことを改めて検証してみたところ、以下のレイテンシーが確認できました。

  • Web Presenterからブラウザへの取り込み: 約0.2秒(6フレーム)
  • IVS経由でのスマホへの配信: 約0.3秒(7フレーム)
  • 合計: 約0.5秒

実際にこの秒数は、リアルタイムなカメラワークの調整など、フレーム単位での精密な同期が必要な用途には厳しいですが、中継開始の確認やスタジオから中継への切り替わりタイミングの把握といった用途であれば十分実用的です。
もし、大型中継があった際に、送り返しのデバイスが不足しているときは十分有効ではないかと思いました。

ちなみにMBSで使っている送り返しアプリではどれくらいか比較してみたところ、0.2秒程度のレイテンシーでした。
基準となっている映像は上の画像のモニターからのレイテンシーを測っているため、このモニターまで届くまでに数フレームか遅れている可能性はあったとしても、圧倒的に早かったです。

既存の専用アプリが高速な理由として、おそらく映像取り込みの最適化やクラウドを経由しないP2P通信が考えられます。一方、IVSを使った実装ではAWS経由での配信のため物理的な距離による遅延が発生します。この差を考慮すると、0.5秒という結果は十分実用的と言えます。

専用アプリには送り返しという性能面では及びませんが、IVSを使った実装には既存のデバイスで利用できる点や必要な機能を柔軟にカスタマイズできる点、ライセンス費用を削減できる点などのメリットがあります。例えば報道原稿やテレビマスターからのリメイン時間など、送り返し映像と同時に必要な情報を配信することも可能です。こうした特性を活かし、用途に応じて使い分けることで、より効率的な番組制作が実現できそうです。

Next Post Previous Post