HTML5Experts.jp

WebRTCでキャスしよう!片方向リアルタイム映像配信を作ろう

連載: WebRTC (3)

こんにちは!がねこまさしです。「WebRTCを使ってみよう」シリーズの最新話をお送りします。今回は、簡易的な放送局を作ってみましょう。

片方向配信の特徴

WebRTCを使った音声通話、ビデオチャットのサンプルには、双方向のものが多く見られます。ライブラリもそれを前提とした作りのモノが多いようです。なので今回は、片方向配信を実際に動かしてみましょう。

片方向配信には、双方向通信とは異なる特徴があります。

特に同時接続数はは双方向では4~5人が実用範囲なのに対し、片方向では10~30人程度に対して1つのPCから配信できます。ちょっとした仲間内のイベントや、社内イベントであれば、十分にカバーできるのではないでしょうか?(社内で動かせば、社内ネットワーク内で完結するので、セキュリティ部門に怒られることもないでしょうし…)

片方向配信をつなぐまで

今回の記事は、技術的には過去の記事「WebRTCを使って複数人で話してみよう」の内容でカバーされています。違いはPeer-to-Peer接続までの手順にあります。

片方向配信では、話す側(talk)と見る側(watch)が非対称です。どちらから通信を始めるかで、2つのシナリオに分かれます。

(A) 配信中に、新たに見る人(watch)が現れて、視聴を開始する

この場合、Peer-to-Peer確立までのシグナリングの流れは、次のようになります。

(2)視聴者(watch)が待機しているところに、話す側(talk)が配信を開始する


こちらの場合は、Peer-to-Peer確立までのシグナリングの流れは、次のようになります。

先ほどのシーケンスの前に、開始の合図(talk_ready)を加えただけですね。

シグナリングサーバーのソースコード

シグナリングサーバーは、過去の記事「WebRTCを使って複数人で話してみよう」と同じものが使えます。ただしこの時はSocket.IO v0.9を使っていたので、今回はSocket.IO v1.0/v1.1の場合を掲載しておきます。サーバー開始の部分と、部屋名の保持の仕方が少し異なります。

		var BROADCAST_ID = '_broadcast_';
		
		// -- create the socket server on the port ---
		var srv = require('http').Server();
		var io = require('socket.io')(srv);
		var port = 9001;
		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) {
			  //// for v0.9
			  //socket.set('roomname', room);
		
			  // for v1.0
			  socket.roomname = room;
			}
		
			function getRoomname() {
			  var room = null;
		
			  //// for v0.9
			  //socket.get('roomname', function(err, _room) {
			  //  room = _room;
			  //});
		
			  // for v1.0
			  room = socket.roomname;
		
			  return room;
			}
		
		
			function emitMessage(type, message) {
			  // ----- multi room ----
			  var roomname = getRoomname();
		
			  if (roomname) {
				console.log('===== message broadcast to room -->' + 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) {
				message.from = socket.id;
		
				// get send target
				var target = message.sendto;
				if ( (target) && (target != BROADCAST_ID) ) {
				  console.log('===== message emit to -->' + 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() {
				console.log('-- user disconnect: ' + socket.id);
				// --- emit ----
				emitMessage('user disconnected', {id: socket.id});
		
				// --- leave room --
				var roomname = getRoomname();
				if (roomname) {
				  socket.leave(roomname);
				}
		
			});
		
		});

クライアント:配信側(talk)のソースコード

以前の複数人でのケースとの違いを見ていきましょう。配信側では、相手側の映像を受け取る必要がないので、複数のvideoを処理する部分はごっそり削れます。また配信側の通信処理は、複数人で話す場合ととても近いです。

Peer-to-Peerの管理

以前と同じく、複数のPeer-to-Peer接続を管理するために便宜上のクラスと、関連する関数を作っておきます。

  
		// -------------- multi connections --------------------
		  var MAX_CONNECTION_COUNT = 10;
		  var connections = {}; // Connection hash
		  function Connection() { // Connection Class
			var self = this;
			var id = "";  // socket.id of partner
			var peerconnection = null; // RTCPeerConnection instance
			//var established = false; // is Already Established
			//var iceReady = false;
		  }
		
		  function getConnection(id) {
			var con = null;
			con = connections[id];
			return con;
		  }
		
		  function addConnection(id, connection) {
			connections[id] = connection;
		  }
		
		  function getConnectionCount() {
			var count = 0;
			for (var id in connections) {
			  count++;
			}
		
			console.log('getConnectionCount=' + count);
			return count;
		  }
		
		  function isConnectPossible() {
			if (getConnectionCount() < MAX_CONNECTION_COUNT)
			  return true;
			else
			  return false;
		  }
		
		  function getConnectionIndex(id_to_lookup) {
			var index = 0;
			for (var id in connections) {
			  if (id == id_to_lookup) {
				return index;
			  }
		
			  index++;
			}
		
			// not found
			return -1;
		  }
		
		  function deleteConnection(id) {
			delete connections[id];
		  }
		
		  function stopAllConnections() {
			for (var id in connections) {
			  var conn = connections[id];
			  conn.peerconnection.close();
			  conn.peerconnection = null;
			  delete connections[id];
			}
		  }
		
		  function stopConnection(id) {
			var conn = connections[id];
			if(conn) {
			  console.log('stop and delete connection with id=' + id);
			  conn.peerconnection.close();
			  conn.peerconnection = null;
			  delete connections[id];
			}
			else {
			  console.log('try to stop connection, but not found id=' + id);
			}
		  }
		
		  function isPeerStarted() {
			if (getConnectionCount() > 0) {
			  return true;
			}
			else {
			  return false;
			}
		  }

※以前は自前の過剰なステータス管理フラグを使っていましたが、無用なのでやめました。

シグナリングサーバーへの接続とイベント処理

シグナリングサーバーに対して、socket.ioクライアントを使って接続しておきます。また、接続時(会議室への入室)、切断時、メッセージ受信時のイベントハンドラを設定します。

		  // ---- socket ------
		  // create socket
		  var socketReady = false;
		  var port = 9001;
		  var socket = io.connect('http://signaling.yourdomain:' + port + '/'); // サーバのURLに変更
		
		  // socket: channel connected
		  socket.on('connect', onOpened)
				.on('message', onMessage)
				.on('user disconnected', onUserDisconnect);
		
		  function onOpened(evt) {
			console.log('socket opened.');
			socketReady = true;
			
			var roomname = getRoomName(); // 会議室名を取得する
			socket.emit('enter', roomname);
			console.log('enter to ' + roomname);
		  }
		
		  // socket: accept connection request
		  function onMessage(evt) {
			var id = evt.from;
			var target = evt.sendto;
			var conn = getConnection(id);
		
			if (evt.type === 'talk_request') {
			  if (! isLocalStreamStarted()) {
				console.warn('local stream not started. ignore request');
				return;
			  }
		
			  console.log("receive request, start offer.");
			  sendOffer(id);
			  return;
			}
			else if (evt.type === 'answer' && isPeerStarted()) {  // **
			  console.log('Received answer, settinng answer SDP');
			  onAnswer(evt);
			} else if (evt.type === 'candidate' && isPeerStarted()) { // **
			  console.log('Received ICE candidate...');
			  onCandidate(evt);
			}
			else if (evt.type === 'bye') { // **
			  console.log("got bye.");
			  stopConnection(id);
			}
		  }
		
		  function onUserDisconnect(evt) {
			console.log("disconnected");
			if (evt) {
			  stopConnection(evt.id);
			}
		  }
		
		  function getRoomName() { // たとえば、 URLに  ?roomname  とする
			var url = document.location.href;
			var args = url.split('?');
			if (args.length > 1) {
			  var room = args[1];
			  if (room != "") {
				return room;
			  }
			}
			return "_defaultroom";
		  }

応答するメッセージは、talk_request, answer, candidate です。talk_ready, offerは来ないはずなので、処理はしていません。

映像、音声の取得開始

		  // ---------------------- video handling -----------------------
		  // start local video
		  function startVideo() {
			navigator.webkitGetUserMedia({video: true, audio: true},
			  function (stream) { // success
				localStream = stream;
				localVideo.src = window.webkitURL.createObjectURL(stream);
				localVideo.play();
				localVideo.volume = 0;
		
				// auto start
				tellReady();
			  },
			  function (error) { // error
				console.error('An error occurred:');
				console.error(error);
				return;
			  }
			);
		  }

特に変わったところはありませんが、映像取得後に tellReady()を呼び、その中で「準備できたよ(talk_ready)」と通知しています。

配信要求への応答

視聴側(watch)から、配信要求(talk_request)があった場合、次の処理が呼び出されます。

		  var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':false, 'OfferToReceiveVideo':false }};
		
		  function sendOffer(id) {
			var conn = getConnection(id);
			if (!conn) {
			  conn = prepareNewConnection(id);
			}
		
			conn.peerconnection.createOffer(function (sessionDescription) { // in case of success
			  conn.peerconnection.setLocalDescription(sessionDescription);
			  sessionDescription.sendto = id;
			  sendSDP(sessionDescription);
			}, function () { // in case of error
			  console.log("Create Offer failed");
			}, mediaConstraints);
		  }
		
		  // ---------------------- connection handling -----------------------
		  function prepareNewConnection(id) {
			var pc_config = {"iceServers":[]};
			var peer = null;
			try {
			  peer = new webkitRTCPeerConnection(pc_config);
			} catch (e) {
			  console.log("Failed to create PeerConnection, exception: " + e.message);
			}
			var conn = new Connection();
			conn.id = id;
			conn.peerconnection = peer;
			peer.id = id;
			addConnection(id, conn);
		
			// send any ice candidates to the other peer
			peer.onicecandidate = function (evt) {
			  if (evt.candidate) {
				console.log(evt.candidate);
				sendCandidate({type: "candidate", 
								  sendto: conn.id,
								  sdpMLineIndex: evt.candidate.sdpMLineIndex,
								  sdpMid: evt.candidate.sdpMid,
								  candidate: evt.candidate.candidate});
			  } else {
				console.log("ICE event. phase=" + evt.eventPhase);
			  }
			};
		
			console.log('Adding local stream...');
			peer.addStream(localStream);
		
			return conn;
		  }

複数人の双方向のケースと違い、映像や音声を受け取る必要がないので、mediaConstraintsの内容がどちらも受信不要(false)にしています。実際に通信を行うオブジェクトを用意するのは、prepareNewConnection()の中で行っています。

配信開始時の通知

反対に、配信側(talk)から新たに配信開始を通知する処理はこちらです。単にsocket越しに部屋内にメッセージを投げるだけですね。

		  function tellReady() {
			if (! isLocalStreamStarted()) {
			  alert("Local stream not running yet. Please [Start Video] or [Start Screen].");
			  return;
			}
			if (! socketReady) {
			  alert("Socket is not connected to server. Please reload and try again.");
			  return;
			}
		
			// call others, in same room
			console.log("tell ready to others in same room, befeore offer");
			socket.json.send({type: "talk_ready"});
		  }

SDP、ICEのやり取り

SDPやICE Candidateも、socket越しに相手(1人)に送るだけです。

		  function sendSDP(sdp) {
			var text = JSON.stringify(sdp);
			console.log("---sending sdp text ---");
			console.log(text);
			
			// send via socket
			socket.json.send(sdp);
		  }
		
		  function sendCandidate(candidate) {
			var text = JSON.stringify(candidate);
			console.log("---sending candidate text ---");
			console.log(text);
			
			// send via socket
			socket.json.send(candidate);
		  }

Answerや、Candidateを受け取った場合は、PeerConnectionに覚えさせます。

		  function setAnswer(evt) {
			var id = evt.from;
			var conn = getConnection(id);
			if (! conn) {
			  console.error('peerConnection not exist!');
			  return
			}
			conn.peerconnection.setRemoteDescription(new RTCSessionDescription(evt));
		  }
		  
		  function onCandidate(evt) {
		   var id = evt.from;
			var conn = getConnection(id);
			if (! conn) {
			  console.error('peerConnection not exist!');
			  return;
			}
		   
			var candidate = new RTCIceCandidate({sdpMLineIndex:evt.sdpMLineIndex, sdpMid:evt.sdpMid, candidate:evt.candidate});
			console.log("Received Candidate...")
			console.log(candidate);
			conn.peerconnection.addIceCandidate(candidate);
		  }

クライアント:視聴側(watch)のソースコード

視聴側は自分のユーザーメディアは取得しません。映像も1つだけ受け取るので処理はシンプルです。

Peer-to-Peerの管理

Peer-to-Peerは1つだけなので、本来便宜上のクラス、関数は不要です。とは言えtalk側と共通にするために(あるいは複数人からの変更を減らすため)、同じコードにしておきました。

		  // -------------- multi connections --------------------
		  var MAX_CONNECTION_COUNT = 1;
		  var connections = {}; // Connection hash
		  function Connection() { // Connection Class
			var self = this;
			var id = "";  // socket.id of partner
			var peerconnection = null; // RTCPeerConnection instance
		  }
		
		  function getConnection(id) {
			var con = null;
			con = connections[id];
			return con;
		  }
		
		  function addConnection(id, connection) {
			connections[id] = connection;
		  }
		
		  function getConnectionCount() {
			var count = 0;
			for (var id in connections) {
			  count++;
			}
		
			console.log('getConnectionCount=' + count);
			return count;
		  }
		
		  function isConnectPossible() {
			if (getConnectionCount() < MAX_CONNECTION_COUNT)
			  return true;
			else
			  return false;
		  }
		
		  function getConnectionIndex(id_to_lookup) {
			var index = 0;
			for (var id in connections) {
			  if (id == id_to_lookup) {
				return index;
			  }
		
			  index++;
			}
		
			// not found
			return -1;
		  }
		
		  function deleteConnection(id) {
			delete connections[id];
		  }
		
		  function stopAllConnections() {
			for (var id in connections) {
			  var conn = connections[id];
			  conn.peerconnection.close();
			  conn.peerconnection = null;
			  delete connections[id];
			}
		  }
		
		  function stopConnection(id) {
			var conn = connections[id];
			if(conn) {
			  console.log('stop and delete connection with id=' + id);
			  conn.peerconnection.close();
			  conn.peerconnection = null;
			  delete connections[id];
			}
			else {
			  console.log('try to stop connection, but not found id=' + id);
			}
		  }
		
		  function isPeerStarted() {
			if (getConnectionCount() > 0) {
			  return true;
			}
			else {
			  return false;
			}
		  }

※同じと言いましたが、1点違いがありました。 MAX_CONNECTION_COUNT = 1 にしています。

シグナリングサーバーへの接続とイベント処理

シグナリングサーバーへの接続はtalkと同じです。処理するメッセージの種類が異なります。

		  // ---- socket ------
		  // create socket
		  var socketReady = false;
		  var port = 9001;
		  var socket = io.connect('http://signaling.yourdomain:' + port + '/');
		  
		  // socket: channel connected
		  socket.on('connect', onOpened)
				.on('message', onMessage)
				.on('user disconnected', onUserDisconnect);
		
		  function onOpened(evt) {
			console.log('socket opened.');
			socketReady = true;
			
			var roomname = getRoomName(); // 会議室名を取得する
			socket.emit('enter', roomname);
			console.log('enter to ' + roomname);
		  }
		
		  // socket: accept connection request
		  function onMessage(evt) {
			var id = evt.from;
			var target = evt.sendto;
			var conn = getConnection(id);
		
			if (evt.type === 'talk_ready') {
			  if (conn) {
				return;  // already connected
			  }
		
			  if (isConnectPossible()) {
				socket.json.send({type: "talk_request", sendto: id });
			  }
			  else {
				console.warn('max connections. so ignore call');
			  }
			  return;
			}
			else if (evt.type === 'offer') {
			  console.log("Received offer, set offer, sending answer....")
			  onOffer(evt);	  
			}
			else if (evt.type === 'candidate' && isPeerStarted()) { // **
			  console.log('Received ICE candidate...');
			  onCandidate(evt);
			}
			else if (evt.type === 'end_talk') { // **
			  console.log("got talker bye.");
			  detachVideo(id); // force detach video
			  stopConnection(id);
			}
		
		  }
		
		  function onUserDisconnect(evt) {
			console.log("disconnected");
			if (evt) {
			  detachVideo(evt.id); // force detach video
			  stopConnection(evt.id);
			}
		  }
		
		  function getRoomName() { // たとえば、 URLに  ?roomname  とする
			var url = document.location.href;
			var args = url.split('?');
			if (args.length > 1) {
			  var room = args[1];
			  if (room != "") {
				return room;
			  }
			}
			return "_defaultroom";
		  }

応答するメッセージは talk_ready, offer, candidate です。talk_requestやanswerには反応しません。

配信の要求

配信の依頼は、socket.ioで会議室内全員に(相手を特定せずに)投げます。

		  function sendRequest() {
			if (! socketReady) {
			  alert("Socket is not connected to server. Please reload and try again.");
			  return;
			}
		
			// call others, in same room
			console.log("send request in same room, ask for offer");
			socket.json.send({type: "talk_request"});
		
		  }

SDP受信時の処理、ストリーム受信時の処理

talkからOffer SDPを受け取ったら、Peer-to-Peer通信の準備をします。PeerConnectionを生成し、Offerを覚えて、Answerを返します。

		  var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':true, 'OfferToReceiveVideo':true }};
		
		  function setOffer(evt) {
			var id = evt.from;
			var conn = getConnection(id);
			if (! conn) {
			  conn = prepareNewConnection(id);
			  conn.peerconnection.setRemoteDescription(new RTCSessionDescription(evt));
			}
			else {
			  console.error('peerConnection alreay exist!');
			}
		  }
		  
		  function sendAnswer(evt) {
			console.log('sending Answer. Creating remote session description...' );
			var id = evt.from;
			var conn = getConnection(id);
			if (! conn) {
			  console.error('peerConnection not exist!');
			  return;
			}
		
			conn.peerconnection.createAnswer(function (sessionDescription) { 
			  // in case of success
			  conn.peerconnection.setLocalDescription(sessionDescription);
			  sessionDescription.sendto = id;
			  sendSDP(sessionDescription);
			}, function () { // in case of error
			  console.log("Create Answer failed");
			}, mediaConstraints);
		  }

PeerConnectionを準備しているのは、prepareNewConnection()の中です。talkとほとんど同じですが、一部異なる部分があります。

		  // ---------------------- connection handling -----------------------
		  function prepareNewConnection(id) {
			var pc_config = {"iceServers":[]};
			var peer = null;
			try {
			  peer = new webkitRTCPeerConnection(pc_config);
			} catch (e) {
			  console.log("Failed to create PeerConnection, exception: " + e.message);
			}
			var conn = new Connection();
			conn.id = id;
			conn.peerconnection = peer;
			peer.id = id;
			addConnection(id, conn);
		
			// send any ice candidates to the other peer
			peer.onicecandidate = function (evt) {
			  if (evt.candidate) {
				console.log(evt.candidate);
				sendCandidate({type: "candidate", 
								  sendto: conn.id,
								  sdpMLineIndex: evt.candidate.sdpMLineIndex,
								  sdpMid: evt.candidate.sdpMid,
								  candidate: evt.candidate.candidate});
			  } else {
				console.log("on ice event. phase=" + evt.eventPhase);
			  }
			};
		
			//console.log('Adding local stream...');
			//peer.addStream(localStream); // 自分の映像ストリームは無し
		
			// 相手のストリームのハンドラを追加
			peer.addEventListener("addstream", onRemoteStreamAdded, false);
			peer.addEventListener("removestream", onRemoteStreamRemoved, false)
		
			// when remote adds a stream, hand it on to the local video element
			function onRemoteStreamAdded(event) {
			  console.log("Added remote stream");
			  remoteVideo.src = window.webkitURL.createObjectURL(event.stream);
			}
		
			// when remote removes a stream, remove it from the local video element
			function onRemoteStreamRemoved(event) {
			  console.log("Remove remote stream");
			  detachVideo(this.id);
			}
		
			return conn;
		  }

自分のストリーム(localStream)がない代わりに、相手のストリーム(RemoteStream)のハンドラを用意しています。

配信開始通知を受けた場合

talk側から talk_ready を受け取った場合、まだ接続が確立していなければ、talk_requestを返します。その後処理は配信の要求と同様に進みます。

		  function onMessage(evt) {
			var id = evt.from;
			var target = evt.sendto;
			var conn = getConnection(id);
		
			if (evt.type === 'talk_ready') {
			  if (conn) {
				return;  // already connected
			  }
		
			  if (isConnectPossible()) {
				socket.json.send({type: "talk_request", sendto: id });
			  }
			  else {
				console.warn('max connections. so ignore call');
			  }
			  return;
			}

動かしてみよう

配信前

配信開始後

このように、複数のブラウザに配信を行うことができます。ぜひ社内などで試してみてください。シグナリングサーバーを流用してテキストチャットなどを付けると、より楽しく利用できますよ!
※今回のソースには含まれていませんが、TURNサーバーを用意すれば、Firewall/NATの内側から外側に配信することも可能です。

クライアント側のソースコード(全体)

配信側と視聴側の主な違い

配信側(talk)と視聴側(watch)の仕組みは似通っていますが、違う部分もあります。改めて違う部分を整理しておきます。
(1) 配信側だけがユーザーメディアを取得する。PeerConnectionを生成したときに、そのストリームを追加する。


(2) 受信側だけが、相手のメディアストリームの接続、除去イベントを処理する。


(3) 配信側と受信側は異なるメッセージに応答する。

配信側(talk.html)

		<!DOCTYPE html>
		<html>
		<head>
		  <title>Broadcast Talk</title>  
		  <meta http-equiv="content-type" content="text/html; charset=UTF-8">
		</head>
		<body>
		  <button type="button" onclick="startVideo();">Start video</button>
		  <button type="button" onclick="stopVideo();">Stop video</button>
		  &nbsp;&nbsp;&nbsp;&nbsp;
		  <button type="button" onclick="tellReady();">On Air</button>
		  <br />
		  <div style="position: relative;">
		   <video id="local-video" autoplay style="width: 240px; height: 180px; border: 1px solid black;"></video>
		  </div>
		  
		  <!---- socket ※自分のシグナリングサーバーに合わせて変更してください------>
		  <script src="http://signaling.yourdomain:9001/socket.io/socket.io.js"></script>
		  
		  <script>
		  var localVideo = document.getElementById('local-video');
		  var localStream = null;
		  var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':false, 'OfferToReceiveVideo':false }};
		
		  
		  function isLocalStreamStarted() {
			if (localStream) {
			  return true;
			}
			else {
			  return false;
			}
		  }
		
		  // -------------- multi connections --------------------
		  var MAX_CONNECTION_COUNT = 10;
		  var connections = {}; // Connection hash
		  function Connection() { // Connection Class
			var self = this;
			var id = "";  // socket.id of partner
			var peerconnection = null; // RTCPeerConnection instance
		  }
		
		  function getConnection(id) {
			var con = null;
			con = connections[id];
			return con;
		  }
		
		  function addConnection(id, connection) {
			connections[id] = connection;
		  }
		
		  function getConnectionCount() {
			var count = 0;
			for (var id in connections) {
			  count++;
			}
		
			console.log('getConnectionCount=' + count);
			return count;
		  }
		
		  function isConnectPossible() {
			if (getConnectionCount() < MAX_CONNECTION_COUNT)
			  return true;
			else
			  return false;
		  }
		
		  function getConnectionIndex(id_to_lookup) {
			var index = 0;
			for (var id in connections) {
			  if (id == id_to_lookup) {
				return index;
			  }
		
			  index++;
			}
		
			// not found
			return -1;
		  }
		
		  function deleteConnection(id) {
			delete connections[id];
		  }
		
		  function stopAllConnections() {
			for (var id in connections) {
			  var conn = connections[id];
			  conn.peerconnection.close();
			  conn.peerconnection = null;
			  delete connections[id];
			}
		  }
		
		  function stopConnection(id) {
			var conn = connections[id];
			if(conn) {
			  console.log('stop and delete connection with id=' + id);
			  conn.peerconnection.close();
			  conn.peerconnection = null;
			  delete connections[id];
			}
			else {
			  console.log('try to stop connection, but not found id=' + id);
			}
		  }
		
		  function isPeerStarted() {
			if (getConnectionCount() > 0) {
			  return true;
			}
			else {
			  return false;
			}
		  }
		
		  
		  // ---- socket ------
		  // create socket
		  var socketReady = false;
		  var port = 9001;
		  var socket = io.connect('http://signaling.yourdomain:' + port + '/'); // ※自分のシグナリングサーバーに合わせて変更してください
		
		  // socket: channel connected
		  socket.on('connect', onOpened)
				.on('message', onMessage)
				.on('user disconnected', onUserDisconnect);
		
		  function onOpened(evt) {
			console.log('socket opened.');
			socketReady = true;
			
			var roomname = getRoomName(); // 会議室名を取得する
			socket.emit('enter', roomname);
			console.log('enter to ' + roomname);
		  }
		
		  // socket: accept connection request
		  function onMessage(evt) {
			var id = evt.from;
			var target = evt.sendto;
			var conn = getConnection(id);
		
			if (evt.type === 'talk_request') {
			  if (! isLocalStreamStarted()) {
				console.warn('local stream not started. ignore request');
				return;
			  }
		
			  console.log("receive request, start offer.");
			  sendOffer(id);
			  return;
			}
			else if (evt.type === 'answer' && isPeerStarted()) {  
			  console.log('Received answer, settinng answer SDP');
			  onAnswer(evt);
			} else if (evt.type === 'candidate' && isPeerStarted()) { 
			  console.log('Received ICE candidate...');
			  onCandidate(evt);
			}
			else if (evt.type === 'bye') { 
			  console.log("got bye.");
			  stopConnection(id);
			}
		  }
		
		  function onUserDisconnect(evt) {
			console.log("disconnected");
			if (evt) {
			  stopConnection(evt.id);
			}
		  }
		
		  function getRoomName() { // たとえば、 URLに  ?roomname  とする
			var url = document.location.href;
			var args = url.split('?');
			if (args.length > 1) {
			  var room = args[1];
			  if (room != "") {
				return room;
			  }
			}
			return "_defaultroom";
		  }
		  
		  
		  
		  function onAnswer(evt) {
			console.log("Received Answer...")
			console.log(evt);
			setAnswer(evt);
		  }
		  
		  function onCandidate(evt) {
		   var id = evt.from;
			var conn = getConnection(id);
			if (! conn) {
			  console.error('peerConnection not exist!');
			  return;
			}
		   
			var candidate = new RTCIceCandidate({sdpMLineIndex:evt.sdpMLineIndex, sdpMid:evt.sdpMid, candidate:evt.candidate});
			console.log("Received Candidate...")
			console.log(candidate);
			conn.peerconnection.addIceCandidate(candidate);
		  }
		
		  function sendSDP(sdp) {
			var text = JSON.stringify(sdp);
			console.log("---sending sdp text ---");
			console.log(text);
			
			// send via socket
			socket.json.send(sdp);
		  }
		  
		  function sendCandidate(candidate) {
			var text = JSON.stringify(candidate);
			console.log("---sending candidate text ---");
			console.log(text);
			
			// send via socket
			socket.json.send(candidate);
		  }
		  
		  // ---------------------- video handling -----------------------
		  // start local video
		  function startVideo() {
			navigator.webkitGetUserMedia({video: true, audio: true},
			  function (stream) { // success
				localStream = stream;
				localVideo.src = window.webkitURL.createObjectURL(stream);
				localVideo.play();
				localVideo.volume = 0;
		
				// auto start
				tellReady();
			  },
			  function (error) { // error
				console.error('An error occurred:');
				console.error(error);
				return;
			  }
			);
		  }
		
		  // stop local video
		  function stopVideo() {
			hangUp();
		
			localVideo.src = "";
			localStream.stop();
			localStream = null;
		  }
		
		  // ---------------------- connection handling -----------------------
		  function prepareNewConnection(id) {
			var pc_config = {"iceServers":[]};
			var peer = null;
			try {
			  peer = new webkitRTCPeerConnection(pc_config);
			} catch (e) {
			  console.log("Failed to create PeerConnection, exception: " + e.message);
			}
			var conn = new Connection();
			conn.id = id;
			conn.peerconnection = peer;
			peer.id = id;
			addConnection(id, conn);
		
			// send any ice candidates to the other peer
			peer.onicecandidate = function (evt) {
			  if (evt.candidate) {
				console.log(evt.candidate);
				sendCandidate({type: "candidate", 
								  sendto: conn.id,
								  sdpMLineIndex: evt.candidate.sdpMLineIndex,
								  sdpMid: evt.candidate.sdpMid,
								  candidate: evt.candidate.candidate});
			  } else {
				console.log("ICE event. phase=" + evt.eventPhase);
				//conn.established = true;
			  }
			};
		
			console.log('Adding local stream...');
			peer.addStream(localStream);
		
			return conn;
		  }
		
		  function sendOffer(id) {
			var conn = getConnection(id);
			if (!conn) {
			  conn = prepareNewConnection(id);
			}
		
			conn.peerconnection.createOffer(function (sessionDescription) { // in case of success
			  conn.peerconnection.setLocalDescription(sessionDescription);
			  sessionDescription.sendto = id;
			  sendSDP(sessionDescription);
			}, function () { // in case of error
			  console.log("Create Offer failed");
			}, mediaConstraints);
		  }
		
		  function setAnswer(evt) {
			var id = evt.from;
			var conn = getConnection(id);
			if (! conn) {
			  console.error('peerConnection not exist!');
			  return
			}
			conn.peerconnection.setRemoteDescription(new RTCSessionDescription(evt));
		  }
		  
		  // -------- handling user UI event -----
		  function tellReady() {
			if (! isLocalStreamStarted()) {
			  alert("Local stream not running yet. Please [Start Video] or [Start Screen].");
			  return;
			}
			if (! socketReady) {
			  alert("Socket is not connected to server. Please reload and try again.");
			  return;
			}
		
			// call others, in same room
			console.log("tell ready to others in same room, befeore offer");
			socket.json.send({type: "talk_ready"});
		  }
		
		  
		  // stop the connection upon user request
		  function hangUp() {
			console.log("Hang up.");
			socket.json.send({type: "end_talk"});
			stopAllConnections();
		  }
		
		  
		  </script>
		</body>
		</html>

視聴側(watch.html)

		<!DOCTYPE html>
		<html>
		<head>
		  <title>broadcast watch</title>  
		  <meta http-equiv="content-type" content="text/html; charset=UTF-8">
		</head>
		<body>
		  <button type="button" onclick="sendRequest();">Request</button>
		  <button type="button" onclick="hangUp();">Hang Up</button>
		  <br />
		  <div style="position: relative;">
		   <video id="remote-video" autoplay style="width: 320px; height: 240px; border: 1px solid black;"></video>
		  </div>
		  
		  <!---- socket ※自分のシグナリングサーバーに合わせて変更してください ------>
		  <script src="http://signaling.yourdomain:9001/socket.io/socket.io.js"></script>
		  
		  <script>
		  //var localVideo = document.getElementById('local-video');
		  var remoteVideo = document.getElementById('remote-video');
		  //var localStream = null;
		  var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':true, 'OfferToReceiveVideo':true }};
		
		  function detachVideo(id) {
			if (id) { 
			  var conn = getConnection(id);
			  if (conn) {
				remoteVideo.pause();
				remoteVideo.src = "";
			  }
			}
			else {
			  // force detach
			  remoteVideo.pause();
			  remoteVideo.src = "";
			}
		  }
		
		  function resizeRemoteVideo() {
			console.log('--resize--');
			var top_margin = 40;
			var left_margin = 20;
			var video_margin = 10;
		
			var new_width = window.innerWidth - left_margin - video_margin;
			var new_height = window.innerHeight - top_margin - video_margin;
			remoteVideo.style.width = new_width + 'px';
			remoteVideo.style.height = new_height + 'px';
			remoteVideo.style.top = top_margin + 'px';
			remoteVideo.style.left = left_margin + 'px';
		  }
		  document.body.onresize = resizeRemoteVideo;
		  resizeRemoteVideo();
		
		  // -------------- multi connections --------------------
		  var MAX_CONNECTION_COUNT = 1;
		  var connections = {}; // Connection hash
		  function Connection() { // Connection Class
			var self = this;
			var id = "";  // socket.id of partner
			var peerconnection = null; // RTCPeerConnection instance
		  }
		
		  function getConnection(id) {
			var con = null;
			con = connections[id];
			return con;
		  }
		
		  function addConnection(id, connection) {
			connections[id] = connection;
		  }
		
		  function getConnectionCount() {
			var count = 0;
			for (var id in connections) {
			  count++;
			}
		
			console.log('getConnectionCount=' + count);
			return count;
		  }
		
		  function isConnectPossible() {
			if (getConnectionCount() < MAX_CONNECTION_COUNT)
			  return true;
			else
			  return false;
		  }
		
		  function getConnectionIndex(id_to_lookup) {
			var index = 0;
			for (var id in connections) {
			  if (id == id_to_lookup) {
				return index;
			  }
		
			  index++;
			}
		
			// not found
			return -1;
		  }
		
		  function deleteConnection(id) {
			delete connections[id];
		  }
		
		  function stopAllConnections() {
			for (var id in connections) {
			  var conn = connections[id];
			  conn.peerconnection.close();
			  conn.peerconnection = null;
			  delete connections[id];
			}
		  }
		
		  function stopConnection(id) {
			var conn = connections[id];
			if(conn) {
			  console.log('stop and delete connection with id=' + id);
			  conn.peerconnection.close();
			  conn.peerconnection = null;
			  delete connections[id];
			}
			else {
			  console.log('try to stop connection, but not found id=' + id);
			}
		  }
		
		  function isPeerStarted() {
			if (getConnectionCount() > 0) {
			  return true;
			}
			else {
			  return false;
			}
		  }
		
		  
		  // ---- socket ------
		  // create socket
		  var socketReady = false;
		  var port = 9001;
		  var socket = io.connect('http://signaling.yourdomain:' + port + '/'); // ※自分のシグナリングサーバーに合わせて変更してください 
		  
		  // socket: channel connected
		  socket.on('connect', onOpened)
				.on('message', onMessage)
				.on('user disconnected', onUserDisconnect);
		
		  function onOpened(evt) {
			console.log('socket opened.');
			socketReady = true;
			
			var roomname = getRoomName(); // 会議室名を取得する
			socket.emit('enter', roomname);
			console.log('enter to ' + roomname);
		  }
		
		  // socket: accept connection request
		  function onMessage(evt) {
			var id = evt.from;
			var target = evt.sendto;
			var conn = getConnection(id);
		
			console.log('onMessage() evt.type='+ evt.type);
		
			if (evt.type === 'talk_ready') {
			  if (conn) {
				return;  // already connected
			  }
		
			  if (isConnectPossible()) {
				socket.json.send({type: "talk_request", sendto: id });
			  }
			  else {
				console.warn('max connections. so ignore call');
			  }
			  return;
			}
			else if (evt.type === 'offer') {
			  console.log("Received offer, set offer, sending answer....")
			  onOffer(evt);	  
			}
			else if (evt.type === 'candidate' && isPeerStarted()) {
			  console.log('Received ICE candidate...');
			  onCandidate(evt);
			}
			else if (evt.type === 'end_talk') { 
			  console.log("got talker bye.");
			  detachVideo(id); // force detach video
			  stopConnection(id);
			}
		
		  }
		
		  function onUserDisconnect(evt) {
			console.log("disconnected");
			if (evt) {
			  detachVideo(evt.id); // force detach video
			  stopConnection(evt.id);
			}
		  }
		
		  function getRoomName() { // たとえば、 URLに  ?roomname  とする
			var url = document.location.href;
			var args = url.split('?');
			if (args.length > 1) {
			  var room = args[1];
			  if (room != "") {
				return room;
			  }
			}
			return "_defaultroom";
		  }
		  
		  
		  function onOffer(evt) {
			console.log("Received offer...")
			console.log(evt);
			setOffer(evt);
			sendAnswer(evt);
		  }
		 
		  function onCandidate(evt) {
			var id = evt.from;
			var conn = getConnection(id);
			if (! conn) {
			   console.error('peerConnection not exist!');
			   return;
			}
			
			var candidate = new RTCIceCandidate({sdpMLineIndex:evt.sdpMLineIndex, sdpMid:evt.sdpMid, candidate:evt.candidate});
			console.log("Received Candidate...")
			console.log(candidate);
			conn.peerconnection.addIceCandidate(candidate);
		  }
		
		  function sendSDP(sdp) {
			var text = JSON.stringify(sdp);
			console.log("---sending sdp text ---");
			console.log(text);
			
			// send via socket
			socket.json.send(sdp);
		  }
		  
		  function sendCandidate(candidate) {
			var text = JSON.stringify(candidate);
			console.log("---sending candidate text ---");
			console.log(text);
			
			// send via socket
			socket.json.send(candidate);
		  }
		  
		
		  // ---------------------- connection handling -----------------------
		  function prepareNewConnection(id) {
			var pc_config = {"iceServers":[]};
			var peer = null;
			try {
			  peer = new webkitRTCPeerConnection(pc_config);
			} catch (e) {
			  console.log("Failed to create PeerConnection, exception: " + e.message);
			}
			var conn = new Connection();
			conn.id = id;
			conn.peerconnection = peer;
			peer.id = id;
			addConnection(id, conn);
		
			// send any ice candidates to the other peer
			peer.onicecandidate = function (evt) {
			  if (evt.candidate) {
				console.log(evt.candidate);
				sendCandidate({type: "candidate", 
								  sendto: conn.id,
								  sdpMLineIndex: evt.candidate.sdpMLineIndex,
								  sdpMid: evt.candidate.sdpMid,
								  candidate: evt.candidate.candidate});
			  } else {
				console.log("on ice event. phase=" + evt.eventPhase);
			  }
			};
		
			//console.log('Adding local stream...');
			//peer.addStream(localStream);
		
			peer.addEventListener("addstream", onRemoteStreamAdded, false);
			peer.addEventListener("removestream", onRemoteStreamRemoved, false)
		
			// when remote adds a stream, hand it on to the local video element
			function onRemoteStreamAdded(event) {
			  console.log("Added remote stream");
			  //attachVideo(this.id, event.stream);
			  remoteVideo.src = window.webkitURL.createObjectURL(event.stream);
			}
		
			// when remote removes a stream, remove it from the local video element
			function onRemoteStreamRemoved(event) {
			  console.log("Remove remote stream");
			  detachVideo(this.id);
			}
		
			return conn;
		  }
		
		  function setOffer(evt) {
			var id = evt.from;
			var conn = getConnection(id);
			if (! conn) {
			  conn = prepareNewConnection(id);
			  conn.peerconnection.setRemoteDescription(new RTCSessionDescription(evt));
			}
			else {
			  console.error('peerConnection alreay exist!');
			}
		  }
		  
		  function sendAnswer(evt) {
			console.log('sending Answer. Creating remote session description...' );
			var id = evt.from;
			var conn = getConnection(id);
			if (! conn) {
			  console.error('peerConnection not exist!');
			  return;
			}
		
			conn.peerconnection.createAnswer(function (sessionDescription) { 
			  // in case of success
			  conn.peerconnection.setLocalDescription(sessionDescription);
			  sessionDescription.sendto = id;
			  sendSDP(sessionDescription);
			}, function () { // in case of error
			  console.log("Create Answer failed");
			}, mediaConstraints);
		  }
		
		  function sendRequest() {
			if (! socketReady) {
			  alert("Socket is not connected to server. Please reload and try again.");
			  return;
			}
		
			// call others, in same room
			console.log("send request in same room, ask for offer");
			socket.json.send({type: "talk_request"});
		 
		  }
		 
		  // stop the connection upon user request
		  function hangUp() {
			console.log("Hang up.");
			socket.json.send({type: "bye"});
			detachVideo(null);
			stopAllConnections();
		  }
		
		  
		  </script>
		</body>
		</html>