MikoPBX на Aster conf, TTS скрипт для голосования

от автора

Совсем недавно завершилась ежегодная конференция 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).

Видео с конференции

Источники знаний

Итоги

Используя функционал «Приложения dialplan» возможно реализовать произвольные сценарии значительно расширяющие возможности АТС. К примеру из PHP возможно обратиться к REST API стороннего сервиса и получив ответ выполнить маршрутизацию канала.

Данный пример не претендует на завершенное решение, но является неплохой отправной точкой для интересных решений 🙂

Спасибо всем, кто присутствовал на нашем мастер классе Asterconf, и конечно большое спасибо организатором за три интересных, увлекательных дня, было очень круто!


ссылка на оригинал статьи https://habr.com/ru/articles/580676/