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

752a067e191646eb982f1b92ea4cab5b.jpgВидеоконференции через Skype уже давно заняли свое место в ежедневных коммуникациях, пользователи оценили удобство такого формата общения и все больше компаний стараются проводить встречи именно в этом формате. Но у скайпа есть большой минус: это отдельное приложение, которое трудно интегрировать в другой сервис. А сервисов, куда можно с пользой для дела встроить видеоконференции великое множество, начиная от систем бизнес-автоматизации и заканчивая сервисами группового обучения иностранному языку. Сегодня я покажу вам, как с помощью подручных средств и voximplant за 10 минут собрать движок видеоконференций, работающий прямо из браузера на webRTC и спозволяющий подключаться к конференции с обычных телефонов.

Voximplant использует профили пользователей, которые можно создавать с помощью HTTP API. Для демонстрации видеоконференции мы сделали небольшое приложение, которое по url-приглашению запрашивает имя участника, создает профиль пользователя и возвращает параметры аутентификации https://github.com/voximplant.

В отличие от звука, voximplant передает видео между участниками, peer-to-peer, что соответствует механике работы webRTC. Чтобы организовать конференцию, участникам необходимо сделать видео подключения друг к другу — это будет хорошо работать примерно до десяти пользователей, что с запасом покрывает большинство сценариев работы. А звук будет автоматически микшироваться стандартными механизмами voximplant. Для корректного микширования звука мы создадим две внутренние конференции: #1 для видеовызовов и #2 для участников с обычных телефонов:

761e6b7332a746a18efbe0eca006f405.png

Красные стрелки показывают аудио и видео потоки между участниками конференции в браузере, а синие стрелки показывают аудио-потоки для участников с телефонов. Одно из преимуществ 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.

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

f4c6308014fb4c54a0374c6a4fbed00a.jpg

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

© Habrahabr.ru