Управляем стандартным плеером Sailfish OS с помощью голосовых команд
Для внедрения распознавания и выполнения голосовых команд потребуется пройти четыре простых шага:
- разработать систему команд,
- реализовать распознавание речи,
- реализовать идентификацию и выполнение команд,
- добавить обратную голосовую связь.
Предполагается, что, для лучшего понимания материала, читатель уже имеет базовые знания о 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 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 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. Данный материал может послужить отправной точкой для более глубоких изысканий и экспериментов в данной операционной системе.
Пример работы приведённого кода можно посмотреть на видео:
Автор: Пётр Вытовтов