Совсем недавно завершилась ежегодная конференция Asterconf. Нам посчастливилось в ней участвовать. На этот раз мы приготовили ряд мастер классов по настройке и кастомизации MikoPBX — бесплатной АТС с открытым исходным кодом.
Одной из задач мастер классов стояла разработка скрипта для интерактивного голосования за строительство гаражного кооператива. Голосование должно было производится без участия оператора, автоматизированное, с защитой от повторного голосования и конечно с механизмом генерации речи.
Если заинтересовало, то под кат, подробно разберем пример реализации…
В конце статьи ссылка на видео с конференции…
Хочу обратить внимание, что описанный кейс, лишь пример кастомизации MikoPBX, демонстрация работы с API генерации речи. Его можно использовать как отправную точную для более сложных задач и внедрений, к примеру для функционала интерактивных помощников и функций умной маршрутизации.
Итак, имеем установленную MikoPBX. Для начала приведу примера простого класса для генерации речи с использованием API Yandex:
Класс YandexSynthesize для генерации речи
<?php class YandexSynthesize { public const TTS_DIR = '/storage/usbdisk1/mikopbx/media/yandex-tts'; public const API_KEY = '...'; private string $voice = 'alena'; public function __construct() { if(!file_exists(self::TTS_DIR)){ Util::mwMkdir(self::TTS_DIR); } } /** * Генерирует и скачивает в на внешний диск файл с речью. * * @param $text_to_speech - генерируемый текст * @param $voice - голос * * @return null|string * * https://tts.api.cloud.yandex.net/speech/v1/tts:synthesize */ public function makeSpeechFromText($text_to_speech): ?string { $speech_extension = '.raw'; $result_extension = '.wav'; $speech_filename = md5($text_to_speech . $this->voice); $fullFileName = self::TTS_DIR .'/'. $speech_filename . $result_extension; $fullFileNameFromService = self::TTS_DIR .'/'. $speech_filename . $speech_extension; // Проверим вдург мы ранее уже генерировали такой файл. if (file_exists($fullFileName) && filesize($fullFileName) > 0) { return self::TTS_DIR .'/'. $speech_filename; } // Файла нет в кеше, будем генерировать новый. $post_vars = [ 'lang' => 'ru-RU', 'format' => 'lpcm', 'speed' => '1.0', 'sampleRateHertz' => '8000', 'voice' => $this->voice, 'text' => urldecode($text_to_speech), ]; $fp = fopen($fullFileNameFromService, 'wb'); $curl = curl_init(); curl_setopt($curl, CURLOPT_HTTPHEADER, ["Authorization: Api-Key ".self::API_KEY]); curl_setopt($curl, CURLOPT_FILE, $fp); curl_setopt($curl, CURLOPT_POST, true); curl_setopt($curl, CURLOPT_TIMEOUT, 4); curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query($post_vars)); curl_setopt($curl, CURLOPT_URL, 'https://tts.api.cloud.yandex.net/speech/v1/tts:synthesize'); curl_exec($curl); $http_code = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); curl_close($curl); fclose($fp); if (200 === $http_code && file_exists($fullFileNameFromService) && filesize($fullFileNameFromService) > 0) { exec("sox -r 8000 -e signed-integer -b 16 -c 1 -t raw $fullFileNameFromService $fullFileName"); if (file_exists($fullFileName)) { // Удалим raw файл. @unlink($fullFileNameFromService); // Файл успешно сгененрирован return self::TTS_DIR.'/'.$speech_filename; } } elseif (file_exists($fullFileNameFromService)) { @unlink($fullFileNameFromService); } return null; } }
Файл позволяет:
-
Преобразовать текст в речь
-
Для каждого текста проверяет хэш сумму, если файл уже был создан ранее, то обращения к API не происходит
Требования
-
PHP 7.4+
-
В классе необходимо прописать значение «API_KEY» — ключ для генерации речи Yandex
-
При необходимости следует указать диктора $voice, по умолчанию выбран голос «alena«
Итак, начнем работу. Первым делом необходимо ответить на вызов:
<?php $agi = new AGI(); $agi->set_variable('AGIEXITONHANGUP', 'yes'); $agi->set_variable('AGISIGHUP', 'yes'); $agi->set_variable('__ENDCALLONANSWER', 'yes'); $agi->answer();
Установленные переменные канала необходимы для корректного завершения работы скрипта при hangup на канале.
Поприветствуем клиента:
<?php $ys = new YandexSynthesize(); $infoMessage = 'Добрый день, я голосовой помощник для интерактивного голосования по строительству гаражного кооператива'; $filenameInfo = $ys->makeSpeechFromText($infoMessage);
Результаты голосования будут храниться в файлах. Пример структуры каталогов:
/storage/usbdisk1/mikopbx/log/voting ├── 0 │ ├── 74952232222 │ └── 74952293042 └── 1 └── 79257180000
-
В каталоге «0» сохраняется информация, по номерам, что проголосовали против
-
В каталоге «1«, номера, что проголосовали «ЗА«
Добавим проверку на повторное голосование:
<?php $logDir = '/storage/usbdisk1/mikopbx/log/voting'; $ys = new YandexSynthesize(); $res = Processes::mwExec('ls -l '.$logDir.'/*/'.$agi->request['agi_callerid']); if($res === 0){ $filenameAlert = $ys->makeSpeechFromText('Вы уже голосовали ранее. Результат голосования:'); $agi->exec('Playback', $filenameAlert); $yes = shell_exec('ls -l '.$logDir.'/1/ | grep -v total | wc -l'); $agi->exec('Playback', $ys->makeSpeechFromText('Поддержали '.$yes)); $no = shell_exec('ls -l '.$logDir.'/0/ | grep -v total | wc -l'); $agi->exec('Playback', $ys->makeSpeechFromText('Против '.$no)); $agi->hangup(); exit(0); }
Если клиент уже голосовал со своего номера телефона, то система проверит это и сообщит результат голосования.
Теперь добавим проверку, что звонящий не является роботом. Предложим решить простой пример.
<?php $a = random_int(1, 4); $b = random_int(1, 5); $checkRobots = "Проверим, что Вы не робот. Введите верный ответ в тональном режиме. Решите пример! $a плюс $b"; $filenameCheck = $ys->makeSpeechFromText($checkRobots);
Проверим результат ввода:
<?php $agi->exec('Playback', $filenameInfo); $result = $agi->getData($filenameCheck, 3000, 1); $selectedNum = $result['result']??''; if (empty($selectedNum) || (int)$selectedNum !== ($a + $b)) { $filenameAlert = $ys->makeSpeechFromText("Ответ не верный"); $agi->exec('Playback', $filenameAlert); $filenameAlert = $ys->makeSpeechFromText("Вы ввели цифру " . $selectedNum); $agi->exec('Playback', $filenameAlert); $agi->hangup(); exit(0); } $filenameAlert = $ys->makeSpeechFromText("Пример решен верно."); $agi->exec('Playback', $filenameAlert);
Зафиксируем результат голосования:
<?php $text = 'Если Вы "ЗА" строительство, то нажмите ОДИН, если против, то НОЛЬ'; $filenameAlert = $ys->makeSpeechFromText($text); $result = $agi->getData($filenameAlert, 3000, 1); $selectedNum = (int)($result['result']??'0'); $resultDir = $logDir.'/'.$selectedNum; Util::mwMkdir($resultDir); file_put_contents("$resultDir/{$agi->request['agi_callerid']}", '1'); $filenameAlert = $ys->makeSpeechFromText('Спасибо, ваш голос учтен!'); $agi->exec('Playback', $filenameAlert); $agi->hangup();
Итоговый вариант скрипта:
<?php require_once('Globals.php'); use MikoPBX\Core\System\Util; use MikoPBX\Core\Asterisk\AGI; use MikoPBX\Core\System\Processes; class YandexSynthesize { public const TTS_DIR = '/storage/usbdisk1/mikopbx/media/yandex-tts'; public const API_KEY = '...'; private string $voice = 'alena'; public function __construct() { if(!file_exists(self::TTS_DIR)){ Util::mwMkdir(self::TTS_DIR); } } /** * Генерирует и скачивает в на внешний диск файл с речью. * * @param $text_to_speech - генерируемый текст * @param $voice - голос * * @return null|string * * https://tts.api.cloud.yandex.net/speech/v1/tts:synthesize */ public function makeSpeechFromText($text_to_speech): ?string { $speech_extension = '.raw'; $result_extension = '.wav'; $speech_filename = md5($text_to_speech . $this->voice); $fullFileName = self::TTS_DIR .'/'. $speech_filename . $result_extension; $fullFileNameFromService = self::TTS_DIR .'/'. $speech_filename . $speech_extension; // Проверим вдург мы ранее уже генерировали такой файл. if (file_exists($fullFileName) && filesize($fullFileName) > 0) { return self::TTS_DIR .'/'. $speech_filename; } // Файла нет в кеше, будем генерировать новый. $post_vars = [ 'lang' => 'ru-RU', 'format' => 'lpcm', 'speed' => '1.0', 'sampleRateHertz' => '8000', 'voice' => $this->voice, 'text' => urldecode($text_to_speech), ]; $fp = fopen($fullFileNameFromService, 'wb'); $curl = curl_init(); curl_setopt($curl, CURLOPT_HTTPHEADER, ["Authorization: Api-Key ".self::API_KEY]); curl_setopt($curl, CURLOPT_FILE, $fp); curl_setopt($curl, CURLOPT_POST, true); curl_setopt($curl, CURLOPT_TIMEOUT, 4); curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query($post_vars)); curl_setopt($curl, CURLOPT_URL, 'https://tts.api.cloud.yandex.net/speech/v1/tts:synthesize'); curl_exec($curl); $http_code = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); curl_close($curl); fclose($fp); if (200 === $http_code && file_exists($fullFileNameFromService) && filesize($fullFileNameFromService) > 0) { exec("sox -r 8000 -e signed-integer -b 16 -c 1 -t raw $fullFileNameFromService $fullFileName"); if (file_exists($fullFileName)) { // Удалим raw файл. @unlink($fullFileNameFromService); // Файл успешно сгененрирован return self::TTS_DIR.'/'.$speech_filename; } } elseif (file_exists($fullFileNameFromService)) { @unlink($fullFileNameFromService); } return null; } } $agi = new AGI(); $agi->set_variable('AGIEXITONHANGUP', 'yes'); $agi->set_variable('AGISIGHUP', 'yes'); $agi->set_variable('__ENDCALLONANSWER', 'yes'); $agi->answer(); $logDir = '/storage/usbdisk1/mikopbx/log/voting'; $ys = new YandexSynthesize(); $cmd = 'ls -l '.$logDir.'/*/'.$agi->request['agi_callerid']; $res = Processes::mwExec($cmd); if($res === 0){ $filenameAlert = $ys->makeSpeechFromText('Вы уже голосовали ранее. Результат голосования:'); $agi->exec('Playback', $filenameAlert); $yes = shell_exec('ls -l '.$logDir.'/1/ | grep -v total | wc -l'); $agi->exec('Playback', $ys->makeSpeechFromText('Поддержали '.$yes)); $no = shell_exec('ls -l '.$logDir.'/0/ | grep -v total | wc -l'); $agi->exec('Playback', $ys->makeSpeechFromText('Против '.$no)); $agi->hangup(); exit(0); } $infoMessage = 'Добрый день, я голосовой помощник для интерактивного голосования по строительству гаражного кооператива. '; $filenameInfo = $ys->makeSpeechFromText($infoMessage); $a = random_int(1, 4); $b = random_int(1, 5); $checkRobots = "Проверим, что Вы не робот. Введите верный ответ в тональном режиме. Решите пример! $a плюс $b"; $filenameCheck = $ys->makeSpeechFromText($checkRobots); $agi->exec('Playback', $filenameInfo); $result = $agi->getData($filenameCheck, 3000, 1); $selectedNum = $result['result']??''; if (empty($selectedNum) || (int)$selectedNum !== ($a + $b)) { $filenameAlert = $ys->makeSpeechFromText("Ответ не верный"); $agi->exec('Playback', $filenameAlert); $filenameAlert = $ys->makeSpeechFromText("Вы ввели цифру " . $selectedNum); $agi->exec('Playback', $filenameAlert); $agi->hangup(); exit(0); } $filenameAlert = $ys->makeSpeechFromText("Пример решен верно."); $agi->exec('Playback', $filenameAlert); $text = 'Если Вы "ЗА" строительство, то нажмите ОДИН, если против, то НОЛЬ'; $filenameAlert = $ys->makeSpeechFromText($text); $result = $agi->getData($filenameAlert, 3000, 1); $selectedNum = (int)($result['result']??'0'); $resultDir = $logDir.'/'.$selectedNum; Util::mwMkdir($resultDir); file_put_contents("$resultDir/{$agi->request['agi_callerid']}", '1'); $filenameAlert = $ys->makeSpeechFromText('Спасибо, ваш голос учтен!'); $agi->exec('Playback', $filenameAlert); $agi->hangup();
Теперь распустим скрипт в работу на MikoPBX:
Добавим новое приложение dialplan:
Заполните поля «Название«, «Номер для вызова приложения«, «тип кода«
На вкладке «Программный код» вставьте текст скрипта и выполните действие «Сохранить«.
Направьте входящий маршрут на созданное приложение:
Теперь можно начать тестировать:)
На приложение можно позвонить и с внутреннего номера, набрав его добавочный (2200110).
Видео с конференции
Источники знаний
-
Сайт проекта — www.mikopbx.ru
-
Документация — wiki.mikopbx.com
-
Сайт AsterConf — https://asterconf.ru
-
Репозиторий MikoPbx https://github.com/mikopbx/Core
-
https://wiki.asterisk.org
Итоги
Используя функционал «Приложения dialplan» возможно реализовать произвольные сценарии значительно расширяющие возможности АТС. К примеру из PHP возможно обратиться к REST API стороннего сервиса и получив ответ выполнить маршрутизацию канала.
Данный пример не претендует на завершенное решение, но является неплохой отправной точкой для интересных решений 🙂
Спасибо всем, кто присутствовал на нашем мастер классе Asterconf, и конечно большое спасибо организатором за три интересных, увлекательных дня, было очень круто!
ссылка на оригинал статьи https://habr.com/ru/articles/580676/
Добавить комментарий