ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PaddleSpeech TTSAndroid 编译 armv7 版本:从源码到 APK 的完整实践

PaddleSpeech TTSAndroid 编译 armv7 版本:从源码到 APK 的完整实践 1. 为什么还要折腾 armv7 的离线 TTS 编译PaddleSpeech TTSAndroid 是 PaddleSpeech 官方给出的 Android 端离线语音合成示例工程它把 FastSpeech2 声学模型和 MB-MelGAN 声码器通过 Paddle-Lite 推理框架跑在手机本地不依赖网络就能把文字转成语音。适合谁适合做儿童早教机、老人陪伴设备、工业手持终端、离线播报盒子的开发者——这些设备往往还在用 armv7 架构的低成本芯片系统版本停在 Android 7 到 Android 9 之间内存只有 1GB 到 2GB。问题就出在这里。官方仓库默认的编译脚本和预编译产物基本都奔着 arm64-v8a 去了你直接 clone 下来跑build_android.sh产出的libpaddle_lite_jni.so塞进 armv7 工程里运行时会直接抛UnsatisfiedLinkError或者更隐蔽一点模型加载成功但推理结果全是噪声。我试过在一台 Android 8.1 的展讯平台上部署第一次编译出来的 so 文件放进去App 启动就闪退logcat 里只有一行dlopen failed: library libpaddle_lite_jni.so not found实际上文件明明在jniLibs/armeabi-v7a目录下——这就是典型的架构不匹配。所以这篇内容要解决的核心问题是如何从 Paddle-Lite 源码出发完整编译出 armv7 可用的 Paddle-Lite 预测库和 JNI 动态库再配合 PaddleSpeech 的 TTSAndroid 工程打出能真机运行的 APK。整个过程涉及 Docker 镜像、NDK 工具链、CMake 参数、模型量化转换、JNI 接口修改、Gradle 配置六个环节每一步都有坑。下面按实际操作顺序展开命令和配置都可以直接复制。2. 编译环境准备与 Paddle-Lite 源码拉取2.1 Docker 镜像与容器启动Paddle-Lite 官方推荐用 Docker 来保证工具链一致性尤其是 NDK 版本和 CMake 版本。镜像直接用docker pull paddlepaddle/paddle-lite:2.0.0_beta拉完之后克隆源码。注意这里要克隆 Paddle-Lite 而不是 PaddleSpeech因为我们要编译的是推理框架本身git clone https://github.com/PaddlePaddle/Paddle-Lite.git cd Paddle-Lite git checkout release/v2.10版本选择很关键。release/v2.10对 armv7 的支持比较稳定如果你用 develop 分支slice_compute.cc里的代码结构可能已经变了后面要改的那段get_new_data_from_tensorlist函数位置会对不上。我踩过的坑就是先用 develop 分支编译结果 JNI 接口对不上又回退到 v2.10 重新来。启动容器时把源码目录挂载进去并且用 host 网络模式方便后续下载模型docker run -it --name paddlelite_docker \ -v $PWD/Paddle-Lite:/Paddle-Lite \ --nethost \ paddlepaddle/paddle-lite /bin/bash进入容器后先确认 NDK 路径。镜像里一般预置了 NDK路径在/opt/android-ndk-r17c或者类似位置用ls /opt看一下。如果没有需要手动下载 NDK r17c 并解压到/opt下。为什么强调 r17c因为 Paddle-Lite v2.10 的 CMake 脚本里对 NDK 版本有硬编码检查r18 以上会因为libc的 ABI 变化导致链接失败报undefined reference to __android_log_print之类的错误。2.2 编译 opt 模型转换工具模型转换工具opt是在 x86 容器里跑的用来把 PaddlePaddle 的推理模型转成 Paddle-Lite 的.nb格式。编译命令cd /Paddle-Lite ./lite/tools/build.sh build_optimize_tool --with_extraON这一步大概需要 10 到 15 分钟取决于容器分配的 CPU 核数。编译完成后产物在build.opt/lite/api/opt。如果报CMake Error: Could not find OpenMP在build.sh里加--with_openmpOFF重新跑。编译成功后验证一下./build.opt/lite/api/opt --version能打印版本号就说明工具可用。这一步是整个流程的基础opt 工具编不出来后面模型转换无从谈起。3. 模型下载、量化转换与 armv7 预测库编译3.1 下载 FastSpeech2 与 MB-MelGAN 静态模型PaddleSpeech 的 TTS 流程是两个模型串联FastSpeech2 负责把音素序列转成梅尔频谱MB-MelGAN 负责把梅尔频谱转成波形。两个模型都要下载mkdir -p /Paddle-Lite/models cd /Paddle-Lite/models wget https://paddlespeech.bj.bcebos.com/Parakeet/released_models/fastspeech2/fastspeech2_cnndecoder_csmsc_static_1.0.0.zip wget https://paddlespeech.bj.bcebos.com/Parakeet/released_models/mb_melgan/mb_melgan_csmsc_static_0.1.1.zip unzip fastspeech2_cnndecoder_csmsc_static_1.0.0.zip unzip mb_melgan_csmsc_static_0.1.1.zip解压后目录里会有.pdmodel和.pdiparams两个文件这是 PaddlePaddle 的推理格式。注意fastspeech2_cnndecoder_csmsc_static_1.0.0里的模型文件名可能是fastspeech2_csmsc.pdmodel以实际解压结果为准。3.2 用 opt 工具转成 armv7 的 naive_buffer转换命令要指定--valid_targetsarm并且开启 INT8 量化否则模型体积会大到 armv7 设备内存扛不住cd /Paddle-Lite/build.opt/lite/api ./opt \ --model_file/Paddle-Lite/models/fastspeech2_cnndecoder_csmsc_static_1.0.0/fastspeech2_csmsc.pdmodel \ --param_file/Paddle-Lite/models/fastspeech2_cnndecoder_csmsc_static_1.0.0/fastspeech2_csmsc.pdiparams \ --valid_targetsarm \ --optimize_out_typenaive_buffer \ --optimize_out/Paddle-Lite/models/fastspeech2_csmsc_arm \ --quant_modeltrue \ --quant_typeQUANT_INT8 ./opt \ --model_file/Paddle-Lite/models/mb_melgan_csmsc_static_0.1.1/mb_melgan_csmsc.pdmodel \ --param_file/Paddle-Lite/models/mb_melgan_csmsc_static_0.1.1/mb_melgan_csmsc.pdiparams \ --valid_targetsarm \ --optimize_out_typenaive_buffer \ --optimize_out/Paddle-Lite/models/mb_melgan_csmsc_arm \ --quant_modeltrue \ --quant_typeQUANT_INT8转换完成后得到fastspeech2_csmsc_arm.nb和mb_melgan_csmsc_arm.nb。这两个文件后面要放进 Android 工程的assets目录。如果转换时报Unsupported operator说明模型里有 Paddle-Lite 不支持的算子需要检查 Paddle-Lite 版本是否匹配v2.10 对 FastSpeech2 的算子支持是完整的。3.3 修改 JNI 源码以支持 long 类型输入这是 armv7 编译里最容易被忽略的一步。PaddleSpeech 的 TTSAndroid 工程里音素输入用的是long[]数组但 Paddle-Lite 默认的 JNI 接口只提供了float[]和int[]的setData方法。不改源码的话Java 层调用setData(long[])会找不到对应 native 方法。先改Paddle-Lite/lite/kernels/arm/slice_compute.cc把get_new_data_from_tensorlist函数里那个CHECK_EQ注释掉否则输入 tensor 维度不是[1]时会直接 abortinline std::vectorint64_t get_new_data_from_tensorlist( const std::vectorlite::Tensor* list_new_data_tensor) { std::vectorint64_t vec_new_data; for (size_t i 0; i list_new_data_tensor.size(); i) { auto tensor list_new_data_tensor[i]; LOG(WARNING) tensor dims is : tensor-dims(); // CHECK_EQ(tensor-dims(), DDim({1})) shape of dim tensor should be [1]; if (tensor-precision() PrecisionType::kInt32) { vec_new_data.push_back(static_castint64_t(*tensor-dataint32_t())); } else if (tensor-precision() PrecisionType::kInt64) { vec_new_data.push_back(static_castint64_t(*tensor-dataint64_t())); } else { vec_new_data.push_back(static_castint64_t(*tensor-dataint32_t())); LOG(WARNING) slice StartsTensor or EndsTensor :The dtype of Tensor must be int32 or int64; } } return vec_new_data; }然后在Paddle-Lite/lite/api/android/jni/native/tensor_jni.h里新增声明JNIEXPORT jboolean JNICALL Java_com_baidu_paddle_lite_Tensor_nativeSetData___3J( JNIEnv *, jobject, jlongArray);再在tensor_jni.cc里补上实现JNIEXPORT jboolean JNICALL Java_com_baidu_paddle_lite_Tensor_nativeSetData___3J( JNIEnv *env, jobject jtensor, jlongArray buf) { std::unique_ptrTensor *tensor get_writable_tensor_pointer(env, jtensor); if (tensor nullptr || (*tensor nullptr)) { return JNI_FALSE; } int64_t buf_size (int64_t)env-GetArrayLength(buf); if (buf_size ! product((*tensor)-shape())) { return JNI_FALSE; } int64_t *input (*tensor)-mutable_dataint64_t(); env-GetLongArrayRegion(buf, 0, buf_size, input); return JNI_TRUE; }3.4 编译 armv7 预测动态库改完源码后执行编译脚本参数必须明确指定 armv7 和 c_staticcd /Paddle-Lite ./lite/tools/build_android.sh \ --archarmv7 \ --toolchaingcc \ --android_stlc_static \ --with_javaON \ --with_extraON--android_stlc_static是为了避免 armv7 设备上缺少libc_shared.so导致加载失败。--with_javaON才会编译 JNI 层。编译完成后产物在build.lite.android.armv7.gcc/lite/api/android/jni/下核心文件是libpaddle_lite_jni.so。用file命令确认架构file build.lite.android.armv7.gcc/lite/api/android/jni/libpaddle_lite_jni.so输出里应该看到ELF 32-bit LSB shared object, ARM, EABI5如果是ARM aarch64就说明 arch 参数没生效要检查脚本。4. TTSAndroid 工程配置与 APK 打包4.1 Gradle 配置与 ABI 过滤打开 PaddleSpeech 的 TTSAndroid 工程路径在PaddleSpeech-r1.4.1/demos/TTSAndroid。先改app/build.gradleandroid { compileSdkVersion 28 defaultConfig { applicationId com.baidu.paddle.lite.demo.tts minSdkVersion 15 targetSdkVersion 28 versionCode 1 versionName 1.0 ndk { abiFilters armeabi-v7a } } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro } } } dependencies { implementation fileTree(include: [*.jar], dir: libs) implementation com.android.support:appcompat-v7:28.0.0 implementation com.android.support.constraint:constraint-layout:1.1.3 implementation com.android.support:design:28.0.0 implementation files(libs/PaddlePredictor.jar) }abiFilters只保留armeabi-v7a这样打包时不会把 arm64 的 so 混进去。PaddlePredictor.jar要从 Paddle-Lite 编译产物里拷贝到app/libs目录路径是build.lite.android.armv7.gcc/lite/api/android/jni/PaddlePredictor.jar。4.2 修改 Predictor.java 适配 long 输入com/baidu/paddle/lite/demo/tts/Predictor.java里的runModel方法参数要从float[]改成long[]public boolean runModel(long[] phones) { if (!isLoaded()) { return false; } Date start new Date(); Tensor am_output_handle getAMOutput(phones, AMPredictor); wav getVOCOutput(am_output_handle, VOCPredictor); Date end new Date(); inferenceTime (end.getTime() - start.getTime()); Log.d(TAG, runModel: inferenceTime ms); return true; } public Tensor getAMOutput(long[] phones, PaddlePredictor am_predictor) { Tensor phones_handle am_predictor.getInput(0); long[] dims {phones.length}; phones_handle.resize(dims); phones_handle.setData(phones); am_predictor.run(); Tensor am_output_handle am_predictor.getOutput(0); return am_output_handle; }注意phones_handle.setData(phones)这里调用的就是前面在 JNI 里新增的nativeSetData(long[])方法。如果没改 JNI 源码这行会抛NoSuchMethodError。4.3 放置 so 文件与模型文件把编译好的libpaddle_lite_jni.so放到app/src/main/jniLibs/armeabi-v7a/目录下。如果没有这个目录就手动创建。然后把两个.nb模型文件放到app/src/main/assets/models/下文件名保持fastspeech2_csmsc_arm.nb和mb_melgan_csmsc_arm.nb。工程结构大致是这样TTSAndroid/ app/ libs/ PaddlePredictor.jar src/main/ jniLibs/armeabi-v7a/ libpaddle_lite_jni.so assets/models/ fastspeech2_csmsc_arm.nb mb_melgan_csmsc_arm.nb java/com/baidu/paddle/lite/demo/tts/ Predictor.java MainActivity.java4.4 执行打包用 Android Studio 的Build Build Bundle(s) / APK(s) Build APK(s)或者命令行./gradlew assembleDebug产物在app/build/outputs/apk/debug/app-debug.apk。安装到 armv7 真机上adb install -r app-debug.apk如果安装时报INSTALL_FAILED_NO_MATCHING_ABIS说明 APK 里没有 armv7 的 so回去检查abiFilters和jniLibs目录。5. 真机验证与常见报错排查清单5.1 验证推理是否成功安装后打开 App输入一段中文文本点击合成按钮。logcat 里过滤Predictor标签应该能看到类似输出D/Predictor: runModel: 320ms同时手机能听到合成的语音。如果推理时间超过 2000ms说明模型没量化成功或者设备 CPU 太弱可以尝试把--quant_type改成QUANT_INT8重新转换。5.2 常见报错对照表报错信息原因解决方式UnsatisfiedLinkError: dlopen failed: library libpaddle_lite_jni.so not foundso 文件架构不匹配或未放入 jniLibs用file确认 so 是 ARM 32-bit检查abiFiltersNoSuchMethodError: setData([J)VJNI 未新增 long 数组接口按 3.3 节修改 tensor_jni.h/cc 并重新编译local proxy failed/401模型文件路径错误或未放入 assets检查assets/models/下 .nb 文件名与代码里一致reading choices相关解析错误模型转换时--valid_targets未指定 arm重新用--valid_targetsarm转换OAuth或鉴权失败误用了在线模型接口TTSAndroid 是离线推理不需要网络鉴权检查是否混入了在线 SDKCHECK_EQ(tensor-dims(), DDim({1}))abortslice_compute.cc 未注释 CHECK按 3.3 节注释掉该行重新编译undefined reference to __android_log_printNDK 版本过高换回 NDK r17cINSTALL_FAILED_NO_MATCHING_ABISAPK 缺少 armv7 so确认abiFilters armeabi-v7a且 jniLibs 目录正确5.3 性能调优建议armv7 设备 CPU 主频普遍在 1.2GHz 到 1.5GHz单次合成 10 个字的句子大概需要 300ms 到 800ms。如果觉得慢可以开多线程推理config.setThreads(2); config.setPowerMode(PowerMode.LITE_POWER_HIGH);在Predictor.java的init方法里加上这两行。另外模型量化已经用了 INT8如果还想再压体积可以尝试--quant_typeQUANT_INT16但 armv7 对 INT16 的支持不如 INT8 稳定实测下来 INT8 是性价比最高的选择。6. 接入 TaoToken 做模型调试与 Coding Plan 配置编译和部署过程中如果你需要对比不同模型的推理效果或者想用云端大模型辅助生成音素序列、调试 JNI 代码可以通过 TaoToken 的 API 来快速验证。TaoToken 提供统一的模型调用入口适合在本地编译环境里做辅助调试。先到 TaoToken 控制台 创建一个 API Key然后在 API Keys 页面 复制出来。Base URL 用https://taotoken.net/api不要加 UTM 参数。如果你用 Claude Code 做 JNI 代码的辅助修改可以在项目根目录建一个.claude/settings.json{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_API_KEY: sk-你的Key, ANTHROPIC_MODEL: claude-sonnet-4-20250514 } }这样 Claude Code 就会走 TaoToken 的接口来补全和解释tensor_jni.cc里的 JNI 代码。模型 ID 根据你实际使用的模型填可以在 模型对话页面 先试一下哪个模型对 C 和 JNI 的理解更准。如果你需要长期做 Android NDK 编译和 Agent 辅助开发可以看一下 Coding Plan它按周期计费适合高频调用场景。接入文档在 这里里面有完整的 Base URL、Key 和 Model ID 三件套说明。配置完成后你可以让 Claude Code 帮你检查slice_compute.cc的修改是否会影响其他算子或者让它生成音素到 ID 的映射表。实测下来用云端模型辅助排查 JNI 签名错误比手动翻文档快很多尤其是nativeSetData___3J这种带数组签名的 native 方法模型能直接指出签名格式问题。最后一步把编译好的 APK 装到真机上输入「今天天气不错」这类短句确认能听到清晰的合成语音。如果声音断断续续检查一下getVOCOutput返回的wav数组长度是否和预期一致armv7 上内存对齐问题偶尔会导致波形数据错位。
返回列表