1. 开始之前
在此 Codelab 中,您将使用 Oboe 库 构建一个简单的音乐游戏。Oboe 是一个 C++ 库,使用 Android NDK 中的高性能音频 API。游戏目标是通过轻触屏幕来模仿您听到的拍手模式。
前提条件
- C++ 基础知识,包括如何使用头文件和实现文件。
您将执行的操作
- 使用 Oboe 库播放声音。
- 创建低延迟音频流。
- 混合声音。
- 在时间轴上精确触发声音。
- 将音频与屏幕 UI 同步。
您将需要的内容
- Android Studio 4.1 或更高版本
- 安装了 Android NDK 和构建工具
- 用于测试的运行 Android Lollipop(API 级别 21)或更高版本的 Android 设备(Pixel 设备最适合低延迟音频)
2. 如何玩游戏?
游戏会播放一段持续循环的、带感的四拍伴奏。游戏开始时,它还会在小节的前三拍播放拍手声音。
用户必须在第二个小节开始时通过轻触屏幕,尝试以相同的时机重复这三次拍手。
每次用户轻触时,游戏都会播放拍手声音。如果轻触发生在正确的时间,屏幕会闪烁绿色。如果轻触太早或太晚,屏幕会分别闪烁橙色或紫色。
3. 开始
克隆项目
在 GitHub 上克隆 Oboe 仓库并切换到 game-codelab
分支。
git clone https://github.com/google/oboe cd oboe git checkout game-codelab
在 Android Studio 中打开项目
加载 Android Studio 并打开 Codelab 项目
- 文件 > 打开...
- 选择 oboe/samples 文件夹
运行项目
- 选择 RhythmGame 运行配置。
- 按下
Control+R
构建并运行模板应用。它应该能够编译和运行,但除了将屏幕变为黄色外,没有任何其他功能。您将在本 Codelab 中为游戏添加功能。
打开 RhythmGame 模块
您将在此 Codelab 中处理的文件存储在 RhythmGame 模块中。在项目窗口中展开此模块,确保选择了 Android 视图。
现在展开 cpp/native-lib 文件夹。在此 Codelab 期间,您将编辑 Game.h
和 Game.cpp
。
与最终版本进行比较
在 Codelab 期间,参考存储在 master 分支中的代码最终版本会很有用。Android Studio 使跨分支比较文件更改变得容易。
- 在项目视图中右键单击文件。
- 转到 git > 与分支比较... > master
这将打开一个新窗口,突出显示差异。
4. 架构概览
游戏架构如下
UI
图表的左侧显示了与 UI 相关的对象。
每次屏幕需要更新时(通常每秒 60 次),OpenGL Surface 都会调用 tick
。然后,Game
会指示任何 UI 渲染对象将像素渲染到 OpenGL Surface,屏幕就会更新。
游戏的 UI 非常简单:单个方法 SetGLScreenColor
更新屏幕颜色。以下颜色用于显示游戏中发生的情况
- 黄色——游戏正在加载。
- 红色——游戏加载失败。
- 灰色——游戏正在运行。
- 橙色——用户轻触太早。
- 绿色——用户轻触及时。
- 紫色——用户轻触太晚。
轻触事件
每次用户轻触屏幕时,都会调用 tap
方法,并传入事件发生的时间。
音频
图表的右侧显示了与音频相关的对象。Oboe 提供 AudioStream
类和相关对象,允许 Game
将音频数据发送到音频输出设备(扬声器或耳机)。
每次 AudioStream
需要更多数据时,它都会调用 AudioStreamDataCallback::onAudioReady
。这会将一个名为 audioData
的数组传递给 Game
,Game
必须用 numFrames
个音频帧填充该数组。
5. 播放声音
从发出声音开始!您将加载一个 MP3 文件到内存中,并在用户轻触屏幕时播放它。
构建 AudioStream
一个 AudioStream
允许您与音频设备(如扬声器或耳机)通信。要创建一个,您需要使用 AudioStreamBuilder
。这允许您指定在打开流后希望流具有的属性。
在 Game
类中创建一个新的私有方法 openStream
。首先,将方法声明添加到 Game.h
中。
private: ... bool openStream();
然后将实现添加到 Game.cpp
中。
bool Game::openStream() {
AudioStreamBuilder builder;
builder.setFormat(AudioFormat::Float);
builder.setFormatConversionAllowed(true);
builder.setPerformanceMode(PerformanceMode::LowLatency);
builder.setSharingMode(SharingMode::Exclusive);
builder.setSampleRate(48000);
builder.setSampleRateConversionQuality(
SampleRateConversionQuality::Medium);
builder.setChannelCount(2);
}
这里有很多内容,所以让我们分解一下。
您创建了流构建器并请求了以下属性
setFormat
请求样本格式为 float。setFormatConversionAllowed
允许 Oboe 始终提供具有 float 样本的流,无论底层流格式如何。这对于与仅支持 16 位样本流的 Lollipop 之前设备兼容非常重要。setPerformanceMode
请求低延迟流。您希望最大程度地减少用户轻触屏幕和听到拍手声音之间的延迟。setSharingMode
请求对音频设备的独占访问。这在支持独占访问的音频设备上进一步减少延迟。setSampleRate
将流的采样率设置为每秒 48000 个样本。这与您的源 MP3 文件的采样率相匹配。setSampleRateConversionQuality
设置底层音频设备不支持 48000 采样率时使用的重采样算法的质量。在这种情况下,使用了中等质量的算法。这在重采样质量和计算负载之间提供了良好的权衡。setChannelCount
将流的通道数设置为 2,即立体声流。同样,这与您的 MP3 文件的通道数相匹配。
打开流
现在流已经使用构建器设置好了,您可以继续打开它。为此,使用 openStream
方法,该方法将 std::shared_ptr<AudioStream>
作为其参数。
- 在
Game.h
内部声明一个类型为std::shared_ptr<AudioStream>
的成员变量。
private:
...
std::shared_ptr<AudioStream> mAudioStream;
}
- 将以下代码添加到
Game.cpp
中openStream
的末尾。
bool Game::openStream() {
[...]
Result result = builder.openStream(mAudioStream);
if (result != Result::OK){
LOGE("Failed to open stream. Error: %s", convertToText(result));
return false;
}
return true;
}
此代码尝试打开流,如果发生错误则返回 false
。
加载声音文件
项目在 assets 文件夹中包含一个名为 CLAP.mp3
的文件,其中包含 MP3 音频数据。您将解码该 MP3 文件并将其作为音频数据存储在内存中。
- 打开
Game.h
,并声明一个名为mClap
的std::unique_ptr<Player>
和一个名为bool setupAudioSources
的方法。
private:
// ...existing code...
std::unique_ptr<Player> mClap;
bool setupAudioSources();
- 打开
Game.cpp
并添加以下代码
bool Game::setupAudioSources() {
// Set the properties of our audio source(s) to match those of our audio stream.
AudioProperties targetProperties {
.channelCount = 2,
.sampleRate = 48000
};
// Create a data source and player for the clap sound.
std::shared_ptr<AAssetDataSource> mClapSource {
AAssetDataSource::newFromCompressedAsset(mAssetManager, "CLAP.mp3", targetProperties)
};
if (mClapSource == nullptr){
LOGE("Could not load source data for clap sound");
return false;
}
mClap = std::make_unique<Player>(mClapSource);
return true;
}
这将 CLAP.mp3
解码为与我们的音频流具有相同通道数和采样率的 PCM 数据,然后将其存储在 Player
对象中。
设置回调
到目前为止,一切顺利!您已经有了打开音频流和将 MP3 文件加载到内存的方法。现在,您需要将音频数据从内存传输到音频流中。
为此,您可以使用 AudioStreamDataCallback
,因为这种方法提供了最佳性能。更新您的 Game
类以实现 AudioStreamDataCallback
接口。
- 打开
Game.h
并找到以下行
class Game {
- 将其更改为以下内容
class Game : public AudioStreamDataCallback {
- 覆盖
AudioStreamDataCallback::onAudioReady
方法
public:
// ...existing code...
// Inherited from oboe::AudioStreamDataCallback
DataCallbackResult
onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) override;
onAudioReady
的 audioData
参数是一个数组,您可以使用 mClap->renderAudio
将音频数据渲染到其中。
- 将
onAudioReady
的实现添加到Game.cpp
。
// ...existing code...
DataCallbackResult Game::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) {
mClap->renderAudio(static_cast<float *>(audioData), numFrames);
return DataCallbackResult::Continue;
}
返回值 DataCallbackResult::Continue
告诉流您打算继续发送音频数据,因此回调应继续。如果您返回 DataCallbackResult::Stop
,回调将停止,流将不再播放音频。
要完成数据回调设置,您必须在 openStream
中使用 setDataCallback
告知音频流构建器数据回调对象的位置。请在打开流之前执行此操作。
bool Game::openStream() {
...
builder.setDataCallback(this);
Result result = builder.openStream(mAudioStream);
加载
游戏开始之前,必须发生几件事
- 必须使用
openStream
打开音频流。 - 游戏使用的任何 MP3 文件需要使用
setupAudioSources
进行解码并加载到内存中。
这些操作是阻塞的,取决于 MP3 文件的大小和解码器的速度,可能需要几秒钟才能完成。您应避免在主线程上执行这些操作。否则,您可能会遇到可怕的 ANR。
游戏开始之前必须发生的另一件事是启动音频流。在其他加载操作完成后执行此操作是合理的。
将以下代码添加到现有的 load
方法中。您将在单独的线程中调用此方法。
void Game::load() {
if (!openStream()) {
mGameState = GameState::FailedToLoad;
return;
}
if (!setupAudioSources()) {
mGameState = GameState::FailedToLoad;
return;
}
Result result = mAudioStream->requestStart();
if (result != Result::OK){
LOGE("Failed to start stream. Error: %s", convertToText(result));
mGameState = GameState::FailedToLoad;
return;
}
mGameState = GameState::Playing;
}
情况如下。您正在使用成员变量 mGameState
来跟踪游戏状态。最初是 Loading
,然后变为 FailedToLoad
或 Playing
。您稍后会更新 tick
方法来检查 mGameState
并相应地更新屏幕背景颜色。
您调用 openStream
打开您的音频流,然后调用 setupAudioSources
从内存中加载 MP3 文件。
最后,您通过调用 requestStart
启动音频流。这是一个非阻塞方法,它会尽快启动音频回调。
异步启动
现在您需要做的就是异步调用您的加载方法。为此,您可以使用C++ async 函数,该函数会在单独的线程上异步调用一个函数。更新您的游戏的 start
方法
void Game::start() {
mLoadingResult = std::async(&Game::load, this);
}
这只是异步调用您的 load
方法并将结果存储在 mLoadingResult
中。
更新背景颜色
这一步很简单。根据游戏状态,您可以更新背景颜色。
更新 tick
方法
void Game::tick(){
switch (mGameState){
case GameState::Playing:
SetGLScreenColor(kPlayingColor);
break;
case GameState::Loading:
SetGLScreenColor(kLoadingColor);
break;
case GameState::FailedToLoad:
SetGLScreenColor(kLoadingFailedColor);
break;
}
}
处理轻触事件
您快完成了,只剩下最后一件事。每次用户轻触屏幕时,都会调用 tap
方法。通过调用 setPlaying
启动拍手声音。
- 将以下代码添加到
tap
中
void Game::tap(int64_t eventTimeAsUptime) {
if (mClap != nullptr){
mClap->setPlaying(true);
}
}
- 构建并运行应用。当您轻触屏幕时,应该会听到拍手的声音。
- 给自己鼓掌!
6. 播放多种声音
只播放一个拍手声音很快就会变得无聊。最好也能播放一段带有节拍的伴奏,以便您可以跟着拍手。
到目前为止,游戏只将拍手声音放入音频流中。
使用混音器
要同时播放多种声音,必须将它们混合在一起。方便的是,此 Codelab 提供了一个 Mixer
对象来完成此操作。
创建伴奏和混音器
- 打开
Game.h
并为伴奏和混音器声明另一个std::unique_ptr<Player>
private:
..
std::unique_ptr<Player> mBackingTrack;
Mixer mMixer;
- 在
Game.cpp
中,在setupAudioSources
中加载拍手声音后添加以下代码。
bool Game::setupAudioSources() {
...
// Create a data source and player for your backing track.
std::shared_ptr<AAssetDataSource> backingTrackSource {
AAssetDataSource::newFromCompressedAsset(mAssetManager, "FUNKY_HOUSE.mp3", targetProperties)
};
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);
// Add both players to a mixer.
mMixer.addTrack(mClap.get());
mMixer.addTrack(mBackingTrack.get());
mMixer.setChannelCount(mAudioStream->getChannelCount());
return true;
}
这将 FUNKY_HOUSE.mp3
资源(其中包含与拍手声音资源相同格式的 MP3 数据)的内容加载到 Player
对象中。播放从游戏开始时开始并无限循环。
拍手声音和伴奏都被添加到混音器中,并且混音器的通道数被设置为与您的音频流的通道数相匹配。
更新音频回调
您现在需要告诉音频回调使用混音器而不是拍手声音进行渲染。
- 将
onAudioReady
更新为以下内容
DataCallbackResult Game::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) {
mMixer.renderAudio(static_cast<float*>(audioData), numFrames);
return DataCallbackResult::Continue;
}
- 构建并运行游戏。当您轻触屏幕时,应该会听到伴奏和拍手声音。可以随意即兴演奏一会儿!
7. 将拍手声音排队
现在事情开始变得有趣了。您将开始添加游戏机制。游戏会在特定时间播放一系列拍手声音。这称为拍手模式。
对于这个简单的游戏,拍手模式只是在伴奏的第一个小节的第一拍开始的三个拍手。用户必须在第二个小节的第一拍开始重复相同的模式。
游戏何时应该播放拍手声?
伴奏的速度是每分钟 120 拍,即每 0.5 秒一拍。因此,游戏必须在伴奏中的以下时间播放拍手声音
节拍 | 时间(毫秒) |
1 | 0 |
2 | 500 |
3 | 1,000 |
将拍手事件与伴奏同步
每次调用 onAudioReady
时,伴奏(通过混音器)的音频帧会被渲染到音频流中——这就是用户实际听到的声音。通过计算已写入的帧数,您就知道了确切的播放时间,因此也就知道何时播放拍手声。
考虑到这一点,以下是您如何在恰好正确的时间播放拍手事件的方法
- 使用
convertFramesToMillis
将当前音频帧转换为歌曲位置(毫秒)。 - 使用该时间检查是否到了拍手事件的时间。如果需要,则播放。
通过计算在 onAudioReady
内部写入的帧数,您就知道了确切的播放位置,并且可以确保与伴奏完美同步。
跨线程通信
游戏有三个线程:一个 OpenGL 线程、一个 UI 线程(主线程)和一个实时音频线程。
拍手事件从 UI 线程推送到调度队列,并从音频线程弹出队列。
队列是从多个线程访问的,因此它必须是线程安全的。它还必须是无锁的,这样它就不会阻塞音频线程。这个要求适用于与音频线程共享的任何对象。为什么?因为阻塞音频线程会导致音频毛刺,这是谁都不想听到的!
添加代码
游戏已经包含一个 LockFreeQueue
类模板,当与单个读取线程(在本例中为音频线程)和单个写入线程(UI 线程)一起使用时,它是线程安全的。
要声明 LockFreeQueue
,必须提供两个模板参数
- 每个元素的数据类型:使用
int64_t
,因为它允许的最大时长(毫秒)超过您可能创建的任何音轨长度。 - 队列容量(必须是 2 的幂):有 3 个拍手事件,因此将容量设置为 4。
- 打开
Game.h
并添加以下声明
private:
// ...existing code...
Mixer mMixer;
LockFreeQueue<int64_t, 4> mClapEvents;
std::atomic<int64_t> mCurrentFrame { 0 };
std::atomic<int64_t> mSongPositionMs { 0 };
void scheduleSongEvents();
- 在
Game.cpp
中,创建一个名为scheduleSongEvents
的新方法。 - 在此方法中,将拍手事件入队。
void Game::scheduleSongEvents() {
// Schedule the claps.
mClapEvents.push(0);
mClapEvents.push(500);
mClapEvents.push(1000);
}
- 在流启动之前,从您的
load
方法中调用scheduleSongEvents
,以便在伴奏开始播放之前将所有事件入队。
void Game::load() {
...
scheduleSongEvents();
Result result = mAudioStream->requestStart();
...
}
- 将
onAudioReady
更新为以下内容
DataCallbackResult Game::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) {
auto *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++;
}
return DataCallbackResult::Continue;
}
for
循环对 numFrames
进行迭代。在每次迭代中,它执行以下操作
- 使用
convertFramesToMillis
将当前帧转换为毫秒时间 - 使用该时间检查是否到了拍手事件的时间。如果需要,则发生以下情况
- 事件从队列中弹出。
- 拍手声音被设置为播放状态。
- 从
mMixer
渲染单个音频帧(使用指针算术告诉mMixer
在audioData
的何处渲染帧)。
- 构建并运行游戏。游戏开始时,应该正好按照节拍播放三次拍手声。轻触屏幕仍然会播放拍手声音。
您可以随意通过更改拍手事件的帧值来尝试不同的拍手模式。您还可以添加更多拍手事件。请记住增加 mClapEvents
队列的容量。
8. 添加得分和视觉反馈
游戏播放一个拍手模式,并期望用户模仿该模式。最后,通过对用户的轻触进行评分来完成游戏。
用户轻触的时间对吗?
游戏在第一个小节拍手三次后,用户应该从第二个小节的第一拍开始轻触三次。
您不应该期望用户在恰好正确的时间轻触——那几乎是不可能的!相反,在预期时间之前和之后允许一定的容差。这定义了一个时间范围,您称之为轻触窗口。
如果用户在轻触窗口内轻触,则屏幕闪烁绿色;太早则闪烁橙色;太晚则闪烁紫色。
存储轻触窗口很容易。
- 将每个窗口中心的歌曲位置存储在队列中,就像您对拍手事件所做的那样。然后,当用户轻触屏幕时,您可以从队列中弹出每个窗口。
轻触窗口中心的歌曲位置如下
节拍 | 时间(毫秒) |
5 | 2,000 |
6 | 2,500 |
7 | 3,000 |
- 声明一个新的成员变量来存储这些拍手窗口。
private:
LockFreeQueue<int64_t, kMaxQueueItems> mClapWindows;
- 添加拍手窗口的歌曲位置。
void Game::scheduleSongEvents() {
...
// Schedule the clap windows.
mClapWindows.push(2000);
mClapWindows.push(2500);
mClapWindows.push(3000);
}
比较轻触事件与轻触窗口
当用户轻触屏幕时,您需要知道轻触是否落在当前的轻触窗口内。轻触事件作为系统运行时间(自启动以来的毫秒数)传递,因此您需要将其转换为歌曲中的位置。
幸运的是,这很简单。每次调用 onAudioReady
时,存储当前歌曲位置的运行时间。
- 在头文件中声明一个成员变量来存储歌曲位置
private:
int64_t mLastUpdateTime { 0 };
- 将以下代码添加到
onAudioReady
的末尾
DataCallbackResult Game::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) {
...
mLastUpdateTime = nowUptimeMillis();
return DataCallbackResult::Continue;
}
- 将 tap 方法更新为以下内容
void Game::tap(int64_t eventTimeAsUptime) {
if (mGameState != GameState::Playing){
LOGW("Game not in playing state, ignoring tap event");
} else {
mClap->setPlaying(true);
int64_t nextClapWindowTimeMs;
if (mClapWindows.pop(nextClapWindowTimeMs)){
// Convert the tap time to a song position.
int64_t tapTimeInSongMs = mSongPositionMs + (eventTimeAsUptime - mLastUpdateTime);
TapResult result = getTapResult(tapTimeInSongMs, nextClapWindowTimeMs);
mUiEvents.push(result);
}
}
}
您使用提供的 getTapResult
方法确定用户轻触的结果。然后,您将结果推送到 UI 事件队列中。这在下一节中解释。
更新屏幕
一旦您知道用户轻触的准确性(早、晚、完美),您就需要更新屏幕以提供视觉反馈。
为此,您使用 LockFreeQueue
类的另一个实例,并使用 TapResult
对象创建一个 UI 事件队列。
- 声明一个新的成员变量来存储这些。
private:
LockFreeQueue<TapResult, kMaxQueueItems> mUiEvents;
- 在
tick
方法中,弹出任何待处理的 UI 事件并相应地更新屏幕颜色。 - 更新
tick
中Playing
状态的代码。
case GameState::Playing:
TapResult r;
if (mUiEvents.pop(r)) {
renderEvent(r);
} else {
SetGLScreenColor(kPlayingColor);
}
break;
- 构建并运行游戏。
游戏开始时,您应该会听到三次拍手声。如果您在第二个小节准确地按节拍轻触三次,每次轻触时屏幕都应该闪烁绿色。如果太早,它会闪烁橙色。如果太晚,它会闪烁紫色。祝您好运!
9. 停止和重新启动音乐
处理暂停和关闭事件
如果用户关闭或暂停应用,必须停止音频。将以下代码添加到 Game.cpp
中的 stop
方法中
void Game::stop(){ if (mAudioStream){ mAudioStream->stop(); mAudioStream->close(); mAudioStream.reset(); } }
这将停止音频流,这意味着数据回调将不再被调用,游戏将不再产生任何音频。close
释放与音频流关联的任何资源,而 reset
释放所有权,以便可以安全删除流。
只要调用游戏的 onPause
方法,就会调用 Game::stop
方法。
连接耳机
当用户连接外部音频设备(例如耳机)时,音频不会自动路由到该音频设备。相反,现有的音频流会断开连接。这是有意为之的,允许您根据新音频设备的属性调整内容。
例如,您可以将音频源重采样到音频设备的原生采样率,以避免重采样带来的 CPU 开销。
要监听音频流断开事件,您可以覆盖 AudioStreamErrorCallback::onErrorAfterClose
。这将在音频流关闭后被调用。
- 打开
Game.h
并找到以下行
class Game : public AudioStreamDataCallback {
- 将其更改为以下内容
class Game : public AudioStreamDataCallback, AudioStreamErrorCallback {
- 覆盖
AudioStreamErrorCallback::onErrorAfterClose
方法
public:
// ...existing code...
// Inherited from oboe::AudioStreamErrorCallback.
void onErrorAfterClose(AudioStream *oboeStream, Result error) override;
- 将
onErrorAfterClose
的实现添加到Game.cpp
。
// ...existing code...
void Game::onErrorAfterClose(AudioStream *audioStream, Result error) {
if (result == Result::ErrorDisconnected){
mGameState = GameState::Loading;
mAudioStream.reset();
mMixer.removeAllTracks();
mCurrentFrame = 0;
mSongPositionMs = 0;
mLastUpdateTime = 0;
start();
} else {
LOGE("Stream error: %s", convertToText(result));
}
}
如果音频流断开连接,这将把游戏重置为初始状态并重新启动。
构建并运行游戏。如果将它切换到后台,音频应该停止。将其切换回前台应该会重新启动游戏。
现在尝试连接和断开耳机。游戏每次都应该重新启动。
在实际游戏中,当音频设备更改时,您可能希望保存和恢复游戏状态。
10. 恭喜
恭喜!您使用 Oboe 库构建了一个音乐游戏。