ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

GPT-SoVITS安卓本地部署:ONNX转换与JNI集成实战指南

GPT-SoVITS安卓本地部署:ONNX转换与JNI集成实战指南 最近一直在折腾声音克隆GPT-SoVITS 的效果确实让人惊艳但最大的门槛在于它太重了基本只能待在 PC 上跑。官方推荐用显卡推理CPU 模式勉强能出结果但速度感人。至于手机端网上常见的方案是通过 Termux 或者 Linux Deploy 模拟一套 Linux 环境再把全量 Python 依赖原封不动塞进去。这种思路可行但启动慢、内存占用大、模块兼容问题多而且“模拟层”的额外开销让本来就很吃算力的 TTS 任务雪上加霜。所以我决定换一条路线不用各种“模拟 Linux 环境”的间接方案直接走“交叉编译 轻量推理引擎 JNI 封装”的路线把 GPT-SoVITS 的核心推理能力完整搬进安卓应用里。是的最终效果是在手机上完成“加载模型 → 输入文本 → 输出克隆音色语音”的完整闭环模型跑在本地不依赖服务器也不依赖云端 API。这篇博客我会完整整理这套方案的思路、环境准备、模型转换、安卓工程集成和避坑记录。适合对声音克隆感兴趣的安卓开发者、TTS 方案研究者以及想离线部署 AI 模型到移动端的同学。1. 背景与核心概念1.1 GPT-SoVITS 是什么GPT-SoVITS 是目前很火的一个开源声音克隆与 TTSText-To-Speech文本转语音项目。它可以做到你给它一段几秒钟的参考音频它就能学会这个人的音色然后用这个音色把任意文本读出来。从架构上看GPT-SoVITS 主要包含几个模块文本前端负责把输入文本转成音素序列并提取 BERT 语义特征。GPT 模块基于 GPT 的 AR自回归结构负责预测语义 token 序列。简单理解它负责“想好接下来怎么读”。SoVITS 模块一个基于 VITS 改进的声学模型负责把语义 token 转成 Mel 频谱。声码器Vocoder负责把 Mel 频谱还原成真实波形。GPT-SoVITS 中常见用的是 HiFi-GAN 或 Snac-HiFiGAN。整个链路非常长也是为什么“直接塞进手机跑”并不容易的原因。因为每个模块都有独立的网络结构和推理逻辑不是单个 ONNX 模型就能搞定的。1.2 为什么传统方案不适合移动端很多人在安卓上跑 GPT-SoVITS想到的第一种方式是在手机里装一个 Linux 模拟环境然后复用 PC 端的一套 Python 代码。这个方案在原理上没问题但实际使用体验比较差启动慢每次都要初始化整个 Linux 用户空间内存占用可能拉到 1GB 以上。依赖冲突PC 端很多依赖是 x86_64 架构预编译的在 ARM64 手机上需要重新编译经常遇到某个库编译不过去。CPU/内存占用高Python 解释器本身开销大再把 PyTorch 推理框架加载起来手机很容易发热降频。模型格式不友好PyTorch 的 .ckpt / .pth 权重格式在移动端推理时很不友好需要转成 ONNX、MNN 或 NCNN 等格式。这次我采用的方案是在 PC 上把 GPT-SoVITS 各模块分别导出成 ONNX 模型在安卓端通过 ONNX Runtime 执行推理再用 JNI 封装成 Java/Kotlin 可调的接口。这样既能做到“本地推理”又能避开模拟环境带来的额外开销。1.3 这套方案解决什么问题去掉复杂的 Python 运行时依赖安卓端只需要一个轻量推理库。模型文件提前转换并压缩缩放手机内存占用。推理过程完全离线不需要把音频上传到服务器满足隐私性要求。启动时间从“分钟级”压缩到“秒级”虽然不能和 PC 高端显卡比但可用性提升很明显。2. 环境准备与版本说明2.1 硬件环境PC 端Windows 11 / Ubuntu 22.04至少 16GB 内存。如果你要自己训练声音克隆模型建议有一张 NVIDIA 显卡显存不低于 8GB。如果只做模型转换纯 CPU 也能完成只是速度慢一些。安卓端建议 8GB 内存以上机型支持 ARM64 架构。Android 10 及以上系统兼容性较好。2.2 软件环境用途工具链模型导出Python 3.10、PyTorch 2.x、ONNX 1.14安卓推理ONNX Runtime Android AAR安卓原生层Android NDK r25 或更高版本CMake 3.22集成开发Android Studio 2023.1模型预处理ffmpeg音频采样率转换、librosa特征提取需要提醒大家以上版本只是一个参考组合不同版本的 API 和 ABI 略有差异。如果你使用更新的工具链个别编译参数需要手动调整。2.3 示例项目结构GPTSoVITS-Android/ ├── app/ │ ├── src/main/ │ │ ├── java/com/example/gptsovits/ │ │ │ ├── MainActivity.kt │ │ │ └── GptSoVitsEngine.kt │ │ ├── jni/ │ │ │ ├── CMakeLists.txt │ │ │ └── gptsovits_jni.cpp │ │ └── assets/ │ │ ├── gpt_model/ │ │ ├── sovits_model/ │ │ └── vocoder_model/ │ ├── libs/onnxruntime.aar │ └── build.gradle.kts3. 核心原理拆解从 PyTorch 到移动端推理3.1 为什么需要转 ONNXGPT-SoVITS 训练和推理时用的是 PyTorch。PyTorch 假设模型跑在 x86_64 平台的 Python 进程里对显存、CUDA 工具链有较强依赖。安卓端不可能直接以原结构运行所以我们需要把模型从 PyTorch 格式转换成 ONNX 格式。ONNX 是一种开放模型表示格式可以看作“模型的通用语言”。转换后我们用 ONNX Runtime 在安卓端加载模型。ONNX Runtime 支持 ARM64 CPU 推理和部分 GPU 加速通过 NNAPI 或 OpenCL EP足够满足移动端 TTS 推理需求。3.2 转换 GPT 模块GPT 模块本质上是 Transformer Decoder输入是语义 token 序列和 BERT 特征输出是预测的下一 token 概率分布。转换时我们需要固定输入维度并关闭训练模式# 文件路径export_gpt.py import torch from ONNXRuntime import quantization def export_gpt_model(gpt_model, output_path, max_seq_len256): gpt_model.eval() # 固定输入形状: (batch1, seq_lenmax_seq_len, hidden1024) dummy_input torch.ones(1, max_seq_len, 1024, dtypetorch.float32) # 第二个输入token_id 序列用于 attention mask 或位置编码 dummy_token torch.ones(1, max_seq_len, dtypetorch.int64) torch.onnx.export( gpt_model, (dummy_input, dummy_token), output_path, input_names[semantic_feat, token_id], output_names[logits], dynamic_axes{ semantic_feat: {1: seq_len}, token_id: {1: seq_len}, logits: {1: seq_len}, }, opset_version14, ) print(fGPT 模型已导出: {output_path})需要注意动态轴 seq_len 是为了兼容不同输入文本长度。ONNX Runtime 对动态轴支持通常没问题但移动端配置时需要手动设置输入尺寸。3.3 转换 SoVITS 模块SoVITS 模块输入是语义 token 和文本特征输出是 Mel 频谱。转换思路类似只是模型结构中有一些自定义算子例如相对位置编码、流模型可能需要重写部分逻辑或用 ONNX 官方算子替代# 文件路径export_sovits.py def export_sovits_model(sovits_model, output_path, max_phoneme_len128): sovits_model.eval() dummy_semantic torch.ones(1, max_phoneme_len, 1024, dtypetorch.float32) dummy_text torch.ones(1, max_phoneme_len, dtypetorch.int64) torch.onnx.export( sovits_model, (dummy_semantic, dummy_text), output_path, input_names[semantic_feat, text_token], output_names[mel_spectrogram], dynamic_axes{ semantic_feat: {1: len}, text_token: {1: len}, mel_spectrogram: {2: len}, }, opset_version14, ) print(fSoVITS 模型已导出: {output_path})3.4 声码器转换声码器是最后一环负责把 Mel 频谱还原成 PCM 音频。HiFi-GAN 系列残差网络结构相对简单转换通常比较顺利# 文件路径export_vocoder.py def export_vocoder(vocoder_model, output_path, mel_bins128): vocoder_model.eval() dummy_mel torch.randn(1, mel_bins, 128, dtypetorch.float32) torch.onnx.export( vocoder_model, dummy_mel, output_path, input_names[mel], output_names[audio], dynamic_axes{mel: {2: frames}, audio: {1: samples}}, opset_version14, )转换后建议先用 Python ONNX Runtime 验证一遍输出确认和原 PyTorch 结果一致再拿到安卓端使用。3.5 模型量化移动端内存有限FP32 模型可能达到几百 MB。建议对不敏感模块做 INT8 或 FP16 量化。量化方式有两种动态量化适合 GPT 这种权重占比大的模型推理时再量化到 INT8。静态量化需要校准数据集适合声码器这种计算密集型 CNN。# 文件路径quantize.py from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( gpt_model.onnx, gpt_model_int8.onnx, weight_typeQuantType.QInt8, )量化之后一定做效果对比有些模型量化后音色损失明显需要选择折中方案。4. 完整实战案例把 GPT-SoVITS 集成进安卓4.1 创建安卓工程用 Android Studio 新建一个 Empty Activity 项目包名建议为com.example.gptsovits。在工程的app/libs目录下放入onnxruntime.aar然后在build.gradle.kts中引入// 文件路径app/build.gradle.kts plugins { id(com.android.application) id(org.jetbrains.kotlin.android) } android { namespace com.example.gptsovits compileSdk 34 defaultConfig { applicationId com.example.gptsovits minSdk 26 targetSdk 34 versionCode 1 versionName 1.0 ndk { abiFilters listOf(arm64-v8a) } externalNativeBuild { cmake { cppFlags -stdc17 } } } externalNativeBuild { cmake { path file(src/main/jni/CMakeLists.txt) } } } dependencies { implementation(fileTree(libs) { include(*.aar) }) implementation(androidx.core:core-ktx:1.12.0) implementation(androidx.appcompat:appcompat:1.6.1) implementation(com.google.android.material:material:1.11.0) }注意abiFilters只保留arm64-v8a。如果打包多个架构APK 体积会明显增大而且很多 TTS 模型转换时只针对 ARM64 优化。4.2 编写 C JNI 层在src/main/jni/CMakeLists.txt中编写编译脚本# 文件路径app/src/main/jni/CMakeLists.txt cmake_minimum_required(VERSION 3.22) project(gptsovits_jni) set(CMAKE_CXX_STANDARD 17) # 将本地源码编译成动态库 add_library(gptsovits_jni SHARED gptsovits_jni.cpp) # 链接 ONNX Runtime 静态库 add_library(onnxruntime SHARED IMPORTED) set_target_properties(onnxruntime PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../libs/arm64-v8a/libonnxruntime.so) target_include_directories(gptsovits_jni PRIVATE ${CMAKE_SOURCE_DIR}/../libs/onnxruntime/include) target_link_libraries(gptsovits_jni onnxruntime jnigraphics log android)在gptsovits_jni.cpp中我们实现三个核心方法加载模型、推理 GPT、推理合成。// 文件路径app/src/main/jni/gptsovits_jni.cpp #include jni.h #include string #include android/log.h #include onnxruntime_cxx_api.h #define LOG_TAG GPTSoVITS-JNI #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) // 全局 Ort 环境与会话容器 static Ort::Env env(ORT_LOGGING_LEVEL_WARNING, gptsovits); static Ort::SessionOptions session_options; static std::unique_ptrOrt::Session gpt_session; static std::unique_ptrOrt::Session sovits_session; static std::unique_ptrOrt::Session vocoder_session; extern C JNIEXPORT jboolean JNICALL Java_com_example_gptsovits_GptSoVitsEngine_loadModels( JNIEnv *env, jobject thiz, jstring gpt_path, jstring sovits_path, jstring vocoder_path) { try { const char *gpt env-GetStringUTFChars(gpt_path, nullptr); const char *sovits env-GetStringUTFChars(sovits_path, nullptr); const char *vocoder env-GetStringUTFChars(vocoder_path, nullptr); session_options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); // 使用 4 线程执行推理根据手机核数调整 session_options.SetIntraOpNumThreads(4); gpt_session std::make_uniqueOrt::Session(env, gpt, session_options); sovits_session std::make_uniqueOrt::Session(env, sovits, session_options); vocoder_session std::make_uniqueOrt::Session(env, vocoder, session_options); LOGI(模型加载成功); env-ReleaseStringUTFChars(gpt_path, gpt); env-ReleaseStringUTFChars(sovits_path, sovits); env-ReleaseStringUTFChars(vocoder_path, vocoder); return JNI_TRUE; } catch (const Ort::Exception e) { LOGI(模型加载失败: %s, e.what()); return JNI_FALSE; } }这里的加载过程是一个简化示例。实际项目中模型文件需要先复制到应用缓存目录或者直接放入 assets 后通过AAssetManager读取不能直接传一个assets://路径给 ONNX Runtime。4.3 编写 Kotlin 封装类// 文件路径app/src/main/java/com/example/gptsovits/GptSoVitsEngine.kt package com.example.gptsovits import android.content.Context import android.util.Log class GptSoVitsEngine private constructor() { init { System.loadLibrary(gptsovits_jni) } external fun loadModels(gptPath: String, sovitsPath: String, vocoderPath: String): Boolean companion object { private const val TAG GptSoVitsEngine fun copyModelsIfNeeded(context: Context) { val models listOf( gpt_model_int8.onnx, sovits_model.onnx, vocoder_model.onnx ) for (name in models) { val targetFile context.getExternalFilesDir(null)?.resolve(name) if (targetFile ! null !targetFile.exists()) { context.assets.open(models/$name).use { input - targetFile.outputStream().use { output - input.copyTo(output) } } Log.d(TAG, 模型已复制: $name) } } } fun getModelPath(context: Context, name: String): String { return context.getExternalFilesDir(null)?.resolve(name)?.absolutePath ?: } } }这一步主要是把 assets 里的模型释放到文件系统因为 ONNX Runtime 通常加载的是本地文件路径。4.4 推理流程串联在 MainActivity 中实现输入文字 → 文本前端处理 → GPT 推理 → SoVITS 推理 → 声码器推理 → 播放音频。// 文件路径app/src/main/java/com/example/gptsovits/MainActivity.kt package com.example.gptsovits import android.Manifest import android.content.pm.PackageManager import android.media.AudioAttributes import android.media.MediaPlayer import android.os.Bundle import android.widget.Button import android.widget.EditText import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import java.io.File class MainActivity : AppCompatActivity() { private lateinit var engine: GptSoVitsEngine private lateinit var inputText: EditText private lateinit var statusView: TextView private lateinit var playButton: Button override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) inputText findViewById(R.id.input_text) statusView findViewById(R.id.status) playButton findViewById(R.id.btn_play) engine GptSoVitsEngine() requestAudioPermission() playButton.setOnClickListener { val text inputText.text.toString() if (text.isBlank()) { Toast.makeText(this, 请输入文本, Toast.LENGTH_SHORT).show() returnsetOnClickListener } runInference(text) } initModels() } private fun initModels() { GptSoVitsEngine.copyModelsIfNeeded(this) val gptPath GptSoVitsEngine.getModelPath(this, gpt_model_int8.onnx) val sovitsPath GptSoVitsEngine.getModelPath(this, sovits_model.onnx) val vocoderPath GptSoVitsEngine.getModelPath(this, vocoder_model.onnx) val loaded engine.loadModels(gptPath, sovitsPath, vocoderPath) statusView.text if (loaded) 模型加载成功可以进行合成 else 模型加载失败 } private fun runInference(text: String) { // 这里简化为调用 JNI 中预留的合成函数 // 实际项目中文本前端音素转换、BERT 特征也需要在移动端实现 statusView.text 开始推理... // TODO: 调用合成的 native 方法 } private fun requestAudioPermission() { val permission Manifest.permission.RECORD_AUDIO if (ContextCompat.checkSelfPermission(this, permission) ! PackageManager.PERMISSION_GRANTED ) { permissionLauncher.launch(permission) } } private val permissionLauncher registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted - if (!granted) { Toast.makeText(this, 需要录音权限来录制参考音频, Toast.LENGTH_LONG).show() } } }4.5 文本前端处理上面代码中我留了一个 TODO文本前端。这是整套流程里比较容易被忽略的部分。PC 版 GPT-SoVITS 的文本前端包括中文分词。拼音转换。BERT 特征提取。音素到 ID 的映射。在移动端有两种实现路径将文本前端也用 Python 导出为 ONNX 模型安卓端调用同一个模型完成文本编码。使用安卓原生文本处理库比如用 TinyPinyin 做拼音转换再用一个轻量 BERT ONNX 做语义特征。第一种路径兼容性最好推荐优先尝试。因为 PC 端分词和转拼音的逻辑比较复杂重写很容易出现和训练时不匹配的问题。5. 常见问题与排查思路在把 GPT-SoVITS 搬进安卓的过程中我踩了不少坑下面按高频问题整理成表格问题现象常见原因解决思路ONNX 导出时报不支持算子自定义算子未注册用 ONNX 原生算子重写自定义层或升级 opset 版本安卓端加载模型闪退模型路径不正确 / so 库未放进 APK检查 so 文件是否在 jniLibs 下路径是否可读推理输出全是噪声模型量化后精度损失严重改用 FP16 量化或在声码器模块保留 FP32推理速度太慢线程数设置太小 / 未启用图优化设置SetIntraOpNumThreads(4)和ORT_ENABLE_ALL内存不足模型文件过大使用动态量化压缩模型体积按需加载模块而非一次加载全部合成音频有爆音音频后处理缺少归一化输出 PCM 后除以最大值再写入 Wave 文件5.1 ONNX 导出报错排查常见错误信息是Unsupported operator。这通常说明模型里有自定义层ONNX 导出器不认识。排查步骤打印出错的 node 类型。在 GitHub 上搜索该算子的 ONNX 实现。如果找不到用多个基础算子组合实现相同逻辑。导出后对比 PyTorch 输出误差控制在 1e-3 以内。5.2 安卓端 so 库加载失败如果调用System.loadLibrary(gptsovits_jni)时提示UnsatisfiedLinkError优先检查CMakeLists.txt中生成的库名是否和 Java 层一致。是否只有arm64-v8a目录下有libonnxruntime.so。外围函数是否在.cpp文件中使用extern C导出。5.3 音色不一致问题同一个模型在 PC 上和手机上输出音色差异较大原因通常有两个参考音频处理流程不一致比如重采样方式不同。声码器等模块被量化后引入失真。建议参考音频统一先转为 16kHz 或 32kHz 单声道 WAV 再进行特征提取敏感模块不要量化。6. 最佳实践与工程建议6.1 模型文件管理模型文件最好不要直接打进 APK 的 assets 根目录。建议通过版本号管理assets/models/v1/gpt_model_int8.onnx assets/models/v1/sovits_model.onnx assets/models/v1/vocoder_model.onnx应用启动时检测版本号不匹配则提示用户重新下载模型包。这样后续模型更新时不需要重新发布 APK也方便热更新。6.2 推理线程与性能调优移动端 CPU 推理受发热、系统调度影响很大。建议线程数不要超过 4避免与其他应用争抢 CPU。在合成前检测手机剩余内存和电池温度温度过高时降级为低质量模式。长文本切成短句逐句合成避免一次性生成过长音频导致内存溢出。6.3 音频输出与格式ONNX Runtime 输出的音频数据通常是float32的 PCM 序列。播放前需要转换为PCM16格式并封装成 Wave 文件或直接用 AudioTrack 播放// 文件路径AudioUtils.kt object AudioUtils { fun floatArrayToPcm16(floatArray: FloatArray): ByteArray { val bytes ByteArray(floatArray.size * 2) var offset 0 for (sample in floatArray) { val clamped sample.coerceIn(-1.0f, 1.0f) val pcm (clamped * Short.MAX_VALUE).toInt().toShort() bytes[offset] (pcm.toInt() and 0xFF).toByte() bytes[offset] ((pcm.toInt() shr 8) and 0xFF).toByte() } return bytes } }6.4 安全与合规声音克隆是一把双刃剑。在工程集成时一定要做好基础安全边界只允许用户使用自己录制或拥有授权的音频。应用内需要明确提示“本功能仅用于合法用途”。如果涉及公开分享建议加入语音水印标注是 AI 合成内容。不应将模型文件随意开源到外部避免被滥用。6.5 日志与调试C 层和 Java 层分开打日志便于快速定位问题C 层用__android_log_print输出模型加载、输入尺寸、推理耗时。Java 层用Log.i记录 asset 复制进度和状态切换。在验证阶段加入“参考音频比对”对比输入参考音频和合成音频的 F0 曲线判断音色是否保持。7. 总结与下一步规划这套“不依赖模拟 Linux 环境”的方案核心思路可以概括成一句话把 GPT-SoVITS 的每个子模块全部导出为 ONNX再交给 ONNX Runtime 在安卓本地执行。整个过程绕过了 Python 解释器、PyTorch 运行时和大量系统依赖换来的是更快的启动速度和更低的资源占用。从实际体验来看在骁龙 8 级别的手机上整段合成的耗时已经能达到“可用”水平虽然和 PC 端高端显卡相比还有差距但如果你只是偶尔合成几句语音或者做离线工具类应用这个方向完全走得通。接下来可以继续尝试的方向有三个把文本前端完整用 ONNX 导出让整个链路彻底脱离 Python。尝试 NNAPI 或 OpenCL 加速进一步压低合成延迟。将模型封装成 Android AAR SDK提供给其他开发者调用做成类似“语音克隆引擎”的移动端中间件。如果你也在折腾 GPT-SoVITS 移动端部署或者手头有更好用的端侧 TTS 方案欢迎在评论区交流。折腾过程确实会踩不少坑但只要把整条链路跑通回头看每一步都是值得的。
返回列表