WebSocket — это прогрессивный стандарт полнодуплексной (двусторонней) связи между клиентом и сторонним сервисом в режиме реального времени. Веб-сокеты используются для организации непрерывного обмена данными без дополнительных HTTP-запросов.

И мы рады сообщить вам, что все это стало возможным в Voximplant благодаря новому модулю VoxEngine, который называется – сюрприз – WebSocket. Отныне вы сможете передавать текст и аудио, пользуясь преимуществами веб-сокетов в полной мере. Проще говоря, у вас появился еще один инструмент, чтобы прокачать ваше приложение.

Из этой статьи вы узнаете, как создать исходящее WebSocket-соединение, передать через него аудиопоток и преобразовать его в текст с помощью Google Cloud Speech-to-Text API.
Обратите внимание, что у Voximplant есть встроенная функциональность преобразования речи в текст в реальном времени под управлением модуля ASR. Данный модуль использует функции от Google, Yandex и Tinkoff, см. подробности здесь.

Текущая же статья описывает случай, когда вы хотите использовать сторонний сервис преобразования речи в текст и/или тратить средства с его аккаунта, а не с аккаунта Voximplant.
Начнем с того, что разберемся, какие бывают типы соединений и как их установить.

Есть два типа WebSocket-соединений:
  • исходящее;
  • входящее.

Исходящее


Первое, что вам нужно сделать при создании исходящего соединения — это запустить сценарий VoxEngine. Затем вызвать метод VoxEngine.createWebSocket, который создаст объект WebSocket. Данный метод принимает 2 параметра: URL в формате 'wss: // + domain + path' и protocols (опционально). Вот как это будет выглядеть в коде:

VoxEngine.addEventListener(AppEvents.CallAlerting, function(e) {
    const webSocket = VoxEngine.createWebSocket( /*url*/ "wss://your_link/");
    // You can handle an outgoing WebSocket connection here
});

Если все сработало, метод call.sendMediaTo направит аудиопоток в сокет, а WebSocket.send, в свою очередь, будет пересылать декодированный аудиопоток в формате JSON через него. В результате чего вам будут приходить сообщения от сервиса, обрабатывающего запросы.

Метод WebSocket.close нужен, чтобы закрыть соединение. Обратите внимание, что соединение может быть закрыто как со стороны клиента, так и со стороны сервера.

Входящее


Для того, чтобы разрешить входящие соединения, необходимо сообщить об этом через VoxEngine.allowWebSocketConnections, а также подписаться на событие AppEvents.WebSocket. После чего можно будет принять входящее соединение и получить объект WebSocket: event.WebSocket. Смотрите код ниже:

VoxEngine.allowWebSocketConnections();

VoxEngine.addEventListener(AppEvents.WebSocket, function(e) {
    // You can handle an incoming WebSocket connection here
});

Для создания входящего веб-сокета нужен управляющий accessSecureURL. Его можно взять из события AppEvents.Started или ответа на HTTP-запрос, который запустил сессию. Обратите внимание, что «https» должно быть изменено на 'wss' в URL.

Остальные шаги идентичны представленным в схеме исходящего соединения.

Вам понадобятся


Чтобы заимплементить технологию WebSocket и распознавание речи в ваше приложение, вам понадобятся:
  • аккаунт Voximplant. Если у вас его нет, смело регистрируйтесь по ссылке;
  • приложение Voximplant, а также сценарий, правило и один пользователь. Все это будет создано в данном туториале;
  • простой бэкенд (мы запустим сервер на node.js) с подключенным Cloud client library для Speech-to-Text API;
  • веб-клиент для совершения звонка (мы будем использовать вебфон на phone.voximplant.com).

1. Настройки VOXIMPLANT


Для начала авторизуйтесь в вашем аккаунте на manage.voximplant.com/auth. В меню слева нажмите «Приложения», затем «Новое приложение» и создайте приложение с именем websocket. Зайдите в ваше приложение, переключитесь на вкладку «Сценарии», создайте сценарий и вставьте в него следующий код:

require(Modules.WebSocket);

VoxEngine.addEventListener(AppEvents.CallAlerting, function(e) {
    call = e.call;
    call.answer();

    const webSocket = VoxEngine.createWebSocket( /*url*/ "wss://your_ngrok_link/");

    webSocket.addEventListener(WebSocketEvents.ERROR, function(e) {
        Logger.write("LOG OUTGOING: WebSocketEvents.ERROR");
        call.sendMessage("LOG OUTGOING: WebSocketEvents.ERROR");
    });
    webSocket.addEventListener(WebSocketEvents.CLOSE, function(e) {
        Logger.write("LOG OUTGOING: WebSocketEvents.CLOSE: " + e.reason);
        call.sendMessage("LOG OUTGOING: WebSocketEvents.CLOSE: " + e.reason);
    });
    webSocket.addEventListener(WebSocketEvents.OPEN, function(e) {
        Logger.write("LOG OUTGOING: WebSocketEvents.OPEN");
        Logger.write(JSON.stringify(e))
        call.sendMessage("LOG OUTGOING: WebSocketEvents.OPEN");
    });
    webSocket.addEventListener(WebSocketEvents.MESSAGE, function(e) {
        Logger.write("LOG OUTGOING: WebSocketEvents.MESSAGE: " + e.text);
        call.sendMessage("LOG OUTGOING: WebSocketEvents.MESSAGE: " + e.text);
        if (e.text == "Hi there, I am a WebSocket server") {
            call.sendMediaTo(webSocket, {
                encoding: WebSocketAudioEncoding.ULAW,
                "tag": "MyAudioStream",
                "customParameters": {
                    "param1": "12345"
                }
            });
        }
    });

    call.addEventListener(CallEvents.Disconnected, function(e) {
        Logger.write("LOG OUTGOING: terminating in 1 sec");
        webSocket.close();
        setTimeout(VoxEngine.terminate, 1000);
    });
});

Этот сценарий VoxEngine отправляет аудиопоток в WebSocket, а также отслеживает его события (ERROR, CLOSE, OPEN, MESSAGE). Углубиться в детали сценария мы сможем немного позже.

А сейчас давайте перейдем на вкладку «Роутинг», нажмем «Новое правило» и назовем его socketRule. Теперь осталось лишь выбрать ваш сценарий, а маску оставить по умолчанию ( .* ).


Последнее, что требуется сделать на данном этапе – создать пользователя. Переключитесь на вкладку «Пользователи», нажмите «Создать пользователя», укажите имя (например, socketUser) и пароль, затем кликните «Создать». Эта пара логин-пароль понадобится нам для аутентификации в веб-клиенте на последнем шаге.


Конфигурация завершена, но прежде чем приступить к созданию сервера, давайте рассмотрим, как работает модуль WebSocket в нашем сценарии.

2. Детали сценария


Модуль WebSocket позволяет разработчикам открывать устойчивое соединение и отправлять через него данные. Чтобы использовать этот модуль, мы должны подключить его в самом начале сценария:

require(Modules.WebSocket);

Через метод createWebSocket мы определяем URL-адрес и protocols (опционально). Вы можете узнать, как получить URL для WebSocket из следующего раздела.

const webSocket = VoxEngine.createWebSocket( /*url*/ "wss://your_ngrok_link/");

После создания объекта WebSocket мы продолжаем управление звонком внутри обработчика. А именно, отправляем медиа объекту WebSocket с помощью метода call.sendMediaTo.

Здесь можно установить предпочтительный формат кодировки, тэг и некоторые кастомные параметры. Если вы не установите кодировку, по умолчанию будет использоваться PCM8.

Данный метод вызывается, когда приходит сообщение об успешном соединении. В нашем сценарии код вызова выглядит так:

call.sendMediaTo(webSocket, {
    encoding: WebSocketAudioEncoding.ULAW,
    "tag": "MyAudioStream",
    "customParameters": {
        "param1": "12345"
    }
});

Все остальные события WebSocket, которые вы видите в коде, предназначены для отладки. Соответствующие обработчики ничего не делают, кроме отправки сведений в лог Voximplant-сессии. Вы можете убрать их, если хотите.

Наконец, мы добавляем правильный обработчик завершения передачи данных. В нашем случае сессия Voximplant закончит свою работу через 1 секунду после завершения установленного вызова (Disconnected):

call.addEventListener(CallEvents.Disconnected, function(e) {
    Logger.write("LOG OUTGOING: terminating in 1 sec");
    webSocket.close();
    setTimeout(VoxEngine.terminate, 1000);
});

Теперь, когда логика сценария ясна, пришло время перейти к следующей, очень важной части нашего примера.

3. Backend


Во-первых, убедитесь, что на вашем компьютере установлен Node.js. В противном случае перейдите на основной сайт Node.js и скачайте его.
Затем выполните следующие команды в окне терминала одну за другой, чтобы настроить рабочую среду:

npm install express
npm install ws
npm install @google-cloud/speech

А когда это будет сделано, создайте пустой JS-файл и поместите туда следующий код (нюансы кода будут освещены ниже):

const app = require('express')();
const http = require('http').createServer(app);
const WebSocket = require('ws');
const fs = require('fs');

const wss = new WebSocket.Server({
    server: http
});

// Импортируем клиентскую библиотеку для Google Cloud
const speech = require('@google-cloud/speech');

// Создаем инстанс клиента
const client = new speech.SpeechClient();

const config = {
    encoding: 'MULAW',
    sampleRateHertz: 8000,
    languageCode: 'ru-RU',
};

const request = {
    config,
    interimResults: true,
};

let audioInput = [];
let recognizeStream = null;
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0;

app.get('/', function(req, res) {
    res.send('<h1>Hello world</h1>');
});

wss.on('connection', (ws) => {
    // Создаем поток, позволяющий сохранить переданные данные в файл
    var wstream = fs.createWriteStream('myBinaryFile');
    // Очищаем текущий audioInput
    audioInput = [];
    // Инициируем распознавание аудиопотока
    recognizeStream = client
        .streamingRecognize(request)
        .on('error', err => {
            ws.close();
        })
        .on('data', data => {
            ws.send(data.results[0].alternatives[0].transcript)
            process.stdout.write(
                data.results[0] && data.results[0].alternatives[0] ?
                `Transcription: ${data.results[0].alternatives[0].transcript}\n` :
                `\n\nError occurred, press Ctrl+C\n`
            )
        });

    ws.on('close', (message) => {
        console.log('The time limit for speech recognition has been reached. Please disconnect and call again.');
        wstream.end();
    })
    // Соединение установлено, добавляем логику для события message
    ws.on('message', (message) => {
        // Помещаем аудио в формате base64 в recognizeStream
        try {
            let data = JSON.parse(message)
            if (data.event == "media") {
                let b64data = data.media.payload;
                let buff = new Buffer.from(b64data, 'base64');
                recognizeStream.write(buff);
                wstream.write(buff);
            }
        } catch (err) {
            console.log(message)
        }
    });
    // Отправляем уведомление об установке соединения 
    ws.send('Hi there, I am a WebSocket server');
});

http.listen(3000, function() {
    console.log('listening on *:3000');
});

Теперь, когда сервер настроен, он поможет нам выполнить распознавание речи. Протестируйте свое решение локально, создав туннель на localhost 3000 с помощью ngrok.

Для этого выполните следующие действия:
  1. Установите ngrok, следуя инструкциям на его сайте.
  2. Укажите свой authtoken для ngrok, чтобы привязать клиента к этой учетной записи.
  3. Выполните команду node your_file_name.js, чтобы запустить ваш сервер на localhost: 3000.
  4. Перейдите в папку ngrok на вашем компьютере и выполните команду ./ngrok http 3000, чтобы сделать туннель между работающим локальным сервером и публичным URL.

Обратите внимание на сгенерированный публичный URL, мы используем его в качестве WebSocket URL с префиксом 'wss' в сценарии:



4. Распознавание речи


Вы наверняка заметили, что код нашего бэкенда содержит строки, относящиеся к Google Cloud.

Сама библиотека импортируется следующим образом:

const speech = require('@google-cloud/speech');

Теперь вам нужно указать, как обрабатывать запрос на распознавание речи. Для этого выберите encoding, sampleRateHertz и languageCode в создаваемом config:

const config = {
    encoding: 'MULAW',
    sampleRateHertz: 8000,
    languageCode: 'en-US',
};

Далее, создайте поток на запись, позволяющий сохранить переданные данные в файл:

var wstream = fs.createWriteStream('myBinaryFile');

Когда все настроено, необходимо распарсить сообщение и поместить аудиоданные в формате base64 в recognizeStream:

let data = JSON.parse(message)
if (data.event == "media") {
    b64data = data.media.payload;
    let buff = new Buffer.from(b64data, 'base64');
    recognizeStream.write(buff);
    wstream.write(buff);
}

Сразу после этого будет инициирован запрос на распознавание и начнется обработка этого запроса:

recognizeStream = client
    .streamingRecognize(request)
    .on('data', data => {
        ws.send(data.results[0].alternatives[0].transcript)
    });

И наконец, предоставьте учетные данные вашего service account, чтобы подключить библиотеку Google к ее бэкенду. Для этого перейдите на страницу аутентификации Google и выполните все шаги, перечисленные там. Затем запустите команду экспорта в той же рабочей области (в той же вкладке «Терминал»), что и команду node your_file_name.js:

export GOOGLE_APPLICATION_CREDENTIALS="/home/user/Downloads/[FILE_NAME].json"

Запускаем сокеты


Откройте phone.voximplant.com, заполните форму и нажмите Sign in?. Username и password относятся к пользователю, созданному на шаге 1:


После успешной авторизации нажмите Call? и начните говорить. Облако Speech-to-Text будет преобразовывать вашу речь в текст в режиме реального времени, и вы сможете увидеть этот текст в окне терминала.

Вы справились, поздравляем! Надеемся, что статья пришлась вам по нраву, и желаем больше интеграций и новых челленджей.

Комментарии (9)


  1. namikiri
    25.11.2019 16:44

    декодированный аудиопоток в формате JSON

    Простите, что? Я всё правильно понял — вы заворачиваете бинарные данные в JSON, неважно каким способом? Что мешает посылать напрямую бинарные данные? Зачем так жёстко греть процессор?


    1. nvpushkarskiy2
      25.11.2019 17:57

      Работать с текстовыми данными проще + их поддерживает большее количество серверных библиотек. К тому же Base64 обычно либо реализован железно, либо неплохо оптимизирован на SIMD-инструкциях.

      Ну и да, таким образом мы можем слать несколько аудиоканалов (например, по одному каналу от каждого из участников) в одном потоке, вместо того чтобы использовать бинарный протокол, от которого веб-разработчики часто не в восторге.


      1. namikiri
        26.11.2019 10:29

        от которого веб-разработчики часто не в восторге

        Веб-разработчики и от чистого JS часто не в восторге, а потом получаем пейсбук, посетив которой можно отопить процессором как минимум свою комнату.


  1. RSATom
    26.11.2019 09:19
    +1

    Может оффтопик конечно: С Google Speech To Text API есть нюанс, нельзя просто создать recognizeStream и долго долго слать туда аудио поток — через минуту все сломается, т.к. API имеет лимиты на максимальную длительность аудио потока, как раз в эту самую минуту (несмотря на то что в документации указан лимит в 5 минут, я получал ошибку через 1 минуту, уж не знаю почему так). В связи с чем приходится сильно усложнять логику работы с API, а именно буферизировать аудио пакеты и отслеживать какие из них уже были распознаны, а какие нет. Лично у меня только раза с 3-го получилась рабочая версия, действительно умеющая работать с бесконечным аудио потоком.


    1. imaximova Автор
      26.11.2019 15:23
      +1

      Да, вы абсолютно правы, у гугла есть такой дедлайн. Мы здесь его не учитываем, поскольку цель была показать работу сокетов на простом примере, а для этого минуты в принципе достаточно. Но спасибо за дополнение, будет полезно всем, кто заинтересуется подробнее!


  1. sguslya
    27.11.2019 17:53

    У меня два вопроса
    Зачем все так сложно?
    Почему в twilio все так просто?


    1. imaximova Автор
      27.11.2019 18:12
      +1

      Действительно ли в Twilio все так просто: www.twilio.com/blog/live-transcribing-phone-calls-using-twilio-media-streams-and-google-speech-text ?)
      А вообще, если хотите проще, попробуйте модуль ASR. Об этом есть пометка в начале статьи.


      1. sguslya
        27.11.2019 18:15
        +2

        О ASR выглядит для меня лучше, спасибо!


  1. sguslya
    27.11.2019 18:15

    deleted