Для внедрения распознавания и выполнения голосовых команд потребуется пройти четыре простых шага:
- разработать систему команд,
- реализовать распознавание речи,
- реализовать идентификацию и выполнение команд,
- добавить обратную голосовую связь.
Предполагается, что, для лучшего понимания материала, читатель уже имеет базовые знания о C++, JavaScript, Qt, QML и Linux и ознакомился с примером их взаимодействия в рамках Sailfish OS. Также может быть полезным предварительное знакомство с лекцией по смежной тематике, проведённой в рамках Летней школы Sailfish OS в Иннополисе летом 2016 года, и другими статьями посвященными разработке под данную платформу, которые уже были опубликованы на Хабре.
Разработка системы команд
Разберём простой пример, ограниченный пятью функциями:
- запуск нового воспроизведения музыки,
- возобновление воспроизведения музыки,
- приостановка воспроизведения музыки,
- переход к следующей композиции,
- переход к предыдущей композиции.
Для запуска нового воспроизведения требуется проверить наличие открытого экземпляра плеера (при необходимости создать) и начать воспроизведение музыки в случайном порядке. Для активации будем использовать команду «Включи музыку».
В случае с возобновлением и приостановлением воспроизведения требуется проверить состояние плеера и, при наличии возможности, запустить воспроизведение или поставить его на паузу. Для возобновления воспроизведения будем использовать команду «Играй»; для постановки на паузу — команды «Пауза» и «Стоп».
В случае с навигацией по композициям действует указанный выше принцип проверки состояния аудиоплеера. Для активации навигации вперёд используем команды «Вперёд», «Дальше» и «Следующий»; для активации навигации назад — команды «Назад» и «Предыдущий».
Распознавание речи
Процесс распознавания речевых команд разделяется на три этапа:
- запись голосовой команды в файл,
- распознавание команды на сервере,
- идентификация команды на устройстве.
Запись голосовой команды в файл
В начале необходимо сформировать интерфейс пользователя для захвата голосовой команды. С целью упрощения примера, будем начинать и заканчивать запись по нажатию на кнопку, так как реализация процесса обнаружения начала и конца голосовой команды заслуживает отдельного материала.
IconButton { property bool isRecording: false width: Theme.iconSizeLarge height: Theme.iconSizeLarge icon.source: isRecording ? "image://theme/icon-m-search" : "image://theme/icon-m-mic" onClicked: { if (isRecording) { isRecording = false recorder.stopRecord() yandexSpeechKitHelper.recognizeQuery(recorder.getActualLocation()) } else { isRecording = true recorder.startRecord() } } }
Из кода, представленного выше, видно, что кнопка использует стандартные значения размеров и стандартные иконки (интересная особенность Sailfish OS для унификации интерфейсов приложений) и имеет два состояния. В первом состоянии, когда запись не производится, после нажатия на кнопку начинается запись голосовой команды. Во втором состоянии, когда запись команды активна, после нажатия на кнопку запись останавливается и начинается распознавание голоса.
Для записи речи будем использовать класс QAudioRecorder, предоставляющий высокоуровневый интерфейс управления входным аудиопотоком, а также QAudioEncoderSettings для настройки процесса записи.
class Recorder : public QObject { Q_OBJECT public: explicit Recorder(QObject *parent = 0); Q_INVOKABLE void startRecord(); Q_INVOKABLE void stopRecord(); Q_INVOKABLE QUrl getActualLocation(); Q_INVOKABLE bool isRecording(); private: QAudioRecorder _audioRecorder; QAudioEncoderSettings _settings; bool _recording = false; }; Recorder::Recorder(QObject *parent) : QObject(parent) { _settings.setCodec("audio/PCM"); _settings.setQuality(QMultimedia::NormalQuality); _audioRecorder.setEncodingSettings(_settings); _audioRecorder.setContainerFormat("wav"); } void Recorder::startRecord() { _recording = true; _audioRecorder.record(); } void Recorder::stopRecord() { _recording = false; _audioRecorder.stop(); } QUrl Recorder::getActualLocation() { return _audioRecorder.actualLocation(); } bool Recorder::isRecording() { return _recording; }
Здесь указывается, что запись команды будет вестись в формате wav в нормальном качестве, а также определяются методы для начала и окончания записи, для получения места хранения аудиофайла и состояния процесса записи.
Распознавание команды на сервере
Для трансляции аудиофайла в текст будет использоваться сервис Яндекс SpeechKit Cloud. Всё, что требуется для начала работы с ним — это получить токен в кабинете разработчика. Документация сервиса достаточно подробная, поэтому будем останавливаться лишь на частных моментах.
Первым шагом передадим записанную команду на сервер.
void YandexSpeechKitHelper::recognizeQuery(QString path_to_file) { QFile *file = new QFile(path_to_file); if (file->open(QIODevice::ReadOnly)) { QUrlQuery query; query.addQueryItem("key", "API_KEY"); query.addQueryItem("uuid", _buildUniqID()); query.addQueryItem("topic", "queries"); QUrl url("https://asr.yandex.net/asr_xml"); url.setQuery(query); QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "audio/x-wav"); request.setHeader(QNetworkRequest::ContentLengthHeader, file->size()); _manager->post(request, file->readAll()); file->close(); } file->remove(); }
Здесь формируется POST-запрос к серверу Яндекс, в котором передаются полученный токен, уникальный ID устройства (в данном случае используется MAC-адрес WiFi-модуля) и тип запроса (здесь использован «queries», так как при голосовом взаимодействии с устройством чаще всего используются короткие и точные команды). В заголовках запроса указываются формат аудиофайла и его размер, в теле — непосредственно содержимое. После передачи запроса на сервер файл удаляется за ненадобностью.
В качестве ответа сервер SpeechKit Cloud возвращает XML с вариантами распознавания и степенью уверенности в них. Воспользуемся стандартными средствами Qt для выделения требуемой информации.
void YandexSpeechKitHelper::_parseResponce(QXmlStreamReader *element) { double idealConfidence = 0; QString idealQuery; while (!element->atEnd()) { element->readNext(); if (element->tokenType() != QXmlStreamReader::StartElement) continue; if (element->name() != "variant") continue; QXmlStreamAttribute attr = element->attributes().at(0); if (attr.value().toDouble() > idealConfidence) { idealConfidence = attr.value().toDouble(); element->readNext(); idealQuery = element->text().toString(); } } if (element->hasError()) qDebug() << element->errorString(); emit gotResponce(idealQuery); }
Здесь последовательно просматривается полученный ответ и, для тегов variant, проверяются показатели точности распознавания. Если новый вариант корректнее, то он сохраняется, а сканирование продолжается дальше. По окончанию просмотра ответа посылается сигнал с выделенным текстом команды.
Идентификация команды на устройстве
Наконец, остаётся идентифицировать команду. По окончанию работы метода YandexSpeechKitHelper::_parseResponce, как было указано выше, посылается сигнал gotResponce, содержащий текст команды. Далее требуется его обработать в QML-коде программы.
Connections { target: yandexSpeechKitHelper onGotResponce: { switch (query.toLowerCase()) { case "включи музыку": dbusHelper.startMediaplayerIfNeed() mediaPlayer.shuffleAndPlay() break; case "играй": mediaPlayerControl.play() break; case "пауза": case "стоп": mediaPlayerControl.pause() break; case "вперед": case "дальше": case "следующий": mediaPlayerControl.next() break; case "назад": case "предыдущий": mediaPlayerControl.previous() break; default: generateErrorMessage(query) break; } } }
Здесь используется элемент Connections для обработки поступающего сигнала и сравнения распознанной команды с шаблонами голосовых команд, определёнными ранее.
Управление работающим плеером
Если аудиоплеер открыт, то с ним воможно взаимодействовать через стандартный DBus-интерфейс, доставшийся от большого linux-брата. С его помощью можно перемещаться по списку воспроизведения, начинать или приостанавливать воспроизведение. Делается это с использованием QML-элемента DBusInterface.
DBusInterface { id: mediaPlayerControl service: "org.mpris.MediaPlayer2.jolla-mediaplayer" iface: "org.mpris.MediaPlayer2.Player" path: "/org/mpris/MediaPlayer2" function play() { call("Play", undefined) } function pause() { call("Pause", undefined) } function next() { call("Next", undefined) } function previous() { call("Previous", undefined) call("Previous", undefined) } }
С помощью данного элемента используется DBus-интерфейс стандартного аудиоплеера путём определения четырёх базовых функций. Параметр undefined функции call передаётся в том случае, если DBus-метод не принимает аргументов.
Стоит отметить, что для перехода к предыдущей композиции метод Previous вызывается два раза, так как его одиночный вызов приводит к воспроизведению текущей композиции с начала.
Запуск воспроизведения с нуля
В управлении уже работающим плеером ничего сложного нет. Однако, если имеется желание начать воспроизведение музыки, когда он закрыт — возникает проблема, так как, по умолчанию, функционал запуска стандартного плеера с одновременным воспроизведением всей коллекции не предоставляется.
Но не стоит забывать о том, что Sailfish OS — операционная система с открытым исходным кодом, доступная для свободной модификации. В следствие этого возникшую проблему можно решить в два этапа:
- Расширить функции, предоставляемые плеером через DBus-интерфейс;
- Реализовать запуск плеера (при необходимости) и начать воспроизведение сразу после запуска.
Расширение функций стандартного аудиоплеера
Стандартный аудиоплеер, помимо интерфейса org.mpris.MediaPlayer2.Player, предоставляет интерфейс com.jolla.mediaplayer.ui, определённый в файле /usr/share/jolla-mediaplayer/mediaplayer.qml. Из этого следует, что возможно модифицировать данный файл, добавив необходимую нам функцию.
DBusAdaptor { service: "com.jolla.mediaplayer" path: "/com/jolla/mediaplayer/ui" iface: "com.jolla.mediaplayer.ui" function openUrl(arg) { if (arg[0] == undefined) { return false } AudioPlayer.playUrl(Qt.resolvedUrl(arg[0])) if (!pageStack.currentPage || pageStack.currentPage.objectName !== "PlayQueuePage") { root.pageStack.push(playQueuePage, {}, PageStackAction.Immediate) } activate() return true } function shuffleAndPlay() { AudioPlayer.shuffleAndPlay(allSongModel, allSongModel.count) if (!pageStack.currentPage || pageStack.currentPage.objectName !== "PlayQueuePage") { root.pageStack.push(playQueuePage, {}, PageStackAction.Immediate) } activate() return true } }
Здесь был модифицирован элемент DBusAdaptor, используемый для предоставления DBus-интерфейса, путём добавления метода shuffleAndPlay. В нём используется стандартный функционал плеера для запуска воспроизведения всех композиций в случайном порядке, предоставляемый модулем com.jolla.mediaplayer, и выводится на передний план текущая очередь воспроизведения.
В рамках примера, для простоты, была выполнена простая модификация системного файла. Однако, при распространении программы подобные изменения необходимо оформлять в виде патчей, воспользовавшись соответствующими инструкциями.
Теперь из разрабатываемой программы необходимо обратиться к новому методу. Это выполняется с помощью уже знакомого элемента DBusInterface, в котором осуществляется подключение к определённому выше сервису и реализуется вызов добавленной в плеер функции.
DBusInterface { id: mediaPlayer service: "com.jolla.mediaplayer" iface: "com.jolla.mediaplayer.ui" path: "/com/jolla/mediaplayer/ui" function shuffleAndPlay() { call("shuffleAndPlay", undefined) } }
Запуск плеера, если закрыт
Наконец, последнее, что осталось — запуск аудиоплеера если он закрыт. Условно, задачу можно разделить на два этапа:
- непосредственно запуск плеера,
- ожидание сканирования музыкальной коллекции.
void DBusHelper::startMediaplayerIfNeed() { QDBusReply<bool> reply = QDBusConnection::sessionBus().interface()->isServiceRegistered("com.jolla.mediaplayer"); if (!reply.value()) { QProcess process; process.start("/bin/bash -c \"jolla-mediaplayer &\""); process.waitForFinished(); QDBusInterface interface("com.jolla.mediaplayer", "/com/jolla/mediaplayer/ui", "com.jolla.mediaplayer.ui"); while (true) { QDBusReply<bool> reply = interface.call("isSongsModelFinished"); if (reply.isValid() && reply.value()) break; QThread::sleep(1); } } }
Из кода представленной функции видно, что на первом этапе выполняется проверка наличия необходимого DBus-сервиса. Если он зарегистрирован в системе, то функция завершает работу и выполняется переход к запуску воспроизведения. Если же сервис не найден, то создаётся новый экземпляр аудиоплеера, используя QProcess, с ожиданием полного его запуска. Во второй части функции, с помощью QDBusInterface, проверяется флаг окончания сканирования коллекции музыки на устройстве.
Следует отметить, что для проверки флага сканирования коллекции были сделаны два дополнительных изменения в файле /usr/share/jolla-mediaplayer/mediaplayer.qml.
Во-первых, был модифицирован элемент GriloTrackerModel, предоставляемый модулем com.jolla.mediaplayer, путём добавления флага окончания сканирования.
GriloTrackerModel { id: allSongModel property bool isFinished: false query: { //: placeholder string for albums without a known name //% "Unknown album" var unknownAlbum = qsTrId("mediaplayer-la-unknown-album") //: placeholder string to be shown for media without a known artist //% "Unknown artist" var unknownArtist = qsTrId("mediaplayer-la-unknown-artist") return AudioTrackerHelpers.getSongsQuery("", {"unknownArtist": unknownArtist, "unknownAlbum": unknownAlbum}) } onFinished: { isFinished = true var artList = fetchAlbumArts(3) if (artList[0]) { if (!artList[0].url || artList[0].url == "") { mediaPlayerCover.idleArtist = artList[0].author ? artList[0].author : "" mediaPlayerCover.idleSong = artList[0].title ? artList[0].title : "" } else { mediaPlayerCover.idle.largeAlbumArt = artList[0].url mediaPlayerCover.idle.leftSmallAlbumArt = artList[1] && artList[1].url ? artList[1].url : "" mediaPlayerCover.idle.rightSmallAlbumArt = artList[2] && artList[2].url ? artList[2].url : "" mediaPlayerCover.idle.sourcesReady = true } } } }
Во-вторых, была добавлена ещё одна функция, доступная через DBus-интерфейс com.jolla.mediaplayer.ui, возвращающая значение флага состояния сканирования коллекции аудиофайлов.
function isSongsModelFinished() { return allSongModel.isFinished }
Сообщение об ошибочной команде
Последним элементом примера является голосовое сообщение о неправильной команде. Для этого воспользуемся сервисом синтеза речи Яндекс SpeechKit Cloud.
Audio { id: audio } function generateErrorMessage(query) { var message = "Извините. Команда " + query + " не найдена." audio.source = "https://tts.voicetech.yandex.net/generate?" + "text=\"" + message + "\"&" + "format=mp3&" + "lang=ru-RU&" + "speaker=jane&" + "emotion=good&" + "key=API_KEY" audio.play() }
Здесь был создан объект Audio для воспроизведения сгенерированной речи и объявлена функция generateErrorMessage для формирования запроса к серверу Яндекс и запуска воспроизведения. В запросе передаются следующие параметры:
- text — текст для синтеза (сообщение о неверной голосовой команде),
- format — формат возвращаемого файла (mp3),
- lang — язык фразы (русский),
- speaker — голос озвучки (женский),
- emotion — эмоциональная окраска голоса (доброжелательная),
- key — полученный в начале статьи ключ.
Заключение
В рамках данной статьи рассмотрен простой пример управления воспроизведением музыки в стандартном аудиоплеере Sailfish OS с помощью голосовых команд; получены и повторены базовые знания о распознавании и синтезе речи с помощью Яндекс SpeechKit Cloud с использованием средств Qt, а также принципы взаимодействия программ друг с другом в Sailfish OS. Данный материал может послужить отправной точкой для более глубоких изысканий и экспериментов в данной операционной системе.
Пример работы приведённого кода можно посмотреть на видео:
Автор: Пётр Вытовтов

