我们来制造一些声音吧!在此 Codelab 中,我们将使用 AAudio API 针对 Android 构建一个低延迟的触控合成器应用。
我们的应用会在用户触摸屏幕后尽快发出声音。输入和输出之间的延时称为“延迟”。要打造出色的音频体验,理解并尽可能缩短延迟是其关键。事实上,我们使用 AAudio 的主要原因就在于它能够创建低延迟的音频流。
学习内容
- 制作低延迟音频应用的基本概念
- 如何创建音频流
- 如何处理处于连接和断开连接状态的音频设备
- 如何生成音频数据并将其传递到音频流
- 在 Java 和 C++ 之间通信的最佳做法
- 如何监听界面中的触摸事件
所需条件
- Android Studio 2.3.3 或更高版本
- Android 8.0(API 级别 26)SDK
- 已安装 NDK 和构建工具
- 搭载 Android 8.0(API 级别 26)或更高版本的模拟器或 Android 设备,用于进行测试
- 具备一些 C++ 知识有助于您完成相关学习,但并非必须
当用户点按屏幕时,应用会生成合成的声音。其架构如下:
我们的合成器应用包含 4 个组件:
- 界面 - 采用 Java 编写,MainActivity 类负责接收触摸事件并将其转发到 JNI 桥。
- JNI 桥 - 此 C++ 文件使用 JNI 在我们的界面和 C++ 对象之间提供通信机制。它会将事件从界面转发到音频引擎。
- 音频引擎 - 此 C++ 类可创建播放音频流,并设置用于向音频流提供数据的数据回调
- 振荡器 - 此 C++ 类可根据用于计算正弦波形的简单数学公式来生成数字音频数据
首先,在 Android Studio 中创建一个新项目:
- File -> New -> New Project...
- 将项目命名为“WaveMaker”
按照项目设置向导操作时,将默认值更改为:
- 包含 C++ 支持
- 手机和平板电脑最低 SDK 版本:API 26:Android O
- C++ 标准:C++11
注意:如果您需要参阅 WaveMaker 应用的已完成的源代码,请点击此处。
由于振荡器是生成音频数据的对象,因此最好从振荡器入手。我们会秉持简单至上的原则,并使用振荡器来创建 440Hz 的正弦波。
数字合成方面的基础知识
振荡器是数字合成的基本构建块。振荡器需要生成一系列数字,我们称之为“样本”。每个样本表示一个振幅值,音频硬件会将此值转换为电压以驱动头戴式耳机或扬声器。
以下是一个表示正弦波的样本曲线图:
开始实现之前,请先熟悉一下关于数字音频数据的一些重要术语:
- 样本格式 - 用于表示每个样本的数据类型。常见的样本格式包括 PCM16 和浮点。我们采用浮点的原因之一,在于浮点具有 24 位分辨率,并且在音量较低时,其精确率也较高。
- 帧 - 在生成多声道音频时,样本会归在“帧”中。帧中的每个样本都对应一个不同的音频声道。例如,立体声音频有 2 个声道(左声道和右声道),因此立体声音频的帧有 2 个样本,一个表示左声道,另一个表示右声道。
- 帧速率 - 每秒帧数。这通常称为“采样率”。帧速率和采样率通常含义相同,并可互换使用。常用帧速率值为每秒 44100 帧和 48000 帧。AAudio 使用的是“采样率”这一术语,因此我们将在我们的应用中沿用这一惯例。
创建源文件和头文件
右键点击 /app/cpp
文件夹,然后依次转到 New -> C++ class。
将您的类命名为“Oscillator”。
将以下行添加到 CMakeLists.txt
,从而将 C++ 源文件添加到 build。您可以在 Project 窗口的 External Build Files
部分下找到它。
add_library(...existing source filenames...
src/main/cpp/Oscillator.cpp)
确保您的项目已构建成功。
添加代码
将以下代码添加到 Oscillator.h
文件:
#include <atomic>
#include <stdint.h>
class Oscillator {
public:
void setWaveOn(bool isWaveOn);
void setSampleRate(int32_t sampleRate);
void render(float *audioData, int32_t numFrames);
private:
std::atomic<bool> isWaveOn_{false};
double phase_ = 0.0;
double phaseIncrement_ = 0.0;
};
接下来,将以下代码添加到 Oscillator.cpp
文件:
#include "Oscillator.h"
#include <math.h>
#define TWO_PI (3.14159 * 2)
#define AMPLITUDE 0.3
#define FREQUENCY 440.0
void Oscillator::setSampleRate(int32_t sampleRate) {
phaseIncrement_ = (TWO_PI * FREQUENCY) / (double) sampleRate;
}
void Oscillator::setWaveOn(bool isWaveOn) {
isWaveOn_.store(isWaveOn);
}
void Oscillator::render(float *audioData, int32_t numFrames) {
if (!isWaveOn_.load()) phase_ = 0;
for (int i = 0; i < numFrames; i++) {
if (isWaveOn_.load()) {
// Calculates the next sample value for the sine wave.
audioData[i] = (float) (sin(phase_) * AMPLITUDE);
// Increments the phase, handling wrap around.
phase_ += phaseIncrement_;
if (phase_ > TWO_PI) phase_ -= TWO_PI;
} else {
// Outputs silence by setting sample value to zero.
audioData[i] = 0;
}
}
}
借助 void setSampleRate(int32_t sampleRate)
,我们可以为音频数据设置所需的采样率(稍后将详细介绍我们需要此代码的原因)。它会根据 sampleRate
和 FREQUENCY
来计算 render
中使用的 phaseIncrement_
的值。如果您想更改正弦波的音高,只需更新 FREQUENCY
值即可。
void setWaveOn(bool isWaveOn)
是 isWaveOn_
字段的 setter 方法。此方法可在 render
中用于确定是要输出正弦波还是不发出声音。
每当被调用时,void render(float *audioData, int32_t numFrames)
都会将浮点正弦波值放入相应的 audioData
数组。
numFrames
是我们必须渲染的音频帧的数量。为简单起见,我们的振荡器会每帧输出一个样本,即单声道。
phase_
会存储当前的波相位,并在每个样本生成后增加 phaseIncrement_
。
如果 isWaveOn_
为 false
,则我们只需输出 0 即可(不发出声音)。
以上就是振荡器的作用!但是,如何才能听到正弦波的声音呢?为此,我们需要使用音频引擎…
音频引擎负责完成以下任务:
- 向默认音频设备设置音频流
- 使用数据回调将振荡器关联到音频流
- 开启和关闭振荡器的波输出
- 在不再需要音频流时将其关闭
如果您尚不熟悉 AAudio API,不妨先熟悉一下此 API,以便了解构建音频流和管理音频流状态背后的关键概念。
创建源文件和头文件
与上一步一样,创建一个名为“AudioEngine”的 C++ 类。
将以下几行代码添加到 CMakeLists.txt
,以便将 C++ 源文件和 AAudio 库添加到 build
add_library(...existing source files...
src/main/cpp/AudioEngine.cpp )
target_link_libraries(...existing libraries...
aaudio)
添加代码
将以下代码添加到 AudioEngine.h
文件:
#include <aaudio/AAudio.h>
#include "Oscillator.h"
class AudioEngine {
public:
bool start();
void stop();
void restart();
void setToneOn(bool isToneOn);
private:
Oscillator oscillator_;
AAudioStream *stream_;
};
接下来,将以下代码添加到 AudioEngine.cpp
文件:
#include <android/log.h>
#include "AudioEngine.h"
#include <thread>
#include <mutex>
// Double-buffering offers a good tradeoff between latency and protection against glitches.
constexpr int32_t kBufferSizeInBursts = 2;
aaudio_data_callback_result_t dataCallback(
AAudioStream *stream,
void *userData,
void *audioData,
int32_t numFrames) {
((Oscillator *) (userData))->render(static_cast<float *>(audioData), numFrames);
return AAUDIO_CALLBACK_RESULT_CONTINUE;
}
void errorCallback(AAudioStream *stream,
void *userData,
aaudio_result_t error){
if (error == AAUDIO_ERROR_DISCONNECTED){
std::function<void(void)> restartFunction = std::bind(&AudioEngine::restart,
static_cast<AudioEngine *>(userData));
new std::thread(restartFunction);
}
}
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);
AAudioStreamBuilder_setDataCallback(streamBuilder, ::dataCallback, &oscillator_);
AAudioStreamBuilder_setErrorCallback(streamBuilder, ::errorCallback, this);
// Opens the stream.
aaudio_result_t result = AAudioStreamBuilder_openStream(streamBuilder, &stream_);
if (result != AAUDIO_OK) {
__android_log_print(ANDROID_LOG_ERROR, "AudioEngine", "Error opening stream %s",
AAudio_convertResultToText(result));
return false;
}
// Retrieves the sample rate of the stream for our oscillator.
int32_t sampleRate = AAudioStream_getSampleRate(stream_);
oscillator_.setSampleRate(sampleRate);
// Sets the buffer size.
AAudioStream_setBufferSizeInFrames(
stream_, AAudioStream_getFramesPerBurst(stream_) * kBufferSizeInBursts);
// Starts the stream.
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;
}
void AudioEngine::restart(){
static std::mutex restartingLock;
if (restartingLock.try_lock()){
stop();
start();
restartingLock.unlock();
}
}
void AudioEngine::stop() {
if (stream_ != nullptr) {
AAudioStream_requestStop(stream_);
AAudioStream_close(stream_);
}
}
void AudioEngine::setToneOn(bool isToneOn) {
oscillator_.setWaveOn(isToneOn);
}
此代码的作用如下:
启动引擎
我们的 start()
方法可设置音频流。AAudio 中的音频流用 AAudioStream
对象表示;为了创建音频流,我们需要使用 AAudioStreamBuilder
:
AAudioStreamBuilder *streamBuilder;
AAudio_createStreamBuilder(&streamBuilder);
我们现在可以使用 streamBuilder
设置音频流中的各种参数。
我们的音频格式为浮点数:
AAudioStreamBuilder_setFormat(streamBuilder, AAUDIO_FORMAT_PCM_FLOAT);
我们将采用单声道(一个声道)进行输出:
AAudioStreamBuilder_setChannelCount(streamBuilder, 1);
注意:我们并未设置所有参数,因为我们想让 AAudio 自动处理未设置的参数,其中包括:
- 音频设备 ID - 我们想使用默认音频设备,而没有显式指定要使用的音频设备,例如内置扬声器。您可以使用
AudioManager.getDevices()
获取可用音频设备的列表。 - 流方向 - 默认情况下会创建输出流。如果我们想进行录音,则会改为指定输入流。
- 采样率(稍后会详细介绍)。
性能模式
我们想要尽量降低延迟,因此我们设置的是低延迟性能模式。
AAudioStreamBuilder_setPerformanceMode(streamBuilder, AAUDIO_PERFORMANCE_MODE_LOW_LATENCY);
AAudio 不保证生成的音频流具备此低延迟性能模式。音频流可能无法获取此模式的原因包括:
- 您指定了非原生的采样率、样本格式或每帧样本数(详见下文),这可能会导致重新采样或格式转换。重新采样是指重新计算样本值以得出另一采样率的过程。重新采样和格式转换都可能会增加计算负载和/或延迟。
- 没有可用的低延迟流,这可能是因为所有低延迟流均已被您的应用或其他应用使用
您可以使用 AAudioStream_getPerformanceMode
检查音频流的性能模式。
打开音频流
设置好所有参数后(我们稍后会介绍数据回调),我们可以打开音频流并查看结果:
aaudio_result_t result = AAudioStreamBuilder_openStream(streamBuilder, &stream_);
如果结果不是 AAUDIO_OK
,我们会将输出记录到 Android Studio 的 Android Monitor
窗口并返回 false
。
if (result != AAUDIO_OK){
__android_log_print(ANDROID_LOG_ERROR, "AudioEngine", "Error opening stream", AAudio_convertResultToText(result));
return false;
}
设置振荡器采样率
我们特意未设置音频流的采样率,因为我们想使用其“原生采样率”(原生采样率可避免重新采样和增加延迟)。现在,音频流已打开,接下来,我们可以对其进行查询以了解原生采样率:
int32_t sampleRate = AAudioStream_getSampleRate(stream_);
然后,我们会告知振荡器根据此采样率生成音频数据:
oscillator_.setSampleRate(sampleRate);
设置缓冲区空间
音频流的内部缓冲区空间可直接影响音频流的延迟。缓冲区越大,延迟就越长。
我们将缓冲区空间设为脉冲串大小的两倍。脉冲串是指在每次回调期间写入的离散数据量。这样即可在延迟和欠载保护之间取得充分的平衡。您可以在 AAudio 文档中详细了解如何调整缓冲区空间。
AAudioStream_setBufferSizeInFrames(
stream_, AAudioStream_getFramesPerBurst(stream_) * kBufferSizeInBursts);
启动音频流
现在,一切都已设置完毕,接下来,我们可以启动音频流,让其开始消耗音频数据并触发数据回调。
result = AAudioStream_requestStart(stream_);
数据回调
那么,我们如何才能将音频数据传入音频流呢?我们有两种选择:
- 使用 AAudioStream_write 直接写入音频流
- 使用数据回调函数 AAudioStream_dataCallback
我们采用第二种方式,因为这种方式更适合低延迟应用;每当音频流需要音频数据时,系统就会从高优先级线程调用数据回调函数。
dataCallback 函数
首先,我们在全局命名空间中定义回调函数:
aaudio_data_callback_result_t dataCallback(
AAudioStream *stream,
void *userData,
void *audioData,
int32_t numFrames){
...
}
妙就妙在,我们的 userData
参数是指向 Oscillator
对象的指针。因此,我们可以使用它在 audioData
数组中渲染音频数据。具体方法如下:
((Oscillator *)(userData))->render(static_cast<float*>(audioData), numFrames);
请注意,我们还会将 audioData
数组转换为浮点数,因为这是我们的 render()
方法所预期的格式。
最后,此方法会返回一个值,以便告知音频流继续消耗音频数据。
return AAUDIO_CALLBACK_RESULT_CONTINUE;
设置回调
现在,我们有了 dataCallback
函数,接下来,只需执行简单的操作即可告知音频流通过 start()
方法来使用此函数(::
表示此函数位于全局命名空间中):
AAudioStreamBuilder_setDataCallback(streamBuilder, ::dataCallback, &oscillator_);
启动和停止振荡器
振荡器波输出的开启和关闭都很简单,我们只采用一种方法(此方法会将提示音状态传递到振荡器):
void AudioEngine::setToneOn(bool isToneOn) {
oscillator_.setWaveOn(isToneOn);
}
值得注意的是,即使振荡器的波处于关闭状态,其 render()
方法仍会生成已填入 0 的音频数据(请参阅上文中的“避免预热延迟”部分)。
清理
我们提供了一个 start()
方法用于创建音频流,因此,我们还应该提供一个对应的 stop()
方法用于删除音频流。当不再需要相应音频流时(例如,在我们的应用退出后),您可以调用此方法。此方法会停掉让回调停止的音频流,然后关闭此音频流,从而让系统将其删除。
AAudioStream_requestStop(stream_);
AAudioStream_close(stream_);
使用错误回调处理音频流断开连接
播放流启动后,它会使用默认音频设备。默认音频设备可能是内置扬声器、耳机或某种其他音频设备(例如 USB 音频接口)。
如果默认音频设备发生更改,会出现什么情况?例如,如果用户一开始通过扬声器播放,之后连上了耳机。在这种情况下,音频流会与扬声器断开连接,您的应用也将无法再向输出写入音频样本。它会停止播放。
这可能与用户的预期不符。音频应通过耳机继续播放。(不过,在其他场景下,停止播放可能更适合。)
我们需要通过一个回调来检测音频流断开连接,而且在适当的时候,还需要通过一个函数在新的音频设备上重启相应音频流。
设置错误回调
如需监听音频流断开连接事件,请定义 AAudioStream_errorCallback
类型的函数。
void errorCallback(AAudioStream *stream,
void *userData,
aaudio_result_t error){
if (error == AAUDIO_ERROR_DISCONNECTED){
std::function<void(void)> restartFunction = std::bind(&AudioEngine::restart,
static_cast<AudioEngine *>(userData));
new std::thread(restartFunction);
}
}
每当音频流发生错误时,系统就会调用此函数。如果错误为 AAUDIO_ERROR_DISCONNECTED
,我们可以重启音频流。
请注意,回调无法直接重启音频流。如需重启音频流,我们可以改为创建一个指向 AudioEngine::restart()
的 std::function
,然后通过单独的 std::thread
调用此函数。
最后,我们按照与 start()
中的 dataCallback
相同的方法来设置 errorCallback
。
AAudioStreamBuilder_setErrorCallback(streamBuilder, ::errorCallback, this);
重启音频流
由于可能会出现多个线程调用重启函数的情况(例如,如果我们连续收到多个断开连接事件),因此我们可以使用 std::mutex
来保护代码的关键部分。
void AudioEngine::restart(){
static std::mutex restartingLock;
if (restartingLock.try_lock()){
stop();
start();
restartingLock.unlock();
}
}
关于音频引擎的介绍就是这些,没有其他要做的了…
我们需要通过一种方法让使用 Java 编写的界面能够与 C++ 类进行通信,而这就是 JNI 的用武之地。这种方法签名可能不太易于理解,但幸好只有 3 种!
将文件 native-lib.cpp
重命名为 jni-bridge.cpp
。更改文件名并非必须步骤,但我想更明确地表明此 C++ 文件针对的是 JNI 方法。请务必使用这个重命名的文件更新 CMakeLists.txt
(但保留库的名称 native-lib
)。
将以下代码添加到 jni-bridge.cpp
:
#include <jni.h>
#include <android/input.h>
#include "AudioEngine.h"
static AudioEngine *audioEngine = new AudioEngine();
extern "C" {
JNIEXPORT void JNICALL
Java_com_example_wavemaker_MainActivity_touchEvent(JNIEnv *env, jobject obj, jint action) {
switch (action) {
case AMOTION_EVENT_ACTION_DOWN:
audioEngine->setToneOn(true);
break;
case AMOTION_EVENT_ACTION_UP:
audioEngine->setToneOn(false);
break;
default:
break;
}
}
JNIEXPORT void JNICALL
Java_com_example_wavemaker_MainActivity_startEngine(JNIEnv *env, jobject /* this */) {
audioEngine->start();
}
JNIEXPORT void JNICALL
Java_com_example_wavemaker_MainActivity_stopEngine(JNIEnv *env, jobject /* this */) {
audioEngine->stop();
}
}
我们的 JNI 桥非常简单:
- 我们创建一个
AudioEngine
的静态实例 startEngine()
和stopEngine()
可启动和停止音频引擎touchEvent()
可将触摸事件转换为方法调用,以开启或关闭提示音
最后,我们来创建界面并将其连接到后端…
布局
我们的布局非常简单(我们会在后续 Codelab 中对其进行改进)。此布局只是中间包含 TextView 的 FrameLayout:
将 res/layout/activity_main.xml
更新为以下代码:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/touchArea"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.example.wavemaker.MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/tap_anywhere"
android:textAppearance="@android:style/TextAppearance.Material.Display1" />
</FrameLayout>
将 @string/tap_anywhere
的字符串资源添加到 res/values/strings.xml
:
<resources>
<string name="app_name">WaveMaker</string>
<string name="tap_anywhere">Tap anywhere</string>
</resources>
主 Activity
现在,使用以下代码更新 MainActivity.java
:
package com.example.wavemaker;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.view.MotionEvent;
public class MainActivity extends AppCompatActivity {
static {
System.loadLibrary("native-lib");
}
private native void touchEvent(int action);
private native void startEngine();
private native void stopEngine();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
startEngine();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
touchEvent(event.getAction());
return super.onTouchEvent(event);
}
@Override
public void onDestroy() {
stopEngine();
super.onDestroy();
}
}
此代码的作用如下:
private native void
方法都是在jni-bridge.cpp
中定义的,我们需要在此处进行声明才能使用这些方法- Activity 生命周期事件
onCreate()
和onDestroy()
会调用 JNI 桥以启动和停止音频引擎 - 我们会替换
onTouchEvent()
以接收Activity
的所有触摸事件,并直接将其传递到 JNI 桥以开启和关闭提示音
启动您的测试设备或模拟器,并在其上运行 WaveMaker 应用。点按屏幕时,您应该会听到设备发出清晰的正弦波的声音!
好了,虽然我们的应用不会获得任何音乐创新方面的奖项,但它应该可以展示在 Android 设备上生成低延迟的合成音频所需的基本技术。
别着急,我们会在后续 Codelab 中让我们的应用变得更有趣!感谢您完成此 Codelab 的学习。如果您有任何疑问,请在 android-ndk 群组中提问。