制作更多声波 - 采样器

在此 Codelab 中,我们将构建一个音频采样器。应用会通过手机的内置麦克风录制音频,然后播放该音频。

当您按 Record(录制)按钮时,应用会录制最长 10 秒的音频。当您按 Play(播放)按钮时,录制的音频会播放一次(您需要按住该按钮以播放音频)。或者,您也可以开启 Loop(循环播放)功能,以便反复重放录制内容,直到您松开 Play(播放)按钮。您每按一次 Record(录制),之前录制的音频都会被覆盖。

7eb653b71774dfed.png

学习内容

  • 创建低延迟录制流的基本概念
  • 如何存储和播放通过麦克风录制的音频数据

前提条件

在开始此 Codelab 之前,建议您先完成 WaveMaker 第 1 部分 Codelab,其中介绍了创建音频流的一些基本概念,这些概念未在此 Codelab 中讨论。

所需条件

我们的采样器应用包含四个组件:

  • 界面 - 采用 Java 编写,MainActivity 类负责接收触摸事件并将其转发到 JNI 桥。
  • JNI 桥 - 此 C++ 文件使用 JNI 在界面和 C++ 对象之间提供通信机制。它会将事件从界面转发到音频引擎。
  • 音频引擎 - 此 C++ 类可以创建录制内容并播放音频流。
  • 录音 - 此 C++ 类将音频数据存储在内存中。

其架构如下:

a37150c7e35aa3f8.png

克隆项目

克隆 GitHub 上的 Codelab 代码库。

git clone https://github.com/googlecodelabs/android-wavemaker2

将项目导入 Android Studio

打开 Android Studio 并导入该项目:

  • File -> New -> Import project…
  • 选择“android-wavemaker2”文件夹

运行项目

选择 base 运行配置。

f65428e71e9bdbcf.png

然后,按 Ctrl+R 构建并运行模板应用 - 它应该会编译并运行,但不具备功能。您将在此 Codelab 中向其添加功能。

打开 base 模块

您要在此 Codelab 中使用的文件存储在 base 模块中。在“Project”窗口中展开此模块,并确保已选择 Android 视图。

cae7ee7b54407790.png

注意:您可以在 final 模块中找到 WaveMaker2 应用的已完成的源代码。

SoundRecording 对象表示内存中录制的音频数据。它允许应用将来自麦克风的数据写入内存并从中读取数据进行播放。

首先,我们来弄清楚如何存储这些音频数据。

选择音频格式

首先,我们需要为样本选择音频格式。AAudio 支持两种格式

  • float:单精度浮点(每个样本 4 字节)
  • int16_t:16 位整数(每个样本 2 字节)

为了在较低音量下保证出色的音质,并考虑到其他原因,我们使用 float 样本。如果这会带来内存容量问题,您可以使用 16 位整数,牺牲保真度来增加可用空间。

选择我们需要多少存储空间

假设我们要存储 10 秒的音频数据。如果采样率为每秒 48000 个样本(这是现代 Android 设备上最常见的采样率),我们就需要为 48 万个样本分配内存。

打开 base/cpp/SoundRecording.h,并在文件顶部定义此常量。

constexpr int kMaxSamples = 480000; // 10s of audio data @ 48kHz

定义存储数组

现在,我们具备了定义 float 数组所需的全部信息。将以下声明添加到 SoundRecording.h:

private:
    std::array<float,kMaxSamples> mData { 0 };

{ 0 } 使用聚合初始化将该数组中的所有值设为 0。

实现 write

write 方法具有以下签名:

int32_t write(const float *sourceData, int32_t numFrames);

此方法会在 sourceData 中接收一个音频样本数组。该数组的大小由 numFrames 指定。此方法应返回它实际写出的样本数量。

这可以通过存储下一个可用的写入索引来实现。最初写入索引为 0:

9b3262779d7a0a8c.png

每个样本写入完毕之后,下一个写入索引会加 1,后移一个位置:

2971acee93b9869d.png

这可以用 for 循环轻松实现。将以下代码添加到 SoundRecording.cpp 中的 write 方法

for (int i = 0; i < numSamples; ++i) {
    mData[mWriteIndex++] = sourceData[i];
}
return numSamples;

但是,如果没有足够的空间来存储我们尝试写入的样本,会发生什么情况?会产生不良后果!尝试访问超出范围的数组索引会导致段错误

我们来添加一项检查,以便在 mData 没有足够的剩余空间时更改 numSamples。在现有代码的上方添加以下代码。

if (mWriteIndex + numSamples > kMaxSamples) {
    numSamples = kMaxSamples - mWriteIndex;
}

实现 read

read 方法类似于 write。我们存储下一个读取索引。

488ab2652d0d281d.png

在某个样本读取完毕后递增该索引。

1a7fd22f4bbb4940.png

我们重复此操作,直到读取完所请求的样本数。当系统读取到可用数据的末尾时,会发生什么情况?有两种行为:

  • 如果已启用循环:将读取索引设为零 - 数据数组的开头
  • 如果不使用循环:不执行任何操作 - 不递增读取索引

789c2ce74c3a839d.png

对于这两种行为,我们需要知道系统何时读取到可用数据的末尾。mWriteIndex 可让您方便地得知这一点。它包含已写入数据数组的样本总数。

因此,我们现在可以在 SoundRecording.cpp 中实现 read 方法

int32_t framesRead = 0;
while (framesRead < numSamples && mReadIndex < mWriteIndex){
    targetData[framesRead++] = mData[mReadIndex++];
    if (mIsLooping && mReadIndex == mWriteIndex) mReadIndex = 0;
}
return framesRead;

AudioEngine 会执行以下主要任务:

  • 创建 SoundRecording 实例
  • 创建录制流以通过麦克风录制数据
  • 将录制的数据写入录制流回调中的 SoundRecording 实例
  • 创建播放流以播放录制的数据
  • 从播放流回调中的 SoundRecording 实例读取录制的数据
  • 响应录制、播放和循环操作的界面事件

下面我们开始创建 SoundRecording 实例。

从简单的内容入手。在 AudioEngine.h 中创建 SoundRecording 实例:

private:
    SoundRecording mSoundRecording;

我们要创建两个流:播放流和录制流。我们应该先创建哪个?

我们应该先创建播放流,因为它只有一个提供最低延迟的采样率。创建完播放流后,我们就可以将其采样率提供给录制流构建器了。这可确保播放流和录制流的采样率相同,也就是说,我们无需在这两个流之间执行任何额外的重新采样工作。

播放流属性

使用以下属性填充将创建播放流的 StreamBuilder

  • 方向:未指定,默认为输出
  • 采样率:未指定,默认为延迟时间最短的采样率
  • 格式:浮点
  • 通道数:2(立体声)
  • 性能模式:低延迟
  • 共享模式:独占

创建播放流

现在,我们具备了创建和打开播放流所需的全部条件。将以下代码添加到 AudioEngine.cpp 中 start 方法的顶部。

// Create the playback stream.
StreamBuilder playbackBuilder = makeStreamBuilder();
AAudioStreamBuilder_setFormat(playbackBuilder.get(), AAUDIO_FORMAT_PCM_FLOAT);
AAudioStreamBuilder_setChannelCount(playbackBuilder.get(), kChannelCountStereo);
AAudioStreamBuilder_setPerformanceMode(playbackBuilder.get(), AAUDIO_PERFORMANCE_MODE_LOW_LATENCY);
AAudioStreamBuilder_setSharingMode(playbackBuilder.get(), AAUDIO_SHARING_MODE_EXCLUSIVE);
AAudioStreamBuilder_setDataCallback(playbackBuilder.get(), ::playbackDataCallback, this);
AAudioStreamBuilder_setErrorCallback(playbackBuilder.get(), ::errorCallback, this);

aaudio_result_t result = AAudioStreamBuilder_openStream(playbackBuilder.get(), &mPlaybackStream);

if (result != AAUDIO_OK){
   __android_log_print(ANDROID_LOG_DEBUG, __func__,
                       "Error opening playback stream %s",
                       AAudio_convertResultToText(result));
   return;
}

// Obtain the sample rate from the playback stream so we can request the same sample rate from
// the recording stream.
int32_t sampleRate = AAudioStream_getSampleRate(mPlaybackStream);

result = AAudioStream_requestStart(mPlaybackStream);
if (result != AAUDIO_OK){
   __android_log_print(ANDROID_LOG_DEBUG, __func__,
                       "Error starting playback stream %s",
                       AAudio_convertResultToText(result));
   closeStream(&mPlaybackStream);
   return;
}

请注意,系统已为您创建数据和错误回调的模板方法。如果您需要回顾一下这些方法的工作原理,请参阅第一个 Codelab

录制流属性

使用以下属性创建录制流:

  • 方向:输入(录制为“输入”,而播放为“输出”)
  • 采样率:与输出流相同
  • 格式:浮点
  • 通道数:1(单声道)
  • 性能模式:低延迟
  • 共享模式:独占

创建录制

现在,将以下代码添加到之前在 start 中添加的代码下方。

// Create the recording stream.
StreamBuilder recordingBuilder = makeStreamBuilder();
AAudioStreamBuilder_setDirection(recordingBuilder.get(), AAUDIO_DIRECTION_INPUT);
AAudioStreamBuilder_setPerformanceMode(recordingBuilder.get(), AAUDIO_PERFORMANCE_MODE_LOW_LATENCY);
AAudioStreamBuilder_setSharingMode(recordingBuilder.get(), AAUDIO_SHARING_MODE_EXCLUSIVE);
AAudioStreamBuilder_setFormat(recordingBuilder.get(), AAUDIO_FORMAT_PCM_FLOAT);
AAudioStreamBuilder_setSampleRate(recordingBuilder.get(), sampleRate);
AAudioStreamBuilder_setChannelCount(recordingBuilder.get(), kChannelCountMono);
AAudioStreamBuilder_setDataCallback(recordingBuilder.get(), ::recordingDataCallback, this);
AAudioStreamBuilder_setErrorCallback(recordingBuilder.get(), ::errorCallback, this);

result = AAudioStreamBuilder_openStream(recordingBuilder.get(), &mRecordingStream);

if (result != AAUDIO_OK){
   __android_log_print(ANDROID_LOG_DEBUG, __func__,
                       "Error opening recording stream %s",
                       AAudio_convertResultToText(result));
   closeStream(&mRecordingStream);
   return;
}

result = AAudioStream_requestStart(mRecordingStream);
if (result != AAUDIO_OK){
   __android_log_print(ANDROID_LOG_DEBUG, __func__,
                       "Error starting recording stream %s",
                       AAudio_convertResultToText(result));
   return;
}

下面,我们将讲解一个有趣的环节:实际将麦克风录制的数据存储到 SoundRecording 对象中。

我们在构建录制流时已将数据回调指定为 ::recordingDataCallback。此方法会调用带有以下签名的 AudioEngine::recordingCallback

aaudio_data_callback_result_t AudioEngine::recordingCallback(float *audioData,
                                                            int32_t numFrames)

音频数据在 audioData. 中提供,其大小(以样本数量计)为 numFrames,因为我们是以单声道进行录制,所以每帧仅输出一个样本。

我们只需执行以下操作:

  • 检查 mIsRecording,看看我们是否应在录制
  • 如果我们没在录制,则忽略传入的数据
  • 如果我们正在录制:
  • 使用对应的 write 方法向 SoundRecording 提供 audioData
  • 检查 write 的返回值;如果该值为零,表示 SoundRecording 已满,我们应停止录制
  • 返回 AAUDIO_CALLBACK_RESULT_CONTINUE,以便回调继续

将以下代码添加到 recordingCallback

if (mIsRecording) {
    int32_t framesWritten = mSoundRecording.write(audioData, numFrames);
    if (framesWritten == 0) mIsRecording = false;
}
return AAUDIO_CALLBACK_RESULT_CONTINUE;

与录制流类似,播放流会在需要新数据时调用 playbackDataCallback。此方法会调用带有以下签名的 AudioEngine::playbackCallback,

aaudio_data_callback_result_t AudioEngine::playbackCallback(float *audioData, int32_t numFrames)

在此方法中,我们需要执行以下操作:

  • 使用 fillArrayWithZeros 在数组中填充零
  • 如果我们正在播放录制的数据(由 mIsPlaying 指明),需执行以下操作:
  • mSoundRecording 读取 numFrames 的数据
  • 使用 convertArrayMonoToStereoaudioData 从单声道转换为立体声
  • 如果读取的帧数少于所请求的帧数,表明系统已读取到录制的数据的末尾。将 mIsPlaying 设为 false 以停止播放

将以下代码添加到 playbackCallback

fillArrayWithZeros(audioData, numFrames * kChannelCountStereo);

if (mIsPlaying) {
   int32_t framesRead = mSoundRecording.read(audioData, numFrames);
   convertArrayMonoToStereo(audioData, framesRead);
   if (framesRead < numFrames) mIsPlaying = false;
}
return AAUDIO_CALLBACK_RESULT_CONTINUE;

开始和停止录制

setRecording 方法用于开始和停止录制,其签名如下:

void setRecording(bool isRecording)

当您按下“录制”按钮时,isRecordingtrue;当您松开该按钮时,isRecordingfalse

recordingCallback 内的成员变量 mIsRecording 用于开始/停止存储数据。我们只需将它设为 isRecording 的值即可。

还需要添加另一种行为。录制开始后,mSoundRecording 中的任何现有数据都应被覆盖。这可以使用 clear 来实现,此方法会将 mSoundRecording 的写入索引重置为零。

setRecording 的代码如下:

if (isRecording) mSoundRecording.clear();
mIsRecording = isRecording;

开始和停止播放

播放控件是与录制类似。当您按下或松开“播放”按钮时,系统会调用 setPlayingplaybackCallback 内的成员变量 mIsPlaying 用于开始/停止播放。

当您按“播放”按钮时,我们希望系统从录制的音频数据的开头开始播放。这可以使用 setReadPositionToStart 来实现,此方法会将 mSoundRecording 的读取起始位置重置为零。

setPlaying 的代码如下:

if (isPlaying) mSoundRecording.setReadPositionToStart();
mIsPlaying = isPlaying;

开始/停止循环播放

最后,当 LOOP 开关切换时,系统会调用 setLooping。为此,我们只需将 isOn 参数传递到 mSoundRecording.setLooping 即可:

setLooping 的代码如下:

mSoundRecording.setLooping(isOn);

录制音频的应用必须向用户请求 RECORD_AUDIO 权限。大部分权限处理代码都已写入,但我们仍需声明我们的应用要使用此权限。

将下面一行代码添加到 manifests/AndroidManifest.xml 中的 <manifest> 部分内:

<uses-permission android:name="android.permission.RECORD_AUDIO" />

现在来看看您的辛苦工作是否得到了回报。构建并运行应用,您应该会看到以下界面。

7eb653b71774dfed.png

点按红色按钮即可开始录制。当您按该按钮时,系统会一直录制,最长可持续 10 秒。点按绿色按钮可播放录制的数据。当您按该按钮时,系统会一直播放,直至播放到音频数据的末尾。如果已启用 LOOP,音频数据将会无限期循环播放。

深入阅读

高性能音频示例

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

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