NDK 入门系列主要介绍 JNI 的相关内容,目录如下:
NDK 入门(一)—— JNI 初探
NDK 入门(二)—— 调音小项目
NDK 入门(三)—— JNI 注册与 JNI 线程
NDK 入门(四)—— 静态缓存与 Native 异常
前面介绍了很多 C/C++ 以及 JNI 相关的理论知识,本篇我们来实践一下,实现一个调音小 Demo。这个 Demo 需要用到一个专业的调音软件 FMOD 及其 API,先了解一下 FMOD。
1、FMOD 介绍
FMOD Ex 声音系统是为游戏开发者准备的革命性音频引擎,像 cocos2d,unity3d 游戏引擎默认就集成了 fmod。如今采用了 FMOD 作为音频引擎的游戏包括 Far Cry(孤岛惊魂)、Tom Clancy's Ghost Recon(幽灵行动),甚至著名的 World Of Warcraft(魔兽争霸)。
1.1 FMOD Studio
你可以在 FMOD 官网 下载 FMOD Studio 开始音频创作,该软件对非商业用途免费。
你可以对一段音频从音调、音色等多个方面添加特效以达到想要的特效。上图中就是对音调 PITCH 进行修改,从 0.0 ~ 2.0 有截然不同的效果,后续的 Demo 就会通过对 PITCH 设置不同的值以达到不同角色说话的效果。
1.2 FMOD API
FMOD 音效引擎库是用 C/C++ 写出来的,并且支持 Android 平台在内的多个平台,我们可以在 FMOD 官网 下载 FMOD Engine,将其 API 代码拷贝到我们的 Demo 中编译使用:
2、项目配置
主要是对 CMakeLists.txt 和模块的 build.gradle 进行配置,我们一步一步来。
2.1 拷贝 FMOD API 并配置 CMake
主要是拷贝 FMOD API 的头文件和库文件到我们的 Demo 中,并且配置 CMakeLists.txt 文件。步骤如下:
-
导入头文件,将 fmodstudioapi20219android\api\core\inc 目录下的头文件拷贝到项目 cpp\fmod\inc 目录下,在 CMakeLists.txt 中添加这些头文件:
# 导入头文件 include_directories(fmod/inc)
-
将 \fmod\inc 下的文件添加到 audio 动态库中:
# 将所有 .c .h .cpp 文件都存入 allSources 这个对象中 file(GLOB allSources *.c *.cpp) # 将 allSources 表示的文件全加入 audio 动态库中 add_library(audio SHARED ${allSources})
-
导入库文件,将 fmodstudioapi20219android\api\core\lib 下所需平台的动态库文件拷贝到 app\main\jniLibs 目录下,并将该路径追加到 CMAKE_CXX_FLAGS 这个 C++ 编译器标志中:
# ${CMAKE_CXX_FLAGS} 表示在 CMAKE_CXX_FLAGS 的原值上追加 -L # 所表示的路径:CMakeLists 所在的路径,向上一级到了 main,然后 # 是 main/jniLibs 下 CPU 平台的文件夹 set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${CMAKE_SOURCE_DIR}/../jniLibs/${CMAKE_ANDROID_ARCH_ABI}")
注意 FMOD 提供了四个平台:arm64-v8a、armeabi-v7a、x86、x86_64 的动态库,你只需将需要的动态库拷贝到项目中就好,全都拷贝会增大 APK 体积
-
将 FMOD 动态库链接到目标库。第 3 步的动态库文件有两个,分别是 libfmod.so 和 libfmodL.so,需要将这两个库链接到目标库,也就是我们产出的 audio 库:
# audio 是目标库,fmod 和 fmodL 分别是 libfmod 和 # libfmodL 去掉 lib 前缀的名称 target_link_libraries( audio ${log-lib}fmodfmodL)
关于以上步骤,有几点要说明的:
-
通过 adb 命令查看自己手机的 CPU 是哪个平台的架构:
PS root> adb shell getprop ro.product.cpu.abi x86_64
-
平台库要默认添加到 /app/src/main/jniLibs 目录下,因为 jniLibs 是 Gradle 默认的 C/C++ 的库目录,如果想修改,可以在 build.gradle 中修改源集:
android {// ...sourceSets {main {jniLibs.srcDirs = ['myLibs']}}// ... }
有关在 AS 中对 CMake 配置的完整教程,可以参考官网教程配置 CMake。
2.2 拷贝 fmod.jar 与 gradle 配置
我们还需将 fmodstudioapi20219android\api\core\lib 下的 fmod.jar 拷贝到 app\libs 目录下,然后在 app 模块的 build.gradle 中依赖它:
dependencies {implementation files('libs\\fmod.jar')
}
此外,我们还需配置 abiFilters:
android {defaultConfig {ndk {// 仅在 64 位模拟器上运行,因此只打包该平台abiFilters "x86_64"}}
由于我只在模拟器上运行 Demo,所以只给 abiFilters 配置了 x86_64,这样 gradle 在编译打包时,就只会将 x86_64 目录下的库文件打包进 APK 中,避免默认情况下,gradle 将 jniLibs 下所有平台的库都打包进 APK,使 APK 体积无谓的增大。
到这里基本上就配置完成了,文件结构如下:
3、功能实现
实现效果就是点击 UI 上的按钮,然后播放相应效果的音频。原声资源文件如上图可见,存放在 src/assets 目录下。UI 效果:
那么编码就分为 Java 与 Native 两个部分。
3.1 Java 源码与 Java 头文件
Java 部分代码首先要导入动态库 audio 并且定义播放音效的 Native 方法:
static {System.loadLibrary("audio");}// mode 表示采用哪种音效,audioPath 是原声文件路径private native void nativeChangeVoice(int mode, String audioPath);
然后定义模式常量与路径:
// 正常private static final int MODE_NORMAL = 0;// 萝莉private static final int MODE_LOLITA = 1;// 大叔private static final int MODE_UNCLE = 2;// 惊悚private static final int MODE_THRILLER = 3;// 搞怪private static final int MODE_QUIRKY = 4;// 空灵private static final int MODE_ETHEREAL = 5;private static final String ORIGIN_AUTO_PATH = "file:///android_asset/daxian.mp3";
FMOD 需要初始化才可使用,在 Activity 销毁时要反注册 FMOD:
@Overrideprotected void onCreate(@Nullable Bundle savedInstanceState) {super.onCreate(savedInstanceState);mBinding = ActivityMainBinding.inflate(LayoutInflater.from(this));setContentView(mBinding.getRoot());initButtonListeners();// 初始化 FMODFMOD.init(this);}@Overrideprotected void onDestroy() {super.onDestroy();// 解除 FMOD 的 Receiver 注册FMOD.close();}
功能代码就是给按钮设置监听器,点击后就调用 Native 方法传入对应的音效。注意,这个 Native 方法是一个耗时方法,因此要放在子线程中调用:
// 处理 Native 方法的线程池private ExecutorService mExecutorService = new ThreadPoolExecutor(1, 6, 60,TimeUnit.SECONDS, new LinkedBlockingQueue<>(), new ThreadFactory() {@Overridepublic Thread newThread(Runnable r) {Thread t = Executors.defaultThreadFactory().newThread(r);t.setName("ThreadPool-" + t.getId());return t;}});private void initButtonListeners() {// native 进行的音频处理操作一定要放在子线程中执行mBinding.btnNormal.setOnClickListener(v -> mExecutorService.submit(() ->nativeChangeVoice(MODE_NORMAL, ORIGIN_AUTO_PATH)));mBinding.btnLuoli.setOnClickListener(v -> mExecutorService.submit(() ->nativeChangeVoice(MODE_LOLITA, ORIGIN_AUTO_PATH)));mBinding.btnDashu.setOnClickListener(v -> mExecutorService.submit(() ->nativeChangeVoice(MODE_UNCLE, ORIGIN_AUTO_PATH)));mBinding.btnJingsong.setOnClickListener(v -> mExecutorService.submit(() ->nativeChangeVoice(MODE_THRILLER, ORIGIN_AUTO_PATH)));mBinding.btnGaoguai.setOnClickListener(v -> mExecutorService.submit(() ->nativeChangeVoice(MODE_QUIRKY, ORIGIN_AUTO_PATH)));mBinding.btnKongling.setOnClickListener(v -> mExecutorService.submit(() ->nativeChangeVoice(MODE_ETHEREAL, ORIGIN_AUTO_PATH)));}
最后写一个通知方法,弹出 Toast 告知用户音频已经播放完,这个方法供 Native 调用:
// 供 C++ 展示音频播放完毕的方法。由于 JNI 调用 Java 方法时会忽略掉方法的// 可见度,因此这里使用 private 并不影响 JNI 调用该方法private void playFinish(String msg) {// 因为调用 nativeChangeVoice() 是在线程池中进行的,所以 native 层调用// 本方法也是在子线程中,需要切换到主线程进行 UI 操作new Handler(getMainLooper()).post(() ->Toast.makeText(this, msg, Toast.LENGTH_SHORT).show());}
以上是 Java 代码。接下来要根据这个源文件生成对应的 JNI 的头文件。在 src\main\java 目录下运行命令:
javah com.jni.lesson3.MainActivity
会在该目录下生成一个头文件 com_jni_lesson3_MainActivity.h,里面有声明的常量和 Native 方法:
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_jni_lesson3_MainActivity */#ifndef _Included_com_jni_lesson3_MainActivity
#define _Included_com_jni_lesson3_MainActivity
#ifdef __cplusplus
extern "C" {
#endif
#undef com_jni_lesson3_MainActivity_MODE_NORMAL
#define com_jni_lesson3_MainActivity_MODE_NORMAL 0L
#undef com_jni_lesson3_MainActivity_MODE_LOLITA
#define com_jni_lesson3_MainActivity_MODE_LOLITA 1L
#undef com_jni_lesson3_MainActivity_MODE_UNCLE
#define com_jni_lesson3_MainActivity_MODE_UNCLE 2L
#undef com_jni_lesson3_MainActivity_MODE_THRILLER
#define com_jni_lesson3_MainActivity_MODE_THRILLER 3L
#undef com_jni_lesson3_MainActivity_MODE_QUIRKY
#define com_jni_lesson3_MainActivity_MODE_QUIRKY 4L
#undef com_jni_lesson3_MainActivity_MODE_ETHEREAL
#define com_jni_lesson3_MainActivity_MODE_ETHEREAL 5L
/** Class: com_jni_lesson3_MainActivity* Method: nativeChangeVoice* Signature: (ILjava/lang/String;)V*/
JNIEXPORT void JNICALL Java_com_jni_lesson3_MainActivity_nativeChangeVoice(JNIEnv *, jobject, jint, jstring);#ifdef __cplusplus
}
#endif
#endif
将该文件拷贝到 src\main\cpp 目录下备用。
之所以没用 Kotlin 写上层的代码就是因为 javah 无法直接通过 kt 源代码生成头文件,而使用 kt 直接生成头文件的方法暂时还没跑通。
3.2 Native 播放音频并添加特效
在 native-lib.cpp 中,先要导入 fmod.hpp 和 com_jni_lesson3_MainActivity.h 这两个头文件,然后声明 FMOD 的命名空间,再开始实现 Native 方法:
#include <jni.h>
#include <string>
#include <fmod.hpp> // 导入 fmod 头文件
#include <unistd.h>
#include "com_jni_lesson3_MainActivity.h"// 声明 fmod 的命名空间
using namespace FMOD;extern "C"
JNIEXPORT void JNICALL
Java_com_jni_lesson3_MainActivity_nativeChangeVoice(JNIEnv *env, jobject thiz, jint mode,jstring _path) {// C++11 将字符串字面量视为常量,因此不可以写 // char *resultString = "xxx",需要在其前面加 const 形成// 常量指针,或者使用 std 空间的 string 也是可以的:
// std::string result = "默认音效播放完毕";const char *resultString = "默认音效播放完毕";const char *path = env->GetStringUTFChars(_path, nullptr);/** 1.初始化 FMOD 组件*/// 音效引擎系统System *system = nullptr;System_Create(&system);// 三个参数依次为:最大音轨数、系统初始化标记、额外的驱动数据system->init(32, FMOD_INIT_NORMAL, nullptr);// 声音Sound *sound = nullptr;// 四个参数依次为:音频文件路径、声音初始化标记、额外信息、声音对象的二级指针system->createSound(path, FMOD_DEFAULT, nullptr, &sound);// 音轨(声音播放的通道)Channel *channel = nullptr;// DSP:digital signal process 数字信号处理DSP *dsp = nullptr;/** 2.播放声音*/// 四个参数依次为:声音、分组音轨、是否暂停、通道system->playSound(sound, nullptr, false, &channel);/** 3.增加特效*/switch (mode) {case com_jni_lesson3_MainActivity_MODE_NORMAL:resultString = "原声播放完毕";break;case com_jni_lesson3_MainActivity_MODE_LOLITA:resultString = "萝莉播放完毕";// 创建 DSP 类型的 Pitch,这是音调system->createDSPByType(FMOD_DSP_TYPE_PITCHSHIFT, &dsp);// 将音调 Pitch 调节到 2.0dsp->setParameterFloat(FMOD_DSP_PITCHSHIFT_PITCH, 2.0f);// 添加音效到音轨中channel->addDSP(0, dsp);break;case com_jni_lesson3_MainActivity_MODE_UNCLE:resultString = "大叔播放完毕";// 与上一个 case 类似,将音调调至 0.7system->createDSPByType(FMOD_DSP_TYPE_PITCHSHIFT, &dsp);dsp->setParameterFloat(FMOD_DSP_PITCHSHIFT_PITCH, 0.7f);channel->addDSP(0, dsp);break;case com_jni_lesson3_MainActivity_MODE_QUIRKY:resultString = "搞怪小黄人播放完毕";// 从音轨获取频率并加快至 1.5 倍float frequency;channel->getFrequency(&frequency);channel->setFrequency(frequency * 1.5f);break;case com_jni_lesson3_MainActivity_MODE_THRILLER:resultString = "惊悚播放完毕";/** 调整三个方面的特效:低音调 + 回声 + 颤抖,放在三个音轨上*/// 1.音调调至 0.7system->createDSPByType(FMOD_DSP_TYPE_PITCHSHIFT, &dsp);dsp->setParameterFloat(FMOD_DSP_PITCHSHIFT_PITCH, 0.7f);channel->addDSP(0, dsp);// 2.回声system->createDSPByType(FMOD_DSP_TYPE_ECHO, &dsp);// 设置回声延时为 200 ms,默认 500 msdsp->setParameterFloat(FMOD_DSP_ECHO_DELAY, 200);// 设置回声衰减度为 10,默认 50dsp->setParameterFloat(FMOD_DSP_ECHO_FEEDBACK, 10);channel->addDSP(1, dsp);// 3.颤抖音system->createDSPByType(FMOD_DSP_TYPE_TREMOLO, &dsp);dsp->setParameterFloat(FMOD_DSP_TREMOLO_FREQUENCY, 20);dsp->setParameterFloat(FMOD_DSP_TREMOLO_SKEW, 0.8f);channel->addDSP(2, dsp);break;case com_jni_lesson3_MainActivity_MODE_ETHEREAL:resultString = "空灵播放完毕";system->createDSPByType(FMOD_DSP_TYPE_ECHO, &dsp);dsp->setParameterFloat(FMOD_DSP_ECHO_DELAY, 200);dsp->setParameterFloat(FMOD_DSP_ECHO_FEEDBACK, 10);channel->addDSP(0, dsp);break;}/** 4.播放完毕后进行回收操作并通知 Java 层*/bool isPlaying = true;while (isPlaying) {// 播放完成后会把 isPlaying 修改为 falsechannel->isPlaying(&isPlaying);// 休眠 1 秒,usleep 单位是微秒usleep(1000 * 1000);}// 回收 JNI 对象sound->release();system->close();system->release();env->ReleaseStringUTFChars(_path, path);// 通知 Java 播放完毕jclass javaClass = env->GetObjectClass(thiz);jmethodID playFinishID = env->GetMethodID(javaClass, "playFinish", "(Ljava/lang/String;)V");jstring message = env->NewStringUTF(resultString);env->CallVoidMethod(thiz, playFinishID, message);
}
主要步骤分为 4 大步,已经在注释中详细说明了,基本上就是通过 dsp 对象调节音调、延迟、衰减度、回声等特效实现不同的效果。当然我们不是专业的调音师,调教出来的效果只能说是马马虎虎了。