Русский
Русский
English
Статистика
Реклама

Блог компании voximplant

Оптимизация графики в Voximplant Kit

24.03.2021 14:22:50 | Автор: admin

Что делать, если сценарий крутой и сложный, но из-за этого начинает тормозить? Данным вопросом задались наши разработчики Voximplant Kit и придумали функцию оптимизации. Продолжая серию обновлений Кита, расскажем, как оптимизация заставила большие сценарии летать и с какими проблемами мы столкнулись в процессе её создания.

Почему надо оптимизировать

Многим знакома проблема производительности, вызванная наличием слишком большого количества элементов на странице. Что это значит? В нашем случае чем больше элементов в сценарии Voximplant Kit, тем больше это влияет на скорость визуализации перемещения блоков по холсту (всех вместе и по отдельности), а также на скорость визуализации перемещения и масштабирования самого холста.

Мы рассмотрели разные варианты и пришли к выводу, что в качестве решения отлично подойдёт CSS свойство will-change, позволяющее заранее проинформировать браузер об изменениях, которые могут быть применены к элементу. Благодаря этому свойству можно настроить оптимизацию до начала изменений во избежание затрат на запуск операций, негативно влияющих на отзывчивость страницы.

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

<svg ... > <---- Холст <g transform="matrix(1,0,0,1,224,444)"> <---- Группа элементов внутри svg  <rect>  <rect>

Реализация

У наших разработчиков появилась идея обернуть SVG в div-элемент, чтобы применять все трансформации сначала к нему, а затем при необходимости к самому SVG-элементу с холстом. После того, как трансформации стали применяться к <div>, мы смогли использовать will-change: transform для их отслеживания:

<div> <---- div-обёртка, к которой применяется оптимизация  <svg ... > <---- Холст   <g> <---- Группа элементов внутри svg    <rect>    <rect>

Но появилась ещё одна проблема использование will-change инициирует создание нового слоя, и чем больше ширина и высота элемента, к которому это св-во применяется, тем больше расходуется оперативной памяти для хранения слоя. Справиться с этим помогло уменьшение масштаба SVG в 10 раз. Так, например, при масштабе холста =200% для слоя сwill-change требовалось300 мегабайтоперативки , а после уменьшения масштаба стало нужно всего около3 мегабайт.

Чтобы это осуществить, выставляем параметр zoom = 0.1 и подключаем к работе методtransformToCenterViewport, после чего применяем те же трансформации к div-элементу:

if (isPerfMode) {  this.el.classList.add('perf-mode');  // Меняем масштаб перед включением performance mode  const prevScale = this._viewportMatrix.a;  const point = this.getViewPortCenter();  const zoom = 0.1;       // Уменьшаем исходный svg, чтобы will-change тратил меньше оперативной памяти  this.transformToCenterViewport(point, zoom, true, false, true);  this.initScale = this._viewportMatrix.a;  this.createMatrix();     this.isPerfMode = true;       // Применяем трансформации к элементу-обертке  this.startPerformance();  this.transformToCenterViewport(point, prevScale, false, false, true);}

Т.к. при переходе в режим оптимизации мы уменьшаем SVG, холст становится очень маленьким и неудобным для работы. Чтобы это исправить, применим обратное масштабирование непосредственно к div-элементу:

public startPerformance(force = false) {  ...  this.isPerformance = true;    // Получаем размер области с блоками и отступ от левого угла вьюпорта  const { x, y, width, height } = this.layers.getBBox();  const initScale = this.initScale;    // Ширина и высота для обёртки и смещение по оси x и y для области с блоками  const wrapW = Math.floor(width * initScale) + 2;  const wrapH = Math.floor(height * initScale) + 2;  const layerX = -x * initScale;  const layerY = -y * initScale;    // this.wrapMatrix - матрица div-элемента с холстом   this.wrapMatrix.e = +(this._viewportMatrix.e + x * this._viewportMatrix.a);   this.wrapMatrix.f = +(this._viewportMatrix.f + y * this._viewportMatrix.d);   this.svgWrapper.style.width = wrapW + 'px';   this.svgWrapper.style.height = wrapH + 'px';   this.svgWrapper.style.transform = this.wrapMatrix.toString();   this.svgWrapper.style.willChange = 'transform'; this.layers.style.transform = `matrix(${initScale},0,0,${initScale},${layerX} ,${layerY} )`;}

Со скоростью мы разобрались, но на этом сложности не закончились: при уменьшении масштаба холста стали пропадать детали изображения, а при увеличении оно стало размываться. На решение этой проблемы натолкнула статья о повторном растрировании композитных слоев при изменении масштаба.

После завершения масштабирования (событие о скролле), св-во will-change удаляется на 0.1 секунды и затем устанавливается заново. Это заставляет браузер повторно растрировать слой, возвращая пропавшие детали изображения:

// Добавляем 3d трансформацию, чтобы слой не был удаленthis.svgWrapper.style.transform = this.wrapMatrix.toString() + ' translateZ(0)';this.transformFrameId = requestAnimationFrame(() => {  // Устанавливаем св-во will-change для применения в следующем кадре  this.svgWrapper.style.willChange = '';  this.transformFrameId = requestAnimationFrame(() => {    this.svgWrapper.style.willChange = 'transform';    this.svgWrapper.style.transform = this.wrapMatrix.toString();  });});

Осталось внести последний фикс всегда отображать перемещаемый блок поверх других. В JointJS для перемещения блоков и линков по оси Z существуют методы toFront и toBack (аналог z-index в HTML). Принцип их работы заключается в сортировке элементов и перерисовке блоков и линков, это вызывает задержки.

Наши разработчики придумали следующее: блок, с которым мы взаимодействуем, временно ставится в конец дерева элементов внутри SVG (элемент с самым высоким z-index находится в конце списка) на событие mousedown, а затем возвращается на прежнее место на событие mouseup.

Принцип работы

Режим оптимизации можно протестировать во всех браузерах на основе Chromium (Chrome, Opera, Edge, Yandex Browser и т.п.), а также в браузере Safari. Для сценариев, содержащих от 50 блоков, функция включается автоматически. Самостоятельно включить или отключить её можно, перейдя в меню настроек сценария в правом верхнем углу:

Как только оптимизация будет включена или выключена, в верхней части окна со сценарием появится уведомление:

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

Без оптимизации работа с холстом и его элементами выглядит примерно так (на разных компьютерах с разными мощностями результат может отличаться):

Подключаем оптимизацию и вуаля!

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

Надеемся, что статья была интересной, мы будем продолжать улучшать продукт и делиться успехами и лайфхаками с вами!

Подробнее..

Живые интерактивные логи визуализация логов в Voximplant Kit

30.06.2020 14:13:12 | Автор: admin

Мы продолжаем обновлять Voximplant Kit с помощью JointJS. И рады сообщить о появлении живых логов (live logs) звонков. Насколько они живые и опасны ли для простых юзеров, читайте под катом.

Ранее для анализа звонков в Voximplant Kit пользователям были доступны лишь записи разговоров. Нам же хотелось в дополнение к аудио сделать не просто текстовый лог, а более удобный инструмент для просмотра деталей звонка и анализа ошибок. И поскольку мы имеем дело с low-code/no-code продуктом, появилась идея визуализации логов.

В чем соль?/ Новый концепт


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


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


Управление


Контролы старт\стоп (1) останавливают/возобновляют воспроизведение, а назад\далее (2) точечно перемещают юзера к началу следующего/предыдущего блока. Можно также просто кликать по таймлайну, чтобы начать воспроизведение с определенного момента времени, как с проигрыванием песни.

Если сценарий включает в себя запись разговора, то она будет проигрываться параллельно с перемещением по блокам. Аудиозапись на таймлайне выделена отдельным цветом (3).


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


Спойлер:
Во вкладке Лог мы планируем показывать детали блоков. Они помогут нам понять, почему из блока вышли по определенному порту и были ли ошибки. Например, для блока распознавания мы увидим результаты и ошибки распознавания.
Наибольший интерес здесь будут представлять сложные блоки, такие как DialogFlowConnector, IVR, ASR и т.д.


Переменные


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


Лайфхак


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

Самостоятельно пощупать логи можно на Voximplant Kit.

Так, а что внутри?


Разберемся, как именно динамические логи реализованы в коде. Скажем сразу, от Joint JS мы взяли лишь анимацию и выделение блоков, как в деморежиме. Остальное (что можно на основе этого сделать) наша фантазия.

Кстати, чтобы узнать подробнее о внутренностях деморежима, читайте нашу предыдущую статью.

Получаем timepointы


При переходе в просмотр лога, сервер присылает данные, где содержится список всех пройденных блоков, время входа в них и список переменных, которые менялись во время звонка. Другими словами, на фронте мы получаем два массива объектов: log_path и log_variables.

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

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

Обновляем временную шкалу


После нажатия кнопки play временная шкала начинает обновляться каждые 10 мс. Во время каждого обновления проверяем, совпадает ли текущее время с одним из timepointов:

const found = this.timePoints.find((item) => item === this.playTime);

Если совпадение есть, будем искать все блоки у которых timepoint = текущее время + 600 мс (время, за которое происходит анимация перемещения между блоками).

Код метода updatePlayTime():

updatePlayTime(): void {    const interval = 10;    let expected = Date.now() + interval;    const tick = () => {        const drift = Date.now() - expected;        const found = this.timePoints.find((item) => item === this.playTime);        this.$emit('update', {            time: this.playTime,            found: found !== undefined        });        if (this.playTime >= this.duration) {            this.isPlay = false;            this.playTime = this.duration;            clearTimeout(this.playInterval);            this.$emit('end', this.playTime);            return;        }        expected += interval;        this.playTime += 0.01;        this.playTime = +this.playTime.toFixed(2);        this.updateProgress();        this.playInterval = window.setTimeout(tick, Math.max(0, interval - drift));    };    this.playInterval = window.setTimeout(tick, 10);}

Так же каждые 90 мс мы проверяем совпадения для текущего времени и timepoint'ов у измененных переменных + 4000 мс (время, в течение которого висит уведомление об изменении переменной).

Выделяем блоки


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

Если блоков с timepoint = текущее время + 600 мс несколько, то переход анимируется только к последнему:

if (i === blocks.length - 1) {    await this.selectBlock(blocks[i], 600, true, true);}

Это необходимо, поскольку есть блоки, которые обрабатываются очень быстро. Например, Проверка данных, Изменение данных и т.п. за 1 секунду может быть пройдено сразу несколько блоков. Если их анимировать последовательно, то возникнет отставание от времени таймлайна.

Код метода onUpdateTimeline:

async onUpdateTimeline({    time,    found}) {    this.checkHistoryNotify();    if (!found) return;    // Выделяем группу блоков от первой найденной точки + 600мс    const blocks = this.callHistory.log_path.filter((item) => {        return item.timepoint >= this.logTimer && item.timepoint < this.logTimer + 600;    });    if (blocks.length) {        this.editor.unselectAll();        for (let i = 0; i < blocks.length; i++) {            if (i === blocks.length - 1) {                await this.selectBlock(blocks[i], 600, true, true);                const cell = this.editor.getCellById(blocks[i].idTarget);                this.editor.select(cell);            } else {                await this.selectBlock(blocks[i], 0, false, true);            }        }    }}


И так по кругу, есть совпадение выделяем блоки, если блок в уже в очереди ничего не делаем.

В этом нам помогает метод selectBlock():

async selectBlock(voxHistory, timeout = 700, animate = true, animateLink = true) {    const inQueue = this.selectQueue.find((item) => item[0].targetId === voxHistory.idTarget);    if (!inQueue) this.selectQueue.push(arguments);    return this.exeQueue();}


Перематываем


При перемотке тот же принцип: когда таймлайн переместили, мы получаем время, на которое нужно перемотать и отмечаем блоки с timepoint'ами меньше текущего времени как пройденные:

const forSelect = this.callHistory.log_path.filter((item) => {        const time = accurate ? item.accurateTime : item.timepoint;        return time <= this.logTimer;    });

Анимированный переход делаем к последнему из них.

Код метода onRewind():

async onRewind({    time,    accurate}, animation = true) {    this.editor.unselectAll();    this.stopLinksAnimation();    this.checkHistoryNotify(true);    const forSelect = this.callHistory.log_path.filter((item) => {        const time = accurate ? item.accurateTime : item.timepoint;        return time <= this.logTimer;    });    for (let i = 0; i < forSelect.length; i++) {        if (i === forSelect.length - 1) {            await this.selectBlock(forSelect[i], 600, animation, false);            const cell = this.editor.getCellById(forSelect[i].idTarget);            this.editor.select(cell);        } else {            await this.selectBlock(forSelect[i], 0, false, false);        }    }    if (this.isPlay) this.restartAnimateLink();    this.onEndTimeline();}

Проигрываем аудио


С включение/выключением аудиозаписи дела обстоят еще проще. Если время таймлайна совпадает со стартом записи, она начинает проигрываться и далее время синхронизируется. За это отвечает метод updatePlayer():

updatePlayer() {    if (this.playTime >= this.recordStart && this.player.paused && !this.isEndAudio) {        this.player.play();        this.player.currentTime = this.playTime - this.recordStart;    } else if (this.playTime < this.recordStart && !this.player.paused) {        this.player.pause();    }}

На этом всё! Вот так на основе методов Joint JS и креатива наших разработчиков появились живые логи. Обязательно протестируйте их самостоятельно, если вы этого еще не сделали :)

Здорово, если вам нравится наша серия статей про обновления Кита. Будем и дальше делиться с вами самым свежим и интересным!
Подробнее..

Скрываем номера курьеров и клиентов с помощью key-value хранилища

17.06.2021 18:06:38 | Автор: admin

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

Каждый сервис использует свои решения для маскировки номеров клиентов и курьеров. В данной статье я расскажу, как сделать это с помощью key-value хранилища в Voximplant.

Как это будет работать

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

У нас будет только один нейтральный номер, на который будут звонить и клиент, и курьер. Номер мы арендуем в панели Voximplant. Затем создадим некую структуру данных, где клиент и курьер будут связаны между собой номером заказа (то есть ключом в терминологии key-value storage).

Так при звонке на арендованный номер звонящий введёт номер заказа, и если такой заказ есть в базе, наш сценарий проверит номера телефонов, привязанные к нему. Далее если номер звонящего будет идентифицирован как номер клиента, произойдет соединение с курьером, ответственным за заказ, и наоборот.

Например, звонок курьера клиенту будет выглядеть следующим образом:

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

Перейдем непосредственно к реализации.

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

1) Чтобы начать разработку, войдите в свой аккаунт: manage.voximplant.com/auth. В меню слева нажмите Приложения, затем Создать приложение в правом верхнем углу. Дайте ему имя, например, numberMasking и снова кликните Создать.

2) Зайдите в новое приложение, переключитесь на вкладку Сценарии и создайте сценарий, нажав на +. Назовём его kvs-scenario. Здесь мы будем писать код, но об этом чуть позже.

3) Сначала перейдем во вкладку Роутинг и создадим правило для нашего сценария. Маску (регулярное выражение) оставим .* по умолчанию, так правило будет срабатывать для всех номеров.

4) Далее арендуем реальный городской номер. Для этого перейдем в раздел Номера, выберем и оплатим номер. На него будут звонить и клиент, и курьер, и он будет отображаться вместо их настоящих номеров.

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

5) Осталось привязать его к нашему приложению. Заходим в приложение, открываем вкладку Номера Доступные и нажимаем Прикрепить. В открывшемся окне можно также прикрепить наше правило, тогда оно будет автоматически назначено для входящих вызовов, а все остальные правила будут проигнорированы.

6) Далее необходимо верифицировать аккаунт, чтобы использовать этот номер для звонков.

Отлично, структура готова, осталось заполнить key-value хранилище и добавить код в сценарий.

Key-value хранилище

Чтобы сценарий заработал, нужно положить что-то в хранилище. Это можно сделать, воспользовавшись Voximplant Management API. Я буду использовать Python API client, он работает с Python 2.x или 3.x с установленным pip и setuptools> = 18.5.

1) Зайдем в папку проекта и установим SDK, используя pip:

python -m pip install --user voximplant-apiclient

2) Создадим файл с расширением .py и добавим в него код, при выполнении которого данные о заказе попадут в key-value хранилище. Применим метод set_key_value_item:

from voximplant.apiclient import VoximplantAPI, VoximplantExceptionif __name__ == "__main__":    voxapi = VoximplantAPI("credentials.json")        # SetKeyValueItem example.    KEY = 12345    VALUE = '{"courier": "79991111111", "client": "79992222222"}'    APPLICATION_ID = 1    TTL = 864000        try:        res = voxapi.set_key_value_item(KEY,            VALUE,            APPLICATION_ID,            ttl=TTL)        print(res)    except VoximplantException as e:        print("Error: {}".format(e.message))

Файл с необходимыми credentials вы сможете сгенерировать при создании сервисного аккаунта в разделе Служебные аккаунты в настройках панели.

APPLICATION_ID появится в адресной строке при переходе в ваше приложение.

В качестве ключа (KEY) будет использоваться пятизначный номер заказа, а в качестве значений телефонные номера: courier номер курьера, client номер клиента. TTL нам здесь необходимо для указания срока хранения значений.

3) Осталось запустить файл, чтобы сохранить данные заказа:

python3 kvs.py

Если мы больше не захотим, чтобы клиент и курьер беспокоили друг друга, можно будет удалить их данные из хранилища. Информацию о всех доступных методах key-value storage вы найдёте в нашей документации: management API и VoxEngine.

Код сценария

Код, который необходимо вставить в сценарий kvs-scenario, представлен ниже, его можно смело копировать as is:

Полный код сценария
require(Modules.ApplicationStorage);/** * @param {boolean} repeatAskForInput - была ли просьба ввода произнесена повторно * @param longInputTimerId - таймер на отсутствие ввода * @param shortInputTimerId - таймер на срабатывание фразы для связи с оператором * @param {boolean} firstTimeout - индикатор срабатывания первого таймаута * @param {boolean} wrongPhone - индикатор совпадения номера звонящего с номером, полученным из хранилища * @param {boolean} inputRecieved - получен ли ввод от пользователя *  */let repeatAskForInput;let longInputTimerId;let shortInputTimerId;let firstTimeout = true;let wrongPhone;let inputRecieved;const store = {    call: null,    caller: '',    callee: '',    callid: '74990000000',    operator_call: null,    operatorNumber: '',    input: '',    data: {        call_operator: '',        order_number: '',        order_search: '',        phone_search: '',        sub_status: '',        sub_available: '',        need_operator: '',        call_record: ''    }}const phrases = {    start: 'Здр+авствуйтте. Пожалуйста, -- введите пятизначный номер заказa в тт+ооновом режиме.',    repeat: 'Пожалуйста , , - - введите пятизначный номер заказа в т+оновом режиме,, или нажмите решетку для соединения со специалистом',    noInputGoodbye: 'Вы - ничего не выбрали. Вы можете посмотреть номер заказа в смс-сообщении и позвонить нам снова. Всего д+обровоо до свидания.',    connectToOpearator: 'Для соединения со специалистом,, нажмите решетку',    connectingToOpearator: 'Ожидайте, соединяю со специалистом',    operatorUnavailable: 'К сожалению,, все операторы заняты. Пожалуйста,,, перезвоните позднее. Всего д+обровоо до свидания.',    wrongOrder: 'Номер заказа не найден. Посмотрите номер заказа в смс-сообщении и введите его в т+оновом режиме. Или свяжитесь со специалистом,, нажав клавишу решетка.',    wrongOrderGoodbye: 'Вы ничего не выбрали, всего д+обровоо до свидания.',    wrongPhone: 'Номер телефона не найден. Если вы кли+ент, перезвоните с номера, который использовали для оформления заказа. Если вы курьер, перезвоните с номера, который зарегистрирован в нашей системе. Или свяжитесь со специалистом,,- нажав клавишу решетка.',    wrongPhoneGoodbye: 'Вы ничего не выбрали. Всего доброго, до свидания!',    courierIsCalling: `Вам звонит курьер по поводу доставки вашего заказа, - - ${store.data.order_number}`,    clientIsCalling: `Вам звонит клиент по поводу доставки заказа, - - ${store.data.order_number} `,    courierUnavailable: 'Похоже,,, курь+ер недоступен. Пожалуйста,,, перезвоните через п+ару мин+ут. Всего д+обровоо до свидания.',    clientUnavailable: 'Похоже,,, абонент недоступен. Пожалуйста,,, перезвоните через пп+ару мин+ут. Всего д+обровоо до свидания.',    waitForCourier: 'Ожидайте на линии,, - соединяю с курьером.',    waitForClient: 'Ожидайте на линии,, соединяю с клиентом.'}VoxEngine.addEventListener(AppEvents.Started, async e => {    VoxEngine.addEventListener(AppEvents.CallAlerting, callAlertingHandler);})async function callAlertingHandler(e) {    store.call = e.call;    store.caller = e.callerid;    store.call.addEventListener(CallEvents.Connected, callConnectedHandler);    store.call.addEventListener(CallEvents.Disconnected, callDisconnectedHandler);    store.call.answer();}async function callDisconnectedHandler(e) {    await sendResultToDb();    VoxEngine.terminate();}async function callConnectedHandler() {    store.call.handleTones(true);    store.call.addEventListener(CallEvents.RecordStarted, (e) => {        store.data.call_record = e.url;    });    store.call.record();    store.call.addEventListener(CallEvents.ToneReceived, dtmfHandler);    await say(phrases.start);    addInputTimeouts();}function dtmfHandler(e) {    clearInputTimeouts();    store.input += e.tone;    Logger.write('Введена цифра ' + e.tone)    Logger.write('Полный код ' + store.input)    if (e.tone === '#') {        store.data.need_operator = "Да";        store.call.removeEventListener(CallEvents.ToneReceived);        store.call.handleTones(false);        callOperator();        return;    }    if (!wrongPhone) {        if (store.input.length >= 5) {            repeatAskForInput = true;            Logger.write(`Получен код ${store.input}. `);            store.call.handleTones(false);            store.call.removeEventListener(CallEvents.ToneReceived);            handleInput(store.input);            return;        }    }    addInputTimeouts();}function addInputTimeouts() {    clearInputTimeouts();    if (firstTimeout) {        Logger.write('Запущен таймер на срабатывание фразы для связи с оператором');        shortInputTimerId = setTimeout(async () => {            await say(phrases.connectToOpearator);        }, 1500);        firstTimeout = false;    }    longInputTimerId = setTimeout(async () => {        Logger.write('Сработал таймер на отсутствие ввода от пользователя ' + longInputTimerId);        store.call.removeEventListener(CallEvents.ToneReceived);        store.call.handleTones(false);        if (store.input) {            handleInput(store.input);            return;        }        if (!repeatAskForInput) {            Logger.write('Просим пользователя повторно ввести код');            store.call.handleTones(true);            store.call.addEventListener(CallEvents.ToneReceived, dtmfHandler);            await say(phrases.repeat);            addInputTimeouts();            repeatAskForInput = true;        } else {            Logger.write('Код не введен. Завершаем звонок.');            await say(inputRecieved ? phrases.wrongOrderGoodbye : phrases.noInputGoodbye);            store.call.hangup();        }    }, 8000);    Logger.write('Запущен таймер на отсутствие ввода от пользователя ' + longInputTimerId);}function clearInputTimeouts() {    Logger.write(`Очищаем таймер ${longInputTimerId}. `);    if (longInputTimerId) clearTimeout(longInputTimerId);    if (shortInputTimerId) clearTimeout(shortInputTimerId);}async function handleInput() {    store.data.order_number = store.input;    Logger.write('Ищем совпадение в kvs по введенному коду: ' + store.input)    inputRecieved = true;    let kvsAnswer = await ApplicationStorage.get(store.input);    if (kvsAnswer) {        store.data.order_search = 'Заказ найден';        Logger.write('Получили ответ от kvs: ' + kvsAnswer.value)        let { courier, client } = JSON.parse(kvsAnswer.value);        if (store.caller == courier) {            Logger.write('Звонит курьер')            store.callee = client;            store.data.sub_status = 'Курьер';            store.data.phone_search = 'Телефон найден';            callCourierOrClient();        } else if (store.caller == client) {            Logger.write('Звонит клиент')            store.callee = courier;            store.data.sub_status = 'Клиент';            store.data.phone_search = 'Телефон найден';            callCourierOrClient();        } else {            Logger.write('Номер звонящего не совпадает с номерами, полученными из kvs');            wrongPhone = true;            store.data.phone_search = 'Телефон не найден';            store.input = '';            store.call.handleTones(true);            store.call.addEventListener(CallEvents.ToneReceived, dtmfHandler);            await say(phrases.wrongPhone);            addInputTimeouts();        }    } else {        Logger.write('Совпадение в kvs по введенному коду не найдено');        store.data.order_search = 'Заказ не найден';        store.input = '';        store.call.handleTones(true);        store.call.addEventListener(CallEvents.ToneReceived, dtmfHandler);        await say(phrases.wrongOrder);        Logger.write(`Очищаем таймер ${longInputTimerId}. `);        addInputTimeouts();    }}async function callCourierOrClient() {    clearInputTimeouts();    Logger.write('Начинаем звонок курьеру/клиенту');    await say(store.data.sub_status === 'Курьер' ? phrases.waitForClient : phrases.waitForCourier, store.call);    const secondCall = VoxEngine.callPSTN(store.callee, store.callid);    store.call.startPlayback('http://cdn.voximplant.com/toto.mp3');    secondCall.addEventListener(CallEvents.Connected, async () => {        store.data.sub_available = 'Да';        await say(store.data.sub_status === 'Курьер' ? phrases.courierIsCalling : phrases.clientIsCalling, secondCall);        store.call.stopPlayback();        VoxEngine.sendMediaBetween(store.call, secondCall);    });    secondCall.addEventListener(CallEvents.Disconnected, () => {        store.call.hangup();    });    secondCall.addEventListener(CallEvents.Failed, async () => {        store.data.sub_available = 'Нет';        store.call.stopPlayback();        await say(store.data.sub_status === 'Курьер' ? phrases.clientUnavailable : phrases.courierUnavailable, store.call);        store.call.hangup();    });}async function callOperator() {    Logger.write('Начинаем звонок оператору');    await say(phrases.connectingToOpearator, store.call);    store.call.startPlayback('http://cdn.voximplant.com/toto.mp3');    store.operator_call = VoxEngine.callPSTN(store.operatorNumber, store.callid);    store.operator_call.addEventListener(CallEvents.Connected, async () => {        store.data.call_operator = 'Оператор свободен';        VoxEngine.sendMediaBetween(store.call, store.operator_call);    });    store.operator_call.addEventListener(CallEvents.Disconnected, () => {        store.call.hangup();    });    store.operator_call.addEventListener(CallEvents.Failed, async () => {        store.data.call_operator = 'Оператор занят';        await say(phrases.operatorUnavailable, store.call);        store.call.hangup();    });}async function sendResultToDb() {    Logger.write('Данные для отправки в БД');    Logger.write(JSON.stringify(store.data));    const options = new Net.HttpRequestOptions();    options.headers = ['Content-Type: application/json'];    options.method = 'POST';    options.postData = JSON.stringify(store.data);    await Net.httpRequestAsync('https://voximplant.com/', options);}function say(text, call = store.call, lang = VoiceList.Yandex.Neural.ru_RU_alena) {    return new Promise((resolve) => {        call.say(text, lang);        call.addEventListener(CallEvents.PlaybackFinished, function callback(e) {            resolve(call.removeEventListener(CallEvents.PlaybackFinished, callback));        });    });};

Код тщательно прокомментирован, но в некоторые моменты углубимся подробнее.

Вводим номер заказа

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

store.input += e.tone;

Если звонящий ввел #, сразу соединяем его с оператором:

if (e.tone === '#') {    store.data.need_operator = "Да";    store.call.removeEventListener(CallEvents.ToneReceived);    store.call.handleTones(false);    callOperator();    return;}

Если он ввел последовательность из 5 цифр, вызываем функцию handleInput:

if (store.input.length >= 5) {    repeatAskForInput = true;    Logger.write('Получен код ${store.input}. ');    store.call.handleTones(false);    store.call.removeEventListener(CallEvents.ToneReceived);    handleInput(store.input);    return;}

Ищем заказ в хранилище

Здесь мы будем сравнивать введенный номер заказа с номером в хранилище, используя метод ApplicationStorage.get(), в качестве ключа используем введенную последовательность:

store.data.order_number = store.input;Logger.write('Ищем совпадение в kvs по введенному коду: ' + store.input)inputRecieved = true;let kvsAnswer = await ApplicationStorage.get(store.input);

Если заказ найден, получаем для него номера клиента и курьера:

if (kvsAnswer) {    store.data.order_search = 'Заказ найден';    Logger.write('Получили ответ от kvs: ' + kvsAnswer.value)    let { courier, client } = JSON.parse(kvsAnswer.value);

Теперь осталось разобраться, кому звонить. Если номер звонящего принадлежит курьеру, будем выполнять переадресацию на клиента, если клиенту на курьера. В этом нам поможет функция callCourierOrClient:

if (store.caller == courier) {    Logger.write('Звонит курьер')    store.callee = client;    store.data.sub_status = 'Курьер';    store.data.phone_search = 'Телефон найден';    callCourierOrClient();} else if (store.caller == client) {    Logger.write('Звонит клиент')    store.callee = courier;    store.data.sub_status = 'Клиент';    store.data.phone_search = 'Телефон найден';    callCourierOrClient();}

Если номера нет в хранилище, просим перезвонить с другого номера, который указывался при оформлении заказа:

else {    Logger.write('Номер звонящего не совпадает с номерами, полученными из kvs');    wrongPhone = true;    store.data.phone_search = 'Телефон не найден';    store.input = '';    store.call.handleTones(true);    store.call.addEventListener(CallEvents.ToneReceived, dtmfHandler);    await say(phrases.wrongPhone);    addInputTimeouts();}

И наконец, обрабатываем вариант, когда номер заказа не был найден в базе. В этом случае просим попробовать ввести его снова, предварительно удостоверившись, что номер верный:

else {    Logger.write('Совпадение в kvs по введенному коду не найдено');    store.data.order_search = 'Заказ не найден';    store.input = '';    store.call.handleTones(true);    store.call.addEventListener(CallEvents.ToneReceived, dtmfHandler);    await say(phrases.wrongOrder);    Logger.write(`Очищаем таймер ${longInputTimerId}. `);    addInputTimeouts();}

Звоним клиенту/курьеру

Переходим непосредственно к звонку клиенту/курьеру, то есть к логике функции callCourierOrClient. Здесь мы сообщим звонящему, что переводим его звонок на курьера/клиента, и включим музыку на ожидание. С помощью метода callPSTN позвоним клиенту или курьеру (в зависимости от того, чей номер был ранее идентифицирован как номер звонящего):

await say(store.data.sub_status === 'Курьер' ? phrases.waitForClient : phrases.waitForCourier, store.call);const secondCall = VoxEngine.callPSTN(store.callee, store.callid);store.call.startPlayback('http://cdn.voximplant.com/toto.mp3');

В этот же момент сообщим второй стороне о том, что звонок касается уточнения информации по заказу:

secondCall.addEventListener(CallEvents.Connected, async () => {    store.data.sub_available = 'Да';    await say(store.data.sub_status === 'Курьер' ? phrases.courierIsCalling : phrases.clientIsCalling, secondCall);    store.call.stopPlayback();    VoxEngine.sendMediaBetween(store.call, secondCall);});

Обработаем событие дисконнекта:

secondCall.addEventListener(CallEvents.Disconnected, () => {    store.call.hangup();});

И оповестим звонящего, если вторая сторона недоступна:

secondCall.addEventListener(CallEvents.Failed, async () => {    store.data.sub_available = 'Нет';    store.call.stopPlayback();    await say(store.data.sub_status === 'Курьер' ? phrases.clientUnavailable : phrases.courierUnavailable, store.call);    store.call.hangup();});

За все фразы, который произносит робот, отвечает функция say, а сами фразы перечислены в ассоциативном массиве phrases. В качестве TTS провайдера мы используем Yandex, голос Alena:

function say(text, call = store.call, lang = VoiceList.Yandex.Neural.ru_RU_alena) {    return new Promise((resolve) => {        call.say(text, lang);        call.addEventListener(CallEvents.PlaybackFinished, function callback(e) {            resolve(call.removeEventListener(CallEvents.PlaybackFinished, callback));        });    });};

Кроме всего прочего, наш сценарий записывает звонки, используя метод record, и показывает, как можно сохранить статистику в базу данных (в нашем коде за это отвечает функция sendResultToDb). Это очень важно для бизнеса, поскольку позволяет анализировать статистику, обеспечивать контроль качества и оперативно решать спорные ситуации, которые могли возникнуть в процессе доставки заказа.

Тестируем

Когда полный код добавлен в сценарий, а данные по заказу в хранилище, смело начинайте тестировать.

Позвоним с телефона клиента или курьера на номер, арендованный в панели. Затем введем номер заказа (в нашем случае это 12345) и будем ждать соединения со второй стороной.

Если все сделано верно, клиент и курьер смогут созваниваться и обсуждать детали заказа, не зная настоящих номеров друг друга, а значит, не нарушая прайваси. Круто, не так ли?) Желаем вам успешной разработки и беспроблемной доставки!

P.S. Также мой коллега недавно рассказал, как обезопасить общение клиента и курьера с помощью Voximplant Kit (наш low-code/no-code продукт). Если эта тема вас заинтересовала, переходите по ссылке :)

Подробнее..

А я говорю, возьми Excel и позвони

01.04.2021 14:11:52 | Автор: admin

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

Но в современном мире иметь API недостаточно мало кто хочет формировать HTTP-запросы, передавать параметры, думать про правильную авторизацию. Поэтому мы предлагаем SDK для разных языков программирования: Python, PHP, C# и многих других. И кажется, что этого достаточно, чтобы сделать нашу платформу лёгкой в использовании для очень большой аудитории. Или всё-таки недостаточно?

Обратимся к статистике. По разным данным сейчас в мире насчитывается где-то 15-30 миллионов разработчиков цифра несомненно впечатляющая. Но, например, пользователей MS Excel в мире не менее 100 миллионов. Почему же они должны страдать? Ведь, будем честны, почти каждый из тех, кто хоть раз открывал Excel, явно ощущал недостаток возможностей по управлению коммуникационными платформами в этом без сомнения очень гибком программном продукте. Практически каждый день мы получаем на наш email сотни запросов, которые сводятся к очень простой просьбе: Я хочу звонить из Excel!. Однажды у окон нашего офиса даже выстроились люди с такими требованиями (видели фото выше?) Мы просто не могли оставаться в стороне.

Однако звонки это всё-таки слишком революционно, а главное, потребует установки дополнительных ActiveX-компонентов, что, безусловно, противоречит всем существующим и несуществующим политикам информационной безопасности, поэтому давайте начнём с более простой вещи SDK для работы с нашим API. Из средств разработки в Экселе доступен VBA, для него мы и создадим SDK.

Для того, чтобы выполнить API-запрос, необходимо:

  1. Сформировать URL и тело POST-запроса.

  2. Добавить аутентификационные параметры.

  3. Непосредственно выполнить запрос.

  4. Распарсить результат (в нашем случае это JSON).

Формируем URL и тело POST-запроса

Первая часть, казалось бы, самая простая: нужно просто закодировать параметры в URL-кодировку и склеить их. Но в стандартом VBA не предусмотрена URL-кодировка (позже мы поймём, почему). Ничего страшного, на просторах Интернета есть множество разных решений, выберем одно из них.

Public Function URL_Encode(ByRef txt As String) As String    Dim buffer As String, i As Long, c As Long, n As Long    buffer = String$(Len(txt) * 12, "%")     For i = 1 To Len(txt)        c = AscW(Mid$(txt, i, 1)) And 65535         Select Case c            Case 48 To 57, 65 To 90, 97 To 122, 45, 46, 95  ' Unescaped 0-9A-Za-z-._ '                n = n + 1                Mid$(buffer, n) = ChrW(c)            Case Is <= 127            ' Escaped UTF-8 1 bytes U+0000 to U+007F '                n = n + 3                Mid$(buffer, n - 1) = Right$(Hex$(256 + c), 2)            Case Is <= 2047           ' Escaped UTF-8 2 bytes U+0080 to U+07FF '                n = n + 6                Mid$(buffer, n - 4) = Hex$(192 + (c \ 64))                Mid$(buffer, n - 1) = Hex$(128 + (c Mod 64))            Case 55296 To 57343       ' Escaped UTF-8 4 bytes U+010000 to U+10FFFF '                i = i + 1                c = 65536 + (c Mod 1024) * 1024 + (AscW(Mid$(txt, i, 1)) And 1023)                n = n + 12                Mid$(buffer, n - 10) = Hex$(240 + (c \ 262144))                Mid$(buffer, n - 7) = Hex$(128 + ((c \ 4096) Mod 64))                Mid$(buffer, n - 4) = Hex$(128 + ((c \ 64) Mod 64))                Mid$(buffer, n - 1) = Hex$(128 + (c Mod 64))            Case Else                 ' Escaped UTF-8 3 bytes U+0800 to U+FFFF '                n = n + 9                Mid$(buffer, n - 7) = Hex$(224 + (c \ 4096))                Mid$(buffer, n - 4) = Hex$(128 + ((c \ 64) Mod 64))                Mid$(buffer, n - 1) = Hex$(128 + (c Mod 64))        End Select    Next    URL_Encode = Left$(buffer, n)End Function

Следующий нюанс передача даты и времени. В API Voximplant временные метки принимаются в UTC в формате YYYY-MM-DD hh:mm:ss. В Excel же дата и время хранятся без учёта часового пояса (на самом деле, в самой таблице они вообще хранятся как число с плавающей точкой). Поэтому нам придётся принимать дату/время из таблицы тоже UTC. Мы думаем, что все 100+ миллионов пользователей Excel знают, что такое UTC, и это не вызовет у них никаких вопросов.

Кстати, в VBA есть функция форматирования даты, и она даже работает, но весьма необычным образом. Интересующий нас формат даты описывается так: yyyy-mm-dd hh:mm:ss. То есть mm это либо месяц, либо минуты в зависимости от того, за чем оно следует: за hhили за yyyy (это не шутка, это даже в MSDN описано). В общем, если кто-то захочет вывести время без часов, придётся импровизировать.

Переходим к аутентификации

Здесь нас ожидает самое большое разочарование. Мы в Voximplant предлагаем нашим клиентам использовать JWT, что, конечно, весьма мудрёно, если выполнять запросы из консоли или браузера, но при использовании наших SDK это совершенно никак не усложняет жизнь разработчику. В то же время JWT обеспечивает крайне высокий уровень безопасности.

А что же VBA? К сожалению, разумно простого способа сформировать JWT-подпись просто не существует. Причина в том, что в VBA доступен фреймворк .NET версии 4.x, а функция RSA.ImportPkcs8PrivateKey, необходимая для загрузки приватного ключа из PKCS8, появилась только в .NET 5. Да и вообще, все .NET-разработчики используют для таких задач сторонние библиотеки.

Поэтому нам придётся ограничиться авторизацией с помощью статического API-ключа. Ведь, как известно, один из способов обработки риска информационной безопасности - принятие этого риска. Так и поступим. Ведь нам ну очень-очень нужен этот SDK.

Кадр из кинофильма Большой Лебовски (The Big Lebowski (1998), Polygram Filmed Entertainment, Working Title Films)Кадр из кинофильма Большой Лебовски (The Big Lebowski (1998), Polygram Filmed Entertainment, Working Title Films)

Выполняем запрос

Переходим к третьей части к выполнению самого запроса. Встроенных средств работы с HTTP в VBA нет (теперь понятно, почему нет и функции URL-кодирования, а зачем?).

Но, тем не менее, это достаточно тривиальная манипуляция подключаем необходимый фреймворк MSXML 6.0 и Microsoft Scripting Runtime и выполняем запрос, подключая через COM сам MSXML. Просто!

Function makeRequest(name As String, params As Dictionary, accountId As Integer, apiKey As String) As Object    Dim objHTTP As New MSXML2.XMLHTTP60    Dim jsonData As String    Dim parsedJson As Object    Dim postString As String    postString = ""        Dim iterKey As Variant        For Each iterKey In params.Keys        postString = postString & "&" & iterKey & "=" & URL_Encode(params(iterKey))    Next    Url = "https://api.voximplant.com/platform_api/" + name    objHTTP.Open "POST", Url, False    objHTTP.send "account_id=" & accountId & "&api_key=" & apiKey & postString    jsonData = objHTTP.responseText    Set parsedJson = JsonConverter.ParseJson(jsonData)    Set makeRequest = parsedJsonEnd Function

Парсим JSON

Ну и, наконец, JSON. Как и всё остальное, парсер JSON надо искать где-то вовне экосистемы VBA. К счастью, на дворе 2021 год, есть GitHub, и кто-то уже озадачился созданием JSON-парсера для VBA. Мы взяли вот такой.

Он подключается как отдельный модуль и превращает JSON-строку в Dictionary. То, что нужно!

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

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

А вот для каких-то статистических задач SDK, несмотря на всю абсурдность происходящего, может подойти. Например, можно посчитать, какой из пользователей потратил на звонки больше всех денег за какой-то период.

Для этого пишем вот такую функцию:

Function getTotalCallCost(FromDate, ToDate, Username) As Double    Dim totalCost As Double    Dim lastCount As Integer    Dim offset As Integer    Dim res As Dictionary    Dim RecordsPerRequest As Integer    Dim api As New VoximplantAPI    totalCost = 0    lastCount = 1    offset = 0    RecordsPerRequest = 100        'Pass Voximplant account id and API key    api.SetCredentials 100, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"        Do While lastCount > 0        Set res = api.GetCallHistory(FromDate, ToDate, remote_number:=Username, with_calls:=True, with_records:=True, with_other_resources:=True, offset:=offset, count:=RecordsPerRequest)                Dim session As Variant        Dim item As Variant                For Each session In res("result")            For Each item In session("calls")                totalCost = totalCost + item("cost")            Next            For Each item In session("records")                totalCost = totalCost + item("cost")            Next            For Each item In session("other_resource_usage")                totalCost = totalCost + item("cost")            Next        Next                lastCount = res("count")        offset = offset + RecordsPerRequest    Loop        getTotalCallCost = totalCostEnd Function

И вызываем её следующим образом:

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


Резюме:

При желании можно и для VBA сделать какое-то подобие SDK. При его создании не пострадал ни один разработчик. Ах да, с 1 апреля! :D

Подробнее..

Как мы подружили Flutter с CallKit Call Directory

21.04.2021 14:19:32 | Автор: admin

Flutter+CallKitCallDirectory=Love


Привет!


В этом лонгриде я расскажу о том, как мы в Voximplant пришли к реализации собственного Flutter плагина для использования CallKit во Flutter приложении, и в итоге оказались первыми, кто сделал поддержку блокировки/определения номеров через Call Directory для Flutter.


Что такое CallKit


Apple CallKit это фреймворк для интеграции звонков стороннего приложения в систему.


Если звонок из стороннего приложения отображается как нативный, то тут задействован CallKit. Если звонок из стороннего приложения отображается в списке звонков системного приложения Phone тоже CallKit. Сторонние приложения, выступающие в качестве определителя номера CallKit. Звонки из сторонних приложений, которые не могут пробиться через режим Не беспокоить ну вы поняли.



CallKit предоставляет сторонним разработчикам системный UI для отображения звонков



А что с CallKit на Flutter?


CallKit является частью iOS SDK, во Flutter он не представлен, однако доступ к нему из Flutter возможен путём взаимодействия с нативным кодом. Для использования функциональности этого фреймворка потребуется подключить сторонний плагин, инкапсулирующий взаимодействие Flutter с iOS, или реализовывать всё самостоятельно, например, так:



Пример реализации CallKit сервиса для Flutter, где код iOS приложения (platform code) связывает приложение Flutter с системой




Готовые решения с CallKit на Flutter


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


Существующие плагины частично или полностью оборачивали CallKit API в собственный высокоуровневый API. Таким образом терялась гибкость, а некоторые возможности становились недоступными. Из-за собственной реализации архитектуры и интерфейсов такие плагины содержали свои баги. Документация хромала или отсутствовала, а авторы некоторых из них прекратили поддержку почти сразу, что особенно опасно на быстроразвивающемся Flutter.



Как мы пришли к созданию своего решения


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


Мы задумались о том, чтобы реализовать своё решение с учетом этих недостатков.


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



Наша Реализация


Нам удалось перенести всё CallKit API на Dart с сохранением иерархии классов и механизмов взаимодействия с ними.



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


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


Например, нативное CallKit API CXProviderDelegate.provider(_:execute:) требует синхронно возвращать Bool значение:


optional func provider(_ provider: CXProvider,     execute transaction: CXTransaction) -> Bool

Этот метод вызывается каждый раз, когда нужно обработать новую транзакцию CXTransaction. Можно вернуть true, чтобы обработать транзакцию самостоятельно и уведомить об этом систему. Вернув false, получим дефолтное поведение, при котором для каждого CXAction, содержащегося в транзакции, будет вызван соответствующий метод обработчик в CXProviderDelegate.


Для переноса этого API в плагин требовалось объявить его в Dart коде так, чтобы пользователь мог управлять этим поведением, несмотря на асинхронный характер обмена данными между платформами. Возвращая в нативном коде значение true, мы смогли перенести управление транзакциями в Dart код, где выполняем ручную или автоматическую обработку CXTransaction в зависимости от значения, полученного от пользователя.


Проблемы с асинхронностью возникают и в нативной части. Например, есть iOS фреймворк PushKit, он не является частью CallKit, но часто они используются вместе, так что интеграция с ним была необходима. При получении VoIP пуша требуется немедленно уведомить CallKit о входящем звонке в нативном коде, в противном случае приложение упадет. Для обработки этого нюанса мы решили дать возможность репортить входящие звонки напрямую в CallKit из нативного кода без асинхронного крюка в виде Flutter. В итоге для этой интеграции реализовали несколько хелперов в нативной части плагина (доступны через FlutterCallkitPlugin iOS класс) и несколько на стороне Flutter (доступны через FCXPlugin Dart класс).


Дополнительные возможности плагина мы объявили в его собственном классе, чтобы отделить интерфейс плагина от интерфейса CallKit.

Как зарепортить входящий звонок напрямую в CallKit

При получении VoIP пуша вызывается один из методов PKPushRegistryDelegate.pushRegistry(_: didReceiveIncomingPushWith:). Здесь необходимо создать экземпляр CXProvider и вызвать reportNewIncomingCall для уведомления CallKit о звонке. Так как для дальнейшей работы со звонком необходим тот же экземпляр провайдера, мы добавили метод FlutterCallkitPlugin.reportNewIncomingCallWithUUID с нативной стороны плагина. При его вызове плагин сам зарепортит звонок в CXProvider, а так же вызовет FCXPlugin.didDisplayIncomingCall хендлер на стороне Dart для продолжения работы со звонком.


func pushRegistry(_ registry: PKPushRegistry,                  didReceiveIncomingPushWith payload: PKPushPayload,                  for type: PKPushType,                  completion: @escaping () -> Void) {    // Достаем необходимые данные из пуша    guard let uuidString = payload["UUID"] as? String,        let uuid = UUID(uuidString: uuidString),        let localizedName = payload["identifier"] as? String    else {        return    }    let callUpdate = CXCallUpdate()    callUpdate.localizedCallerName = localizedName    let configuration = CXProviderConfiguration(        localizedName: "ExampleLocalizedName"    )        // Репортим звонок в плагин, а он зарепортит его в CallKit    FlutterCallkitPlugin.sharedInstance.reportNewIncomingCall(        with: uuid,        callUpdate: callUpdate,        providerConfiguration: configuration,        pushProcessingCompletion: completion    )}


Подводя итог: главной фишкой нашего плагина является то, что его использование на Flutter практически не отличается от использования нативного CallKit на iOS.


One more thing


Но оставалось ещё кое-что в Apple CallKit, что мы не реализовали у себя (и не реализовал никто в доступных сторонних решениях). Это поддержка Call Directory App Extension.



Что такое Call Directory


CallKit умеет блокировать и определять номера, доступ к этим возможностям для разработчиков открыт через специальное системное расширение Call Directory. Подробнее про iOS app extensions можно почитать в App Extension Programming Guide.



Call Directory app extension позволяет блокировать и/или идентифицировать номера


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


Например, при получении входящего звонка iOS пытается определить или найти звонящего в списке заблокированных стандартными средствами. Если номер не был найден, система может запросить данные у доступных Call Directory расширений, чтобы так или иначе обработать звонок. В этот момент расширение должно эти номера достать из некого хранилища номеров. Само приложение может заполнять это хранилище номерами из своих баз в любое время. Таким образом, взаимодействия между расширением и приложением нет, обмен данными происходит через общее хранилище.



Пример архитектуры для реализации Call Directory


Примеры с передачей номеров в Call Directory уже есть на хабре: раз и два.


Подробнее про iOS App Extensions: App Extension Programming Guide.



Call Directory Extension на Flutter


Не так давно нам написал пользователь с запросом на добавление поддержки Call Directory. Начав изучать возможность реализации этой фичи, мы выяснили, что сделать Flutter API без необходимости написания пользователями нативного кода не выйдет. Проблема заключается в том, что, как было сказано выше, Call Directory работает в расширении. Оно запускается системой, работает очень короткое время и не зависит от приложения (и в том числе от Flutter). Таким образом, для поддержки этого функционала пользователю плагина так или иначе потребуется реализовать app extension и хранилище данных самостоятельно.



Пример работы с Call Directory во Flutter приложении



Принятое решение


Несмотря на сложности с нативным кодом, мы твёрдо решили сделать использование Call Directory максимально удобным для пользователей нашего фреймворка.


Проверив возможность работы такого расширения в связке с Flutter приложением, мы принялись за проектирование. Решение должно было сохранить все Call Directory Manager API, а также требовать от пользователя минимум написания нативного кода и быть удобным для взаимодействия через Flutter.


Так мы сделали версию 1.2.0 с поддержкой Call Directory Extension.



Как мы реализовывали Call Directory для Flutter


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


  • Перенести интерфейс класса CXCallDirectoryManager (CallKit объект позволяющий управлять Call Directory)
  • Решить, что делать с app extension и хранилищем номеров для него
  • Создать удобный способ передачи данных из Dart в натив и обратно для управления списками номеров из Flutter приложения


Перенос интерфейсов CXCallDirectoryManager во Flutter


Код, приведенный в статье, был специально упрощен для облегчения восприятия, полную версию кода можно найти по ссылкам в конце статьи. Для реализации плагина мы использовали Objective-C, так как он был выбран основным в проекте ранее. Интерфейсы CallKit представлены на Swift для простоты.


Интерфейс


Первым делом посмотрим, что конкретно требуется перенести:


extension CXCallDirectoryManager {    public enum EnabledStatus : Int {        case unknown = 0        case disabled = 1        case enabled = 2    }}open class CXCallDirectoryManager : NSObject {    open class var sharedInstance: CXCallDirectoryManager { get }    open func reloadExtension(        withIdentifier identifier: String,        completionHandler completion: ((Error?) -> Void)? = nil    )    open func getEnabledStatusForExtension(        withIdentifier identifier: String,        completionHandler completion: @escaping (CXCallDirectoryManager.EnabledStatus, Error?) -> Void    )    open func openSettings(        completionHandler completion: ((Error?) -> Void)? = nil    )}

Воссоздадим аналог CXCallDirectoryManager.EnabledStatus энама в Dart:


enum FCXCallDirectoryManagerEnabledStatus {  unknown,  disabled,  enabled}

Теперь можно объявить класс и методы. Необходимости в sharedInstance в нашем интерфейсе нет, так что сделаем обычный Dart класс со static методами:


class FCXCallDirectoryManager {  static Future<void> reloadExtension(String extensionIdentifier) async { }  static Future<FCXCallDirectoryManagerEnabledStatus> getEnabledStatus(    String extensionIdentifier,  ) async { }  static Future<void> openSettings() async { }}

Сохранение API важно, но так же важно учитывать платформенные и языковые code-style, чтобы использование интерфейса было понятно и удобно для пользователей плагина.


Для API в Dart мы использовали более короткое название без слов-связок (длинное название пришло из objective-C) и заменили completion блок на Future. Future является стандартным механизмом, используемым для получения результата выполнения асинхронных методов в Dart. Мы также возвращаем Future из большинства Dart методов плагина, потому что коммуникация с нативным кодом происходит асинхронно.


Было getEnabledStatusForExtension(withIdentifier:completionHandler:)


Стало Future getEnabledStatus(extensionIdentifier)




Реализация


Для коммуникации между Flutter и iOS будем использовать FlutterMethodChannel.


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



On the Flutter side


Создадим объект MethodChannel:


const MethodChannel _methodChannel =  const MethodChannel('plugins.voximplant.com/flutter_callkit');


On the iOS side


Первым делом iOS класс плагина нужно подписать на протокол FlutterPlugin, чтобы иметь возможность взаимодействовать с Flutter:


@interface FlutterCallkitPlugin : NSObject<FlutterPlugin>@end

При инициализации плагина создадим FlutterMethodChannel с таким же идентификатором, что мы использовали выше:


+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {    FlutterMethodChannel *channel        = [FlutterMethodChannel           methodChannelWithName:@"plugins.voximplant.com/flutter_callkit"          binaryMessenger:[registrar messenger]];    FlutterCallkitPlugin *instance         = [FlutterCallkitPlugin sharedPluginWithRegistrar:registrar];    [registrar addMethodCallDelegate:instance channel:channel];}

Теперь можно использовать этот канал для вызова iOS методов из Flutter.



Рассмотрим подробно реализацию методов в Dart и нативной части плагина на примере getEnabledStatus.



On the Flutter side


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


Про MethodChannel

MethodChannel API позволяет асинхронно получить результат вызова из нативного кода посредством Future, но накладывает ограничения на передаваемые типы данных.




Итак, нам потребуется передать имя метода (его будем использовать в нативном коде для того, чтобы идентифицировать вызов) и аргумент extensionIdentifier в MethodChannel.invokeMethod, а затем преобразовать результат из простейшего типа int в FCXCallDirectoryManagerEnabledStatus. На случай ошибки в нативном коде следует обработать PlatformException.


static Future<FCXCallDirectoryManagerEnabledStatus> getEnabledStatus(  String extensionIdentifier,) async {  try {    // Воспользуемся объектом MethodChannel для вызова    // соответствующего метода в платформенном коде    // с аргументом extensionIdentifier.    int index = await _methodChannel.invokeMethod(      'Plugin.getEnabledStatus',      extensionIdentifier,    );    // Преобразуем результат в энам     // FCXCallDirectoryManagerEnabledStatus    // и вернем его значение пользователю    return FCXCallDirectoryManagerEnabledStatus.values[index];  } on PlatformException catch (e) {    // Если что-то пошло не так, обернем ошибку в собственный тип     // и отдадим пользователю    throw FCXException(e.code, e.message);  }}

Обратите внимание на идентификатор метода который мы использовали:


Plugin.getEnabledStatus


Слово перед точкой используется, для определения модуля ответственного за тот или иной метод.


getEnabledStatus идентично названию метода во Flutter, а не в iOS (или Android).




On the iOS side


Теперь переместимся в платформенный код и реализуем бэкенд для этого метода.


Вызовы через FlutterMethodChannel попадают в метод handleMethodCall:result:.


С помощью переданного ранее идентификатора можно определить, что за метод был вызван, достать из него аргументы и запустить выполнение логики. Больше пояснений в комментариях к коду:


- (void)handleMethodCall:(FlutterMethodCall*)call                  result:(FlutterResult)result {    // Вызовы из Flutter можно идентифицировать по названию,    // которое передается в `FlutterMethodCall.method` проперти    if ([@"Plugin.getEnabledStatus" isEqualToString:call.method]) {        // При передаче аргументов с помощью MethodChannel,         // они упаковываются в `FlutterMethodCall.arguments`        // Извлечем extensionIdentifier, который         // мы передали сюда ранее из Flutter кода        NSString *extensionIdentifier = call.arguments;        if (isNull(extensionIdentifier)) {            // Если аргументы не валидны, вернём ошибку через             // `result` обработчик            // Ошибка должна быть упакована в `FlutterError`            // Она вылетит в виде PlatformException в Dart коде            result([FlutterError errorInvalidArguments:@"extensionIdentifier must not be null"]);            return;}        // Теперь, когда метод обнаружен,        // а аргументы извлечены и провалидированы,         // можно реализовать саму логику        // Для взаимодействия с этой функциональностью CallKit // потребуется экземпляр CallDirectoryManager        CXCallDirectoryManager *manager             = CXCallDirectoryManager.sharedInstance;        // Вызываем метод CallDirectoryManager        // с требуемой функциональностью        // и ожидаем результата        [manager             getEnabledStatusForExtensionWithIdentifier:extensionIdentifier            completionHandler:^(CXCallDirectoryEnabledStatus status,                                            NSError * _Nullable error) {            // completion с результатом вызова запустился,             // можем пробросить результат в Dart            // предварительно сконвертировав его в подходящие типы,             // так как через MethodChannel можно передавать            // лишь некоторые определенные типы данных.            if (error) {                // Ошибки передаются упакованные в `FlutterError`                result([FlutterError errorFromCallKitError:error]);            } else {                // Номера передаются упакованные в `NSNumber`                // Так как этот энам представлен значениями `NSInteger`,                 // выполним требуемое преобразование                result([self convertEnableStatusToNumber:enabledStatus]);            }}];    }}


По аналогии реализуем оставшиеся два метода FCXCallDirectoryManager



On the Flutter side


static Future<void> reloadExtension(String extensionIdentifier) async {  try {    // Задаем идентификатор, передаем аргумент     // и вызываем платформенный метод    await _methodChannel.invokeMethod(      'Plugin.reloadExtension',      extensionIdentifier,    );  } on PlatformException catch (e) {    throw FCXException(e.code, e.message);  }}static Future<void> openSettings() async {  try {    // А этот метод не принимает аргументов     await _methodChannel.invokeMethod(      'Plugin.openSettings',    );  } on PlatformException catch (e) {    throw FCXException(e.code, e.message);  }}


On the iOS side


if ([@"Plugin.reloadExtension" isEqualToString:call.method]) {    NSString *extensionIdentifier = call.arguments;    if (isNull(extensionIdentifier)) {        result([FlutterError errorInvalidArguments:@"extensionIdentifier must not be null"]);        return;    }    CXCallDirectoryManager *manager         = CXCallDirectoryManager.sharedInstance;    [manager         reloadExtensionWithIdentifier:extensionIdentifier        completionHandler:^(NSError * _Nullable error) {        if (error) {            result([FlutterError errorFromCallKitError:error]);        } else {            result(nil);        }    }];}if ([@"Plugin.openSettings" isEqualToString:call.method]) {    if (@available(iOS 13.4, *)) {        CXCallDirectoryManager *manager             = CXCallDirectoryManager.sharedInstance;        [manager             openSettingsWithCompletionHandler:^(NSError * _Nullable error) {            if (error) {                result([FlutterError errorFromCallKitError:error]);            } else {                result(nil);            }        }];    } else {        result([FlutterError errorLowiOSVersionWithMinimal:@"13.4"]);    }}


Готово, CallDirectoryManager реализован и может быть использован.


Подробнее про Platform-Flutter взаимодействие



App Extension и хранилище номеров


Так как из-за нахождения Call Directory в iOS расширении мы не сможем предоставить его реализацию с плагином, а работа с платформенным кодом обычно непривычна для Flutter разработчиков, не знакомых с нативной разработкой, постараемся по максимуму помочь им с помощью Документации!


Реализуем полноценный пример app extension и хранилища и подключим их к example app нашего плагина.


В качестве простейшего варианта хранилища используем UserDefaults, которые обернем в propertyWrapper.


Примерно так выглядит интерфейс нашего хранилища:


// Доступ к хранилищу из iOS приложения@UIApplicationMainfinal class AppDelegate: FlutterAppDelegate {    @UserDefault("blockedNumbers", defaultValue: [])    private var blockedNumbers: [BlockableNumber]    @UserDefault("identifiedNumbers", defaultValue: [])    private var identifiedNumbers: [IdentifiableNumber]}// Доступ к хранилищу из app extensionfinal class CallDirectoryHandler: CXCallDirectoryProvider {    @UserDefault("blockedNumbers", defaultValue: [])    private var blockedNumbers: [BlockableNumber]    @UserDefault("identifiedNumbers", defaultValue: [])    private var identifiedNumbers: [IdentifiableNumber]    @NullableUserDefault("lastUpdate")    private var lastUpdate: Date?}


Код имплементации хранилища:


UserDefaults


Код iOS приложения:


iOS App Delegate


Код iOS расширения:


iOS App Extension


Обратите внимание, что примеры хранилища и расширения это не часть плагина, а часть example приложения, идущего в комплекте с ним.


Передача номеров из Flutter в iOS и обратно


Итак, app extension настроен и связан с хранилищем, необходимые методы CallDirectoryManager реализованы, осталась последняя деталь научиться передавать номера из Flutter в платформенное хранилище или, наоборот, запрашивать номера оттуда.


Наиболее простым вариантом кажется взвалить передачу данных на пользователя плагина, тогда ему придется самостоятельно организовывать MethodChannel или использовать другие сторонние решения по управлению хранилищем. И, безусловно, кому-то это даже подойдет! :) А для остальных сделаем простое и удобное API, чтобы пробрасывать номера прямо через наш фреймворк. Этот функционал будем делать опциональным, чтобы не ограничивать тех, кому удобнее использовать свои способы передачи данных.



Интерфейс


Посмотрим, какие интерфейсы могут понадобиться:


  • Добавление блокируемых/идентифицируемых номеров в хранилище
  • Удаление блокируемых/идентифицируемых номеров из хранилища
  • Запрос блокируемых/идентифицируемых номеров из хранилища


On the Flutter side


Для методов-хелперов мы ранее решили использовать классы плагина FCXPlugin (Flutter) и FlutterCallkitPlugin (iOS). Однако Call Directory является узкоспециализированным функционалом, который используется далеко не в каждом проекте. Поэтому хотелось вынести это в отдельный файл, но оставить доступ через объект класса FCXPlugin, для этого подойдет extension:


extension FCXPlugin_CallDirectoryExtension on FCXPlugin {  Future<List<FCXCallDirectoryPhoneNumber>> getBlockedPhoneNumbers()    async { }  Future<void> addBlockedPhoneNumbers(    List<FCXCallDirectoryPhoneNumber> numbers,  ) async { }  Future<void> removeBlockedPhoneNumbers(List<FCXCallDirectoryPhoneNumber> numbers,  ) async { }  Future<void> removeAllBlockedPhoneNumbers() async { }  Future<List<FCXIdentifiablePhoneNumber>> getIdentifiablePhoneNumbers()    async { }  Future<void> addIdentifiablePhoneNumbers(List<FCXIdentifiablePhoneNumber> numbers,  ) async { }  Future<void> removeIdentifiablePhoneNumbers(List<FCXCallDirectoryPhoneNumber> numbers,  ) async { }  Future<void> removeAllIdentifiablePhoneNumbers() async { }}


On the iOS side


Чтобы со стороны Flutter получить доступ к номерам, которые находятся в неком хранилище на стороне iOS, пользователю плагина нужно будет как-то связать свою базу номеров с плагином. Для этого дадим ему такой интерфейс:



@interface FlutterCallkitPlugin : NSObject<FlutterPlugin>@property(strong, nonatomic, nullable)NSArray<FCXCallDirectoryPhoneNumber *> *(^getBlockedPhoneNumbers)(void);@property(strong, nonatomic, nullable)void(^didAddBlockedPhoneNumbers)(NSArray<FCXCallDirectoryPhoneNumber *> *numbers);@property(strong, nonatomic, nullable)void(^didRemoveBlockedPhoneNumbers)(NSArray<FCXCallDirectoryPhoneNumber *> *numbers);@property(strong, nonatomic, nullable)void(^didRemoveAllBlockedPhoneNumbers)(void);@property(strong, nonatomic, nullable)NSArray<FCXIdentifiablePhoneNumber *> *(^getIdentifiablePhoneNumbers)(void);@property(strong, nonatomic, nullable)void(^didAddIdentifiablePhoneNumbers)(NSArray<FCXIdentifiablePhoneNumber *> *numbers);@property(strong, nonatomic, nullable)void(^didRemoveIdentifiablePhoneNumbers)(NSArray<FCXCallDirectoryPhoneNumber *> *numbers);@property(strong, nonatomic, nullable)void(^didRemoveAllIdentifiablePhoneNumbers)(void);@end


Для каждого типа взаимодействия с хранилищем предусмотрен свой обработчик, который будет вызываться нашим фреймворком каждый раз, когда с Flutter стороны был вызван соответствующий хелпер-метод.


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


Реализация


Теперь реализуем связь между объявленными методами-хелперами во Flutter и обработчиками в iOS.


Методов много, а работают они +- одинаково, поэтому будем рассматривать два из них с противоположным направлением движения данных.

Get identifiable numbers



On the Flutter side


Future<List<FCXIdentifiablePhoneNumber>> getIdentifiablePhoneNumbers() async {  try {    // Вызываем платформенный метод и сохраняем результат    List<dynamic> numbers = await _methodChannel.invokeMethod(      'Plugin.getIdentifiablePhoneNumbers',    );    // Типизируем результат и возвращаем пользователю    return numbers      .map(        (f) => FCXIdentifiablePhoneNumber(f['number'], label: f['label']))      .toList();  } on PlatformException catch (e) {    throw FCXException(e.code, e.message);  }}


On the iOS side


if ([@"Plugin.getIdentifiablePhoneNumbers" isEqualToString:call.method]) {    if (!self.getIdentifiablePhoneNumbers) {        // Проверяем существует-ли обработчик,        // если нет  возвращаем ошибку        result([FlutterError errorHandlerIsNotRegistered:@"getIdentifiablePhoneNumbers"]);        return;    }    // Используя обработчик, запрашиваем номера у пользователя    NSArray<FCXIdentifiablePhoneNumber *> *identifiableNumbers        = self.getIdentifiablePhoneNumbers();    NSMutableArray<NSDictionary *> *phoneNumbers        = [NSMutableArray arrayWithCapacity:identifiableNumbers.count];    // Оборачиваем каждый номер в словарь,     // чтобы иметь возможность передать их через MethodChannel     for (FCXIdentifiablePhoneNumber *identifiableNumber in identifiableNumbers) {        NSMutableDictionary *dictionary             = [NSMutableDictionary dictionary];        dictionary[@"number"]             = [NSNumber numberWithLongLong:identifiableNumber.number];        dictionary[@"label"]             = identifiableNumber.label;        [phoneNumbers addObject:dictionary];    }    // Отправляем номера во Flutter    result(phoneNumbers);}


Add identifiable numbers



On the Flutter side


Future<void> addIdentifiablePhoneNumbers(  List<FCXIdentifiablePhoneNumber> numbers,) async {  try {    // Готовим номера для передачи через MethodChannel    List<Map> arguments = numbers.map((f) => f._toMap()).toList();    // Отправляем номера в нативный код    await _methodChannel.invokeMethod(      'Plugin.addIdentifiablePhoneNumbers',      arguments    );  } on PlatformException catch (e) {    throw FCXException(e.code, e.message);  }}


On the iOS side


if ([@"Plugin.addIdentifiablePhoneNumbers" isEqualToString:call.method]) {    if (!self.didAddIdentifiablePhoneNumbers) {        // Проверяем существует-ли обработчик,        // если нет  возвращаем ошибку        result([FlutterError errorHandlerIsNotRegistered:@"didAddIdentifiablePhoneNumbers"]);        return;    }    // Достаем переданные в аргументах номера    NSArray<NSDictionary *> *numbers = call.arguments;    if (isNull(numbers)) {        // Проверяем их валидность        result([FlutterError errorInvalidArguments:@"numbers must not be null"]);        return;    }    NSMutableArray<FCXIdentifiablePhoneNumber *> *identifiableNumbers        = [NSMutableArray array];    // Типизируем номера    for (NSDictionary *obj in numbers) {        NSNumber *number = obj[@"number"];        __auto_type identifiableNumber            = [[FCXIdentifiablePhoneNumber alloc] initWithNumber:number.longLongValue                                                                                     label:obj[@"label"]];        [identifiableNumbers addObject:identifiableNumber];    }    // Отдаём типизированные номера в обработчик пользователю    self.didAddIdentifiablePhoneNumbers(identifiableNumbers);    // Сообщаем во Flutter о завершении операции    result(nil);}


Остальные методы реализуются по аналогии, полный код:




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


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



Reload extension


Метод reloadExtension(withIdentifier:completionHandler:) используется для перезагрузки расширения Call Directory. Это может потребоваться, например, после добавления новых номеров в хранилище, чтобы они попали в CallKit.


Использование идентично нативному CallKit API: обращаемся к FCXCallDirectoryManager и запрашиваем перезагрузку по заданному extensionIdentifier:


final String _extensionID =  'com.voximplant.flutterCallkit.example.CallDirectoryExtension';Future<void> reloadExtension() async {  await FCXCallDirectoryManager.reloadExtension(_extensionID);}


Get identified numbers



On the Flutter side


Запрашиваем список идентифицируемых номеров через класс плагина из Flutter:


final FCXPlugin _plugin = FCXPlugin();Future<List<FCXIdentifiablePhoneNumber>> getIdentifiedNumbers() async {  return await _plugin.getIdentifiablePhoneNumbers();}


On the iOS side


Добавляем обработчик getIdentifiablePhoneNumbers, который плагин использует для передачи заданных номеров во Flutter. Будем передавать в него номера из нашего хранилища identifiedNumbers:


private let callKitPlugin = FlutterCallkitPlugin.sharedInstance@UserDefault("identifiedNumbers", defaultValue: [])private var identifiedNumbers: [IdentifiableNumber]// Добавляем обработчик событий запроса номеровcallKitPlugin.getIdentifiablePhoneNumbers = { [weak self] in    guard let self = self else { return [] }    // Возвращаем номера из хранилища в обработчик    return self.identifiedNumbers.map {        FCXIdentifiablePhoneNumber(number: $0.number, label: $0.label)    }}


Теперь номера из пользовательского хранилища будут попадать в обработчик, а из него через плагин во Flutter.



Add identified numbers



On the Flutter side


Передаем номера, которые хотим идентифицировать, в объект плагина:


final FCXPlugin _plugin = FCXPlugin();Future<void> addIdentifiedNumber(String number, String id) async {  int num = int.parse(number);  var phone = FCXIdentifiablePhoneNumber(num, label: id);  await _plugin.addIdentifiablePhoneNumbers([phone]);}


On the iOS side


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


private let callKitPlugin = FlutterCallkitPlugin.sharedInstance@UserDefault("identifiedNumbers", defaultValue: [])private var identifiedNumbers: [IdentifiableNumber]// Добавляем обработчик событий добавления номеровcallKitPlugin.didAddIdentifiablePhoneNumbers = { [weak self] numbers in    guard let self = self else { return }    // Сохраняем в хранилище номера, переданные плагином в обработчик    self.identifiedNumbers.append(        contentsOf: numbers.map {            IdentifiableNumber(identifiableNumber: $0)        }    )    // Номера в Call Directory обязательно должны быть отсортированы    self.identifiedNumbers.sort()}


Теперь номера из Flutter будут попадать в плагин, из него в обработчик события, а оттуда в пользовательское хранилище номеров. При следующей перезагрузке Call Directory расширения они станут доступны CallKit для идентификации звонков.


Полные примеры:




Итог


У нас получилось дать возможность использовать CallKit Call Directory из Flutter!


Детали платформенных коммуникаций по прежнему скрыты в недрах плагина, нативное API сохранено, а необходимая к написанию пользовательская iOS реализация хорошо задокументирована.


Теперь во Flutter можно относительно просто блокировать и/или определять номера с помощью нативного Call Directory.



Пример работы с Call Directory в Flutter приложении с использованием flutter_callkit_voximplant



Результаты:


  • Интерфейс CallDirectoryManager полностью перенесен
  • Добавлен простой способ передачи номеров из Flutter кода в iOS, оставлена возможность использовать собственные решения передачи данных
  • Архитектура решения описана в README с визуальными схемами для лучшего понимания
  • Добавлен полноценный работоспособный example app, использующий всю функциональность Call Directory, реализующий пример платформенных модулей (таких как iOS расширение и хранилище данных)


Полезные ссылки


Source код flutter_callkit на GitHub


Example app код на GitHub


Полная документация по использованию Call Directory с flutter_callkit


CallKit Framework Documentation by Apple


App Extension Programming Guide by Apple


Writing custom platform-specific code by Flutter

Подробнее..

Перевод RED Улучшение качества звука с помощью резервирования

04.09.2020 14:06:16 | Автор: admin

Еще в апреле 2020 года Citizenlab сообщил о довольно слабом шифровании Zoom и заявил, что Zoom использует аудиокодек SILK. К сожалению, статья не содержала исходных данных, чтобы это подтвердить и дать мне возможность обращаться к ней в дальнейшем. Однако благодаря Натали Сильванович из Google Project Zero и инструменту трассировки Frida я смог получить дамп некоторых необработанных кадров SILK. Их анализ вдохновил меня взглянуть на то, как WebRTC обрабатывает звук. Что касается восприятия качества вызова в целом, больше всего на него влияет качество звука, поскольку мы склонны замечать даже небольшие сбои. Всего десяти секунд анализа было достаточно, чтобы отправиться в настоящее приключение на поиски возможных улучшений качества звука, обеспечиваемых WebRTC.

Я имел дело с нативным клиентом Zoom еще в 2017 году (до поста DataChannel) и обратил внимание, что его аудиопакеты иногда были очень большими в сравнении с пакетами решений на базе WebRTC:

На приведенном выше графике показано количество пакетов с определенной длиной полезной нагрузки UDP. Пакеты длиной от 150 до 300 байт необычное явление, если сравнивать с типичным вызовом WebRTC. Они намного длиннее, чем пакеты, которые мы обычно получаем от Opus. Мы заподозрили наличие упреждающего контроля ошибок (FEC) или резервирования, но без доступа к незашифрованным кадрам было трудно сделать еще какие-то выводы или что-то предпринять.

Незашифрованные кадры SILK в новом дампе показали очень похожее распределение. После конвертации кадров в файл и последующего воспроизведения короткого сообщения (спасибо Джакомо Вакке за очень полезный пост в блоге, описывающий необходимые шаги) я вернулся в Wireshark и посмотрел на пакеты. Вот пример из трех пакетов, который мне показался особенно интересным:

packet 7:e9e4ab17ad8b9b5176b1659995972ac9b63737f8aa4d83ffc3073d3037b452fe6e1ee5e6e68e6bcd73adbd59d3d31ea5fdda955cbb7fpacket 8: e790ba4908639115e02b457676ea75bfe50727bb1c44144d37f74756f90e1ab926ef930a3ffc36c6a8e773a780202af790acfbd6a4dff79698ea2d96365271c3dff86ce6396203453951f00065ec7d26a03420496fpacket 9:e93997d503c0601e918d1445e5e985d2f57736614e7f1201711760e4772b020212dc854000ac6a80fb9a5538741ddd2b5159070ebbf79d5d83363be59f10efe790ba4908639115e02b457676ea75bfe50727bb1c44144d37f74756f90e1ab926ef930a3ffc36c6a8e773a780202af790acfbd6a4dff79698ea2d96365271c3dff86ce6396203453951f00065ec7d26a03420496fe9e4ab17ad8b9b5176b1659995972ac9b63737f8aa4d83ffc3073d3037b452fe6e1ee5e6e68e6bcd73adbd59d3d31ea5fdda955cbaef

Пакет 9 содержит два предыдущих пакета, пакет 8 1 предыдущий пакет. Такая избыточность вызвана использованием формата LBRR Low Bit-Rate Redundancy, как показало глубокое изучение декодера SILK (который можно найти в интернет-проекте, представленном командой Skype или в репозитории GitHub):


Zoom использует SKP_SILK_LBRR_VER1, но с двумя резервными пакетами. Если каждый UDP пакет содержит не только текущий аудиокадр, но и два предыдущих, он будет устойчивым, даже если вы потеряете два из трех пакетов. Так, может быть, ключ к качеству звука Zoom это секретный рецепт бабушкиного Skype?

Opus FEC


Как добиться того же с помощью WebRTC? Следующим очевидным шагом было рассмотрение Opus FEC.

LBRR, низкоскоростное резервирование, от SILK, также есть и в Opus (помните, что Opus это гибридный кодек, который использует SILK для нижнего конца диапазона битрейта). Тем не менее Opus SILK сильно отличается от оригинального SILK, исходный код которого когда-то открыл Skype, как и часть LBRR, которая используется в режиме контроля ошибок.

В Opus контроль ошибок не просто добавляется после исходного аудиокадра, он предшествует ему и кодируется в потоке битов. Мы пробовали экспериментировать с добавлением нашего собственного контроля ошибок с помощью Insertable Streams API, но это требовало полного перекодирования для вставки информации в битовый поток перед фактическим пакетом.


Хотя усилия и не увенчались успехом, они позволили собрать некоторые статистические данные о влиянии LBRR, которые продемонстрированы на рисунке выше. LBRR использует битрейт до 10 кбит/с (или две трети скорости передачи данных) при больших потерях пакетов. Репозиторий доступен по ссылке. Данная статистика не отображается при вызове WebRTC getStats() API, поэтому результаты оказались весьма занимательными.

Помимо проблемы с необходимостью перекодировки, Opus FEC в WebRTC, как выяснилось, настроен несколько бесполезно:

  • Он активируется только при потере пакетов, а мы хотели бы, чтобы резервная информация хранилась и до возникновения каких-либо проблем. Ребята из Slack писали об этом еще в 2016 году. Это означает, что мы не можем включить его по умолчанию и защитить себя от случайных нерегулярных потерь.
  • Объем прямого исправления ошибок ограничен 25%. Выше этого значения оно неэффективно.
  • Битрейт для FEC вычитается из целевого максимального битрейта (см. здесь).

Вычитание битрейта FEC из целевого максимального битрейта совершенно не имеет смысла FEC активно снижает битрейт основного потока. Поток с более низким битрейтом обычно приводит к снижению качества. Если нет потери пакетов, которую можно исправить с помощью FEC, то FEC только ухудшит качество, а не улучшит его. Почему так происходит? Основная теория состоит в том, что одной из причин потери пакетов является перегрузка. Если вы столкнулись с перегрузкой, вы не захотите отправлять больше данных, потому что это только усугубит проблему. Однако, как описывает Эмиль Ивов в своем замечательном выступлении на KrankyGeek от 2017 года, перегрузка не всегда является причиной потери пакетов. Кроме того, этот подход также игнорирует любые сопутствующие видеопотоки. Стратегия FEC на основе перегрузок для аудио Opus не имеет особого смысла, когда вы отправляете сотни килобит видео вместе с относительно небольшим потоком Opus со скоростью 50 кбит/с. Возможно, в будущем мы увидим какие-то изменения в libopus, а пока хотелось бы попробовать отключить его, ведь в настоящее время он включен в WebRTC по умолчанию.

Делаем вывод, что это не работает

RED


Если нам нужно реальное резервирование, у RTP есть решение под названием RTP Payload for Redundant Audio Data, или RED. Оно довольно старое, RFC 2198 был написан в 1997 году. Решение позволяет помещать несколько полезных нагрузок RTP с различными временными метками в один и тот же RTP пакет при относительно небольших затратах.

Использование RED для помещения одного или двух резервных аудиокадров в каждый пакет дало бы гораздо большую устойчивость к потере пакетов, чем Opus FEC. Но это возможно лишь путем удвоения или утроения битрейта аудио с 30 кбит/с до 60 или 90 кбит/с (с дополнительными 10 кбит/с для заголовка). Хотя по сравнению с более чем 1 мегабитом видеоданных в секунду это не так уж плохо.

Библиотека WebRTC включала в себя второй кодер и декодер для RED, что теперь стало излишним! Несмотря на попытки удалить неиспользуемый audio-RED-code, мне удалось применить этот кодер, прилагая относительно небольшие усилия. Полная история решения есть в системе отслеживания багов WebRTC.

И оно доступно в виде пробной версии, включаемой при запуске Chrome со следующими флагами:
--force-fieldtrials=WebRTC-Audio-Red-For-Opus/Enabled/

Затем RED может быть включен через SDP согласование; он отобразится следующим образом:
a=rtpmap:someid red/48000/2

По умолчанию он не включен, поскольку есть среды, где использование дополнительной пропускной способности не очень хорошая идея. Чтобы использовать RED, измените порядок следования кодеков так, чтобы он был перед кодеком Opus. Это можно сделать, используя API RTCRtpTransceiver.setCodecPreferences, как показано здесь. Очевидно, что другой альтернативой является изменение SDP вручную. Формат SDP также мог бы обеспечить способ настройки максимального уровня резервирования, но семантика предложение-ответ в RFC 2198 была не до конца ясна, поэтому я решил отложить это на время.

Продемонстрировать, как это все работает можно с помощью запуска в аудиопримере. Так выглядит ранняя версия с одним пакетом резервирования:


По умолчанию битрейт полезной нагрузки (красная линия) почти в два раза выше, чем без резервирования на скорости почти 60 кбит/сек. DTX (прерывистая передача) это механизм сохранения пропускной способности, который посылает пакеты только при обнаружении голоса. Как и ожидалось, при использовании DTX влияние битрейта несколько смягчается, как мы видим в конце вызова.



Проверка длины пакетов демонстрирует ожидаемый результат: пакеты в среднем в два раза длиннее (выше) по сравнению с нормальным распределением длины полезной нагрузки, показанным ниже.

Это все еще немного отличается от того, что делает Zoom, где мы наблюдали частичное резервирование. Давайте повторим график длины пакетов Zoom, приведенный ранее, чтобы посмотреть на них в сравнении:

Добавление поддержки обнаружения голосовой активности (VAD)


Opus FEC отправляет резервные данные только в том случае, если в пакете присутствует голосовая активность. То же самое должно быть применено и к реализации RED. Для этого кодировщик Opus должен быть изменён для отображения корректной информации о VAD, которая определяется на уровне SILK. При такой настройке битрейт достигает 60 кбит/с только при наличии речи (в сравнении с постоянными 60+ кбит/с):


и спектр становится больше похож на то, что мы видели у Zoom:


Изменение, позволяющее этого достичь, еще не появилось.

Поиск правильного расстояния


Расстояние это количество резервных пакетов, то есть количество предыдущих пакетов в текущем. В процессе работы над поиском правильного расстояния мы обнаружили, что если RED с расстоянием 1 это круто, то RED с расстоянием 2 еще круче. Наша лабораторная оценка моделировала случайную потерю пакетов = 60%. В этой среде Opus + RED воспроизводил отличный звук, в то время как Opus без RED показывал себя сильно хуже. WebRTC getStats() API дает очень полезную возможность измерить это, сравнивая процент скрытых сэмплов, получаемый путем деления concealedSamples на totalSamplesReceived.

На странице аудиосэмплов эти данные легко получить с помощью данного фрагмента кода JavaScript, вставленного в консоль:
(await pc2.getReceivers()[0].getStats()).forEach(report => {  if(report.type === "track") console.log(report.concealmentEvents, report.concealedSamples, report.totalSamplesReceived, report.concealedSamples / report.totalSamplesReceived)})

Я провел пару тестов с потерей пакетов, используя не очень известный, но очень полезный флаг WebRTCFakeNetworkReceiveLossPercent:
--force-fieldtrials=WebRTC-Audio-Red-For-Opus/Enabled/WebRTCFakeNetworkReceiveLossPercent/20/

При 20% потерях пакетов и включенном по умолчанию FEC не было большой разницы в качестве звука, но была небольшая разница в метрике:
сценарий процент потерь
без red 18%
без red, FEC отключен 20%
red с расстоянием 1 4%
red с расстоянием 2 0.7%

Без RED или FEC метрика почти совпадает с запрошенной потерей пакетов. Есть эффект от FEC, но он невелик.

Без RED при потере 60% качество звука стало довольно плохим, немного металлическим, а слова трудными для понимания:
сценарий процент потерь
без red 60%
red с расстоянием 1 32%
red с расстоянием 2 18%

Были некоторые слышимые артефакты при RED с расстоянием = 1, но почти идеальный звук с расстоянием 2 (что является количеством избыточности, которое используется в настоящее время).
Есть ощущение, что человеческий мозг может выдержать какой-то определенный уровень тишины, возникающей нерегулярно. (А Google Duo, судя по всему, использует алгоритм машинного обучения, чтобы чем-то тишину заполнить).

Измерение производительности в реальном мире


Мы надеемся, что включение RED в Opus улучшит качество звука, хотя в отдельных случаях может сделать и хуже. Эмиль Ивов вызвался провести пару тестов прослушивания по методу POLQA-MOS. Это было сделано в прошлом для Opus, так что у нас есть исходные данные для сравнения.
Если первоначальные тесты покажут многообещающий результат, то мы проведем масштабный эксперимент на основной развертке Jitsi Meet, применяя процентные метрики потерь, которые мы использовали выше.

Обратите внимание, что для медиасерверов и SFU включение RED происходит немного сложнее, поскольку серверу может потребоваться управлять RED ретрансляцией для выбора клиентов, как в случае, если не у всех клиентов поддерживаются конференции RED. Также некоторые клиенты могут находиться на канале с ограниченной пропускной способностью, где RED не требуется. Если конечная точка не поддерживает RED, SFU может удалить ненужное кодирование и отправить Opus без обертки. Аналогичным образом он может реализовать сам RED и использовать его при повторной отправке пакетов от конечной точки, передающей Opus, на конечную точку, поддерживающую RED.

Большое спасибо Jitsi/88 Inc за спонсирование этого приключения и ребятам из Google, которые рассмотрели/предоставили фидбек о необходимых изменениях.

А без Натали Сильванович я бы так и застрял, глядя на зашифрованные байты!
Подробнее..

Перевод Машинное обучение в Hum to Search от Google

04.12.2020 16:09:39 | Автор: admin

Навязчивые мелодии (англ. earworms) хорошо известное и порой раздражающее явление. Как только одна из таких застревает в голове, от нее бывает довольно трудно избавиться. Исследования показали, что так называемое взаимодействие с оригинальной композицией, будь то ее прослушивание или пропевание, помогает прогнать навязчивую мелодию. Но что, если вы не можете вспомнить название песни, а можете только напеть мотив?

При использовании существующих методов сравнения мелодии, которую мы пытаемся воспроизвести, с ее исходной полифонической студийной записью возникает ряд сложностей. Звук концертной или студийной записи с текстом, бэк-вокалом и инструментами может сильно отличаться от того, что можем напеть мы. Кроме того, по ошибке или по задумке в нашей версии могут быть совсем не те высота, тональность, темп или ритм песни. Вот почему так много текущих подходов, применяемых к системе query by humming, сопоставляют напетую мелодию с базой данных уже существующих мелодий или других напевок этой песни вместо того, чтобы идентифицировать ее напрямую. Однако этот тип подходов часто основан на ограниченной базе данных, требующей обновления вручную.

Запущенная в октябре функция Hum to Search представляет собой новую полностью машинно-обучаемую систему Google Search, которая позволяет человеку найти песню, если он напоет или промычит ее. В отличие от существующих методов, этот подход создает эмбеддинг из спектрограммы песни, минуя создание промежуточного представления. Это позволяет модели сравнивать нашу мелодию непосредственно с исходной (полифонической) записью без необходимости иметь другую напевку или MIDI-версию каждого трека. Также не требуется использовать сложную созданную вручную логику для извлечения мелодии. Такой подход значительно упрощает базу данных для Hum to Search, позволяя постоянно добавлять в нее эмбеддинги оригинальных треков со всего мира, даже последние релизы.

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


Многие существующие системы распознавания музыки перед обработкой аудиосэмпла преобразуют его в спектрограмму, чтобы найти более правильное совпадение. Однако есть одна проблема в распознавании напетой мелодии она зачастую содержит относительно мало информации, как в этом примере песни Bella Ciao. Разницу между напетой версией и тем же сегментом из соответствующей студийной записи можно визуализировать с помощью спектрограмм, показанных ниже:


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

Учитывая изображение слева, модель должна найти аудио, соответствующее правому изображению, в коллекции из более чем 50 миллионов похожих изображений (соответствующих сегментам студийных записей других песен). Для этого модель должна научиться фокусироваться на доминирующей мелодии и игнорировать бэк-вокал, инструменты и тембр голоса, а также различия, возникающие из-за фонового шума или реверберации. Чтобы на глаз определить доминирующую мелодию, которая могла бы использоваться для сопоставления этих двух спектрограмм, вы можете поискать сходство в линиях в нижней части приведенных изображений.

Предшествующие попытки реализовать поиск музыки, в частности, в контексте распознавания музыки, звучащей в кафе или клубах, продемонстрировали, как к этой проблеме может быть применено машинное обучение. Now Playing, выпущенный в 2017 году для телефонов Pixel, использует встроенную глубокую нейросеть для распознавания песен без необходимости подключения к серверу, а Sound Search, позже разработавший эту технологию, использует ее для распознавания на базе сервера для более быстрого и точного поиска более 100 миллионов песен. Следующей задачей стало применение того, что было изучено в этих релизах, для распознавания музыки из аналогично большой библиотеки, но уже по напетым отрывкам.

Настраиваем машинное обучение


Первым шагом в развитии Hum to Search было изменение моделей распознавания музыки, используемых в Now Playing и Sound Search, для работы с записями напевок. В принципе, многие подобные поисковые системы (например, распознавание изображений) работают аналогичным образом. Для обучения нейронная сеть получает на вход пару (напевку и исходную запись) для создания эмбеддинга для каждого входа, которые позже будут использоваться для сопоставления с напетой мелодией.


Настройка обучения нейронной сети

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

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

Данные для обучения


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

Для улучшения работы модели на мелодиях без слов мы сгенерировали дополнительные тренировочные данные с искусственным мычанием из существующего набора аудиоданных с использованием SPICE, модели извлечения высоты тона, разработанной нашей расширенной командой в рамках проекта FreddieMeter. SPICE извлекает значения высоты из заданного звука, которые мы затем используем для генерации мелодии, состоящей из дискретных звуковых тонов. Самая первая версия этой системы трансформировала данный оригинальный отрывок вот в это.


Генерация мычания из спетого аудиофрагмента

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

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

Улучшение машинного обучения


Обучая модель Hum to Search, мы начали с функции triplet loss. Как было показано, она хорошо справляется с различными задачами классификации, такими как классификация изображений или записанной музыки. Если дана пара аудио, соответствующая одной и той же мелодии (точки R и P в пространстве вложения, показанном ниже), функции triplet loss будет игнорировать определенные части обучающих данных, полученных из другой мелодии. Это помогает улучшить поведение при обучении как в случае, если модель находит другую мелодию, которая слишком простая и уже далеко от R и P (см. точку E), так и в случае, когда она слишком сложна, учитывая текущий этап обучения модели, и оказывается слишком близкой к R но по нашим данным, она представляет собой другую мелодию (см. точку H).


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

Мы обнаружили, что можем повысить точность модели, приняв во внимание эти дополнительные обучающие данные (точки H и E), а именно сформулировав общее понятие уверенности модели на серии примеров: насколько уверена модель, что все данные, которые она видит, можно классифицировать правильно, или она видит примеры, которые не соответствуют ее нынешнему пониманию? Основываясь на этом понятии, мы добавили потерю, которая приближает уверенность модели к 100% во всех областях встраиваемого пространства, что привело к повышению точности и запоминаемости нашей модели.

Вышеупомянутые изменения, в частности вариация, аугментация и комбинация обучающих данных, позволили модели нейросети, используемой в поиске Google, распознавать спетые или напетые мелодии. Текущая система достигает высокого уровня точности в базе данных из более полумиллиона песен, которые мы постоянно обновляем. Этой коллекции песен еще есть куда расти, чтобы включить в него больше музыки со всего света.

Чтобы потестить эту фичу, откройте последнюю версию приложения Google, нажмите на иконку микрофона и скажите What's this song или кликните на Search a song. Теперь можете напеть или насвистеть мелодию! Надеемся, Hum to Search поможет избавиться от навязчивых мелодий или же просто найти и послушать какой-то трек, не вводя его название.
Подробнее..

Перевод Почему стоит обратить внимание на подход low-codeno-code

16.02.2021 16:13:42 | Автор: admin

Все мы в последнее время довольно много слышим о платформах low-code/no-code. Платформы без кода обещают сделать разработку программного обеспечения столь же простой, как использование Wordа или PowerPointа, чтобы обычный бизнес-пользователь смог продвигать проекты без дополнительных затрат (денег и времени) на команду инженеров. В отличие от платформ без кода, low-code по-прежнему требует определенных навыков программирования, однако обещает ускорить разработку программного обеспечения, позволяя разработчикам работать с предварительно написанными компонентами кода.

Согласно Gartner, к 2024 году 65% разработанных приложений будут относиться low-code.

Еще в 2017 году я участвовал в раннем сравнительном тестировании производительности традиционной разработки (с использованием Java) и проектом low-code/no-code, основанном на моделях. Результаты были впечатляющими: при использовании метода low-code/no-code производительность увеличивалась в 5-7 раз. Опрос, проведенный компанией No-Code Census в 2020 году, показал прирост производительности в 4,6 раза по сравнению с традиционным программированием.

Low-code/no-code: Фрагментация платформы

Область low-code/no-code довольна сложна и включает в себя многочисленные решения, платформы и субрынки. Например, существуют субрынки, ориентированные на крупные, средние и малые предприятия. Корпоративные платформы low-code/no-code обеспечивают высокую масштабируемость, производительность, безопасность и интеграцию с приложениями предприятия. Они, как правило, дороже остальных. Ниже представлен Магический Квадрант Gartner для корпоративных low-code платформ:

Gartner дает платформе low-code (LCAP) следующее определение: Это платформа, которая поддерживает быструю разработку приложений, одноэтапную раскатку, выполнение и управление с использованием декларативных абстракций программирования высокого уровня, таких как языки программирования на основе моделей и метаданных.

G2 предлагает аналогичный обзор для малого бизнеса. Рынок платформ для малого бизнеса и для корпораций не особо пересекается. Некоторые поставщики платформ для малого бизнеса не столь известны и популярны в больших корпорациях. Аналогично малые и средние предприятия обычно не склонны покупать корпоративные платформы, в первую очередь из-за их цены и сложности.

Неудивительно, что многие платформы low-code являются платформами управления бизнес-процессами. BPM уже давно поддерживает разработку на основе моделей (Model-driven Development), где нужно нарисовать диаграммы, объясняющие, как должно работать программное обеспечение, прежде чем его создавать. Эта схема похожа на процессный подход BPM, при котором для задания бизнес-процесса необходимо в правильном порядке расположить блоки, представляющие собой подпроцессы. (Самым популярным стандартом отображения процессов, поддерживаемым большинством BPM-платформ, является BPMN). Поэтому процессно-ориентированные решения достаточно популярны. Примерами low-code/no-code платформ для BPM являются Appian, Pega, Outsystems.

Но существуют и другие примеры под эгидой low-code/no-code:

Веб-платформы для использования предприятиями любого размера. Ведущими конкурентами являются WordPress, Wix, Squarespace и WebFlow.

Платформы управления базами данных, начиная от таких, как Mendix, и заканчивая такими, как Airtable. Существуют также low-code/no-code платформы баз данных NoSQL, например, KgBase, предназначенная для построения графов знаний.

Платформы с автоматизированной интеграцией, среди которых несколько новых и интересных, например, Zapier, Parabola и Integromat. С помощью них вы можете быстро разрабатывать мощные и сложные схемы интеграций. Вот пример рабочего процесса Parabola, в котором данные извлекаются из API, с ними выполняются некоторые действия, а затем данные отправляются в другой API. Процесс можно запускать по запросу, по расписанию или через вебхуки.

Разработка мобильных приложений. Большинство платформ low-code/no-code, таких как Bubble, предоставляют возможности адаптивного пользовательского интерфейса, другие предлагают встроенную поддержку ведущих мобильных OC (iOS и Android). Thunkable пожалуй, лучший пример low-code/no-code платформы для разработки мобильных приложений.

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

Другие категории платформ нацелены на определенные области или ниши приложений:

  • E-commerce и онлайн-магазины: лидирующим примером здесь является Shopify.

  • Управление рабочим процессом: отличный пример Monday.com.

  • Приложения ERP (планирование ресурсов предприятия): в качестве интересного примера (также указанного в MQ Gartner) можно привести Zoho. Еще одна важная и впечатляющая платформа для ERP и CRM это Salesforce.

  • Блокчейн и Интернет вещей: Atra.

  • Искусственный интеллект: сейчас мы начинаем наблюдать появление таких инструментов, как C3 AI Ex Machina.

Челленджи low-code/no-code

Платформы low-code/no-code имеют множество преимуществ, но в то же времясоздают определенные трудности и требуют обучения для работы с ними. Многие передовые практики только появляются и относительно незрелы, и следовательно, растет ответственность при их использовании. Что касается традиционного программирования, здесь накоплен огромный опыт, существуют сильные сообщества, а передовые практики задокументированы. Во многих отношениях low code/no-code находится в зачаточном состоянии даже несмотря на то, что разработка на основе моделей существует уже давно (особенно платформы BPM).

Вот некоторые из наиболее серьезных проблем:

1. Необходимость изменения культуры: low-code/no-code требует изменения культуры организации, будь то корпорация или стартап. Изменить культуру для избавления от лишних процессов непросто, это требует схожего видения и одобрения руководства, а также выделения бюджета и полномочий для центра компетенций по цифровой трансформации low-code/no-code.

2. Время и усилия на изучение платформ: low-code/no-code увеличивает скорость и производительность, но инструменты и платформы нетривиальны, и для развития необходимого уровня владения ими требуется время. Это один из наиболее неправильно понимаемых аспектов low-code/no-code. Сложные программные конструкции, такие как вложенные циклы, не так уж и просты на любой платформе.

3. Необходимость использования нескольких платформ: одни платформы имеют более полную функциональность, другие нет. Unqork и Bubble, например, предназначены для любого сценария использования и поэтому предлагают множество вариантов интеграции с корпоративными системами. Кроме того, они могут взять много полезного из других компонентов, специализирующихся в определенных областях; например, Bubble вместе, скажем, с Parabola или плагином Zapier можно использовать для автоматической интеграции. С возможностями управления данными и интеграциями в Parabola или Zapier работать легче, чем с нативными от Bubble. Существуют и другие плагины или технологические компоненты, дополняющие платформы low-code/no-code: посмотрите, например, список технологических партнеров Unqork или полный список плагинов для Bubble.

4. Недостаточность ресурсов и поддержки сообщества: в мире существуют миллионы, или даже десятки миллионов разработчиков обычных языков программирования, множество онлайн-курсов, а также книги и материалы, доступные для таких языков, как Java или C#, есть множество сообществ и ресурсов для аутсорсинга. Совсем иначе дела обстоят для low-code/no-code, особенно для более новых платформ.

5. Сбивающее с толку ценообразование: корпоративные low-code/no-code платформы, как правило, неоправданно дороги. Платформы для среднего и малого рынка менее затратны, но, как правило, менее масштабируемы. А использование нескольких платформ для создания комплексного решения еще больше усложняет вопросы ценообразования.

Это лишь некоторые из основных проблем. Они ясно дают понять, что low-code/no-code не панацея. Тем не менее, такой подход остается серьезной тенденцией для разработки инновационных решений как для действующих предприятий, так и для стартапов.

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

Готовы ли вы к переходу на low-code/no-code?

Примечание переводчика: наша компания предоставляет как low-code/no-code омниканальный облачный контакт-центр Voximplant Kit с широкими возможностями автоматизации обслуживания клиентов, так и serverless-платформу Voximplant для традиционной разработки с набором API для создания голосовых, видео- и текстовых коммуникаций.

Подробнее..

Перевод Облачный гейминг с открытым исходным кодом на WebRTC p2p, мультиплеер, zero latency

20.07.2020 14:14:41 | Автор: admin

ПО как услуга, инфраструктура как услуга, платформа как услуга, коммуникационная платформа как услуга, видеоконференции как услуга, а что насчет облачных игр как услуги? Уже было предпринято несколько попыток создания облачных игр (Cloud Gaming), например, Stadia, недавно запущенная компанией Google. Stadia не новичок в WebRTC, но могут ли другие использовать WebRTC так же?

Тхань Нгуен (Thanh Nguyen) решил проверить эту возможность на своем опенсорсном проекте CloudRetro. CloudRetro основан на Pion, популярной WebRTC библиотеке на базе Go (спасибо Шону из группы разработчиков Pion за помощь в подготовке этой статьи). В данной статье Тхань делает обзор архитектуры своего проекта, а также рассказывает, что полезного он узнал и с какими челленджами столкнулся во время работы.

Вступление


В прошлом году, когда Google анонсировал Stadia, мне просто снесло крышу. Идея настолько уникальна и инновационна, что я постоянно задавался вопросом, как такое вообще возможно при существующих технологиях. Желание лучше разобраться в этой теме побудило меня создать свою версию опенсорсной облачной игры. Результат был просто фантастический. Ниже я хотел бы поделиться процессом работы над моим годичным проектом.

TLDR: короткая слайд-версия с основными моментами

Почему за облачными играми будущее


Я верю, что Cloud Gaming скоро станет новым поколением не только игр, но и других областей информатики. Облачные игры это вершина модели клиент/сервер. Такая модель максимизирует управление бэкэндом и минимизирует работу фронтенда за счет размещения игровой логики на удаленном сервере и потоковой передачи изображений/аудио клиенту. Сервер выполняет тяжелую обработку, поэтому клиент больше не зависит от аппаратных ограничений.

Google Stadia, по сути, позволяет играть в AAA-игры (т.е. высококлассные игры-блокбастеры) на интерфейсе вроде YouTube. Та же методология может быть применена и к другим тяжелым оффлайновым приложениям, таким как операционная система или 2D/3D графический дизайн и т.д. чтобы мы могли стабильно запускать их на устройствах с низкими техническими характеристиками на разных платформах.


Будущее этой технологии: представляете, если бы Microsoft Windows 10 работал в браузере Chrome?

Облачные игры технически сложны


Гейминг одна из тех редких областей, где требуется постоянная быстрая реакция пользователя. Если изредка мы встречаемся с задержкой в 2 секунды при клике на странице, это приемлемо. Видеопотоки в прямом эфире, как правило, отстают на несколько секунд, но все равно предлагают достаточное удобство в использовании. Однако, если игра часто задерживается на 500 мс, играть просто невозможно. Наша цель достичь чрезвычайно низкой задержки, чтобы разрыв между вводом и медиа был как можно меньше. Поэтому традиционный подход к потоковому видео здесь неприменим.


Общий шаблон облачной игры

Опенсорсный проект CloudRetro


Я решил создать тестовый образец облачной игры, чтобы проверить, возможно ли всё это при таких жестких сетевых ограничениях. Для проверки концепции я выбрал Golang, поскольку это наиболее знакомый мне язык и хорошо подходящий для данной реализации по многим другим причинам, как выяснилось позже. Go простой и развивается очень быстро; каналы в Go отлично подходят для управления многопоточностью.

Проект CloudRetro.io облачный игровой сервис с открытым исходным кодом для ретро-игры. Цель проекта привнести в традиционные ретро-игры наиболее комфортные игровые ощущения и добавить мультиплеер.
Подробно ознакомиться с проектом можно здесь: https://github.com/giongto35/cloud-game.

Функциональность CloudRetro


Для демонстрации всей мощи облачных игр в CloudRetro используются ретро-игры. Что позволяет получить множество уникальных игровых впечатлений.

  • Портативность игры
    • Мгновенное воспроизведение при открытии страницы; загрузка и установка не нужны
    • Работает в мобильном браузере, так что для запуска не нужно никакое программное обеспечение

  • Игровые сеансы можно совместно использовать на нескольких устройствах и хранить в облаке для следующего входа
  • Игру можно стримить, а можно играть в нее сразу несколькими пользователями:
    • Crowdplay типа TwitchPlayPokemon, только более кросплатформенный и более риалтаймовый
    • Оффлаин игры в онлаине. Играть могут много пользователеи без настроики сети. В Samurai Shodown теперь можно играть 2 игрокам по сети CloudRetro


    Демо-версия многопользовательской онлайн-игры на разных устройствах

    Инфраструктура


    Требования и стек технологий


    Ниже приведен список требований, которые я установил перед началом проекта.

    1. Один игрок
    Это требование может показаться не слишком важным и очевидным здесь, но это один из моих ключевых выводов, это позволяет облачным играм держаться как можно дальше от традиционных потоковых сервисов. Если мы сосредоточимся на однопользовательской игре, мы сможем избавиться от централизованного сервера или CDN, потому что нам не нужно делать потоковую передачу в массы. Вместо того, чтобы загружать потоки на поглощающий сервер или передавать пакеты на централизованный сервер WebSocket, сервисные потоки передаются пользователю напрямую через одноранговое соединение WebRTC.

    2. Медиапоток с низкой задержкой
    Читая про Stadia, я часто встречаю в некоторых статьях упоминание WebRTC. Я понял, что WebRTC выдающаяся технология, и она прекрасно подходит для использования в облачных играх. WebRTC это проект, который предоставляет веб-браузерам и мобильным приложениям связь в реальном времени через простой API. Он обеспечивает одноранговое соединение, оптимизирован для медиа и имеет встроенные стандартные кодеки, такие как VP8 и H264.

    Я отдал предпочтение обеспечению максимально комфортной работы пользователей, а не сохранению высокого качества графики. В алгоритме допустимы некоторые потери. В Google Stadia есть дополнительный шаг по уменьшению размера изображения на сервере, и кадры масштабируются до более высокого качества перед передачей одноранговым узлам.

    3. Распределенная инфраструктура с географической маршрутизацией
    Вне зависимости от того, насколько оптимизирован алгоритм сжатия и код, сеть все равно является решающим фактором, который больше всего способствует задержке. Архитектура должна иметь механизм сопряжения ближайшего к пользователю сервера для сокращения времени приема-передачи (RTT). Архитектура должна иметь 1 координатора и несколько потоковых серверов, распределенных по всему миру: Запад США, Восток США, Европа, Сингапур, Китай. Все потоковые серверы должны быть полностью изолированы. Система может регулировать свое распределение, когда сервер присоединяется к сети или выходит из нее. Таким образом, при большом трафике, добавление дополнительных серверов позволяет осуществлять горизонтальное масштабирование.

    4. Браузерная совместимость
    Облачные игры предстают в наилучшем свете, когда требует от пользователей по минимуму. Это значит, что есть возможность запуска в браузере. Браузеры помогают сделать игровой процесс максимально комфортным для пользователей, избавив их от установки программного и аппаратного обеспечения. Браузеры также помогают обеспечить кросс-платформенность для мобильных и десктопных версий. К счастью, WebRTC отлично поддерживается в различных браузерах.

    5. Четкое разделение игрового интерфейса и сервиса
    Я рассматриваю сервис облачных игр как платформу. У каждого должна быть возможность подключать к платформе что угодно. Сейчас я интегрировал LibRetro с сервисом облачных игр, потому что LibRetro предлагает красивый интерфейс игрового эмулятора для ретро-игр, таких как SNES, GBA, PS.

    6. Комнаты для мультиплеера, crowd play и внешнее связывание (deep-link) с игрой
    CloudRetro поддерживает множество новых геймплеев, таких как CrowdPlay и Online MultiPlayer для ретро-игр. Если несколько пользователей откроют один и тот же deep-link на разных компьютерах, они увидят одну и ту же запущенную игру и даже смогут присоединиться к ней.

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

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

    8. Нет привязки к одному облаку
    Инфраструктура CloudRetro размещается на различных облачных провайдерах (Digital Ocean, Alibaba, пользовательский провайдер) для различных регионов. Я активирую запуск в контейнере Docker для инфраструктуры и настраиваю сетевые параметры с помощью bash-скрипта, чтобы избежать зависимости от одного облачного провайдера. Сочетая это с NAT Traversal в WebRTC, мы можем получить гибкость для развертывания CloudRetro на любой облачной платформе и даже на машинах любого пользователя.

    Архитектурный дизайн


    Воркер: (или потоковый сервер, упомянутый выше) множит игры, запускает кодирующий пайплайн и передает закодированное медиа пользователям. Инстансы воркера распространяются по всему миру, и каждый воркер может обрабатывать несколько пользовательских сессий одновременно.

    Координатор: отвечает за сопряжение нового пользователя с наиболее подходящим воркером для потоковой передачи. Координатор взаимодействует с воркерами через WebSocket.

    Хранилище игровых состояний: центральное удаленное хранилище для всех состояний игры. Это хранилище обеспечивает такие важные функции, как, например, удаленное сохранение/загрузка.


    Верхнеуровневая архитектура CloudRetro

    Пользовательский сценарий


    Когда новый пользователь открывает CloudRetro на шагах 1 и 2, показанных на рисунке ниже, координатор вместе со списком доступных воркеров запрашивается на первую страницу. После этого на шаге 3 клиент рассчитывает задержки для всех кандидатов с помощью HTTP запроса ping. Этот список задержек затем отправляется обратно координатору, чтобы он мог определить наиболее подходящего воркера для обслуживания пользователя. На шаге 4 ниже создается игра. Между пользователем и назначенным воркером устанавливается потоковое соединение WebRTC.

    Пользовательский сценарий после получения доступа

    Что внутри воркера


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


    Взаимодействие компонентов воркера

    Основные составляющие:

    • WebRTC: клиентский компонент, принимающий пользовательский ввод и выводящий закодированное медиа с сервера.
    • Игровой эмулятор: игровой компонент. Благодаря библиотеке Libretro система способна запускать игру внутри одного и того же процесса и внутренне перехватывать медиа и поток ввода.
    • Внутриигровые кадры захватываются и отправляются в кодировщик.
    • Изображение/аудио кодировщик: кодирующий пайплайн, который принимает медиакадры, кодирует их в фоновом режиме и выводит закодированные изображения/аудио.

    Реализация


    CloudRetro полагается на WebRTC как на магистральную технологию, поэтому перед тем, как углубиться в подробности реализации на Golang, я решил рассказать о самом WebRTC. Это потрясающая технология, которая сильно помогла мне в достижении задержки потоковой передачи данных равной всего лишь доле секунды.

    WebRTC


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

    NAT Traversal


    WebRTC известен своей функциональностью NAT Traversal. WebRTC предназначен для одноранговой коммуникации. Его цель найти наиболее подходящий прямой маршрут, избегая NAT-шлюзов и брандмауэров для одноранговой связи через процесс под названием ICE. В рамках этого процесса API WebRTC находят ваш публичный IP-адрес с помощью серверов STUN и переадресовывают его на сервер ретрансляции (TURN), когда прямое соединение не может быть установлено.

    Однако CloudRetro не полностью использует эту возможность. Его одноранговые соединения существуют не между пользователями, а между пользователями и облачными серверами. Серверная часть модели имеет меньше ограничений на прямую связь, чем обычное пользовательское устройство. Это позволяет делать предварительное открытие входящих портов или использование публичных IP-адресов напрямую, так как сервер не находится за NAT.

    Раньше я хотел превратить проект в платформу распространения игр для Cloud Gaming. Идея заключалась в том, чтобы позволить создателям игр предоставлять игры и потоковые ресурсы. А пользователи взаимодействовали бы с провайдерами напрямую. В такой децентрализованной манере CloudRetro является всего лишь средой для подключения сторонних потоковых ресурсов к пользователям, что делает его более масштабируемым, когда на нем больше не висит хостинг. Роль WebRTC NAT Traversal здесь очень важна для облегчения инициализации однорангового соединения на сторонних потоковых ресурсах, что упрощает подключение создателя к сети.

    Сжатие видео


    Сжатие видео это незаменимая часть пайплайна, которая в значительной степени способствует плавности потока. Несмотря на то, что не обязательно знать все детали кодирования видео в VP8/H264, понимание концепции помогает разбираться в параметрах скорости потокового видео, отлаживать неожиданное поведение и настраивать задержку.

    Сжатие видео для потокового сервиса является сложной задачей, потому что алгоритм должен гарантировать, что общее время кодирования + время передача по сети + время декодирования настолько мало, насколько это возможно. Кроме того, процесс кодирования должен быть последовательным и непрерывным. Некоторые взаимные уступки при кодировании не применимы например, мы не можем предпочесть длительное время кодирования меньшему размеру файла и времени декодирования, или использовать непоследовательное сжатие.

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


    Сравнение видеокадров на примере Pacman

    Сжатие аудио


    Аналогичным образом, алгоритм сжатия звука опускает данные, которые не могут быть восприняты человеком. Opus на данный момент является аудиокодеком с наилучшей производительностью. Он разработан для передачи аудиоволны по протоколу упорядоченной датаграммы, такому как RTP (Real Time Transport Protocol протокол передачи трафика реального времени). Его задержка меньше, чем у mp3 и aac, а качество выше. Задержка обычно составляет около 5~66,5 мс.

    Pion, WebRTC в Golang


    Pion это проект с открытым исходным кодом, который перетаскивает WebRTC на Golang. Вместо обычного врапинга нативных C++ библиотек WebRTC, Pion является нативной Golang-реализацией WebRTC с лучшей производительностью, интеграцией с Go, а также контролем версий на протоколах WebRTC.

    Библиотека также обеспечивает потоковую передачу данных с большим количеством отличных встроенных модулей с задержкой менее секунды. Она имеет свою собственную реализацию STUN, DTLS, SCTP и т.д. и некоторые эксперименты с QUIC и WebAssembly. Сама по себе эта опенсорсная библиотека является действительно хорошим источником обучения с отличной документацией, реализацией сетевых протоколов и классными примерами.

    Комьюнити Pion, возглавляемое очень страстным создателем, довольно оживленное, там ведется много качественных дискуссий о WebRTC. Если вас интересует эта технология, присоединяйтесь к http://pion.ly/slack вы узнаете много нового.

    Написание CloudRetro на Golang



    Реализация воркера на Go

    Каналы Go в действии


    Благодаря красивому дизайну каналов Go, проблемы потоковой передачи событий и параллелизма значительно упрощаются. Как и на диаграмме, в разных GoRoutines параллельно работают несколько компонентов. Каждый компонент управляет своим состоянием и общается по каналам. Выборочное утверждение Golang заставляет обработать по одному атомарному событию каждый момент времени в игре (game tick). Это означает, что для такого дизайна блокировка не нужна. Например, когда пользователь сохраняется, требуется полный снэпшот состояния игры. Это состояние должно оставаться непрерывным, выполняя вход до тех пор, пока сохранение не будет завершено. Во время каждого game tickа бэкэнд может обрабатывать только операцию сохранения или ввода, что делает процесс потокобезопасным.

    func (e *gameEmulator) gameUpdate() {for {select {case <-e.saveOperation:e.saveGameState()case key := <-e.input:e.updateGameState(key)case <-e.done:e.close()return}    }}
    

    Fan-in / Fan-out


    Этот шаблон Golang отлично подходит для моего сценария использования CrowdPlay и Multiple Player. Следуя этому шаблону, все пользовательские входы в одной комнате встраиваются в центральный входной канал. Игровые медиа затем разворачиваются на всех пользователей в одной комнате. Таким образом, мы достигаем разделения состояния игры между несколькими игровыми сессиями разных пользователей.


    Синхронизация между различными сеансами

    Недостатки Golang


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

    Кроме того, garbage collector в Golang неуправляем, из-за чего иногда возникают подозрительные длинные паузы. Это сильно мешает работе потокового приложения в реальном времени.

    CGO


    В проекте используется существующая VP8/H264 библиотека Golang с открытым исходным кодом для сжатия медиа и Libretro для игровых эмуляторов. Все эти библиотеки являются просто обертками библиотеки C в Go с использованием CGO. Некоторые из недостатков перечислены в этом посте Dave Cheney. Проблемы, с которыми я столкнулся:

    • невозможность поймать краш в CGO, даже с помощью Golang RecoveryCrash;
    • невозможность определить узкое место в производительности, когда мы не можем обнаружить детализированные проблемы в CGO.

    Заключение


    Я достиг своей цели разобрался в облачных игровых сервисах и создал платформу, которая помогает играть в ностальгические ретро-игры с моими друзьями онлайн. Создание этого проекта было бы невозможным без библиотеки Pion и поддержки сообщества Pion. Я чрезвычайно благодарен за его интенсивное развитие. Простые API, предоставленные WebRTC и Pion, обеспечили плавную интеграцию. Мое первое доказательство концепции было выпущено на той же неделе, несмотря на то, что я заранее не знал об одноранговой связи (P2P).

    Несмотря на простоту интеграции, P2P-потоковое вещание действительно является очень сложной областью в компьютерной науке. Ей приходится иметь дело со сложностью многолетних сетевых архитектур, таких как IP и NAT для создания одноранговой сессии. За время работы над этим проектом я накопил много ценных знаний о сети и оптимизации производительности, поэтому рекомендую всем попробовать построить P2P-продукты с помощью WebRTC.

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

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru