Синтез и распознавание речи с помощью Yandex Speech API

Материал из Mindsellers
Перейти к: навигация, поиск

Python

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

В первую очередь потребуется создать аккаунт на Облаке. Привязав любую пластиковую карту(с карты будет списана и тут же возвращена незначительная сумма) мы получим 4000 тестовых рублей аж на два месяца на использование любых облачных сервисов компании. Нас же интересуют исключительно речевые технологии. На момент написания статьи расценки таковы: синтез 1 млн символов - 183 рубля, распознавание фрагмента до 15 секунд - 15.2 копейки, что вполне доступно.

Яндекс предлагает довольно гибкое распределение ролей доступа к системе, но если мы используем только речевые технологии, то нам будет достаточно создать сервисный аккаунт и получить для него API-ключ, который мы будем в дальнейшем использовать в скриптах. Инструкция по данному вопросу доступна по ссылке.

Итак, рассмотрим типовой диалог:

Здравствуйте, Иван Иванович. Ваш заказ номер 234 456 доступен для получения в пункте выдачи по адресу Ленина, 1. Если вы хотите поговорить 
с оператором, произнесите слово ОПЕРАТОР
Соединяем с оператором/всего доброго

Начнем со скрипта, который будет генерировать любой текст. На сайте Яндекса есть пример реализации на python, однако в примере рассматривается iam-аутентификация, а мы хотим работать по API-ключу, да и всякие свойства API описаны отдельно. Итак, листинг скрипта ниже, но для того, чтобы он работал, необходимо установить в системе sox, а также поставить requests и pysox через pip

pip install requests
pip install sox


#!/usr/bin/env python

import argparse
import requests
import sox
import os

def synthesize(output, text):
    url = 'https://tts.api.cloud.yandex.net/speech/v1/tts:synthesize'
    headers = {
        'Authorization': 'Api-Key ' + 'AQVN33ioCUgKDF-XDXXXXXXXXX-oWqt7zIrX0ZW-', #авторизация
    }

    data = {               
        'text': text,     #cинтезируемый текст
        'lang': 'ru-RU',  #язык
        'voice': 'alyss', #голос
        'emotion': 'good',
        'format': 'lpcm', #формат
        'sampleRateHertz': '8000'
    }

    resp=requests.post(url, headers=headers, data=data, stream=True, verify=False)
    if resp.status_code != 200:
       raise RuntimeError("Invalid response received: code: %d, message: %s" % (resp.status_code, resp.text))

    for chunk in resp.iter_content(chunk_size=None):
        with open(output, "wb") as f:
          for content in chunk:
            f.write(content)

    tfm = sox.Transformer()
    tfm.set_input_format(file_type='raw', rate=8000, bits=16, channels=1, encoding='signed-integer')
    tfm.build(output, output+'.wav') 
    os.remove(output)


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--text", required=True, help="Text for synthesize")
    parser.add_argument("--output", required=True, help="Output file name")
    args = parser.parse_args()
    synthesize(args.output, args.text)  


Итак, мы принимаем 2 аргумента - текст и имя файла. Расширение мы указывать не будем, так как яндекс отдаст нам сырой файл, без заголовков, и далее, с помощью sox, нам все равно придется его превращать в кошерный wav. На выходе мы получим файл с расширением wav, который без проблем "сожрет" asterisk.

Теперь давайте рассмотрим скрипт, который будет заниматься распознаванием текста. В примере Яндекса он написан на python3, что нам, в общем, вполне подходит, так как результат его действий нам все равно нужно получить без перевода каретки(а то asterisk не сможет его обработать), а python2 по умолчанию так не умеет.

#!/usr/bin/env python3
import urllib.request
import json
import sys
import os

file=sys.argv[1]

FOLDER_ID = "default" # Идентификатор каталога
API = "AQVN33ioCUgKDF-XXXXXXXXX-oWqt7zIrX0ZW-" 

with open(file, "rb") as f:
    data = f.read()

params = "&".join([
    "topic=general",
    "format=lpcm",
    "sampleRateHertz=8000",
    "lang=ru-RU"
])

url = urllib.request.Request("https://stt.api.cloud.yandex.net/speech/v1/stt:recognize?%s" % params, data=data)
url.add_header("Authorization", "Api-Key %s" % API)

responseData = urllib.request.urlopen(url).read().decode('UTF-8')
decodedData = json.loads(responseData)

os.remove(file)

if decodedData.get("error_code") is None:
    resp=decodedData.get("result")
    if resp.startswith('да') or resp.startswith('хорошо') or resp.startswith('норм') or resp.startswith('ладно') or 'оператор' in resp:
      print('yes',end='')
    elif resp.startswith('нет'):
      print('no',end='')
    elif 'абонент' in resp:
      print('no',end='')     
    elif 'авто' in resp:
      print('no',end='')
    elif resp=='':
      print('no',end='')
    else:
      print('dont',end='')



Здесь, в принципе, все понятно без комментариев - мы по-прежнему указываем в данных, что именно мы передаем, подсовываем файл с записью и получаем результат. В зависимости от того, что было распознано, мы получаем от скрипта в stdout yes, no или dont, соответственно, Да, Нет и Не понял.

В рассматриваемом примере, к сожалению, никакой интеграции asterisk с crm-системой нет, так что пойдем по пути наименьшего сопротивления, и будем принимать данные через get-запрос. Сделать это можно по-разному: от написания собственного мультипоточного socket-сервера до использования django, но я решил использовать супер-легковесный web-фреймворк под названием bottle. Так что начинаем с

pip install bottle

Сам фреймворк целиком состоит из единственного файла, так что можно его просто скачать и положить в каталог со своим приложением. Итак, перейдем к коду:


#!/usr/bin/env python
# -*- coding: utf-8 -*-
from bottle import run, request, get
import os
from syn import synthesize

def autodial(mobile,order):
	with open('/tmp/'+str(mobile)+str(order), 'w') as callfile:
		callfile.write('Channel: Local/'+mobile+'@from-internal'+'\n')
		callfile.write('Callerid: Autodial <'+mobile+'>\n')
		callfile.write('Context: from-internal\n')
		callfile.write('Extension: 900\n')
		callfile.write('Priority: 1\n')
		callfile.write('WaitTime: 25000\n')
		callfile.write('Set: __ORDER='+str(order)+'\n')
	os.rename('/tmp/'+str(mobile)+str(order), '/var/spool/asterisk/outgoing/'+str(mobile)+str(order))	

@get('/msg')
def index():
    postdata = request.body.read()
    name = request.query.get("name")
    surname = request.query.get("surname")
    number = request.query.get("number")
    order = request.query.get("order")
    address = request.query.get("address")
    synthesize(output='/tmp/'+str(order)+'-name',text=str(name)+' '+str(surname))
    synthesize(output='/tmp/'+str(order)+'-order',text=str(order))
    synthesize(output='/tmp/'+str(order)+'-address',text=str(address))
    autodial(mobile=number,order=order)
   

run(host='192.168.0.22', port=8000, debug=False)


Итак, после запуска сервера на указанном адресе и порту, мы отслеживаем исключительно get-запросы в /msg. Функция autodial в данном случае формирует callfile для asterisk, причем только после того, как мы сформировали необходимые файлы. Функция synthesize импортируется из первого скрипта. Запустим скрипт и в браузере откроем ссылку

http://192.168.0.22:8000/msg?name=Иван&surname=Петров&order=23232&address=Ленина 1

Результатом работы данного кода будет:

  • три файла с названиями вида /tmp/23232-name.wav /tmp/23232-order.wav /tmp/23232-address.wav
  • callfile, который осуществит вызов клиента, соединит его со служебным номером 900, а также передаст в контекст переменную ORDER, соответствующую номеру заказа.

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

./syn.py --output='/opt/autodial/syn/сonnect' --text='Ваш звонок переводится на оператора '

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

address.wav
goodbye.wav
hello.wav
operator.wav
order.wav
сonnect.wav

размещенные в указанном каталоге.

Самое время заняться asterisk. По старой традиции я покажу пример с FreePBX, так как на чистом asterisk все во-первых, чуть проще, а во-вторых, работают более опытные люди, которым мои контексты, в общем-то, и не нужны.

В секцию [from-internal-custom] файла extensions_custom.conf внесем следующее:

exten => 900,1,Answer()
exten => 900,n,Noop(${ORDER})
exten => 900,n,Playback(/opt/autodial/syn/hello)    ;здравствуйте
exten => 900,n,Playback(/tmp/${ORDER}-name)         ;Иван Петров
exten => 900,n,Playback(/opt/autodial/syn/order)    ;ваш заказ с номером
exten => 900,n,Playback(/tmp/${ORDER}-order)        ;23232
exten => 900,n,Playback(/opt/autodial/syn/address)  ;готов к выдаче по адресу
exten => 900,n,Playback(/tmp/${ORDER}-address)      ;Ленина 1
exten => 900,n,Playback(/opt/autodial/syn/operator) ;Если вы хотите соединиться
                                                    ;с оператором, скажите ОПЕРАТОР  
exten => 900,n(record),Monitor(wav,/tmp/${UNIQUEID},o) ;включаем запись
exten => 900,n,Wait(2)                              ;ждем 2 секунды
exten => 900,n,StopMonitor()                        ;останавливаем
exten => 900,n,Noop(/tmp/${UNIQUEID}-in.wav)
exten => 900,n,Set(RESULT=${SHELL(/usr/bin/python3 /opt/autodial/yandex.py /tmp/${UNIQUEID}-in.wav)})
                                                    ;передаем запись скрипту и ждем результата         
exten => 900,n,Noop(${RESULT})
exten => 900,n,GotoIf($["${RESULT}" = "yes"]?yes)   ;если он yes, переходим на метку
exten => 900,n,Playback(/opt/autodial/syn/goodbye)  ;иначе прощаемся
exten => 900,n,Hangup()
exten => 900,n(yes),Playback(/opt/autodial/syn/сonnect) ;Ваш звонок переводится на оператора
exten => 900,n,Goto(from-internal,600,1)            ;Соединение с оператором или очередью
exten => 900,n,Hangup()


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

На этом, собственно, все. Из возможных доработок видится логичным записать заранее все адреса пунктов выдачи, и не генерировать их, а в переменной address в get-запросе передавать просто порядковый номер пункта. Записи в таком случае имеет смысл назвать address-1, address-2 и так далее, при формировании callfile передать также переменную ADDRESS, ну и соответственно, проигрывать файл с именем address-${ADDRESS} из контекста.