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)
negus
24.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
atrolov
25.06.2015 11:32А если так получиться, что будет всегда WebRTC to WebRTC, тогда использование WebSDK будет бесплатно?
На странице цен это не совсем ясно, выбираю from WebSDK to WebSDK получается 0,21 руб./мин.eyeofhell
25.06.2015 13:42Это не то чтобы очень распространенный вариант использования, обычно звонят все же либо на сотовые телефоны, либо с сотовых телефонов :).
atrolov
25.06.2015 14:50Возможно, но не ответили, так бесплатно будет или за деньги?
eyeofhell
25.06.2015 17:40Peer-to-peer подключения с некоторыми ограничениями бесплатны. Ограничения нужны, так как наша инфраструктура все равно используется для сигналинга, javascript в облаке. Для TURN серверов, если не получилось NATP Penetration. И мы бы не хотели, чтобы через нашу инфраструктуру бесплатно звонило пол интернета, нам-то она денег по количеству нагрузки :). Более подробно об ограничениях можно обсудить в привате.
eyeofhell
24.06.2015 16:01Как коллега уже написал в топике, самое простое решение — peer-to-peer звонки всех ко всем с использованием webRTC. Нет проблем с перекодированием видео и адаптацией битрейта. Каждый участник в своем
<video>
элементе со своим битрейтом.negus
24.06.2015 16:10Мы разрабатываем приложения для конференций с десятками и сотнями участников, поэтому, как я написал выше, вариант P2P нам не подходит.
Alerman
А у Вас предусмотрена возможность использования TURN сервера в случае если p2p между пользователями не работает? Пишут, что по статистике 14% пользователей не могут подключиться через p2p (у них NAT), им нужен relay сервер (TURN)
yaroslavb Автор
Да, у нас собственные TURN сервера, которые используются, если не получилось пробить NAT