 Видеоконференции через Skype уже давно заняли свое место в ежедневных коммуникациях, пользователи оценили удобство такого формата общения и все больше компаний стараются проводить встречи именно в этом формате. Но у скайпа есть большой минус: это отдельное приложение, которое трудно интегрировать в другой сервис. А сервисов, куда можно с пользой для дела встроить видеоконференции великое множество, начиная от систем бизнес-автоматизации и заканчивая сервисами группового обучения иностранному языку. Сегодня я покажу вам, как с помощью подручных средств и voximplant за 10 минут собрать движок видеоконференций, работающий прямо из браузера на webRTC и спозволяющий подключаться к конференции с обычных телефонов.
Видеоконференции через Skype уже давно заняли свое место в ежедневных коммуникациях, пользователи оценили удобство такого формата общения и все больше компаний стараются проводить встречи именно в этом формате. Но у скайпа есть большой минус: это отдельное приложение, которое трудно интегрировать в другой сервис. А сервисов, куда можно с пользой для дела встроить видеоконференции великое множество, начиная от систем бизнес-автоматизации и заканчивая сервисами группового обучения иностранному языку. Сегодня я покажу вам, как с помощью подручных средств и voximplant за 10 минут собрать движок видеоконференций, работающий прямо из браузера на webRTC и спозволяющий подключаться к конференции с обычных телефонов.Voximplant использует профили пользователей, которые можно создавать с помощью HTTP API. Для демонстрации видеоконференции мы сделали небольшое приложение, которое по url-приглашению запрашивает имя участника, создает профиль пользователя и возвращает параметры аутентификации https://github.com/voximplant.
В отличие от звука, voximplant передает видео между участниками, peer-to-peer, что соответствует механике работы webRTC. Чтобы организовать конференцию, участникам необходимо сделать видео подключения друг к другу — это будет хорошо работать примерно до десяти пользователей, что с запасом покрывает большинство сценариев работы. А звук будет автоматически микшироваться стандартными механизмами voximplant. Для корректного микширования звука мы создадим две внутренние конференции: #1 для видеовызовов и #2 для участников с обычных телефонов:

Красные стрелки показывают аудио и видео потоки между участниками конференции в браузере, а синие стрелки показывают аудио-потоки для участников с телефонов. Одно из преимуществ voximplant — возможность гибкой работы с разными потоками на стороне облака, что позволяет создавать самые разные решения.
Для начала зарегистрируемся в voximplant.com и создадим новое приложение с именем “videoconf”.
Затем в настройках этого приложения создадим первый, самый простой сценарий. Он будет отвечать за отправку p2p аудио/видео между web клиентами и называется “VideoConferenceP2P”:
VoxEngine.forwardCallToUserDirect();
Следующий сценарий в телефонии принято называть “gatekeeper” — он обрабатывает звонок от web-клиента и дальше перенаправляет его в конференцию с соответствующим conferenceID, полученным из webSDK, плюс обеспечивает передачу текстовых сообщений между конференцией и клиентом, для нотификации о подключении новых участников. Назовем этот сценарий “VideoConferenceGatekeeper”:
/**
* Video Conference Gatekeeper
* Handle inbound calls and route them to the conference
*/
var call,
    conferenceId,
	conf;
/**
* Inbound call handler
*/
VoxEngine.addEventListener(AppEvents.CallAlerting, function (e) {
  	// Get conference id from headers
  	conferenceId = e.headers['X-Conference-Id'];
  	Logger.write('User '+e.callerid+' is joining conference '+conferenceId);  	
  
  	call = e.call;
  	/**
    * Play some audio till call connected event
    */
	call.startEarlyMedia();
  	call.startPlayback("http://cdn.voximplant.com/bb_remix.mp3", true);
  	/**
    * Add event listeners
    */
  	call.addEventListener(CallEvents.Connected, sdkCallConnected);
  	call.addEventListener(CallEvents.Disconnected, function (e) {
		VoxEngine.terminate();
	});
	call.addEventListener(CallEvents.Failed, function (e) {
		VoxEngine.terminate();
	});
  	call.addEventListener(CallEvents.MessageReceived, function(e) {
      	Logger.write("Message Received: "+e.text);
        try {
          var msg = JSON.parse(e.text);
        } catch(err) {
          Logger.write(err);
        }
      
      	if (msg.type == "ICE_FAILED") {
      		conf.sendMessage(e.text);	
        } else if (msg.type == "CALL_PARTICIPANT") {
          	conf.sendMessage(e.text);
        }
  	});
  	// Answer the call
  	call.answer();
});
/**
* Connected handler
*/
function sdkCallConnected(e) {
  	// Stop playing audio
  	call.stopPlayback();
  	Logger.write('Joining conference');
  	// Call conference with specified id
  	conf = VoxEngine.callConference('conf_'+conferenceId, call.callerid(), call.displayName(), {"X-ClientType": "web"});  
  	Logger.write('CallerID: '+call.callerid()+' DisplayName: '+call.displayName());
  	// Add event listeners
  	conf.addEventListener(CallEvents.Connected, function (e) {
      Logger.write("VideoConference Connected");
      VoxEngine.sendMediaBetween(conf, call);
    });  
  	conf.addEventListener(CallEvents.Disconnected, VoxEngine.terminate);
  	conf.addEventListener(CallEvents.Failed, VoxEngine.terminate);
    conf.addEventListener(CallEvents.MessageReceived, function(e) {
      call.sendMessage(e.text);
    });  
}
Следующий сценарий — для входящих звонков с обычных телефонов на телефонный номер конференции, который можно арендовать в пару кликов через интерфейс voximplant. После соединение синтезатор голоса промит звонящего ввести идентификатор конференции и осуществляет подключение. Назовем этот сценарий “VideoConferencePSTNgatekeeper”:
var pin = "", call;
VoxEngine.addEventListener(AppEvents.CallAlerting, function (e) {
	call = e.call;
	e.call.addEventListener(CallEvents.Connected, handleCallConnected);
	e.call.addEventListener(CallEvents.Disconnected, handleCallDisconnected);
	e.call.answer();
});
function handleCallConnected(e) {
	e.call.say("Hello, please enter your conference pin using keypad and press pound key to join the conference.", Language.UK_ENGLISH_FEMALE);
	e.call.addEventListener(CallEvents.ToneReceived, function (e) {
		e.call.stopPlayback();		
		if (e.tone == "#") {
			// Try to call conference according the specified pin
          	var conf = VoxEngine.callConference('conf_'+pin, e.call.callerid(), e.call.displayName(), {"X-ClientType": "pstn_inbound"});
          	conf.addEventListener(CallEvents.Connected, handleConfConnected);
          	conf.addEventListener(CallEvents.Failed, handleConfFailed);
		} else {
			pin += e.tone;
		}
	});
	e.call.handleTones(true);
}
function handleConfConnected(e) {
	VoxEngine.sendMediaBetween(e.call, call);
}
function handleConfFailed(e) {
  	VoxEngine.terminate();
}
function handleCallDisconnected(e) {
	VoxEngine.terminate();
}
Последний и самый большой сценарий отвечает за создание двух конференций, подключение и отключение участников, управляет аудио потоками и удаляет ставшие не нужными профили отключившихся пользователей. Назовем этот сценарий “VideoConference”, если вы будете копировать код из примера — не забудьте подставить свои значения “account_name” и “api_key”:
/**
* Require Conference module to get conferencing functionality
*/
require(Modules.Conference);
var videoconf,
	pstnconf,
	calls = [],
	pstnCalls = [],
	clientType,
    /**
    * HTTP API Access Info for user auto delete
    */
	apiURL = "https://api.voximplant.com/platform_api",
	account_name = "your_voximplant_account_name",
	api_key = "your_voximplant_api_key";
// Add event handler for session start event
VoxEngine.addEventListener(AppEvents.Started, handleConferenceStarted);
function handleConferenceStarted(e) {
    // Create 2 conferences right after session to manage audio in the right way
	videoconf = VoxEngine.createConference();
	pstnconf = VoxEngine.createConference();
}
/**
* Handle inbound call
*/
VoxEngine.addEventListener(AppEvents.CallAlerting, function (e) {
  	// get caller's client type
  	clientType = e.headers["X-ClientType"];
 	// Add event handlers depending on the client type	
	if (clientType == "web") {
		e.call.addEventListener(CallEvents.Connected, handleParticipantConnected);
		e.call.addEventListener(CallEvents.Disconnected, handleParticipantDisconnected);
	} else {
      	pstnCalls.push(e.call);
		e.call.addEventListener(CallEvents.Connected, handlePSTNParticipantConnected);
		e.call.addEventListener(CallEvents.Disconnected, handlePSTNParticipantDisconnected);
	}
	e.call.addEventListener(CallEvents.Failed, handleConnectionFailed);
	e.call.addEventListener(CallEvents.MessageReceived, handleMessageReceived);
  	// Answer the call
  	e.call.answer();
});
/**
* Message handler
*/
function handleMessageReceived(e) {
	Logger.write("Message Recevied: " + e.text);
	try {
		var msg = JSON.parse(e.text);
	} catch (err) {
		Logger.write(err);
	}
	
	if (msg.type == "ICE_FAILED") {
		// P2P call failed because of ICE problems - sending notification to retry
		var caller = msg.caller.substr(0, msg.caller.indexOf('@'));
		caller = caller.replace("sip:", "");
		Logger.write("Sending notification to " + caller);
		var call = getCallById(caller);
		if (call != null) call.sendMessage(JSON.stringify({
			type: "ICE_FAILED",
			callee: msg.callee,
          	displayName: msg.displayName
		}));
	} else if (msg.type == "CALL_PARTICIPANT") {
		// Conference participant decided to add PSTN participant (outbound call)
		for (var k = 0; k < calls.length; k++) calls[k].sendMessage(e.text);
		Logger.write("Calling participant with number " + msg.number);
		var call = VoxEngine.callPSTN(msg.number);
		pstnCalls.push(call);
		call.addEventListener(CallEvents.Connected, handleOutboundCallConnected);
		call.addEventListener(CallEvents.Disconnected, handleOutboundCallDisconnected);
		call.addEventListener(CallEvents.Failed, handleOutboundCallFailed);
	}
}
/**
* PSTN participant connected
*/
function handleOutboundCallConnected(e) {
	e.call.say("You have joined a conference", Language.UK_ENGLISH_FEMALE);
	e.call.addEventListener(CallEvents.PlaybackFinished, function (e) {
		for (var k = 0; k < calls.length; k++) calls[k].sendMessage(JSON.stringify({
			type: "CALL_PARTICIPANT_CONNECTED",
			number: e.call.number()
		}));
      	VoxEngine.sendMediaBetween(e.call, pstnconf);
      	e.call.sendMediaTo(videoconf);
	});
} 
/**
* PSTN participant disconnected
*/
function handleOutboundCallDisconnected(e) {
	Logger.write("PSTN participant disconnected " + e.call.number());
  	removePSTNparticipant(e.call);
	for (var k = 0; k < calls.length; k++) calls[k].sendMessage(JSON.stringify({
		type: "CALL_PARTICIPANT_DISCONNECTED",
		number: e.call.number()
	}));
}
/**
* Call to PSTN participant failed
*/
function handleOutboundCallFailed(e) {
	Logger.write("Call to PSTN participant " + e.call.number() + " failed");
  	removePSTNparticipant(e.call);
	for (var k = 0; k < calls.length; k++) calls[k].sendMessage(JSON.stringify({
		type: "CALL_PARTICIPANT_FAILED",
		number: e.call.number()
	}));
}
function removePSTNparticipant(call) {
  	for (var i = 0; i < pstnCalls.length; i++) {
        if (pstnCalls[i].number() == call.number()) {
            Logger.write("Caller with number " + call.number() + " disconnected");
            pstnCalls.splice(i, 1);
        }
    }
}
function handleConnectionFailed(e) {
	Logger.write("Participant couldn't join the conference");
}
function participantExists(callerid) {
	for (var i = 0; i < calls.length; i++) {
		if (calls[i].callerid() == callerid) return true;
	}
	return false;
}
function getCallById(callerid) {
	for (var i = 0; i < calls.length; i++) {
		if (calls[i].callerid() == callerid) return calls[i];
	}
	return null;
}
/**
* Web client connected
*/
function handleParticipantConnected(e) {
	if (!participantExists(e.call.callerid())) calls.push(e.call);
	e.call.say("You have joined the conference.", Language.UK_ENGLISH_FEMALE);
	e.call.addEventListener(CallEvents.PlaybackFinished, function (e) {
      	videoconf.sendMediaTo(e.call);
      	e.call.sendMediaTo(pstnconf);
		sendCallsInfo();
	});
}
function sendCallsInfo() {
  	var info = {
        peers: [],
        pstnCalls: []
    };
    for (var k = 0; k < calls.length; k++) {
        info.peers.push({
            callerid: calls[k].callerid(),
            displayName: calls[k].displayName()
        });
    }
    for (k = 0; k < pstnCalls.length; k++) {
        info.pstnCalls.push({
            callerid: pstnCalls[k].number()
        });
    }
    for (var k = 0; k < calls.length; k++) {
        calls[k].sendMessage(JSON.stringify(info));          	
    }
}
/**
* Inbound PSTN call connected
*/
function handlePSTNParticipantConnected(e) {
	e.call.say("You have joined the conference .", Language.UK_ENGLISH_FEMALE);
	e.call.addEventListener(CallEvents.PlaybackFinished, function (e) {
		VoxEngine.sendMediaBetween(e.call, pstnconf);
      	e.call.sendMediaTo(videoconf);
      	for (var k = 0; k < calls.length; k++) calls[k].sendMessage(JSON.stringify({
			type: "CALL_PARTICIPANT_CONNECTED",
			number: e.call.callerid(),
          	inbound: true
		}));
	});
}
/**
* Web client disconnected
*/
function handleParticipantDisconnected(e) {
	Logger.write("Disconnected:");
	for (var i = 0; i < calls.length; i++) {
		if (calls[i].callerid() == e.call.callerid()) {
          	/**
            * Make HTTP request to delete user via HTTP API
            */
			var url = apiURL + "/DelUser/?account_name=" + account_name +
				"&api_key=" + api_key +
				"&user_name=" + e.call.callerid();
			Net.httpRequest(url, function (res) {
				Logger.write("HttpRequest result: " + res.text);
			});
			Logger.write("Caller with id " + e.call.callerid() + " disconnected");
			calls.splice(i, 1);
		}
	}
	if (calls.length == 0) VoxEngine.terminate();
}
function handlePSTNParticipantDisconnected(e) {
  	removePSTNparticipant(e.call);
	for (var k = 0; k < calls.length; k++) calls[k].sendMessage(JSON.stringify({
		type: "CALL_PARTICIPANT_DISCONNECTED",
		number: e.call.callerid()
	}));
}
Чтобы облако voximplant знало, когда выполнять какой сценарий, сценарии подключаются к приложению с помощью правил. Нам понадобятся следующие правила:
- InboundFromPSTN, в Pattern указываем телефонный номер конференции, в сценарии указываем “VideoConferencePSTNgatekeeper”
- InboundCall, в Pattern указываем строку “joinconf” (это номер, который мы будем набирать из Web SDK при подключении к конференции), в сценарии указываем “VideoConferenceGatekeeper”
- Fwd, в Pattern указываем строку “conf_[A-Za-z0-9]+”, в сценарии указываем “VideoConference” — это правило будет срабатывать при звонке в конференцию через “callConference”.
- P2P, в Pattern оставляем “.*”, в сценарии указываем
- “VideoConferenceP2P”
Порядок расположения правил важен! Для перетаскивания (изменения приоритета) можно использовать drag'n'drop.
В результате настройки правил для приложения должны выглядит вот так:

Это все, что нужно настроить в облаке. Frontend часть сервиса делается с помощью нашего web sdk и довольно проста. После подключения нужно совершить звонок на “joinconf” и передать в заголовке “conferenceid”. Когда пользователь становится участником конференции, в событии MessageReceived он получат список веб-клиентов и можно инициировать исходящие peer-to-peer звонки с помощью сценария “P2P” для получения видео от тех клиентов, к которым еще нет подключений. для включения именно P2P-режима передается специальный хедер “X-DirectCall” в методе “call”. Также Frontend часть размещает на экране прямоугольники видеотрансляций и позволяет пригласить участника исходящим звонком из сценария конференции. Исходный код всех сценариев и клиентского приложения доступен на нашем GitHub-аккаунте
Комментарии (18)
 - negus24.06.2015 13:42- Спасибо за информацию, посмотрим VoxImplant :-) 
 
 P2P с видео может хорошо работать только для 5-6 и менее участников и подходит больше для демонстрации технологии. Из информации на вашем сайте следует, что вы также предлагаете ли функциональность WebRTC Media Server для большей масштабируемости, в связи с чем несколько вопросов:
 — Как расчитывается цена 1 цент за минуту — это одна минута на подписчика? Т. е. для one-to-many с 4-мя подписчиками надо умножить на 4, а для видеоконференции с тем же числом участников — еще на 4?
 — Как вы решаете проблему разного качества интернет-соединения участников при доставке потока одного ко многим? Битрейт определяется по самому «слабому» подписчику или же есть поддержка адаптивного битрейта или динамически изменяемой частоты кадров для каждого участника?
 
 Спасибо! - yaroslavb Автор24.06.2015 15:58- Если видео не идет через наш сервер (то есть peer-to-peer), то это будет бесплатно: voximplant.com/pricing  - atrolov25.06.2015 11:32- А если так получиться, что будет всегда WebRTC to WebRTC, тогда использование WebSDK будет бесплатно? 
 На странице цен это не совсем ясно, выбираю from WebSDK to WebSDK получается 0,21 руб./мин. - eyeofhell25.06.2015 13:42- Это не то чтобы очень распространенный вариант использования, обычно звонят все же либо на сотовые телефоны, либо с сотовых телефонов :).  - atrolov25.06.2015 14:50- Возможно, но не ответили, так бесплатно будет или за деньги?  - eyeofhell25.06.2015 17:40- Peer-to-peer подключения с некоторыми ограничениями бесплатны. Ограничения нужны, так как наша инфраструктура все равно используется для сигналинга, javascript в облаке. Для TURN серверов, если не получилось NATP Penetration. И мы бы не хотели, чтобы через нашу инфраструктуру бесплатно звонило пол интернета, нам-то она денег по количеству нагрузки :). Более подробно об ограничениях можно обсудить в привате. 
 
 
 
 
  - eyeofhell24.06.2015 16:01- Как коллега уже написал в топике, самое простое решение — peer-to-peer звонки всех ко всем с использованием webRTC. Нет проблем с перекодированием видео и адаптацией битрейта. Каждый участник в своем - <video> элементе со своим битрейтом. - negus24.06.2015 16:10- Мы разрабатываем приложения для конференций с десятками и сотнями участников, поэтому, как я написал выше, вариант P2P нам не подходит. 
 
 
 
           
 


Alerman
А у Вас предусмотрена возможность использования TURN сервера в случае если p2p между пользователями не работает? Пишут, что по статистике 14% пользователей не могут подключиться через p2p (у них NAT), им нужен relay сервер (TURN)
yaroslavb Автор
Да, у нас собственные TURN сервера, которые используются, если не получилось пробить NAT