Help us understand the problem. What is going on with this article?

SkyWayによるビデオ・音声通話の技術概要

この記事は「マイスター・ギルド:暑中見舞!夏のアドベントカレンダー2020」3日目の記事です。

初めに...

コロナの流行が始まったとき、「Stay Home」対策で自宅に閉じ込められたとき、ITワーカーの私たちは特に自宅からリモートで仕事をすることが可能でラッキーでした。
残念ながら、私たちのほとんどはリモートでの作業に慣れておらず、最初は上司、同僚、お客さん等とリモート通信が特に困難でした。
特殊なツールを使用しても改善されましたが、同じオフィスで作業するほど自然ではありません。
弊社のMeister Guildでもその新しい作業環境に答えるツールを探して色々なツールを使ってみた:

  • Zoom:ヴァーチャル背景を使える
  • Discord:完全に無料
  • Remo:ヴァーチャルルームに入れる、共有ホワイトボードもある
  • Spatial Chat:距離によると声の高さが変わる

ビデオ会議ができるツールはほかにもあります:

各ツールが得点と弱点を持つけど「これ!」ってなるツールがなかったので「私たちの理想なビデオ会議のツール作れるかな?」と思って調査することになりました。

ビデオ会議のツール作りの調査

そのようなツールを一から開発するのはとても大変な仕事になるので、開発をスピードアップするWebRTCフレームワークを探しました。
日本製で無料プランあり、NTTコミュニケーションズが作成したWebRTCフレームワークを見つけました:image.png
ユーザー認証をテストするために、認証付きのLaravelアプリケーションを作成し、ユーザーのメールをbase64でエンコーディングしてPeerIDとして使用しました。

SkyWayとは

ホームページによるとSkyWayは:

ビデオ・音声通話の機能をアプリケーションに簡単に実装できる、
マルチプラットフォームなSDK & フルマネージドなAPIサービスです。

無料プランで下記のSDKを使える:
- Javascript SDK
- iOS SDK
- Android SDK
- WebRTC Gateway
- APIキー認証

有料プランで録音SDK管理APIも使えるんですが例えば録音も録画も普通のMedia Capture and Streams API (Media Streams)でできる。

Javascriptサンプル:

ビデオ会議

SkyWay Room example

P2Pビデオ通話

SkyWay P2P Media example

P2Pテキスト通話

SkyWay P2P Data example

録音と録画

WebRTC samples MediaRecorder

P2Pビデオ通話

通話の相手は一人です。

基本

CDNからSDKをインポートする:

headタグ内
  <script src="https://cdn.webrtc.ecl.ntt.com/skyway-latest.js"></script>

カメラ映像を表示するvideo要素を追加する:

bodyタグ内
  <video id="my-video" width="400px" autoplay muted playsinline></video>

カメラ映像・マイク音声を取得する:

bodyタグ下部のscriptタグ内
  let localStream;

  // カメラ映像取得
  navigator.mediaDevices.getUserMedia({video: true, audio: true})
    .then( stream => {
    // 成功時にvideo要素にカメラ映像をセットし、再生
    const videoElm = document.getElementById('my-video')
    videoElm.srcObject = stream;
    videoElm.play();
    // 着信時に相手にカメラ映像を返せるように、グローバル変数に保存しておく
    localStream = stream;
  }).catch( error => {
    // 失敗時にはエラーログを出力
    console.error('mediaDevice.getUserMedia() error:', error);
    return;
  });

通話の相手のは「peer」と呼ばれてる。
通話出来るように自分のPeerオブジェクトを作成して相手のPeerオブジェクトと繋がる。

Peerオブジェクトの作成

Peerオブジェクトを作成するときに引数のIDを渡さない場合はランダムなIDが生成される:

scriptタグ内
        const peer = new Peer({
            key: '<SkyWayのAPIキー>',
            debug: 3
        });

PeerオブジェクトのIDはpeer.idで取得できる。

またはメールアドレスなどからIDを生成できる。
例えばLaravelのコントローラーでbase64にエンコード:

app/Http/Controllers/Controller.php
    public function index()
    {
        $user = Auth::user();
        return view('videochat',['user'=>['email'=>rtrim(base64_encode($user->email),"=")]]);
    }

ページでPeerオブジェクトに渡す:

scriptタグ内
            const peer = new Peer('{{$user['email']}}',{
                key: '<SkyWayのAPIキー>',
                debug: 3
            });

発信

相手のカメラ映像を表示するvideo要素を追加する:

bodyタグ内
  <video id="their-video" width="400px" autoplay muted playsinline></video>

相手へ発信してリスナーで接続することを待つ:

scriptタグ内
// 発信処理
const mediaConnection = peer.call('<相手のPeerID>', localStream);
setEventListener(mediaConnection);

接続ができたときにビデオ要素を設定する:

scriptタグ内
let remoteStream;
// イベントリスナを設置する関数
const setEventListener = mediaConnection => {
  mediaConnection.on('stream', stream => {
    // video要素にカメラ映像をセットして再生
    const videoElm = document.getElementById('their-video')
    videoElm.srcObject = stream;
    remoteStream = stream;
    videoElm.play();
  });
}

着信

相手側はPeerオブジェクトのcallイベントを待って着信の時ビデオ要素を設定する:

scriptタグ内
//着信処理
peer.on('call', mediaConnection => {
  mediaConnection.answer(localStream);
  setEventListener(mediaConnection);
});

映像・音声はオン・オフ等

マイク音声オフ

ミュートする:

scriptタグ内
    localStream.getAudioTracks().forEach(track => track.enabled = false);
音声オフ

相手の音声を削音する:

scriptタグ内
    remoteStream.getAudioTracks().forEach(track => track.enabled = false);

※ 音全部消したいときマイク音声もオフしなければならない。

カメラ映像オフ

カメラの映像を消す:

scriptタグ内
    localStream.getVideoTracks().forEach(track => track.enabled = false);
反響キャンセリング

反響を消す:

scriptタグ内
    localStream.getAudioTracks().forEach(track => {
        let constraints = track.getConstraints();
        constraints.echoCancellation = true;
        track.applyConstraints(constraints);
    });

ビデオ会議

ビデオ会議はビデオ通話との違いは2つ:
- 相手の数は一人以上になる
- roomオブジェクトで他のユーザーの存在(presence)が確認できる

基本

CDNからSDKをインポートする:

headタグ内
  <script src="https://cdn.webrtc.ecl.ntt.com/skyway-latest.js"></script>

カメラ映像を表示するvideo要素を追加する:

bodyタグ内
  <video id="js-local-stream"></video>

カメラ映像・マイク音声を取得する:

bodyタグ下部のscriptタグ内
  // カメラ映像取得
const localStream = await navigator.mediaDevices
    .getUserMedia({
      audio: true,
      video: true,
    })
    .catch(console.error);

  // Render local stream
  const localVideo = document.getElementById('js-local-stream');
  localVideo.muted = true;
  localVideo.srcObject = localStream;
  localVideo.playsInline = true;
  await localVideo.play().catch(console.error);

相手達のカメラ映像を表示する要素を追加する:

bodyタグ内
    <div class="remote-streams" id="js-remote-streams"></div>

Peerオブジェクトの作成

PeerオブジェクトのIDを生成する。
例えばLaravelのコントローラーでbase64にエンコード:

app/Http/Controllers/Controller.php
    public function index()
    {
        $user = Auth::user();
        return view('videochat',['user'=>['email'=>rtrim(base64_encode($user->email),"=")]]);
    }

ページでPeerオブジェクトに渡す:

scriptタグ内
            const peer = new Peer('{{$user['email']}}',{
                key: '<SkyWayのAPIキー>',
                debug: 3
            });

roomオブジェクト

Peerオブジェクトが生成された後でroomに参加する:

scriptタグ内
  peer.on('open', () => {
    const room = peer.joinRoom('test', {
      mode: 'sfu',
      stream: localStream,
    });
  });

※ roomは2つのタイプがある:'sfu'(通信がサーバーを通す)と'mesh'(通信が直接にPeerへ発信する)。

roomのイベント

open

roomに入ったとき:

scriptタグ内
    room.once('open', () => {
      ...
    });
close

roomを出たとき:

scriptタグ内
    room.once('close', () => {
      // テキスト通信を止める
      sendTrigger.removeEventListener('click', onClickSend);
      // 相手達のビデオストリームを止める
      Array.from(remoteVideos.children).forEach(remoteVideo => {
        remoteVideo.srcObject.getTracks().forEach(track => track.stop());
        remoteVideo.srcObject = null;
        remoteVideo.remove();
      });
    });
peerJoin

一人がroomに入ったとき:

scriptタグ内
    room.on('peerJoin', peerId => {
      ...
    });
peerLeave

一人がroomを出たとき:

scriptタグ内
    room.on('peerLeave', peerId => {
      // ストリームを閉じてvideo要素を消す
      const remoteVideo = remoteVideos.querySelector(
        `[data-peer-id=${peerId}]`
      );
      remoteVideo.srcObject.getTracks().forEach(track => track.stop());
      remoteVideo.srcObject = null;
      remoteVideo.remove();
    });
stream

roomに入った一人のストリームを表示:

scriptタグ内
    room.on('stream', async stream => {
      // video要素を生成
      const newVideo = document.createElement('video');
      newVideo.srcObject = stream;
      newVideo.playsInline = true;
      // peerLeaveイベントのときにストリームを見つけるためにpeerIdを付ける
      newVideo.setAttribute('data-peer-id', stream.peerId);
      remoteVideos.append(newVideo);
      await newVideo.play().catch(console.error);
    });

data

メッセージを着信したとき:

scriptタグ内
    room.on('data', ({ data, src }) => {
      // メッセージと発信者を表示
      messages.textContent += `${src}: ${data}\n`;
    });

テキスト発信

メッセージを発信するとき:

scriptタグ内
    sendTrigger.addEventListener('click', onClickSend);

    function onClickSend() {
      // websocketでroomの皆さんにメッセージを起こる
      room.send(localText.value);
      // メッセージと発信者を表示
      messages.textContent += `${peer.id}: ${localText.value}\n`;
      // インプットを消す
      localText.value = '';
    }
  });

映像・音声はオン・オフ等

マイク音声オフ

ミュートする:

scriptタグ内
    localStream.getAudioTracks().forEach(track => track.enabled = !audioInStatus);
音声オフ

相手の音声を削音する:

scriptタグ内
    remoteVideos.forEach(video => {
      video.srcObject.getAudioTracks().forEach(track => track.enabled = !audioOutStatus);
    });

※ 音全部消したいときマイク音声もオフしなければならない。

カメラ映像オフ

カメラの映像を消す:

scriptタグ内
    localStream.getVideoTracks().forEach(track => track.enabled = false);
反響キャンセリング

反響を消す:

scriptタグ内
    localStream.getAudioTracks().forEach(track => {
        let constraints = track.getConstraints();
        constraints.echoCancellation = true;
        track.applyConstraints(constraints);
    });

音声通話

navigator.mediaDevices.getUserMedia()の引数でメディアのタイプ(画像・音声・両方)等を選択できる:

scriptタグ内
  // カメラ映像取得
const localStream = await navigator.mediaDevices
    .getUserMedia({
      audio: true,
      video: false,
    })

画面共有

自分の画面をMediaStreamとして取得できる

scriptタグ内
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });

このメディアストリームを使って画面共有機能が実現できる。

終わりに...

WebRTCを使用できるのでウェブアプリケーションの元に使用できるフレームワークと思いました。

その調査をして私の理想なリモートワークのツールについていっぱいなアイデアが生まれて社長が本気で開発始めようのは本気になって欲しいです。

参考

m-gild
最先端技術のMEISTERを目指し、お互い切磋琢磨するGUILD。Webシステム/サービス開発、スマホアプリ開発、AR/VR/MR開発など、様々なニーズに応えます。
https://www.m-gild.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away