在此 Codelab 中,我们将构建一个音频采样器。应用会通过手机的内置麦克风录制音频,然后播放该音频。
当您按 Record(录制)按钮时,应用会录制最长 10 秒的音频。当您按 Play(播放)按钮时,录制的音频会播放一次(您需要按住该按钮以播放音频)。或者,您也可以开启 Loop(循环播放)功能,以便反复重放录制内容,直到您松开 Play(播放)按钮。您每按一次 Record(录制),之前录制的音频都会被覆盖。
学习内容
- 创建低延迟录制流的基本概念
- 如何存储和播放通过麦克风录制的音频数据
前提条件
在开始此 Codelab 之前,建议您先完成 WaveMaker 第 1 部分 Codelab,其中介绍了创建音频流的一些基本概念,这些概念未在此 Codelab 中讨论。
所需条件
- Android Studio 3.0.0 或更高版本
- Android 8.0(API 级别 26)SDK
- 已安装 NDK 和构建工具
- 搭载 Android 8.0(API 级别 26)或更高版本的模拟器或 Android 设备,用于进行测试
- 具备一些 C++ 知识有助于您完成相关学习,但并非必须
我们的采样器应用包含四个组件:
- 界面 - 采用 Java 编写,MainActivity 类负责接收触摸事件并将其转发到 JNI 桥。
- JNI 桥 - 此 C++ 文件使用 JNI 在界面和 C++ 对象之间提供通信机制。它会将事件从界面转发到音频引擎。
- 音频引擎 - 此 C++ 类可以创建录制内容并播放音频流。
- 录音 - 此 C++ 类将音频数据存储在内存中。
其架构如下:
克隆项目
克隆 GitHub 上的 Codelab 代码库。
git clone https://github.com/googlecodelabs/android-wavemaker2
将项目导入 Android Studio
打开 Android Studio 并导入该项目:
- File -> New -> Import project…
- 选择“android-wavemaker2”文件夹
运行项目
选择 base 运行配置。
然后,按 Ctrl+R 构建并运行模板应用 - 它应该会编译并运行,但不具备功能。您将在此 Codelab 中向其添加功能。
打开 base 模块
您要在此 Codelab 中使用的文件存储在 base
模块中。在“Project”窗口中展开此模块,并确保已选择 Android 视图。
注意:您可以在 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:
每个样本写入完毕之后,下一个写入索引会加 1,后移一个位置:
这可以用 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
。我们存储下一个读取索引。
在某个样本读取完毕后递增该索引。
我们重复此操作,直到读取完所请求的样本数。当系统读取到可用数据的末尾时,会发生什么情况?有两种行为:
- 如果已启用循环:将读取索引设为零 - 数据数组的开头
- 如果不使用循环:不执行任何操作 - 不递增读取索引
对于这两种行为,我们需要知道系统何时读取到可用数据的末尾。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
的数据 - 使用
convertArrayMonoToStereo
将audioData
从单声道转换为立体声 - 如果读取的帧数少于所请求的帧数,表明系统已读取到录制的数据的末尾。将
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)
当您按下“录制”按钮时,isRecording
为 true;当您松开该按钮时,isRecording
为 false。
recordingCallback
内的成员变量 mIsRecording
用于开始/停止存储数据。我们只需将它设为 isRecording
的值即可。
还需要添加另一种行为。录制开始后,mSoundRecording
中的任何现有数据都应被覆盖。这可以使用 clear
来实现,此方法会将 mSoundRecording
的写入索引重置为零。
setRecording
的代码如下:
if (isRecording) mSoundRecording.clear();
mIsRecording = isRecording;
开始和停止播放
播放控件是与录制类似。当您按下或松开“播放”按钮时,系统会调用 setPlaying
。playbackCallback
内的成员变量 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" />
现在来看看您的辛苦工作是否得到了回报。构建并运行应用,您应该会看到以下界面。
点按红色按钮即可开始录制。当您按该按钮时,系统会一直录制,最长可持续 10 秒。点按绿色按钮可播放录制的数据。当您按该按钮时,系统会一直播放,直至播放到音频数据的末尾。如果已启用 LOOP,音频数据将会无限期循环播放。