Oboeライブラリの音楽ゲームサンプルコードを読んでみた

Androidエンジニアのぬまです。

今日は、先日登壇したDroidKaigiにて紹介したOboeライブラリを使用したサンプルコードを読んでいきます。

OboeとはAndroid NDKの高性能オーディオAPIを使用するC++ライブラリです。

ちなみに、OboeはハイパフォーマンスなゲームオーディオとしてAndroid Game SDKに組み込まれています*1

なぜOboeを使う必要があるのか

Oboe はAndroidバイスの 99 % 以上で最低レイテンシを提供している実績があります。 VoicyのAndroidアプリでは現状、喫緊でOboeを採用する必要性は正直ないです。

ですが、収録前の音声読み込み時間やSEの再生までのレイテンシー, 端末依存による音質の違いなど、音質やパフォーマンスチューニングの余地はたくさんあり、ナレッジを蓄積していきた考えです。

C++で書く必要がありますが、コード量は少なく大変使いやすいコードベースです。また英語ではありますが、手厚いコミュニティサポートもあります。加えて、昨今のカラオケや音ゲーなど音を扱うアプリがOboeを採用する動きがあります。*2

基本的な実装

オーディオストリームを作成する

ストリームの構築を行います。 AAudioStream で表現される「オーディオ ストリーム」に対して読み書きすることにより、オーディオ データをやり取りします。

ストリームの状態遷移

bool AudioEngine::start() {
    AAudioStreamBuilder *streamBuilder;
    AAudio_createStreamBuilder(&streamBuilder);
    // 浮動小数点数の音声フォーマット
    AAudioStreamBuilder_setFormat(streamBuilder, AAUDIO_FORMAT_PCM_FLOAT);
    // 出力はモノラル
    AAudioStreamBuilder_setChannelCount(streamBuilder, 1);
    // 低レイテンシのパフォーマンス モードを設定
    AAudioStreamBuilder_setPerformanceMode(streamBuilder, AAUDIO_PERFORMANCE_MODE_LOW_LATENCY);
    // AAudioStream_writeよりデータ コールバック関数のアプローチのほうが低レイテンシ アプリに適している。
    // データ コールバック関数は、ストリームに音声データが必要になるたびに、優先度の高いスレッドから呼び出されます。
    // これで dataCallback 関数の準備ができたので、start() メソッドからそれを使用するようストリームに指示することも簡単に行えます
    // (:: は、関数がグローバル名前空間にあることを示します)。
    AAudioStreamBuilder_setErrorCallback(streamBuilder, ::errorCallback, this);

    // AAUDIO_OK 以外の結果が出た場合は、出力を Android Studio の Android Monitor ウィンドウ
    // に記録し、false を返します。
    aaudio_result_t result = AAudioStreamBuilder_openStream(streamBuilder, &stream_);
    if (result != AAUDIO_OK) {
        return false;
    }

    // すべての設定が完了したので、ストリームを開始して、音声データの使用とデータ コールバックのトリガーを開始できます。
    result = AAudioStream_requestStart(stream_);
    if (result != AAUDIO_OK) {
        __android_log_print(ANDROID_LOG_ERROR, "AudioEngine", "Error starting stream %s",
                            AAudio_convertResultToText(result));
        return false;
    }

    AAudioStreamBuilder_delete(streamBuilder);
    return true;
}

音ゲーのミニマムをつくる

Oboeのgithubリポジトリにはいくつかサンプルコードが含まれていて、今回はその中でも簡単な音楽ゲーム [RhythmGame]のコードを参照しながらなにやってるかみていきます。

どんなゲーム?

音に合わせてタイミングよく画面タップするゲームです。 4拍子のトラックが連続的にループします。 ゲーム開始時に、すぐに音楽が再生され、最初の小節で3拍で手拍子の音が流れます。

ユーザーは、2小節目が始まったら画面をタップして、最初に流れた1小節目と同じタイミングで3回拍手を繰り返します。

Audio timeline

タップするたびに、拍手音が鳴ります。タイミングよくタップすると、画面が緑色に点滅します。 タップするタイミングが早すぎるとオレンジ色に、遅すぎると紫色に点滅します。

サウンドのミキシング

複数のサウンドを同時に再生するには、それらをミックスする必要があります。 このサンプルにはミキサーオブジェクトが提供されています。

 // 拍手音のデータソースとプレーヤーを作成する
    std::shared_ptr<AAssetDataSource> mClapSource {
            AAssetDataSource::newFromCompressedAsset(mAssetManager, "CLAP.mp3")
    };

    if (mClapSource == nullptr){
        LOGE("Could not load source data for clap sound");
        return false;
    }
    mClap = std::make_unique<Player>(mClapSource);

    // BGMのデータソースとプレーヤーを作成します。
    std::shared_ptr<AAssetDataSource> backingTrackSource {
            AAssetDataSource::newFromCompressedAsset(mAssetManager, "FUNKY_HOUSE.mp3")
    };
    if (backingTrackSource == nullptr){
        LOGE("Could not load source data for backing track");
        return false;
    }
    mBackingTrack = std::make_unique<Player>(backingTrackSource);
    mBackingTrack->setPlaying(true);
    mBackingTrack->setLooping(true);

    // 両方のプレーヤーをミキサーに加える
    mMixer.addTrack(mClap.get());
    mMixer.addTrack(mBackingTrack.get());

    return true;

音とタップイベントを正確に同期させる

AudioStreamクラスがあります。 onAudioReadyが呼ばれるたびに、BGMのオーディオフレームが(ミキサーを通して)オーディオストリームにレンダリングされます。 書き込まれたフレームの数を数えることで、正確な再生時間と、拍手音を再生するタイミングをえることができます。

これを踏まえて、拍手イベントを正確なタイミングで再生する方法を説明します。

現在再生しているオーディオフレームを曲の位置にミリ秒単位で変換します。 この曲の位置で拍手をする必要があるかどうかをチェックする。必要であれば、再生するといった制御を行います。

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

    float *outputBuffer = static_cast<float *>(audioData);
    int64_t nextClapEventMs;

    for (int i = 0; i < numFrames; ++i) {

        mSongPositionMs = convertFramesToMillis(
                mCurrentFrame,
                mAudioStream->getSampleRate());

        if (mClapEvents.peek(nextClapEventMs) && mSongPositionMs >= nextClapEventMs){
            mClap->setPlaying(true);
            mClapEvents.pop(nextClapEventMs);
        }
        mMixer.renderAudio(outputBuffer+(oboeStream->getChannelCount()*i), 1);
        mCurrentFrame++;
    }

    mLastUpdateTime = nowUptimeMillis();

    return DataCallbackResult::Continue;
}

画面上のUIとオーディオを同期させる

Game.cppの、scheduleSongEventsというメソッドで、拍手イベントを待ち受けます。

void Game::scheduleSongEvents() {
   mClapEvents.push(0);
   mClapEvents.push(500);
   mClapEvents.push(1000);
}

その他

音声の再生や録音は以下のCodelabから学べます。 https://developer.android.com/codelabs/making-waves-2-sampler?hl=ja#6