見出し画像

【新機能】SkyWayの録音・録画機能がP2P Roomの通話を対象に利用可能になりました

まえがき

みなさまこんにちは。SkyWayチームのryuryuと申します。
SkyWayはこの度、録音・録画機能において機能の追加を行いました。本記事ではその利用方法の説明を行います。

概要

SkyWayでは、録音・録画機能を提供しています。
こちらの機能はRoom内に存在するメディアデータをSFUサーバーを介して録音・録画するものとなっています。

これまでSFUを利用したRoomでのみ利用できる機能でしたが、今回P2P Roomでの通話も録音・録画できる機能が追加されました。今回P2Pを利用した録音録画の利用を解説します。
なお、SkyWayおよび録音・録画機能の基本的な利用方法は以下をご参照ください。
https://skyway.ntt.com/ja/docs/
https://skyway.ntt.com/ja/docs/user-guide/recording/recording-overview/

使い方

ユーザー間の通信を行う P2P Room とは別に、録音・録画機能を利用するための SFU Room を同時に利用することで、ユーザー間の通信は P2P Room を利用しながら録音・録画機能を利用できます。なお、この際に専用の設定を行うことで、SFU通信料とSFUリソース確保料は生じません。

今回作るサンプルアプリ

クライアント側の表示例

今回は録音・録画機能の記事の クイックスタート の構成を追従しつつ、P2P RoomとSFU Roomを併用する形で実装を行います。クライアントアプリとサーバーの2つから構成します。
まず、通話を行うクライアントはサーバーにRoomの作成をリクエストします。リクエストを受けたサーバーは通話を行うP2P用のRoomと、録音・録画処理を行うSFU用のRoomを作成します。それらのRoomを利用するためのトークンをクライアントに返します。

トークンを受け取ったクライアントは、P2P RoomとSFU Roomのそれぞれに参加します。通話自体はP2P Room内で行いますが、SFU Roomの中でも録音・録画用に同じメディアデータをpublishしておきます。
クライアントがサーバーに録音・録画処理をリクエストすると、SFU Room内のPublicationを録音・録画するようにSkyWayにリクエストを送ります。ここからSFU Room内の情報を元に録音・録画処理を開始されます。

こうしてP2P Roomでの通話を利用しながら、同時に録音・録画処理を行うことができます。
今回はこちらのアプリをJavaScript SDK を利用して作成します。

アカウントの準備

SkyWayコンソール( https://console.skyway.ntt.com/ )からアプリケーションを作成し、アプリケーションIDとシークレットキーを控えます。

なお、開発・検証目的であればFreeプラン(利用上限あり)のアカウントで利用することができます。
詳しくは 料金 のページをご参照ください。

ストレージの設定

任意のクラウドストレージに対してストレージの設定を行います。詳細は以下を参照してください。

環境構築

Node.js のバージョン 20 以降をインストールし、任意の作業ディレクトリに tutorial ディレクトリを作成します。tutorial ディレクトリ直下に以下の内容の package.json ファイルを作成します。

{
  "name": "tutorial",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "client": "parcel client/index.html",
    "server": "node server/main.js"
  },
  "browserslist": ["last 3 chrome versions"],
  "dependencies": {
    "@skyway-sdk/room": "^1.7.0",
    "cors": "^2.8.5",
    "express": "^4.18.2",
    "jsrsasign": "^11.1.0"
  },
  "devDependencies": {
    "parcel": "^2.11.0"
  }
}

サーバーサイド

録音・録画機能はサーバーサイドから SkyWay Recording API を通じて操作するので、そのためのサーバーアプリケーションを作成する必要があります。

ソースファイルの作成

tutorial/server/main.js ファイルを作成し、以下のコードを記述します。

// tutorial/server/main.js

import cors from 'cors';
import express from 'express';
import jsrsasign from 'jsrsasign';
import { SkyWayAuthToken, nowInSec, uuidV4 } from '@skyway-sdk/token';
import crypto from 'crypto';

const appId = 'ここにアプリケーションIDをペーストしてください';
const secret = 'ここにシークレットキーをペーストしてください';

const gcsConfig = {
  service: 'GOOGLE_CLOUD_STORAGE',
  credential: JSON.stringify({
    // サービスアカウントの鍵のJSONファイルの内容をコピーペーストする
    type: '',
    project_id: '',
    private_key_id: '',
    private_key: '',
    client_email: '',
    client_id: '',
    auth_uri: '',
    token_uri: '',
    auth_provider_x509_cert_url: '',
    client_x509_cert_url: '',
  }),
  bucket: '',
};

const s3Config = {
  service: 'AMAZON_S3',
  bucket: '',
  accessKeyId: '',
  secretAccessKey: '',
  region: '',
};

const wasabiConfig = {
  service: 'WASABI',
  bucket: '',
  accessKeyId: '',
  secretAccessKey: '',
  endpoint: '',
};

const recordingApiBaseUrl = 'https://recording.skyway.ntt.com/v1';
const channelApiUrl = 'https://channel.skyway.ntt.com/v1/json-rpc';

appId,secret には自身のアプリケーションの値を入力してください。
また、利用するクラウドストレージに合わせて gcsConfig、s3Config、wasabiConfig のいずれかに必要な情報を入力してください。 wasabiConfig の endpoint は、 https://s3.<リージョン名>.wasabisys.com のように設定してください。 (例: https://s3.ap-northeast-1.wasabisys.com )

SkyWay Admin Auth Token の作成

SkyWay の Recording API および Channel API を操作するために SkyWay Admin Auth Token を作成する必要があります。
SkyWay Admin Auth Token の詳細な仕様は次の記事を参照してください。
SkyWay Admin Auth Token

// tutorial/server/main.js

// Recording API と Channel API を操作するためのトークン
const createSkyWayAdminAuthToken = () => {
  const token = jsrsasign.KJUR.jws.JWS.sign(
    'HS256',
    JSON.stringify({ alg: 'HS256', typ: 'JWT' }),
    JSON.stringify({
      exp: nowInSec() + 60,
      iat: nowInSec(),
      jti: uuidV4(),
      appId,
    }),
    secret
  );
  return token;
};

SkyWay Auth Token の作成

SkyWay のクライアントサイド SDK で利用する SkyWay Auth Token を作成します。
SkyWay Auth Token の詳細な仕様は次の記事を参照してください。
SkyWay Auth Token
以下では、通話を行うRoomの名称と録音録画を行うRoomの名称を分けて設定しています。
録音録画を行うRoomでは、maxSubscribers の設定を 0 と設定することによって録音・録画に伴うSFU 通信料とSFUリソース確保料が発生しなくなっています。なお、こちらの設定はSkyWay Auth Token のバージョン 3 から対応しております。

// tutorial/server/main.js

// クライアント用のトークン
const createSkywayAuthToken = (roomName, recordingSFURoom) => {
  const token = new SkyWayAuthToken({
    jti: uuidV4(),
    iat: nowInSec(),
    exp: nowInSec() + 60 * 60 * 24,
    version: 3,
    scope: {
      appId: appId,
      rooms: [
        {
          id: "*",
          name: roomName,
          methods: ["create", "close", "updateMetadata"],
          sfu: {
            enabled: false,
          },
          member: {
            id: "*",
            name: "*",
            methods: ["publish", "subscribe", "updateMetadata"],
          },
        },
        {
          id: "*",
          name: recordingSFURoom,
          methods: ["create", "close", "updateMetadata"],
          sfu: {
            enabled: true,
            maxSubscribersLimit: 0,
          },
          member: {
            id: "*",
            name: "*",
            methods: ["publish", "subscribe", "updateMetadata"],
          },
        },
      ],
    },
  }).encode(secret);
  return token;
};

サーバーフレームワークの設定

このチュートリアルアプリでは、サーバーフレームワークとして express を利用します。こちらの設定を行います。

// tutorial/server/main.js

const app = express();
app.use(cors());
app.use(express.json());

Roomの入室管理

クライアントから Room 名を受け取って、その Room 名の Room を SkyWay Channel API で作成しています。
この際に、合わせて録音・録画用のRoomをもう1つ作成し、このidをroomNameと紐づけて保存します。

// tutorial/server/main.js

const tokenHashRoomIdMap = {};
const roomNameIdMap = {};
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");

app.post("/rooms/:roomName/join", async (req, res) => {
  const { roomName: p2pRoomName } = req.params;

  console.log("join", { p2pRoomName });

  // 入力のroomNameのroomを作成する
  await fetch(channelApiUrl, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${createSkyWayAdminAuthToken()}`,
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: uuidV4(),
      method: "findOrCreateChannel",
      params: {
        name: p2pRoomName,
      },
    }),
  });
  // 入力の録音用のRoomを作成する
  const sfuRoomName = p2pRoomName + "-recording";
  const sfuRoomResponse = await fetch(channelApiUrl, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${createSkyWayAdminAuthToken()}`,
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: uuidV4(),
      method: "findOrCreateChannel",
      params: {
        name: sfuRoomName,
      },
    }),
  });

  const {
    result: {
      channel: { id: sfuRoomId },
    },
  } = await sfuRoomResponse.json();
  roomNameIdMap[p2pRoomName] = sfuRoomId;

  // 入室できるroomNameを制限したトークンを作成する
  const token = createSkywayAuthToken(p2pRoomName, sfuRoomName);
  // 今後の録音・録画の開始、終了操作を認証するためにtokenとroomIdの紐付けを行う
  tokenHashRoomIdMap[sha256(token)] = sfuRoomId;

  res.send({ token });
});

録音・録画 の開始

クライアントから受け取ったroomNameから録音・録画用に作成したRoomのidを取得します。その Room の全 Publication を録音・録画するように CreateRecordingSession API を呼び出します。

// tutorial/server/main.js

const roomNameRecordingMap = {};

app.post("/rooms/:roomName/start", async (req, res) => {
  console.log("start", req.params);
  const { roomName } = req.params;
  const { authorization } = req.headers;
  const recordingRoomId = roomNameIdMap[roomName];

  console.log("start", { roomName, recordingRoomId });

  if (roomNameRecordingMap[roomName]) {
    res.status(200).send({ message: "already recording" });
    return;
  }

  // roomIdとtokenの紐付けを確認する
  if (recordingRoomId !== tokenHashRoomIdMap[sha256(authorization ?? "")]) {
    res.status(403).send({ message: "Forbidden" });
    return;
  }

  // Recordingを開始する
  const response = await fetch(
    `${recordingApiBaseUrl}/channels/${recordingRoomId}/sessions`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${createSkyWayAdminAuthToken()}`,
      },
      body: JSON.stringify({
        input: {
          kind: "SFU",
          publications: [{ id: "*" }], // すべてのPublicationを保存する
        },
        output: gcsConfig, // Amazon S3を使う場合は s3Config を、Wasabiを使う場合は wasabiConfig を指定
      }),
    }
  );

  const { error, id } = await response.json();
  if (error) {
    res.status(500).send({ message: error.message });
    return;
  }
  roomNameRecordingMap[roomName] = id;

  res.status(201).send({ id });
});

録音・録画の終了

RecordingSession を削除するとすべての録音・録画処理が終了し、録音・録画ファイルが指定したクラウドストレージに保存されます。

// tutorial/server/main.js

app.delete("/rooms/:roomName/stop", async (req, res) => {
  const { roomName } = req.params;
  const { authorization } = req.headers;
  const recordingRoomId = roomNameIdMap[roomName];
  const sessionId = roomNameRecordingMap[roomName];

  console.log("stop", { roomName, recordingRoomId, sessionId });

  // roomIdとtokenの紐付けを確認する
  if (recordingRoomId !== tokenHashRoomIdMap[sha256(authorization ?? "")]) {
    res.status(403).send({ message: "Forbidden" });
    return;
  }

  // 録音・録画を終了する
  const response = await fetch(
    `${recordingApiBaseUrl}/channels/${recordingRoomId}/sessions/${sessionId}`,
    {
      method: "DELETE",
      headers: {
        Authorization: `Bearer ${createSkyWayAdminAuthToken()}`,
      },
    }
  );
  // 録音・録画したファイルの一覧を取得する
  const { error, files } = await response.json();
  if (error) {
    res.status(500).send({ message: error.message });
    return;
  }

  const filePaths = files.map((file) => file.path);
  // Recordingしたファイルのパスを出力する。クラウドストレージのこのパスにアップロードされている
  console.log("filePaths", filePaths);

  delete roomNameRecordingMap[recordingRoomId];

  res.status(200).send({ filePaths });
});

サーバーの起動

ポート 9090 でサーバーを起動します。
tutorial/server/main.js

// tutorial/server/main.js

app.listen(9090);
console.log('Server is running on http://localhost:9090');

クライアントサイド

以下を参考に tutorial/client/index.html ファイルを作成してください。

// tutorial/client/index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <title>SkyWay Tutorial</title>
  </head>
  <body>
    <p>ID: <span id="my-id"></span></p>
    <div>
      room name: <input id="room-name" type="text" />
      <button id="join">join</button>
      <button id="startRecording">start recording</button>
      <button id="stopRecording">stop recording</button>
    </div>
    <video id="local-video" width="400px" muted playsinline></video>
    <div id="button-area"></div>
    <div id="remote-media-area"></div>
    <script type="module" src="main.js"></script>
  </body>
</html>

次にスクリプトファイルを用意します。 以下を参照して、tutorial/client/main.js ファイルを作成してください。

// tutorial/client/main.js

import {
  SkyWayContext,
  SkyWayRoom,
  SkyWayStreamFactory,
} from "@skyway-sdk/room";

(async () => {
  const localVideo = document.getElementById("local-video");
  const buttonArea = document.getElementById("button-area");
  const remoteMediaArea = document.getElementById("remote-media-area");
  const roomNameInput = document.getElementById("room-name");
  const myId = document.getElementById("my-id");
  const joinButton = document.getElementById("join");
  const startRecordingButton = document.getElementById("startRecording");
  const stopRecordingButton = document.getElementById("stopRecording");

  const { audio, video } =
    await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream();
  video.attach(localVideo);
  await localVideo.play();

入室時の処理を作成します。この際に通話用のRoomと別に録音・録画用のRoomを作成し、そこで通話用と同様にPublishを行います。

// tutorial/client/main.js

  joinButton.onclick = async () => {
    const roomName = roomNameInput.value;
    if (roomName === "") return;
    const response = await fetch(
      `http://localhost:9090/rooms/${roomName}/join`,
      {
        method: "POST",
      }
    );
    const { token } = await response.json();

    // ユーザー間の通信にはP2P Roomを利用します
    const context = await SkyWayContext.Create(token);
    const room = await SkyWayRoom.Find(context, { name: roomName }, "p2p");
    const me = await room.join();

    myId.textContent = me.id;

    await me.publish(audio);
    await me.publish(video);
    // 録音録画機能を利用するために、SFU Roomを利用します
    const sfuRoomName = roomName + "-recording";
    const recordingSFURoom = await SkyWayRoom.Find(context, {
      type: "sfu",
      name: sfuRoomName,
    });
    const recordingAgent = await recordingSFURoom.join();

    // 一度PublishしたStreamは再度Publishすることができないので、改めて録音・録画用のStreamを取得します
    const { audio: recordingAudio, video: recordingVideo } =
      await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream();
    await recordingAgent.publish(recordingAudio, {
      maxSubscribers: 0,
    });
    await recordingAgent.publish(recordingVideo, {
      maxSubscribers: 0,
    });

通話用のRoomでのsubscribe処理を記述します。なお、録音・録画用のRoomに関してはmaxSubscribers オプションを 0 に設定しているためsubscribe処理を行うことができません。このままの状態で録音・録画専用の Publication として扱われています。

// tutorial/client/main.js

    const subscribeAndAttach = (publication) => {
      if (publication.publisher.id === me.id) return;

      const subscribeButton = document.createElement("button");
      subscribeButton.textContent = `${publication.publisher.id}: ${publication.contentType}`;
      buttonArea.appendChild(subscribeButton);

      subscribeButton.onclick = async () => {
        const { stream } = await me.subscribe(publication.id);

        let newMedia;
        switch (stream.track.kind) {
          case "video":
            newMedia = document.createElement("video");
            newMedia.playsInline = true;
            newMedia.autoplay = true;
            break;
          case "audio":
            newMedia = document.createElement("audio");
            newMedia.controls = true;
            newMedia.autoplay = true;
            break;
          default:
            return;
        }
        stream.attach(newMedia);
        remoteMediaArea.appendChild(newMedia);
      };
    };

    room.publications.forEach(subscribeAndAttach);
    room.onStreamPublished.add((e) => subscribeAndAttach(e.publication));

サーバー側に録音・録画の開始/停止をリクエストする処理を作成して完成です。

// tutorial/client/main.js

    startRecordingButton.onclick = async () => {
      await fetch(`http://localhost:9090/rooms/${room.name}/start`, {
        method: "POST",
        headers: {
          Authorization: context.authTokenString,
        },
      });
    };

    stopRecordingButton.onclick = async () => {
      await fetch(`http://localhost:9090/rooms/${room.name}/stop`, {
        method: "DELETE",
        headers: {
          Authorization: context.authTokenString,
        },
      });
    };
  };
})().catch((e) => console.error("main error", e));

チュートリアルアプリの実行

アプリケーションの起動

ターミナルで npm run server を実行してサーバーを起動します。
別のターミナルで npm run client を実行してクライアントを起動します。なお、この際にターミナルにローカルアドレスが表示されるので、そのアドレスをブラウザで開いてください。
Room に join したあと、startRecording ボタンを押すと録音・録画が始まります。stopRecording ボタンを押すと録音・録画が終了します。

保存されたファイルの確認

stopRecording ボタンを押すと、サーバー側の標準出力にクラウドストレージにアップロードされたファイルのパスが出力されます。 クラウドストレージのコンソールにアクセスし、ファイルがアップロードされていることを確認してください。
保存されるファイルの詳細な仕様については概要の録音・録画ファイルの項目を参照してください。

終わりに

P2P Roomを利用した通話を行いながら、同時に録音・録画処理を行う方法を紹介しました。
ぜひ便利になったSkyWayを利用してみてください。


SkyWayのサービスサイトはこちら https://go.skyway.ntt.com/ja
SkyWay導入に関するご質問はこちらの問い合わせからお願いします。 https://go.skyway.ntt.com/note_request

いいなと思ったら応援しよう!