Делаем видео-конференции в браузере за 10 минут

  • Tutorial
Видеоконференции через 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 to 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, в маске указываем телефонный номер конференции, в сценарии указываем “VideoConferencePSTNgatekeeper”
  • InboundCall, в маске указываем строку “joinconf” (это номер, который мы будем набирать из Web SDK при подключении к конференции), в сценарии указываем “VideoConferenceGatekeeper”
  • Fwd, в маске указываем строку “conf_[A-Za-z0-9]+”, в сценарии указываем “VideoConference” — это правило будет срабатывать при звонке в конференцию через “callConference”.
  • P2P, маску оставляем “.*”, в сценарии указываем “VideoConferenceP2P”

Порядок расположения правил важен! Для перетаскивания (изменения приоритета) можно использовать drag'n'drop.

В результате настройки правила для приложения должны выглядеть вот так:


Это все, что нужно настроить в облаке. Frontend часть сервиса делается с помощью нашего web sdk и довольно проста. После подключения нужно совершить звонок на “joinconf” и передать в заголовке “conferenceid”. Когда пользователь становится участником конференции, в событии MessageReceived он получат список веб-клиентов и можно инициировать исходящие peer-to-peer звонки с помощью сценария “P2P” для получения видео от тех клиентов, к которым еще нет подключений. для включения именно P2P-режима передается специальный хедер “X-DirectCall” в методе “call”. Также Frontend часть размещает на экране прямоугольники видеотрансляций и позволяет пригласить участника исходящим звонком из сценария конференции. Исходный код всех сценариев и клиентского приложения доступен на нашем GitHub-аккаунте.
Voximplant
109,18
Облачная платформа голосовой и видеотелефонии
Поделиться публикацией

Комментарии 18

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

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

      Спасибо!
        0
        Если видео не идет через наш сервер (то есть peer-to-peer), то это будет бесплатно: voximplant.com/pricing
          0
          Мои вопросы как раз о ситуации когда видео идет через сервер.
            +1
            Режим подключения многих видео-участников к серверу (MCU) пока не реализован, поэтому и цена на него отсутствует.
            0
            А если так получиться, что будет всегда WebRTC to WebRTC, тогда использование WebSDK будет бесплатно?
            На странице цен это не совсем ясно, выбираю from WebSDK to WebSDK получается 0,21 руб./мин.
              0
              Это не то чтобы очень распространенный вариант использования, обычно звонят все же либо на сотовые телефоны, либо с сотовых телефонов :).
                0
                Возможно, но не ответили, так бесплатно будет или за деньги?
                  0
                  Peer-to-peer подключения с некоторыми ограничениями бесплатны. Ограничения нужны, так как наша инфраструктура все равно используется для сигналинга, javascript в облаке. Для TURN серверов, если не получилось NATP Penetration. И мы бы не хотели, чтобы через нашу инфраструктуру бесплатно звонило пол интернета, нам-то она денег по количеству нагрузки :). Более подробно об ограничениях можно обсудить в привате.
            0
            Как коллега уже написал в топике, самое простое решение — peer-to-peer звонки всех ко всем с использованием webRTC. Нет проблем с перекодированием видео и адаптацией битрейта. Каждый участник в своем
            <video>
            элементе со своим битрейтом.
              +1
              Да, где-то так оно и происходит.
                0
                Мы разрабатываем приложения для конференций с десятками и сотнями участников, поэтому, как я написал выше, вариант P2P нам не подходит.
              +1
              Вижу Григория в конференции :)
                0
                Мда, засветился :)
                  +1
                  Справа-сверху? )
                    0
                    Справа-сверху у нас arbitrary video stream. Не зря в облаке сидит javascript — коммутировать можно всякое разное, нужным клиенту образом.
                      0
                      Я догадался, но от юмора не смог удержаться )
                      Спасибо, отличный сервис делаете!

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое