1. 开始之前
在本 Codelab 中,您将使用 Oboe 库 构建一个简单的音乐游戏,Oboe 库是一个 C++ 库,它使用 Android NDK 中的高性能音频 API。游戏的目标是通过点击屏幕来复制您听到的拍手模式。
先决条件
- C++ 的基本知识,包括如何使用头文件和实现文件。
您将做什么
- 使用 Oboe 库播放声音。
- 创建低延迟音频流。
- 将声音混合在一起。
- 在时间轴上精确触发声音。
- 将音频与屏幕上的 UI 同步。
您需要什么
- Android Studio 4.1 或更高版本
- 已安装 Android NDK 和构建工具
- 运行 Android Lollipop(API 级别 21)或更高版本的 Android 设备,用于测试(Pixel 设备最适合低延迟音频)
2. 您如何玩游戏?
游戏播放了一个持续循环的 funky 四拍伴奏。游戏开始时,它还会在 小节 的前三个拍上播放一个拍手声。
用户必须尝试在第二小节开始时点击屏幕,以相同的时间重复这三个拍手。
每次用户点击时,游戏都会播放一个拍手声。如果点击是在正确的时间,屏幕会闪烁绿色。如果点击过早或过晚,屏幕会分别闪烁橙色或紫色。
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 相关的对象。
OpenGL 表面每次需要更新屏幕时都会调用 tick
,通常每秒 60 次。然后,Game
指示任何 UI 渲染对象将像素渲染到 OpenGL 表面,并更新屏幕。
游戏的 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
启动音频流。这是一个非阻塞方法,它会尽快启动音频回调。
异步启动
您现在所要做的就是异步调用您的 load 方法。为此,您可以使用 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. 播放多个声音
只播放一个鼓掌声会很快变得无聊。最好也播放一个伴奏,您可以在伴奏的节奏上鼓掌。
到目前为止,游戏只将鼓掌声放入音频流中。
使用混音器
要同时播放多个声音,您必须将它们混合在一起。方便的是,已提供了一个 Mixer
对象,它在这个代码实验室中执行此操作。
创建伴奏和混音器
- 打开
Game.h
并声明另一个std::unique_ptr<Player>
用于伴奏和一个Mixer
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 拍。因此,游戏必须在伴奏中的以下时间播放鼓掌声
拍子 | 时间(毫秒) |
1 | 0 |
2 | 500 |
3 | 1,000 |
将鼓掌事件与伴奏同步
每次调用 onAudioReady
时,伴奏的音频帧(通过混音器)都会渲染到音频流中,这就是用户实际听到的内容。通过计算已写入的帧数,您可以知道确切的播放时间,因此可以知道何时播放鼓掌声。
考虑到这一点,以下是如何在完全正确的时间播放鼓掌事件
- 将当前音频帧转换为以毫秒为单位的歌曲位置。
- 检查是否需要在此歌曲位置播放鼓掌声。如果需要,则播放它。
通过计算在 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 库构建了一个音乐游戏。