制作声波第 1 部分 - 构建合成器

我们来制造一些声音吧!在此 Codelab 中,我们将使用 AAudio API 针对 Android 构建一个低延迟的触控合成器应用。

我们的应用会在用户触摸屏幕后尽快发出声音。输入和输出之间的延时称为“延迟”。要打造出色的音频体验,理解并尽可能缩短延迟是其关键。事实上,我们使用 AAudio 的主要原因就在于它能够创建低延迟的音频流。

学习内容

  • 制作低延迟音频应用的基本概念
  • 如何创建音频流
  • 如何处理处于连接和断开连接状态的音频设备
  • 如何生成音频数据并将其传递到音频流
  • 在 Java 和 C++ 之间通信的最佳做法
  • 如何监听界面中的触摸事件

所需条件

当用户点按屏幕时,应用会生成合成的声音。其架构如下:

213d64e35fa7035c.png

我们的合成器应用包含 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 的正弦波

数字合成方面的基础知识

振荡器数字合成的基本构建块。振荡器需要生成一系列数字,我们称之为“样本”。每个样本表示一个振幅值,音频硬件会将此值转换为电压以驱动头戴式耳机或扬声器。

以下是一个表示正弦波的样本曲线图:

5e5f107a4b6a2a48.png

开始实现之前,请先熟悉一下关于数字音频数据的一些重要术语:

  • 样本格式 - 用于表示每个样本的数据类型。常见的样本格式包括 PCM16 和浮点。我们采用浮点的原因之一,在于浮点具有 24 位分辨率,并且在音量较低时,其精确率也较高。
  • - 在生成多声道音频时,样本会归在“帧”中。帧中的每个样本都对应一个不同的音频声道。例如,立体声音频有 2 个声道(左声道和右声道),因此立体声音频的帧有 2 个样本,一个表示左声道,另一个表示右声道。
  • 帧速率 - 每秒帧数。这通常称为“采样率”。帧速率和采样率通常含义相同,并可互换使用。常用帧速率值为每秒 44100 帧和 48000 帧。AAudio 使用的是“采样率”这一术语,因此我们将在我们的应用中沿用这一惯例。

创建源文件和头文件

右键点击 /app/cpp 文件夹,然后依次转到 New -> C++ class

31d616d7c001c02e.png

将您的类命名为“Oscillator”。

59ce6364705b3c3c.png

将以下行添加到 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),我们可以为音频数据设置所需的采样率(稍后将详细介绍我们需要此代码的原因)。它会根据 sampleRateFREQUENCY 来计算 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_);

数据回调

那么,我们如何才能将音频数据传入音频流呢?我们有两种选择:

我们采用第二种方式,因为这种方式更适合低延迟应用;每当音频流需要音频数据时,系统就会从高优先级线程调用数据回调函数。

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 中对其进行改进)。此布局只是中间包含 TextViewFrameLayout

4a039cdf72e4846f.png

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 群组中提问。

深入阅读

高性能音频示例

Android NDK 文档中的高性能音频指南

Android 音频视频最佳做法 - 2017 年 Google I/O 大会