Видеоконференции через 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)


  1. Alerman
    24.06.2015 12:45

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


    1. yaroslavb Автор
      24.06.2015 15:54

      Да, у нас собственные TURN сервера, которые используются, если не получилось пробить NAT


  1. negus
    24.06.2015 13:42

    Спасибо за информацию, посмотрим VoxImplant :-)

    P2P с видео может хорошо работать только для 5-6 и менее участников и подходит больше для демонстрации технологии. Из информации на вашем сайте следует, что вы также предлагаете ли функциональность WebRTC Media Server для большей масштабируемости, в связи с чем несколько вопросов:
    — Как расчитывается цена 1 цент за минуту — это одна минута на подписчика? Т. е. для one-to-many с 4-мя подписчиками надо умножить на 4, а для видеоконференции с тем же числом участников — еще на 4?
    — Как вы решаете проблему разного качества интернет-соединения участников при доставке потока одного ко многим? Битрейт определяется по самому «слабому» подписчику или же есть поддержка адаптивного битрейта или динамически изменяемой частоты кадров для каждого участника?

    Спасибо!


    1. yaroslavb Автор
      24.06.2015 15:58

      Если видео не идет через наш сервер (то есть peer-to-peer), то это будет бесплатно: voximplant.com/pricing


      1. negus
        24.06.2015 16:04

        Мои вопросы как раз о ситуации когда видео идет через сервер.


        1. aylarov
          24.06.2015 18:54
          +1

          Режим подключения многих видео-участников к серверу (MCU) пока не реализован, поэтому и цена на него отсутствует.


      1. atrolov
        25.06.2015 11:32

        А если так получиться, что будет всегда WebRTC to WebRTC, тогда использование WebSDK будет бесплатно?
        На странице цен это не совсем ясно, выбираю from WebSDK to WebSDK получается 0,21 руб./мин.


        1. eyeofhell
          25.06.2015 13:42

          Это не то чтобы очень распространенный вариант использования, обычно звонят все же либо на сотовые телефоны, либо с сотовых телефонов :).


          1. atrolov
            25.06.2015 14:50

            Возможно, но не ответили, так бесплатно будет или за деньги?


            1. eyeofhell
              25.06.2015 17:40

              Peer-to-peer подключения с некоторыми ограничениями бесплатны. Ограничения нужны, так как наша инфраструктура все равно используется для сигналинга, javascript в облаке. Для TURN серверов, если не получилось NATP Penetration. И мы бы не хотели, чтобы через нашу инфраструктуру бесплатно звонило пол интернета, нам-то она денег по количеству нагрузки :). Более подробно об ограничениях можно обсудить в привате.


    1. eyeofhell
      24.06.2015 16:01

      Как коллега уже написал в топике, самое простое решение — peer-to-peer звонки всех ко всем с использованием webRTC. Нет проблем с перекодированием видео и адаптацией битрейта. Каждый участник в своем

      <video>
      элементе со своим битрейтом.


      1. yaroslavb Автор
        24.06.2015 16:01
        +1

        Да, где-то так оно и происходит.


      1. negus
        24.06.2015 16:10

        Мы разрабатываем приложения для конференций с десятками и сотнями участников, поэтому, как я написал выше, вариант P2P нам не подходит.


  1. RoboForm
    24.06.2015 14:20
    +1

    Вижу Григория в конференции :)


    1. eyeofhell
      24.06.2015 15:59

      Мда, засветился :)


    1. LeonidZ
      24.06.2015 23:17
      +1

      Справа-сверху? )


      1. eyeofhell
        25.06.2015 08:40

        Справа-сверху у нас arbitrary video stream. Не зря в облаке сидит javascript — коммутировать можно всякое разное, нужным клиенту образом.


        1. LeonidZ
          25.06.2015 09:12

          Я догадался, но от юмора не смог удержаться )
          Спасибо, отличный сервис делаете!