1. 开始之前
在本 Codelab 中,您将使用 Oboe 库 构建一个简单的音乐游戏,这是一个使用 Android NDK 中高性能音频 API 的 C++ 库。游戏的目标是通过点击屏幕来复制您听到的拍手模式。
先决条件
- 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 相关的对象。
每次需要更新屏幕时,OpenGL Surface 都会调用 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;
}
- 将点击方法更新为以下内容
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库构建了一个音乐游戏。