Страх и ненависть в переговорке: курим VideoSDK API, Vosk и Python

16300e5e60cf6552b6f179be8ae7ded2.png

Каждый год — новая полоса препятствий для человечества. И IT не исключение, а полноценный участник этого забега, особенно в нашей стране.

Серьёзные испытания для совместной работы и коммуникаций начались в 2019: коронавирус (он, между прочим, ещё вроде как есть) показал, насколько сложно переходить от офисов и аудиторий к удалённой работе и учёбе. В 2022 иностранные IT-гиганты ушли из России и прихватили с собой известные продукты и сервисы, не раз выручавшие всех нас. Тем не менее, количество пользователей видеосвязи во всём мире постоянно растёт, да и переговорные комнаты никуда не делись вопреки трендам на удалёнку и работу с импровизированных рабочих мест. Так или иначе мы адаптировались под новые условия. А вот адаптировать эти условия под себя — задача не из простых.

Сегодня поговорим о кастомных решениях для видеоконференцсвязи (далее — ВКС) с минимальными затратами человеко-часов и финансов на их создание. Я параноик Брать готовый open-source — меня не устраивает, всем известны случаи встраивания bad code в проекты с открытым исходным кодом с целью нанести ущерб пользователям из России. Поэтому за основу берём что-то отечественное с корпоративным уклоном, с открытым API и подходом «без регистрации и смс». 

Меня зовут Антон Бааджи и сегодня на примере Python, Vosk и TrueConf VideoSDK я покажу, как можно адаптировать корпоративный продукт под себя. Неважно, чем вы занимаетесь —  администрированием сетей видеосвязи или разработкой ПО, главное, чтобы вас, как и меня, не устраивало текущее положение дел. Поехали!

VideoSDK — это видео движок для создания кастомных решений для встраиваемых систем (видеокиосков, терминалов самообслуживания), который не имеет своего графического интерфейса для управления. Сам по себе движок не является готовым решением под ключ, но выполняет роль инструмента для интеграции со сторонними приложениями и управления с помощью API. Варианты его применения также включают в себя и переговорные комнаты, и один из примеров ниже это покажет.

Список используемых модулей

  • pyVideoSDK;

  • websocket-client;

  • orjson, просто потому что он быстрее json (тут и тут);

  • queue — для создания очередей;

  • Levenshtein — вычисление сходства строк;  

  • vosk — распознавание речи (speech-to-text);  

  • pyttsx3 — генерация речи из текста для Windows;

  • pyfestival — генерация речи из текста для Linux;

  • sounddevice — захват звука с микрофона и вывода звука на динамики;

  • soundfile — чтение звуковых (WAV) файлов.

Как это работает?

VideoSDK управляется API по обыкновенной схеме запрос-ответ. Для отправки запросов и получения ответов используется WebSocket, по которому осуществляется обмен сообщениями в формате JSON.

Описание запросов и их ответов доступно в документации API TrueConf VideoSDK. Исходя из нее мы будем именовать запросы — командами, а ответы — уведомлениями

Команды

Для выполнения какого-либо действия нужно послать команду (запрос):

  • accept — принять вызов;

  • call — позвонить пользователю или послать запрос на участие в групповой конференции;

  • getAbook — получить адресную книгу.

Уведомления

Уведомления делятся на два типа:

  1. Ответы на запрос. 

  2. Уведомления, генерируемые в следствии наступления какого-то события.  

Ответ на запрос будет содержать поле method с названием команды и поле result, которое в свою очередь будет содержать true либо, в других случаях, результат выполнения команды. Если произошла ошибка выполнения команды (внутренняя или некорректный запрос), result будет содержать false и дополнительное поле error с текстовым описанием ошибки.

Пример ответа

{ 
"method" : "getAbook", 
"result" : true,
 "abook":[{данные пользователей из адресной книги}]
}

Уведомление будет содержать поле method : "event" и поле event с названием произошедшего события.

Пример уведомления

{
"event": "audioCapturerMute",
"mute": false,
"method": "event"
}

Разработчиками VideoSDK подготовлены библиотеки для взаимодействия с ним на Python (pyVideoSDK) и на С++ (videosdk). То есть вам не понадобится писать свою обертку для отправки/получения с API. Удобно, правда? Все примеры в статье будут показаны на Python.

Получение авторизационных данных

В @TrueConfSDKPromoBot нужно нажать кнопку Free Accounts. Вам будет выдан один основной аккаунт и два дополнительных. Free Accounts with QR выдаст эти же данные, но с QR-кодом. Его можно показать в камеру VideoSDK и он автоматически авторизуется на тестовом сервере от разработчиков. VideoSDK воспроизведет звуковое уведомление, если считывание и авторизация прошли успешно.

9095fd545e7faeeac5ee626679c062e5.png

Как запускать VideoSDK и где взять его IP?

Всегда в первую очередь нужно запускать VideoSDK с параметром --pin, а потом уже запускать код. Так происходит, потому что, скрипт подключается (создает сессию) к запущенному экземпляру VideoSDK. В качестве пина можно использовать любую строку.

Windows:
"C:\Program Files\TrueConf\VideoSDK\VideoSDK.exe" --pin 123

Linux:
trueconf-videosdk --pin 123

После этого на экране у вас отобразится окно VideoSDK c данными для подключения (IP:port). 

a416d1dc5bd48e74ba015a90f4d00b60.png

Начало

В своих примерах я использую отдельный файлconfig.pyс настройками подключения и авторизации.

# Connection settings
IP: str = "192.168.1.5"
PORT: int = 88
PIN: str = "123"
DEBUG: bool = False # Write more debug information to the console and to the log-file

# Authorization settings
# IP or DNS TrueConf Server
TRUECONF_SERVER: str = "connect.trueconf.com" 

# TrueConf ID of administrator, operator or user
TRUECONF_ID: str = "name@connect.trueconf.com"

# TrueConf ID of administrator, operator or user
PASSWORD: str = "MyVeryStrongPassword"

Помимо настроек в этом файле есть еще список слов для голосовых команд, словарь для преобразования слов в числа и фразы для обратной связи VideoSDK с пользователем, а также класс для воспроизведения звуковых уведомлений.

Импорт модулей

Пакет pyVideoSDK состоит из нескольких составляющих модулей:

  • основной модуль init.py. Создает сессию на websocket, устанавливает соединение, производит логирование, регистрирует пользовательские хендлеры.

    import pyVideoSDK

  • мethods.py. В этом модуле все API-команды инкапсулированы в методы класса Methods для удобной работы с ними.

    from pyVideoSDK.methods import Methods

  • consts.py. Здесь описаны все наименования команд и уведомлений, а также их параметры.

    from pyVideoSDK.consts import EVENT, METHOD_RESPONSE

    import pyVideoSDK.consts as C

Также импортируем конфигурационный модуль:

import config

Инициализация объектов

Для работы с VideoSDK, создайте экземпляр класса VideoSDK используя функцию open_session из модуля pyVideoSDK:

sdk = pyVideoSDK.open_session(ip = config.IP, port = config.PORT, pin = config.PIN, debug = config.DEBUG)

Функция создаст экземпляр, установит соединение, и вернет объект для дальнейшей работы.

Аналогично этому создайте экземпляр класса для работы с командами, передав ему переменную sdk:

methods = Methods(sdk)

Такая вот небольшая подготовительная работа. Дальше я покажу, как надо зарегистрировать обработчик (хендлер) событий для уведомлений и команд.

Как понять, что происходит?

Каждая команда после выполнения, а также уведомления присылают ответ в формате JSON. Чтобы отловить конкретное событие из всего потока уведомлений, нужно зарегистрировать хендлер. 

Хендлер (обработчик) — это функция, которая вызывается какой-либо программной системой в ответ на наступление какого-либо события.

Чтобы зарегистрировать хендлер, нужно обернуть функцию-обработчик декоратором:

@sdk.handler(type_of_response[C.name_of_response])
def on_getSomeInfo():
    pass

type_of_response — это один из двух типов событий:  

  • METHOD_RESPONSE — ответ API на вызванный на выполнение команды;

  • EVENT — уведомление об изменении состояния в работе VideoSDK.

C.name_of_response — имя обрабатываемого события. Имена событий отличаются по префиксу, для method_response — M, а для event-ов — EV:

@sdk.handler(METHOD_RESPONSE[C.M_getMonitorsInfo])
@sdk.handler(EVENT[C.EV_appStateChanged])

Декорированная функция всегда должна принимать один позиционный параметр, назовем его response

@sdk.handler(type_of_response[C.name_of_response]) 
def on_getSomeInfo(response):
    pass

После добавления обработчика можно выполнить команду. Для примера приведен код получения информации о мониторах:  

@sdk.handler(METHOD_RESPONSE[C.M_getMonitorsInfo])
def on_getMonitorInfo(response):
	print(response['currentMonitor'])

### SOME OF YOUR CODE

if __name__ == '__main__':
	methods.getMonitorsInfo()
	sdk.run()

Метод run() держит предварительно созданную сессию открытой, пока есть подключение к VideoSDK (см. раздел Инициализация объектов выше). На самом деле здесь все просто. В методе крутится бесконечный цикл и каждую 0,2 секунды проверяет соединение.

def run(self):
    print("\nPress Ctrl+c for exit.\n")
    try:
        while True:
            if not self.isConnected():
                break
            time.sleep(0.2)
    except KeyboardInterrupt:
        print('Exit by Ctrl + c')
    except CustomSDKException as e:
        print('VideoSDK error: {e}')

Итак, мы разобрались как добавлять обработчики и вызывать команды, ниже мы рассмотрим несколько «боевых» примеров.

Примеры использования

Авторизация на сервере

Авторизоваться на сервере можно, если мы к нему подключены. Поэтому нужно отловить уведомление serverConnected, которое возникает только при успешном присоединении к серверу, и выполнить команду login c параметрами login и password.

@sdk.handler(EVENT[C.EV_serverConnected])
def on_serverConnected(response):
    """Need to login"""
    methods.login(config.TRUECONF_ID, config.PASSWORD)

Автоматический перенос окна с контентом на второй монитор 

Предположим, что наша переговорная комната участвует в конференции и один из пользователей показывает контент в отдельном потоке. Тогда, если у нас подключено два экрана, очень удобно выводить окно с показом контента сразу на второй (не занятый окнами из конференции) дисплей. В этом поможет тот факт, что такой контентный поток будет помечен особым тегом »#contentSharing». Для переноса видеоокна подойдет команда moveVideoSlotToMonitor с двумя параметрами:

  • callId — идентификатор слота (окна), который нужно вынести;

  • monitorIndex — числовой индекс монитора.

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

  1. Выполнить команду getMonitorsInfo (ранее рассматривалась в данной статье).

  2. Проверить чему равно поле currentMonitor.

  3. Вывести слот на противоположный.

@sdk.handler(METHOD_RESPONSE[C.M_getMonitorsInfo])
def on_getMonitorInfo(response):
    global IndexSlideshowMonitor
    for monitor in response['monitors']:
        if monitor['index'] == response['currentMonitor']:
            continue
        IndexSlideshowMonitor = monitor['index']
        break
@sdk.handler(EVENT[C.EV_videoMatrixChanged])
def on_moveVideoSlotToMonitor(response):
    methods.getMonitorsInfo()
    for i in response["participants"]:
        if '#contentSharing' in i['peerId']:
            methods.moveVideoSlotToMonitor(callId=i['peerId'], monitorIndex=IndexSlideshowMonitor)
            break

a60fa828c08ccafe3bff263d1afca31f.gif

Голосовой вызов абонента

Весь код я расписывать не буду, только логику. Для голосового распознавания я использую Vosk API, т.к. он работает оффлайн. Я думаю, все понимают как работает голосовой вызов, например, в том же Google Assistant или Siri. Нам нужно:

  1. Получить список абонентов. 

  2. Распознать речь и отделить команду Набери от ее параметров Ивана Петрова.

  3. Сопоставить введенные ФИО с адресной книгой из VideoSDK.

  4. Вызвать найденного абонента по его логину (в терминологии производителя решения TrueConf ID).

Получение списка абонентов

Для получения списка абонентов я выполнил команду getABook, «словил» уведомление с помощью функции on_getAbook и сохранил полученные данные в словарь Abook в формате ключ (peerDn, отображаемое имя, DisplayName) : значение (peerId, TrueConf ID).

on_getAbook

@room.handler(METHOD_RESPONSE[C.M_getAbook])
def on_getAbook(response):
    global Abook
    Abook = {}
    for user in response['abook']:
        try:
            Abook[user['peerDn']] = user['peerId']
        except IndexError:
            continue

Использование глобальных переменных считается плохой практикой, но иногда это оправдано. Выходит, что мне нужно сохранить список абонентов в словарь для дальнейшего использования. Функция, которая выполняет это действие, отдекорирована хендлером, который принимает только один параметр, что в свою очередь накладывает  ограничения — мы не можем вызвать нашу функцию, и передать в нее «кастомные» аргументы. Хендлер работает как коллбек. Было создано событие (getABook) — отработала функция, события нет — функция «спит». 

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

Структура словаря Аbook

{'Алексей Клинц': 'klintz@example.server.com',
 'Алиса Лесова': 'elisa@example.server.com',
 'Анастасия Лебедева': 'lebedeva@example.server.com',
 'Андрей Ковалев': 'kovalev@example.server.com',
 'Анна Швец': 'shvets@example.server.com',
 'Борис Макаров': 'makarov@example.server.com',
 'Виктория Листьева': 'listeva@example.server.com'}

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

Как было уже написано, я использую Vosk для распознавания речи. Про него достаточно много написано информации, например, тут на Хабре, поэтому сильно углубляться не будем. Но некоторые моменты затрону:  

  1. Vosk сильно привередлив к звуку и постороннему шуму. Поэтому в идеале нужно организовать в помещении звукоизоляцию или внедрить шумоподавление для улучшения распознавания голосового ввода.

  2. Мы используем русскоязычную облегченную модель, которая подходит для Android/iOS и RPi. Эта модель не требовательна к ресурсам и может работать на устройствах с VideoSDK. Но точность распознавания речи по сравнению с полной моделью может быть снижена. Не популярные имена и фамилии, а также окончания слов распознаются плохо.

Сопоставление данных

Т.к. используемая облегченная модель неидеально распознает некоторые окончания в русском языке, то нужно решать и эту проблему. Дополнительная сложность заключалась в том, что я не знал, какие данные могут быть в адресной книге. Метод getABook возвращает отображаемое имя (DisplayName), оно имеет ограничение в 64 символа и по сути может быть любым. Из-за этого нельзя просто проверить, находится ли ключ (ФИО) в словаре и вызвать абонента с таким отображаемым именем по логину (TrueConf ID).

Я подумал: «А что если сравнивать строки на схожесть?». Было принято использовать алгоритм неточного сравнения строк — Сходство Джаро-Винклера. Что это и какие алгоритмы существуют, можно почитать на Хабре. Для Python существует модуль, в котором реализован алгоритм Джаро — Винклера. Он называется Levenshteinи его также надо не забыть импортировать через import Levenshtein.

def contact_diff(response):
	match_list = {}
for name in Abook:
match_list[name] = Levenshtein.jaro_winkler(response, name)
return max(match_list, key=buf.get,default=0)

Отображаемое имя может иметь вид:  

  1. Иванов Владимир

  2. Иванов Владимир Андреевич 

  3. 999–000

  4. +79081234567

  5. любая другая строка не больше 64 символов. 

Vosk числа распознает отлично, но в результате он их записывает строкой, а не цифрами. Например, фразу Набери 123-456-789 он распознает как Набери один два три четыре пять шесть семь восемь девять или как Набери сто двадцать три четыреста пятьдесят шесть семьсот восемьдесят девять, в зависимости от способа произношения фразы. И для алгоритма Джаро-Винклера это имеет значение. Поэтому потребовалось дописать функцию проверки строкового числа и перевода его в цифровое представление. 

def text_to_number(text_with_number: tuple):
    numbers = config.NUMERIC[config.LANG]
    return "".join([str(numbers[i]) if i in numbers else i for i in text_with_number])

Далее, в процессе тестирования стало ясно, что некоторые фамилии Vosk делит на части, предполагая, что это разные слова. 

Пример распознавания некоторых фамилий

Дугинец → ['дуги', 'нет']
Мастеровенко → ['мастера', 'венка']
Якупов → ['я', 'кубов']

Поэтому пришла мысль конкатенировать все аргументы, которые идут после команды набора (в примере используются ключевые слова «набери» и «позвони»). В итоге получилось так:  

Николай Мастеровенко → ['николай', 'мастера', 'венка'] → 'николаймастеравенка'

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

Результат распознавания Vosk всегда возвращает в нижнем регистре, в свою очередь у отображаемого имени (DispayName) нет каких-либо требований на этот счет. Пользователь может задать себе имя, например, С0нЯ КОшкин@. Конечно, в корпоративной среде так делать никто не будет, но мы пишем универсальный код, который будет работать в разных ситуациях и результат не будет меняться. Смотрим — чувствителен ли алгоритм Джаро-Винклера к регистру? И таки да, чувствителен. 

Пример схожести строк в разном регистре

борисмакаров --- БорисМакаров = 0.888888888888889
борисмакаров --- борисмакаров = 1.0

Процент совпадения, как вы видите, меняется аж на целых 12%, и это много. Поэтому нужно к каждое отображаемое имя из адресной книги нужно перевести к нижнему регистру, благо есть функция lower ():

display_name.lower()

После сопоставления входных данных (ФИО) с данными из адресной книги мы получаем словарь со значениями степени совпадения строк. По логике вещей, кандидат на вызов тот, у кого выше степень похожести. И все бы ничего, но и тут снова возник маленький нюанс. Алгоритм Джаро-Винклера для двух строк разного размера, но совпадающих на 6 букв, выдает результат схожести 84%. 

сергейалександровичковалев --- сергейпетров = 0.8435897435897436

Таким образом, мы видим, что нельзя «по логике вещей» брать контакт с максимальным совпадением. Поэтому я добавил дополнительную проверку процента схожести, если совпадение больше 85%, то совершить вызов. Вынес этот параметр в отдельную переменную SIMILARITY_PERCENTAGE в config.py, так его легко можно править при необходимости в зависимости от особенностей формирования адресной книги.

В результате вышеизложенного наша функция contact_diff обросла дополнительным функционалом и приобрела такой вид:  

def contact_diff(response):
    match_list = {}
    for display_name in Abook:
        concat_name = ''.join(display_name.split()).lower()
        match_list[display_name] = Levenshtein.jaro_winkler(
            text_to_number(response), concat_name)
    return max(match_list.items(), key=lambda x: x[1])

Функция возвращает наиболее подходящего абонента для вызова, а именно его DisplayName и процент схожести.  

Вызов абонента

Для вызова абонента надо выполнить команду call с аргументом peerId.

peerId — уникальный идентификатор пользователя (TrueConf ID), которому нужно позвонить.

methods.call(TrueConfID)

Вызов абонента с помощью собственного интерфейса PyQT

Используя QT (PyQT), вы можете разработать собственный брендированный интерфейс для звонков через VideoSDK. Разработчики заботливо подготовили для всех желающих сэмпл с кнопкой для вызова консультанта. Это будет полезно, например, в видеотерминалах или как еще их называют — информационных киосках. Они могут быть размещены на вокзалах, в аэропортах, банках, торговых центрах. А еще в системе «умного города» — скажем, для туриста, который ищет куда бы сходить. Такой терминал решает вопрос удалённой помощи — путешественник при необходимости может получить помощь от консультанта по вопросам посещения культурных мест, заселения в гостиницу и т.п.

GIF с наглядным применением сэмпла CallButton

afdd6816cb197ec427c6ef7500e39782.gif

Как запустить этот сэмпл?  

  1. Получить авторизационные данные.

  2. Запустить VideoSDK c параметром --pin (см. правило запуска).

  3. Авторизоваться в VideoSDK.

  4. В main.py прописать IP, порт VideoSDK и PIN и TrueConf ID в переменную CALL_ID.

  5. Запустить скрипт.

Вывод

Если у вас есть немного свободного времени, желание и начальные знания программирования, то стоит попробовать. Таким образом можно решить многие бизнес-задачи:  

  1. Вызов работника банка с помощью одной кнопки в терминале/банкомате.

  2. Вызов консультанта в информационном киоске.

  3. Голосовое управление в переговорной комнате. 

  4. Внедрить видеосвязь в свое приложение написанное на QT, например, для связи с доктором  в приложение больницы.  

Ссылки на сэмплы

  1. Голосовой вызов и автоматический перенос контента на второй экран:
    https://github.com/TrueConf/pyVideoSDK-VoiceControl

  2. CallButton — вызов с помощью кнопки PyQT:
    https://github.com/TrueConf/CallButton

© Habrahabr.ru