HTML5Experts.jp

シグナリングを拡張して、複数人で通信してみよう ーWebRTC入門2016

連載: WebRTC入門2016 (4)

こんにちは! 2014年に連載した「WebRTCを使ってみよう!」シリーズのアップデート記事も4回目となり、佳境に入りました。前回の1対1の通信をベースに、今回はより実用的なビデオチャットを目指して複数人で通信可能なように拡張してみましょう。

複数人、複数会議室を目指して

前回作ったのは、1つのシグナリングサーバーに対して、同時に1ペアだけが利用できる仕組みでした。これを複数人で、複数会議室で利用できるようにしていきましょう。こちらの図の左のBeforeの状態から、右のAfterの状態を実現します。

複数人で通信するためには

WebRTCはPeer-to-Peer (P2P) で通信する仕組みです。なので複数人と通信するためには、複数のRTCPeerConnectionを用意する必要があります。図にするとこんな感じです。

また、P2P通信を開始するためにSDP(Offer/Answer)を交換する必要がありますが、それぞれの相手ごとに生成、交換しなければなりません。

P2Pなので当然かもしれませんが、私は最初に複数の相手と通信しようとして混乱してしまいました。この考え方を踏まえていれば、あとは力技になります。

シグナリングサーバーの対応

シグナリングサーバーは前回の1対1の時と同じく、Node.jsを使って用意しましょう。複数会議室を実現するのに便利なため、今回はwsモジュールではなく、より高機能のsocket.ioを使います。Node.jsのインストールは終わっていると思うので、コマンドプロンプト/ターミナルから、次のコマンドを実行してください。 ※必要に応じて、sudoなどをご利用ください。

npm install socket.io

2014年にはsocket.ioはv0.9でしが、今回はv1.4.xになっています。

シグナリングサーバーのコードは前回の1対1と同様にメッセージを中継するのが役目ですが、接続してきたクライアントがルーム(会議室)に入室要求を送ってきたら、socket.ioのサーバ側でそのルームに join() してあげます。

    // ---- multi room ----
    socket.on('enter', function(roomname) {
      socket.join(roomname);
      console.log('id=' + socket.id + ' enter room=' + roomname);
      setRoomname(roomname);
    });

function setRoomname(room) {
  socket.roomname = room;
}

クライアントからのメッセージ送信には、次の2つのパターンがあります。

シグナリングサーバーでは、送信先が指定されていればその相手だけに、指定されていなければルーム内の全員(送信者以外)にメッセージを送ります。 ※その際に、送信元を特定できるID(socket.ioが管理しているID)を追加しています。

    socket.on('message', function(message) {
        message.from = socket.id; // 送信元のIDをメッセージに追加

    // get send target
    var target = message.sendto;
    if (target) { // 特定の相手に送る場合
      socket.to(target).emit('message', message); 
      return;
    }

    // broadcast in room
    emitMessage('message', message);
});

// ルーム内の全員に送る場合
function emitMessage(type, message) {
  // ----- multi room ----
  var roomname = getRoomname();

  if (roomname) {
    // ルーム内に送る
    socket.broadcast.to(roomname).emit(type, message);
  }
  else {
    // ルーム未入室の場合は、全体に送る
    socket.broadcast.emit(type, message);
  }
}</pre> 

サーバーの全体のソースは次の通りです。これを例えば signaling_room.js というファイル名で保存します。

"use strict";

var srv = require('http').Server(); var io = require('socket.io')(srv); var port = 3002; srv.listen(port); console.log('signaling server started on port:' + port);

// This callback function is called every time a socket // tries to connect to the server io.on('connection', function(socket) { // ---- multi room ---- socket.on('enter', function(roomname) { socket.join(roomname); console.log('id=' + socket.id + ' enter room=' + roomname); setRoomname(roomname); });

function setRoomname(room) {
  socket.roomname = room;
}

function getRoomname() {
  var room = socket.roomname;
  return room;
}

function emitMessage(type, message) {
  // ----- multi room ----
  var roomname = getRoomname();

  if (roomname) {
    console.log('===== message broadcast to room --&gt;' + roomname);
    socket.broadcast.to(roomname).emit(type, message);
  }
  else {
    console.log('===== message broadcast all');
    socket.broadcast.emit(type, message);
  }
}

// When a user send a SDP message
// broadcast to all users in the room
socket.on('message', function(message) {
    var date = new Date();
    message.from = socket.id;
    console.log(date + 'id=' + socket.id + ' Received Message: ' + JSON.stringify(message));

    // get send target
    var target = message.sendto;
    if (target) {
      console.log('===== message emit to --&gt;' + target);
      socket.to(target).emit('message', message);
      return;
    }

    // broadcast in room
    emitMessage('message', message);
});

// When the user hangs up
// broadcast bye signal to all users in the room
socket.on('disconnect', function() {
    // close user connection
    console.log((new Date()) + ' Peer disconnected. id=' + socket.id);

    // --- emit ----
    emitMessage('user disconnected', {id: socket.id});

    // --- leave room --
    var roomname = getRoomname();
    if (roomname) {
      socket.leave(roomname);
    }
});

});

コマンドプロンプト/ターミナルから、 次のように起動してください。(ファイル名は適宜置き換えてくださいね)

node signaling_room.js

クライアント側の拡張

次はクライアントとなるブラウザ側の処理を拡張していきます。

socket.io サーバーへの接続

今回はシグナリングサーバーを同じPCの3002番ポートで動かしていると想定します。HTMLファイルの先頭で、socket.ioのクライアント用のjsファイルを読み込みます。

<!doctype html>
<html>
<head>
 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
 <title>multi party</title>
 <script src="http://localhost:3002/socket.io/socket.io.js"></script>
</head>
... 省略 ...
</htm>

クライアントではsocket.ioのサーバー (localhost:3002) に接続します。このとき、ws://~ ではなく、http://~ となることがWebSocketを直接利用した場合と異なります。

  // ----- use socket.io ---
  let port = 3002;
  let socket = io.connect('http://localhost:' + port + '/');
  socket.on('connect', function(evt) {
    // 接続したときの処理
  });

※実際のシグナリングサーバーの環境に合わせて、URLやポート番号は変更してください。

ルーム(会議室)への入室

クライアンではsocket.ioのサーバーに接続したら、希望のルームに入室を依頼します。ルーム名は今回はURLの後ろに ?部屋名 という形で指定することにしてみました。(お好きな方法で指定してください)

  let room = getRoomName();
  socket.on('connect', function(evt) {
    console.log('socket.io connected. enter room=' + room );
    socket.emit('enter', room);
  });

// -- room名を取得 -- function getRoomName() { // たとえば、 URLに ?roomname とする let url = document.location.href; let args = url.split('?'); if (args.length > 1) { let room = args[1]; if (room != '') { return room; } } return '_testroom'; }

複数通信の流れ

1対1の時は相手が1人しかいない前提だったので、ただちにOffer SDP / Answer SDPを送信していました。今回は相手が何人いるか分からない状況からスタートしますし、相手ごとに個々にOffer SDP / Answer SDPを送受信する必要があります。そこで、相手を確認するやりとりを追加しました。図にすると、次のような流れになります。

もう少し細かく見ると、次のような処理を行っています。

ソースの修正:複数通信の準備

それでは、ブラウザ側のソースも手を入れていきましょう。まず、複数のRTCPeerConnectionを扱えるように用意します。

  // ---- for multi party -----
  let peerConnections = [];
  const MAX_CONNECTION_COUNT = 3;

// --- RTCPeerConnections --- function getConnectionCount() { return peerConnections.length; }

function canConnectMore() { return (getConnectionCount() < MAX_CONNECTION_COUNT); }

function isConnectedWith(id) { if (peerConnections[id]) { return true; } else { return false; } }

function addConnection(id, peer) { peerConnections[id] = peer; }

function getConnection(id) { let peer = peerConnections[id]; return peer; }

function deleteConnection(id) { delete peerConnections[id]; }

相手の映像を表示するvideoタグも、動的に生成して複数管理できるようにします。

  let remoteVideos = [];
  let container = document.getElementById('container');

// --- video elements --- function addRemoteVideoElement(id) { let video = createVideoElement('remote_video_' + id); remoteVideos[id] = video; return video; }

function getRemoteVideoElement(id) { let video = remoteVideos[id]; return video; }

function deleteRemoteVideoElement(id) { removeVideoElement('remote_video_' + id); delete remoteVideos[id]; }

function createVideoElement(elementId) { let video = document.createElement('video'); video.width = '160'; video.height = '120'; video.id = elementId;

video.style.border = 'solid black 1px';
video.style.margin = '2px';

container.appendChild(video);

return video;

}

function removeVideoElement(elementId) { let video = document.getElementById(elementId); container.removeChild(video); return video; }

さらにRTCPeerConnectionの接続や、相手からのメディアストリーム、videoタグを連動して扱う処理も追加しておきましょう。

  // --- video elements ---
  function attachVideo(id, stream) {
    let video = addRemoteVideoElement(id);
    playVideo(video, stream);
    video.volume = 1.0;
  }

function detachVideo(id) { let video = getRemoteVideoElement(id); pauseVideo(video); deleteRemoteVideoElement(id); }

function isRemoteVideoAttached(id) { if (remoteVideos[id]) { return true; } else { return false; } }

// --- RTCPeerConnections --- function stopConnection(id) { detachVideo(id);

if (isConnectedWith(id)) {
  let peer = getConnection(id);
  peer.close();
  deleteConnection(id);
}

}

function stopAllConnection() { for (let id in peerConnections) { stopConnection(id); } }

ソースの修正:シグナリングの変更

WebSocket直接利用から、socket.ioの利用に変わったので、シグナリングも変更します。

  // ----- use socket.io ---
  let port = 3002;
  let socket = io.connect('http://localhost:' + port + '/');
  let room = getRoomName();
  socket.on('connect', function(evt) {
    socket.emit('enter', room);
  });
  socket.on('message', function(message) {
    let fromId = message.from;

if (message.type === 'offer') {
  // -- got offer ---
  let offer = new RTCSessionDescription(message);
  setOffer(fromId, offer);
}
else if (message.type === 'answer') {
  // --- got answer ---
  let answer = new RTCSessionDescription(message);
  setAnswer(fromId, answer);
}
else if (message.type === 'candidate') {
  // --- got ICE candidate ---
  let candidate = new RTCIceCandidate(message.ice);
  addIceCandidate(fromId, candidate);
}
else if (message.type === 'call me') {
  if (! isReadyToConnect()) {
    console.log('Not ready to connect, so ignore');
    return;
  }
  else if (! canConnectMore()) {
    console.warn('TOO MANY connections, so ignore');
  }

  if (isConnectedWith(fromId)) {
    // already connnected, so skip
    console.log('already connected, so ignore');
  }
  else {
    // connect new party
    makeOffer(fromId);
  }
}
else if (message.type === 'bye') {
  if (isConnectedWith(fromId)) {
    stopConnection(fromId);
  }
}

}); socket.on('user disconnected', function(evt) { let id = evt.id; if (isConnectedWith(id)) { stopConnection(id); } });

// --- broadcast message to all members in room function emitRoom(msg) { socket.emit('message', msg); }

function emitTo(id, msg) { msg.sendto = id; socket.emit('message', msg); }

通信開始要求の"call me"や、切断要求の"bye"の処理を追加しています。また接続準備が整っていない場合(カメラやマイクを取得していない場合)や、すでに接続中の相手からの接続要求は無視するようにしています。

SDPやICE candidateのハンドリング

複数の相手とOffer/Answer SDPやICE candidateをやり取りするので、相手を意識した処理に拡張します。といっても違いは対応するRTCPeerConnectionのオブジェクトを生成したり取り出したりするところだけで、他は1対1の場合と同様です。すべてを掲載すると長いので全体はGitHubを参照してくただくとして、ここでは例を取り上げます。

Offer SDPの送信

新たに RTCPeerConnectionを生成し、Offer SDPの作成、送信を行います。

  function makeOffer(id) {
    peerConnection = prepareNewConnection(id);
    addConnection(id, peerConnection);

peerConnection.createOffer()
.then(function (sessionDescription) {
  console.log('createOffer() succsess in promise');
  return peerConnection.setLocalDescription(sessionDescription);
}).then(function() {
  console.log('setLocalDescription() succsess in promise');

  // -- Trickle ICEの場合は、初期SDPを相手に送る -- 
  sendSdp(id, peerConnection.localDescription);

  // -- Vanilla ICEの場合には、まだSDPは送らない --
}).catch(function(err) {
  console.error(err);
});

}

function sendSdp(id, sessionDescription) { let message = { type: sessionDescription.type, sdp: sessionDescription.sdp }; emitTo(id, message); }

Answer SDPを受け取った場合の処理

対応するRTCPeerConnectionを取り出し、Answer SDPを覚えさせます。

  function setAnswer(id, sessionDescription) {
    let peerConnection = getConnection(id);
    if (! peerConnection) {
      console.error('peerConnection NOT exist!');
      return;
    }

peerConnection.setRemoteDescription(sessionDescription)
.then(function() {
  console.log('setRemoteDescription(answer) succsess in promise');
}).catch(function(err) {
  console.error('setRemoteDescription(answer) ERROR: ', err);
});

}

ICE candidateのやりとりなども、同様に対応するRTCPeerConnectionのオブジェクトを取り出して行います。また、手動シグナリングで使っていたSDPをテキストエリアに表示する部分も取り除きました。

カメラ、マイクの取得

前回までは映像だけでしたが、今回はマイクの音声も取得してより実用的にしましょう。ただし1台のPCでやる場合はハウリングしてしまうので、ヘッドフォンを使ってください。

  // start local video
  function startVideo() {
    getDeviceStream({video: true, audio: true})
    .then(function (stream) { // success
      localStream = stream;
      playVideo(localVideo, stream);
    }).catch(function (error) { // error
      console.error('getUserMedia error:', error);
      return;
    });
  }

getDeviceStream()は、新旧getUserMedia()をラップするために用意した関数です。これにaudio:trueの指定を渡してマイクも取得しています。

今回マイクの音声も取得するようにしたため、メディアストリームにはビデオとオーディオの2つのトラックが含まれます。このため、相手側のRTCPeerConnectionontrack()イベントが2回呼び出されますが、2回目は無視するように修正しました。(RTCPeerConnection.ontrack()は現在Firefoxのみがサポートしています)

  function prepareNewConnection(id) {
    let pc_config = {"iceServers":[]};
    let peer = new RTCPeerConnection(pc_config);

// --- on get remote stream ---
if ('ontrack' in peer) {
  peer.ontrack = function(event) {
    let stream = event.streams[0];
    if (isRemoteVideoAttached(id)) {
      console.log('stream already attached, so ignore'); // &lt;--- 同じ相手からの2回目以降のイベントは無視する
    }
    else {
      //playVideo(remoteVideo, stream);
      attachVideo(id, stream);
    }
  };
}
else {
  peer.onaddstream = function(event) {
    let stream = event.stream;
    console.log('-- peer.onaddstream() stream.id=' + stream.id);
    //playVideo(remoteVideo, stream);
    attachVideo(id, stream);
  };
}

// ... 省略 ....

}

全体のソース

主要な部分は以上の通りですが、他にも細かい修正があります。全体のソースはGitHubでご覧ください。

接続してみよう

今回のサンプルでは4人まで同時に通信できるようにしてみました。それぞれブラウザを立ち上げて[Start Video]→[Connect]の順にボタンをクリックし接続してください。このように複数の相手と接続できるはずです。

※localhost以外に繋ぐときは、Chromeではカメラ/マイクの取得に失敗します。その場合はFirefoxをご利用ください。

次回は

今回のサンプルでは複数人で同時にビデオチャットができるようにしました。が、Chromeではhttp://~でgetUserMedia()が許可されていないため、他のPCと通信するのは厄介です。

Webサーバだけであれば、GitHub Pagesなどでhttps://~を利用することができますが、シグナリングサーバーのWebSocket通信も暗号化する必要がありまり、そちらは自分で証明書を取って対処しなけばなりません。

そこで次回は番外編として、Firebaseを使った暗号化通信をシグナリングに利用した例をご紹介したいと思います。