WebRTCで複数人接続してみる

0 件のコメント
WebRTCをさわってみる&手動シグナリングしてみるの続きです。

今回は、WebRTCを使って複数人接続してみたいと思います。

1対1で接続するときとの違いは?

前回のおさらいですが、1対1で接続するとこんな感じになる。
図1. 1on1の接続
そして、こういう手順でPeer to Peerさせることができます。
No端末A端末B
1video要素を作っておくvideo要素を作っておく
2Peerを生成Peerを生成
3メディアに接続(カメラの起動を許可すること)
・Peerにストリームを接続し、自身の端末の状態をvideo要素で閲覧できる状態になる
メディアに接続(カメラの起動を許可すること)
・Peerにストリームを接続し、自身の端末の状態をvideo要素で閲覧できる状態になる
4端末Bへ送信するOfferを生成
5端末Aで生成したOfferを受信
6端末Aへ送信するAnswerを生成
7端末Bで生成したAnswerを受信
8端末Bへ送信する経路情報を出力
9端末Aで生成した経路情報を受信
10端末Aへ送信する経路情報を出力
11端末Bで生成した経路情報を受信


じゃあ、複数人接続の場合はどうなるのか?
3端末のときの接続を例として記述します。
図2. 3端末での接続
上記のように接続するには、
端末Aと端末Bと端末Cがそれぞれ、下記のようなPeerを生成しなければなりません。
・端末Aと端末BをつなぐPeerの生成、端末Aと端末CをつなぐPeerの生成
・端末Bと端末AをつなぐPeerの生成、端末Bと端末CをつなぐPeerの生成
・端末Cと端末AをつなぐPeerの生成、端末Cと端末BをつなぐPeerの生成

そして、4台目が追加になる場合は更に1つずつPeerを生成することになり、
それを動的に通知して接続処理をするようにしなければなりません。

結構複雑っす。

どういう流れで複数接続させるか?

3端末での接続手順を記載します。
(灰色のセルは端末未接続時・25〜38、25`〜38`は並行で処理されます)
No端末A端末B端末C
1特定URLにアクセス
2socket.ioでコネクションをはる
3コネクションをはってるユーザー全員にコールする
4いなかったので終わり。
5特定URLにアクセス
6socket.ioでコネクションをはる
7コネクションをはってるユーザー全員にコールする
8端末Bからコールされた
9端末Aのsocketのidを端末Bに返却する
10端末Aのsocketのidを返却される
11Peerを生成
12socketのidを元に、端末AへSDPを送信(オファー)
13Peerを生成
14端末BのSDPを受信(オファーをレシーブ)
15socketのidを元に、端末BへSDPを送信(アンサー)
16端末AのSDPを受信(アンサーをレシーブ)
17端末Aへ端末Bまでの経路情報を送信
18端末Bから経路情報を受信
19端末Bへ端末Aまでの経路情報を送信
20端末Aから経路情報を受信
21Peer to Peer!Peer to Peer!
22特定URLにアクセス
23socket.ioでコネクションをはる
24コネクションをはってるユーザー全員にコールする
下記のAとC / BとCのやりとりは並行して行われる
25端末Cからコールされた
26端末Aのsocketのidを端末Cに返却する
27端末Aのsocketのidを返却される
28Peerを生成
29socketのidを元に、端末AへSDPを送信(オファー)
30Peerを生成
31端末CのSDPを受信(オファーをレシーブ)
32socketのidを元に、端末CへSDPを送信(アンサー)
33端末AのSDPを受信(アンサーをレシーブ)
34端末Aへ端末Cまでの経路情報を送信
35端末Cから経路情報を受信
36端末Cへ端末Aまでの経路情報を送信
37端末Aから経路情報を受信
38Peer to Peer!Peer to Peer!
25`端末Cからコールされた
26`端末Bのsocketのidを端末Cに返却する
27`端末Bのsocketのidを返却される
28`Peerを生成
29`socketのidを元に、端末BへSDPを送信(オファー)
30`Peerを生成
31`端末CのSDPを受信(オファーをレシーブ)
32`socketのidを元に、端末CへSDPを送信(アンサー)
33`端末CのSDPを受信(アンサーをレシーブ)
34`端末Bへ端末Cまでの経路情報を送信
35`端末Cから経路情報を受信
36`端末Cへ端末Bまでの経路情報を送信
37`端末Bから経路情報を受信
38`Peer to Peer!Peer to Peer!

基本的な流れは1対1の時と一緒ですが、
Peerの生成タイミングや、Peer生成後にローカルのストリームとの接続手順などが変わってきます。
(「コネクションはっているユーザー全員にコールする」のレスポンスでオファー送ることもできた。そうすると、通信回数としては最小になりそう。)

作ってみた

このソースを使うと、↓↓のような画面がでるので、手順にそって作業してみてください。
・シグナリングサーバー(Node.js)
/**
* WebRTCのシグナリングサーバー
*/
var port = 8888;
var io = require('socket.io').listen(port);
var messageTypes = {
open: "open",
allCall: "allCall",
callAnswer: "callAnswer",
offer: "offer",
answer: "answer",
candidate: "candidate",
};
io.sockets.on('connection', function(socket) {
var connectGroup = null;
// 接続時の処理
socket.on('open', function(option) {
connectGroup = option.connectGroup
socket.join(option.connectGroup);
socket.emit('open', {
messageTypes: messageTypes,
userId: socket.id
});
});
// 切断時の処理
socket.on('disconnect', function() {
console.log("disconnect");
});
// シグナリング処理
socket.on('message', function(message) {
// 特定のユーザに送信
var target = message.sendto || message.param.sendto;
if (target) {
socket.to(target).emit('message', message);
return;
}
// 接続グループ全員に送信
socket.broadcast.to(connectGroup).emit('message', message);
});
});
view raw app.js hosted with ❤ by GitHub
{
"name": "webrtc",
"version": "0.0.0",
"description": "",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"devDependencies": {
"socket.io": "^1.0.6"
}
}
view raw package.json hosted with ❤ by GitHub
 これをして、node_modulesを取得してください。
$ npm install
そして、これで起動する。
$ node app.js

・Webサーバー(Class風実装は特に見ないでいいです。。
<!DOCTYPE HTML>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<input type="button" value="ここを押したら繋がるけどカメラを先に許可しといてね" onclick="webrtcTest.createConnection();">
<style type="text/css">
video {
width: 80px;
}
</style>
<script type="text/javascript">
// Class風の実装をするライブラリ
(function (window) {
'use strict';
/**
* メソッド内に_superの記述があるかのチェック
* _superがあるメソッドだけ上書き
* _superがあるかどうか判断できない場合は[/ .* /]となって全部上書き
*/
var fnTest = /xyz/.test(function(){xyz;}) ? /\b_super\b/ : /.*/;
function Class() { /* noop. */ }
Class.extend = function (props) {
var SuperClass = this;
function Class() {
if (typeof this.init === 'function') {
this.init.apply(this, arguments);
}
// _superプロパティを書き込み禁止
Object.defineProperty(this, '_super', {
value: undefined,
enumerable: false,
writable: false,
configurable: true
});
}
// prototypeチェーンを作る
Class.prototype = Object.create(SuperClass.prototype, {
constructor: {
value: Class,
enumerable: false,
writable: true,
configurable: true
}
});
// instanceメソッドをセット
Object.keys(props).forEach(function (key) {
var prop = props[key],
_super = SuperClass.prototype[key],
isMethodOverride = (typeof prop === 'function' && typeof _super === 'function' && fnTest.test(prop));
if (isMethodOverride) {
Class.prototype[key] = function () {
var ret,
tmp = this._super;
// _superプロパティを設定
Object.defineProperty(this, '_super', {
value: _super,
enumerable: false,
writable: false,
configurable: true
});
ret = prop.apply(this, arguments);
// _superプロパティを書き込み禁止にする
Object.defineProperty(this, '_super', {
value: tmp,
enumerable: false,
writable: false,
configurable: true
});
return ret;
};
} else {
Class.prototype[key] = prop;
}
});
Class.extend = SuperClass.extend;
return Class;
};
window.Class = Class;
}(window));
</script>
<script src="webrtc.js">
/**
* webrtcに関するクラスを実装
*/
var Webrtc = Class.extend({
/**
* 初期化
* @param {Object} option {
* videoElementId: "",
* iceCandidateCallback: function() {}
* }
*/
init: function(option) {
this.option = option;
this.peer = null;
this.localStream = null;
this.isStartVideo = false;
},
/**
* コネクションをはる
* @return {Boolean} true: RTCPeerConnectionが生成済み, false: 生成できなかった
*/
connect: function() {
if (this.peer) {
return true;
}
if (!this.localStream) {
return false;
}
if (!this.option.videoElementId) {
return false;
}
this.peer = this.createPeer();
this.peer.addStream(this.localStream);
var iceCandidateCallback = this.option.iceCandidateCallback;
this.peer.onicecandidate = function(event) {
iceCandidateCallback && iceCandidateCallback(event.candidate);
};
var videoElementId = this.option.videoElementId;
this.peer.onaddstream = function(event) {
var video = document.getElementById(videoElementId);
video.src = window.webkitURL.createObjectURL(event.stream);
};
return true;
},
/**
* Peerを生成
* @return {Object} RTCPeerConnection
*/
createPeer: function() {
return (new webkitRTCPeerConnection({
"iceServers": [{"url": "stun:stun.l.google.com:19302"}]
}));
},
/**
* videoにカメラを接続
*/
startVideo: function() {
var video;
if (this.option && this.option.videoElementId) {
video = document.getElementById(this.option.videoElementId);
} else {
console.log("no video!!");
}
navigator.webkitGetUserMedia({video: true, audio: true},
(function(stream) {
video.src = window.URL.createObjectURL(stream);
this.localStream = stream;
this.isStartVideo = true;
}.bind(this)),
(function(err) {
this.isStartVideo = false;
console.log(err);
}.bind(this))
);
},
/**
* SDPを生成する
* @param {String} sdpType: offer or answer
* @param {Function} callback
*/
createSdp: function(sdpType, callback) {
var createSdpCallback = function(sdp) {
this.peer.setLocalDescription(sdp,
function() {
callback(sdp);
},
function(err) {
console.log(err);
}
);
}.bind(this);
if (sdpType === "offer") {
this.peer.createOffer(createSdpCallback);
} else if (sdpType === "answer") {
this.peer.createAnswer(createSdpCallback);
}
},
/**
* SDPを受け取る
* @param {Object} sdp
*/
receiveSdp: function(sdp) {
var remoteSdp = new RTCSessionDescription(sdp);
this.peer.setRemoteDescription(remoteSdp,
function() {
if (remoteSdp.type === "offer") {
console.log("receive offer");
}
if (remoteSdp.type === "answer") {
console.log("receive answer");
}
},
function(err) {
console.log(err);
}
);
},
/**
* 経路情報を受け取る
* @param {Object} candidate
*/
receiveCandidate: function(candidate) {
if (candidate) {
var candidate = new RTCIceCandidate(candidate);
this.peer.addIceCandidate(candidate);
} else {
console.log("finish candidate!!");
}
}
});
</script>
<script src="http://localhost:8888/socket.io/socket.io.js"></script>
<script type="text/javascript">
var WebrtcTest = Class.extend({
// 初期化
init: function() {
this.socket = null;
this.userId = null;// 自身のsocket接続ID
this.messageTypes = {};// 通信するメッセージのタイプ
this.webRtcInstances = {};// WebRTCのインスタンスたち
this.localInstanceKey = 'local';// ローカルのWebRTCインスタンスのキー
this.socketReadyState = false;// socket接続ステータス
this.connectGroup = location.search;// socket接続グループ: 接続するグループは一旦クエリストリングで決める
this.connectUrl = 'http://localhost:8888/';
},
// コネクションをはる
createConnection: function() {
this.socket = io.connect(this.connectUrl);
this.socket.on('connect', this.onConnect.bind(this));
this.socket.on('open', this.onOpen.bind(this));
this.socket.on('message', this.onMessage.bind(this));
},
// コネクション確立時の処理
// @param {Object} event
onConnect: function(event) {
if (!this.socketReadyState) {
this.socket.emit('open', {connectGroup: this.connectGroup});// 接続成功時、接続先のグループを通知する
}
this.socketReadyState = true;
},
// コネクション確立後の処理
// @param {Object} event
onOpen: function(event) {
this.messageTypes = event.messageTypes;
this.userId = event.userId;
this.sendMessage(this.messageTypes.allCall, {fromUserId: this.userId});// 接続グループ全員に、新たな接続者がいることを通知する
},
// ローカルのWebRTCのインスタンスを取得する
// @return {Object} WebRTCインスタンス
getLocalInstance: function() {
return this.getWebrtcInstance(this.localInstanceKey);
},
// WebRTCのインスタンスを取得する
// @param {String} id
// @return {Object} WebRTCインスタンス
getWebrtcInstance: function(id) {
if (!this.webRtcInstances[id]) {
this.webRtcInstances[id] = this.createWebrtcInstance(id);
if (id !== this.localInstanceKey) {// 共有するためにローカルのストリームを接続する
this.webRtcInstances[id].localStream = this.webRtcInstances[this.localInstanceKey].localStream;
}
}
return this.webRtcInstances[id];
},
// WebRTCのインスタンスを生成する
// @param {String} id
// @return {Object} WebRTCインスタンス
createWebrtcInstance: function(id) {
var videoElementId = this.getVideoElementId(id);
var video = document.createElement('video');
video.id = videoElementId;
video.autoplay = true;
document.getElementsByTagName('body')[0].appendChild(video);
return new Webrtc({
videoElementId: videoElementId,
iceCandidateCallback: (function(candidate) {
this.sendMessage(this.messageTypes.candidate, {sendto: id, fromUserId: this.userId, candidate: candidate});
}.bind(this))
});
},
// video要素のIDを取得する
// @param {String} id
// @return {String} video要素のID
getVideoElementId: function(id) {
return 'video-' + id;
},
// メッセージ受信処理
// @param {Object} event
onMessage: function(event) {
console.log(event);
// 他のユーザーがログインしたときの応答処理
if (this.messageTypes.allCall === event.param.type) {
console.log("group all call");
var fromUserId = event.param.fromUserId;
this.sendMessage(this.messageTypes.callAnswer, {sendto: fromUserId, fromUserId: this.userId});// 応答する
}
// ログイン時に他ユーザー全員にコールしたときの応答があったときの処理
if (this.messageTypes.callAnswer === event.param.type) {
console.log("call answer");
var fromUserId = event.param.fromUserId;
var webrtc = this.getWebrtcInstance(fromUserId);
if (!webrtc.connect()) {
return false;// videoをオンにしてないときとか
}
webrtc.createSdp("offer", function(sdp) {
this.sendMessage(this.messageTypes.offer, {sendto: fromUserId, fromUserId: this.userId, sdp: sdp});// 応答があった人へオファー
}.bind(this));
}
// オファーを受け取って、アンサーを返す
if (this.messageTypes.offer === event.param.type) {
console.log("offer");
var fromUserId = event.param.fromUserId;
var sdp = event.param.sdp;
var webrtc = this.getWebrtcInstance(fromUserId);
if (!webrtc.connect()) {
return false;// videoをオンにしてないときとか
}
webrtc.receiveSdp(sdp);
webrtc.createSdp("answer", function(sdp) {
this.sendMessage(this.messageTypes.answer, {sendto: fromUserId, fromUserId: this.userId, sdp: sdp});// オファーがきたのでアンサー
}.bind(this));
}
// アンサーを受け取る
if (this.messageTypes.answer === event.param.type) {
console.log("answer");
var fromUserId = event.param.fromUserId;
var webrtc = this.getWebrtcInstance(fromUserId);
var sdp = event.param.sdp;
webrtc.receiveSdp(sdp);
}
// 経路情報を受け取る
if (this.messageTypes.candidate === event.param.type) {
console.log("receive candidate");
var fromUserId = event.param.fromUserId;
var webrtc = this.getWebrtcInstance(fromUserId);
webrtc.receiveCandidate(event.param.candidate);// 飛んできた経路情報を取得
}
},
// メッセージを通知する
// @param {String} messageType
// @param {Object} param
// @param {Function} callback
sendMessage: function(messageType, param, callback) {
param.type = messageType;
this.socket.emit('message', {messageType: messageType, param: param, callback: callback});
}
});
var webrtcTest = new WebrtcTest();
var localVideo = webrtcTest.getLocalInstance();
localVideo.startVideo();
</script>
</body>
</html>
view raw index.html hosted with ❤ by GitHub
(長いっすね。。

上記のソースをDocumentRoot以下に配置し、ブラウザからアクセスして、動作確認をしてみる。
1. 特定のURLへアクセス(筆者はここhttp://localhost/index.html
図3. カメラ・マイクへのアクセス確認
画面右上の「許可」ボタンを押して、カメラ・マイクへのアクセスを許可してください。

2.  カメラの表示確認
図4. カメラ動画表示
1.で「許可」することにより、小さいですが、ローカルのカメラ画像が表示されるようになります。
そして、「ここを押したら繋がるけどカメラを先に許可しといてね」ボタンを押すことで、シグナリングサーバーへアクセスします。
(自分がページを開いてるよ。ってことを通知してるだけで、ここの手順では何も起こりません。)
※上記手順の1〜4です。

3.  リモートのカメラ画像を表示する
図5. リモートのカメラ動画表示
タブをいくつか開いて、1.と2.の手順を実施すると、接続した分だけ上記のようにvideo要素が増えていきます。

終わりに。

タブを複数開いて通知確認しながらテストしてたのですが、頭がこんがらがるし、結構たいへんでした。

シグナリングサーバーを応用! 「WebRTCを使って複数人で話してみよう」を参考にさせて頂きました!

つぎこそはDataChannelやるぞー!

以上!

0 件のコメント :

コメントを投稿