Создаём музыкальную игру с библиотекой Oboe от Google — часть 1

Совсем недавно Google представила библиотеку Oboe для создания аудиоприложений с минимальными задержками. Мы перевели их руководство по созданию простой игры.

Чему вы научитесь:

  • Как проигрывать звуки с помощью библиотеки Oboe;
  • Как создавать аудиопотоки с низкой задержкой;
  • Как смешивать звуки;
  • Как проигрывать звуки точно в нужное время;
  • Как синхронизировать аудио с экранным интерфейсом.

Что вам понадобится:

  • Android Studio 3.0.0 или выше;
  • NDK и Build Tools;
  • Устройство с Android Jelly Bean (16 уровень API) или выше для тестирования. Устройства Pixel подойдут лучше всего;
  • Не помешает знание C++, но оно не является обязательным.

В чём суть игры?

Игра проигрывает клёвый четырёхбитный трек в цикле. Когда игра начинается, она также воспроизводит звук хлопка на первых трёх долях такта.

Пользователь должен повторить три хлопка с теми же промежутками во времени, нажимая на экран после начала второго такта.

При каждом нажатии пользователя игра воспроизводит звук хлопка. Если нажатие происходит в нужное время, экран загорается зелёным цветом. Если нажатие произошло слишком рано или слишком поздно, экран загорается оранжевым или фиолетовым соответственно.

Приготовления

Склонируйте проект

Склонируйте репозиторий Oboe на GitHub и переключитесь на ветку io18codelab:

$ git clone https://github.com/google/oboe 
$ cd oboe
$ git checkout io18codelab

Откройте проект в Android Studio

Запустите Android Studio и откройте проект:

  • File → Open...
  • Выберите папку «oboe/samples».

Обратите внимание, что этот проект содержит все примеры кода для библиотеки Oboe. Нам понадобится только RhythmGame.

Запустите проект

Выберите конфигурацию запуска RhythmGame:

Затем нажмите Ctrl+R для сборки и запуска шаблона приложения — оно должно скомпилироваться и запуститься, но всё, что оно делает, — показывает серый экран. Далее мы будем добавлять функциональность.

Откройте модуль RhythmGame

Файлы, с которыми мы будем работать, хранятся в модуле RhythmGame. Убедившись, что в окне Project выбран режим Android, разверните его.

Теперь разверните папку cpp/native-lib. На протяжении этой статьи мы будем редактировать Game.h и Game.cpp.

Сравниваем с финальной версией

Будет полезно в течение работы сравнивать текущую версию кода с финальной, которая хранится в ветке master. С Android Studio можно легко сравнивать ветки.

Android Studio нужно знать, где находится исполняемый файл git. Как правило, его можно узнать, введя which git в терминале. Затем вы сможете обновить путь до git в Android Studio через Preferences→Version Control→Git.

Сначала нужно разрешить интеграцию с системой контроля версий:

  • VCS→Enable Version Control Integration…
  • Выберите git.

Теперь вы можете сравнивать свой код с версией в ветке master:

  • Нажмите на текущую ветку в правом нижнем углу;
  • Выберите master→Compare.

В результате откроется новое окно. Выберите вкладку «Diff». Должен появиться список файлов с разницей между ними.

Выберите любой файл, чтобы посмотреть различия.

Обзор архитектуры

Архитектура игры:

Пользовательский интерфейс

На левой части графика показаны объекты, связанные с пользовательским интерфейсом.

OpenGL Surface вызывает tick() каждый раз, когда экран нужно обновить, как правило, 60 раз в секунду. Затем Game даёт объектам для рендеринга интерфейса указание отобразить пиксели на поверхности OpenGL, после чего экран обновляется.

Интерфейс игры очень простой: единственный метод SetGLScreenColor() обновляет цвет экрана.

События нажатий

При каждом нажатии на экран вызывается метод tap(), в который передаётся время нажатия.

Аудио

На правой части графика показаны объекты, связанные с аудио. Oboe предоставляет класс AudioStream и связанные объекты, чтобы дать возможность Game отправлять аудиоданные на выходное устройство (динамики или наушники).

Каждый раз, когда AudioStream требуется больше данных, он вызывает метод AudioDataCallback::onAudioReady(). Он передаёт массив audioData в Game, где массив должен заполниться фреймами аудио в количестве numFrames.

Проигрываем звук при нажатии на экран

Чтобы воспроизвести звук хлопка нужен файл, в котором содержатся цифровые аудиоданные и аудиопоток.

Загружаем файл со звуком

В проекте в папке src/main/assets есть файл CLAP.raw, который содержит PCM-аудиоданные в следующем формате:

  • Формат: 16-битное целое число;
  • Каналы: 2 (стерео);
  • Частота дискретизации: 48,000 кГц.

Если вы хотите просмотреть или воспроизвести этот файл, вы можете загрузить его в аудиоредактор Audacity, перейдя в меню по вкладкам File→Import→Raw Data и использовать указанные выше опции.

Чтобы загрузить этот файл в игру, нужно использовать метод SoundRecording::loadFromAssets. Откройте Game.h и объявите поле типа SoundRecording* с именем mClap и значением nullptr:

private:
    //...имеющийся код 
    SoundRecording *mClap{nullptr};

Теперь откройте Game.cpp и добавьте следующий код в метод start():

void Game::start() {
   mClap = SoundRecording::loadFromAssets(mAssetManager, "CLAP.raw");
}

Это необходимо для загрузки PCM-аудиоданных в объект SoundRecording в начале игры.

Создаём аудиопоток

Чтобы создать аудиопоток, воспользуемся AudioStreamBuilder, который позволяет указывать желаемые свойства аудиопотока до его создания.

Устанавливаем свойства потока

Свойства потока должны совпадать со свойствами источника.

Откройте Game.h и объявите поле типа AudioStream* с именем mAudioStream:

private:
    // ...имеющийся код... 
    AudioStream *mAudioStream{nullptr};

Теперь добавьте следующий код в метод start() в Game.cpp:

void Game::start() {
    // ...имеющийся код...

    AudioStreamBuilder builder;
    builder.setFormat(AudioFormat::I16);
    builder.setChannelCount(2);
    builder.setSampleRate(48000);
}

Настраиваем обратный вызов

Воспользуемся обратным вызовом для передачи аудиоданных в поток. Мы делаем именно так, потому что такой подход обеспечивает лучшую производительность.

Чтобы использовать обратный вызов, нужно объявить объект, который реализует интерфейс AudioDataCallback. Вместо создания нового объекта можно реализовать этот интерфейс в Game. Откройте Game.h, найдите строку class Game { и замените её на class Game : public AudioStreamCallback {.

Теперь нужно переопределить метод AudioStreamCallback::onAudioReady():

public:
    // ...имеющийся код... 
     
    // Наследуется от oboe::AudioStreamCallback
    DataCallbackResult
    onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) override;

Параметр audioData для onAudioReady является массивом, который заполняется аудиоданными с помощью mClap->renderAudio.

Примечание Каждый раз, когда вы получаете контейнерный массив типа void *, не забывайте привести его к формату текущего потока данных и предоставляйте данные только в этом формате. В противном случае это обернётся ужасными шумами!

Добавим реализацию onAudioReady() в Game.cpp:

// ...имеющийся код... 

DataCallbackResult Game::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) {
    mClap->renderAudio(static_cast<int16_t *>(audioData), numFrames);
    return DataCallbackResult::Continue;
}

Возвращаемое значение DataCallbackResult::Continue говорит потоку о вашем намерении продолжать посылать аудиоданные, а это значит, что обратные вызовы должны продолжаться. Если вернуть DataCallbackResult::Stop, обратные вызовы прекратятся и поток больше не будет воспроизводить аудио.

Чтобы завершить создание обратного вызова, нужно сообщить конструктору потоков, где найти объект обратного вызова с помощью setCallback() в start():

void Game::start() {
    // ...имеющийся код... 
    builder.setSampleRate(48000);
 
    builder.setCallback(this);
}

Создание и запуск аудиопотока

Когда всё готово, создать и запустить поток не составляет труда. Добавьте этот код в start():

void Game::start() {
    // ...имеющийся код... 
    builder.setCallback(this);    

    // Создаём поток
    Result result = builder.openStream(&mAudioStream);
    if (result != Result::OK){
        LOGE("Failed to open stream. Error: %s", convertToText(result));
    }

    // Запускаем поток
    result = mAudioStream->requestStart();
    if (result != Result::OK){
       LOGE("Failed to start stream. Error: %s", convertToText(result));
    }
}

Проверка ошибок: всегда проверяйте результат операций потока и тщательно обрабатывайте все ошибки. Почти все методы в Oboe возвращают объект Result, который можно проверить на наличие Result::OK и привести к читабельной строке с помощью convertToText().

Обработка нажатий

Метод tap() вызывается при каждом нажатии на экран. Начнём воспроизведение звука хлопка, вызвав setPlaying(). Добавьте этот код в tap():

void Game::tap(int64_t eventTimeAsUptime) {
    mClap->setPlaying(true);
}

Соберите и запустите приложение. При нажатии на экран вы должны услышать звук хлопка.

Оптимизация задержки аудиопотока

Как вы могли заметить, между нажатием на экран и воспроизведением звука проходит определённое время, которое называют задержкой. Как правило, она исходит из двух источников: тачскрина и аудиопотока.

Мы можем оптимизировать задержку аудиопотока, изменив его свойства на следующие:

  • Установив значение PerfomanceMode равным LowLatency;
  • Установив значение SharingMode равным Exclusive;
  • Уменьшив размер внутреннего буфера.

Откройте Game.cpp и добавьте эти строки сразу после создания обратного вызова в start():

void Game::start() {
    // ...имеющийся код... 
    builder.setCallback(this);
    
    builder.setPerformanceMode(PerformanceMode::LowLatency);
    builder.setSharingMode(SharingMode::Exclusive);
    // ...имеющийся код...  
}

Установить размер буфера можно только после создания потока. Добавьте этот код после создания потока в start():

void Game::start() {
    // ...existing code... 
    Result result = builder.openStream(&mAudioStream);
    if (result != Result::OK){
       LOGE("Failed to open stream. Error: %s", convertToText(result));
    }
    
    // Уменьшаем задержку потока, установив размер буфера кратным размеру «всплеска»
    mAudioStream->setBufferSizeInFrames(mAudioStream->getFramesPerBurst() * 2);
    // ...имеющийся код... 
}

Так мы устанавливаем размер буфера равным двум «всплескам» — данным, которые записываются в течение каждого обратного вызова. Мы выбираем именно два всплеска, так как это хороший компромисс между задержкой и защитой от буферной недогрузки. Это часто называют многократной буферизацией и используют в графическом программировании.

Соберите и запустите приложение. Вы должны заметить, что время между нажатием и звуком хлопка уменьшилось и игра ощущается более отзывчивой. Хорошая работа!

Воспроизведение нескольких звуков

Один звук может быстро надоесть. Было бы неплохо проигрывать на фоне какой-нибудь бит, под который можно нажимать на экран.

На данный момент игра помещает в аудиопоток только звуки хлопков.

Используем микшер

Чтобы проигрывать несколько звуков одновременно, нужно смешать их с помощью Mixer.

Создаём фоновую дорожку и микшер

Откройте Game.h и объявите ещё один SoundRecording* для фоновой музыки и Mixer:

private:
   // ...имеющийся код... 
   SoundRecording *mClap{nullptr};
   
   SoundRecording *mBackingTrack{nullptr};
   Mixer mMixer;

Теперь добавьте в Game.cpp этот код после загрузки звука хлопка в start():

void Game::start() {
    mClap = SoundRecording::loadFromAssets(mAssetManager, "CLAP.raw");
    mBackingTrack = SoundRecording::loadFromAssets(mAssetManager, "FUNKY_HOUSE.raw");
    mBackingTrack->setPlaying(true);
    mBackingTrack->setLooping(true);
    mMixer.addTrack(mClap);
    mMixer.addTrack(mBackingTrack);
    // ...имеющийся код... 
}

Этот код загружает содержимое ресурса FUNKY_HOUSE.raw (который содержит PCM-данные в том же формате, что и ресурс звука хлопка) в объект SoundRecording. Воспроизведение начинается при запуске игры и продолжается бесконечно.

Звук хлопка и фоновая музыка добавляются в микшер.

Обновляем обратный вызов

Теперь нам нужно сделать так, чтобы обратный вызов использовал микшер вместо звука хлопка. Обновите onAudioReady() следующим образом:

DataCallbackResult Game::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) {

    mMixer.renderAudio(static_cast<int16_t*>(audioData), numFrames);
    return DataCallbackResult::Continue;
}

Соберите и запустите игру. Вы должны услышать фоновую дорожку и звук хлопка при нажатии на экран.

В следующей части мы займёмся механикой игры и визуальной частью.

Перевод статьи «Build a Musical Game using Oboe»

Подобрали три теста для вас:
— А здесь можно применить блокчейн?
Серверы для котиков: выберите лучшее решение для проекта и проверьте себя.
Сложный тест по C# — проверьте свои знания.

Также рекомендуем: