Многие знают и пользуются такими возможностями операционной системы Android, как Google Now и Google Assistant, которые позволяют не только вовремя получать полезную информацию и что-либо искать в интернете, но и управлять устройством с помощью голосовых команд. К сожалению, Sailfish OS (операционная система, разрабатываемая финской компанией Jolla и российской компанией Открытая мобильная платформа) не предоставляет такой возможности «из коробки». Как результат, было решено восполнить отсутствие этих удобств своими силами. Одной из функций разрабатываемого решения является возможность управления музыкальным плеером с помощью голосовых команд, техническая сторона которой и будет рассмотрена в данной статье.
Для внедрения распознавания и выполнения голосовых команд потребуется пройти четыре простых шага:
Предполагается, что, для лучшего понимания материала, читатель уже имеет базовые знания о C++, JavaScript, Qt, QML и Linux и ознакомился с примером их взаимодействия в рамках Sailfish OS. Также может быть полезным предварительное знакомство с лекцией по смежной тематике, проведённой в рамках Летней школы Sailfish OS в Иннополисе летом 2016 года, и другими статьями посвященными разработке под данную платформу, которые уже были опубликованы на Хабре.
Разберём простой пример, ограниченный пятью функциями:
Для запуска нового воспроизведения требуется проверить наличие открытого экземпляра плеера (при необходимости создать) и начать воспроизведение музыки в случайном порядке. Для активации будем использовать команду «Включи музыку».
В случае с возобновлением и приостановлением воспроизведения требуется проверить состояние плеера и, при наличии возможности, запустить воспроизведение или поставить его на паузу. Для возобновления воспроизведения будем использовать команду «Играй»; для постановки на паузу — команды «Пауза» и «Стоп».
В случае с навигацией по композициям действует указанный выше принцип проверки состояния аудиоплеера. Для активации навигации вперёд используем команды «Вперёд», «Дальше» и «Следующий»; для активации навигации назад — команды «Назад» и «Предыдущий».
Процесс распознавания речевых команд разделяется на три этапа:
В начале необходимо сформировать интерфейс пользователя для захвата голосовой команды. С целью упрощения примера, будем начинать и заканчивать запись по нажатию на кнопку, так как реализация процесса обнаружения начала и конца голосовой команды заслуживает отдельного материала.
Из кода, представленного выше, видно, что кнопка использует стандартные значения размеров и стандартные иконки (интересная особенность Sailfish OS для унификации интерфейсов приложений) и имеет два состояния. В первом состоянии, когда запись не производится, после нажатия на кнопку начинается запись голосовой команды. Во втором состоянии, когда запись команды активна, после нажатия на кнопку запись останавливается и начинается распознавание голоса.
Для записи речи будем использовать класс QAudioRecorder, предоставляющий высокоуровневый интерфейс управления входным аудиопотоком, а также QAudioEncoderSettings для настройки процесса записи.
Здесь указывается, что запись команды будет вестись в формате wav в нормальном качестве, а также определяются методы для начала и окончания записи, для получения места хранения аудиофайла и состояния процесса записи.
Для трансляции аудиофайла в текст будет использоваться сервис Яндекс SpeechKit Cloud. Всё, что требуется для начала работы с ним — это получить токен в кабинете разработчика. Документация сервиса достаточно подробная, поэтому будем останавливаться лишь на частных моментах.
Первым шагом передадим записанную команду на сервер.
Здесь формируется POST-запрос к серверу Яндекс, в котором передаются полученный токен, уникальный ID устройства (в данном случае используется MAC-адрес WiFi-модуля) и тип запроса (здесь использован «queries», так как при голосовом взаимодействии с устройством чаще всего используются короткие и точные команды). В заголовках запроса указываются формат аудиофайла и его размер, в теле — непосредственно содержимое. После передачи запроса на сервер файл удаляется за ненадобностью.
В качестве ответа сервер SpeechKit Cloud возвращает XML с вариантами распознавания и степенью уверенности в них. Воспользуемся стандартными средствами Qt для выделения требуемой информации.
Здесь последовательно просматривается полученный ответ и, для тегов variant, проверяются показатели точности распознавания. Если новый вариант корректнее, то он сохраняется, а сканирование продолжается дальше. По окончанию просмотра ответа посылается сигнал с выделенным текстом команды.
Наконец, остаётся идентифицировать команду. По окончанию работы метода YandexSpeechKitHelper::_parseResponce, как было указано выше, посылается сигнал gotResponce, содержащий текст команды. Далее требуется его обработать в QML-коде программы.
Здесь используется элемент Connections для обработки поступающего сигнала и сравнения распознанной команды с шаблонами голосовых команд, определёнными ранее.
Если аудиоплеер открыт, то с ним воможно взаимодействовать через стандартный DBus-интерфейс, доставшийся от большого linux-брата. С его помощью можно перемещаться по списку воспроизведения, начинать или приостанавливать воспроизведение. Делается это с использованием QML-элемента DBusInterface.
С помощью данного элемента используется DBus-интерфейс стандартного аудиоплеера путём определения четырёх базовых функций. Параметр undefined функции call передаётся в том случае, если DBus-метод не принимает аргументов.
Стоит отметить, что для перехода к предыдущей композиции метод Previous вызывается два раза, так как его одиночный вызов приводит к воспроизведению текущей композиции с начала.
В управлении уже работающим плеером ничего сложного нет. Однако, если имеется желание начать воспроизведение музыки, когда он закрыт — возникает проблема, так как, по умолчанию, функционал запуска стандартного плеера с одновременным воспроизведением всей коллекции не предоставляется.
Но не стоит забывать о том, что Sailfish OS — операционная система с открытым исходным кодом, доступная для свободной модификации. В следствие этого возникшую проблему можно решить в два этапа:
Стандартный аудиоплеер, помимо интерфейса org.mpris.MediaPlayer2.Player, предоставляет интерфейс com.jolla.mediaplayer.ui, определённый в файле /usr/share/jolla-mediaplayer/mediaplayer.qml. Из этого следует, что возможно модифицировать данный файл, добавив необходимую нам функцию.
Здесь был модифицирован элемент DBusAdaptor, используемый для предоставления DBus-интерфейса, путём добавления метода shuffleAndPlay. В нём используется стандартный функционал плеера для запуска воспроизведения всех композиций в случайном порядке, предоставляемый модулем com.jolla.mediaplayer, и выводится на передний план текущая очередь воспроизведения.
В рамках примера, для простоты, была выполнена простая модификация системного файла. Однако, при распространении программы подобные изменения необходимо оформлять в виде патчей, воспользовавшись соответствующими инструкциями.
Теперь из разрабатываемой программы необходимо обратиться к новому методу. Это выполняется с помощью уже знакомого элемента DBusInterface, в котором осуществляется подключение к определённому выше сервису и реализуется вызов добавленной в плеер функции.
Наконец, последнее, что осталось — запуск аудиоплеера если он закрыт. Условно, задачу можно разделить на два этапа:
Из кода представленной функции видно, что на первом этапе выполняется проверка наличия необходимого DBus-сервиса. Если он зарегистрирован в системе, то функция завершает работу и выполняется переход к запуску воспроизведения. Если же сервис не найден, то создаётся новый экземпляр аудиоплеера, используя QProcess, с ожиданием полного его запуска. Во второй части функции, с помощью QDBusInterface, проверяется флаг окончания сканирования коллекции музыки на устройстве.
Следует отметить, что для проверки флага сканирования коллекции были сделаны два дополнительных изменения в файле /usr/share/jolla-mediaplayer/mediaplayer.qml.
Во-первых, был модифицирован элемент GriloTrackerModel, предоставляемый модулем com.jolla.mediaplayer, путём добавления флага окончания сканирования.
Во-вторых, была добавлена ещё одна функция, доступная через DBus-интерфейс com.jolla.mediaplayer.ui, возвращающая значение флага состояния сканирования коллекции аудиофайлов.
Последним элементом примера является голосовое сообщение о неправильной команде. Для этого воспользуемся сервисом синтеза речи Яндекс SpeechKit Cloud.
Здесь был создан объект Audio для воспроизведения сгенерированной речи и объявлена функция generateErrorMessage для формирования запроса к серверу Яндекс и запуска воспроизведения. В запросе передаются следующие параметры:
В рамках данной статьи рассмотрен простой пример управления воспроизведением музыки в стандартном аудиоплеере Sailfish OS с помощью голосовых команд; получены и повторены базовые знания о распознавании и синтезе речи с помощью Яндекс SpeechKit Cloud с использованием средств Qt, а также принципы взаимодействия программ друг с другом в 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<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. Данный материал может послужить отправной точкой для более глубоких изысканий и экспериментов в данной операционной системе.
Пример работы приведённого кода можно посмотреть на видео:
Автор: Пётр Вытовтов