ARTICLE DETAIL

资讯详情

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

Android离线中文TTS集成:espeak-ng从交叉编译到JNI封装

Android离线中文TTS集成:espeak-ng从交叉编译到JNI封装 说实话第一次在 Android 里接 espeak-ng 时我心里是有点犯嘀咕的。主要原因很简单这玩意儿在 Linux 命令行里一条espeak hello就能出声但要在 Android 上跑起来牵扯到 NDK 交叉编译、JNI 封装、中文语音数据加载、音频播放链路每一步都够让人焦头烂额。等真正跑通之后回头看espeak-ng 反而是我目前用过的最省心的离线中文 TTS 方案——不需要开发者账号不申请 API Key不上报任何音频数据一个.so文件加一份语音数据目录就能在 App 里实现真正开箱即用的中文朗读。这篇文章就把我从零到一的完整接入过程拆开讲从方案选型、NDK 编译、JNI 封装、中文调优到实际踩过的坑全部记录下来。适合这几类人看想在 App 里快速加入离线朗读功能但不想碰云服务的人想给阅读类 App 配一个本地语音引擎的玩家以及做嵌入式播报、无障碍辅助工具、车载提示这类对隐私和延迟有要求的场景。我不保证 espeak-ng 的音质能比得上各路神经网络 TTS但它体积小、无授权限制、离线可用这套特性在特定场景下是真的能打。1. 为什么是 espeak-ng四类离线中文 TTS 方案的取舍先说结论再讲过程。我在选型的时候其实考虑了四类方案最后才落到 espeak-ng 上这个权衡过程本身对很多人就有参考价值。1.1 市面上离线中文 TTS都有哪些选择第一类是 Android 系统自带的TextToSpeechAPI。这是最省事的路径不用引入任何库代码就那么几行。但它的毛病在于依赖系统 TTS 引擎的安装情况很多国产 ROM 里默认的中文语音引擎不是没装就是语音包缺失你调用setLanguage(Locale.CHINESE)之后返回的LANG_MISSING_DATA和LANG_NOT_SUPPORTED真是能把人逼疯。而且它的音色、语速、回调时机都不受你控制想要个性化几乎没有空间。另外有些设备上系统 TTS 引擎本身还会弹正在下载语音数据的交互这对一个自动播报功能来说是致命的。第二类是云厂商的 TTS SDK比如各家语音平台。音质确实自然但是要注册应用、申请 Key、配置签名、写权限说明还受网络环境和免费配额限制。即便做离线语音包版本授权逻辑也复杂商业授权费用不低而且 SDK 体积大集成后包里塞进去几十上百 MB 很常见。对于一个小工具类 App 来说为了一个朗读功能去背一个几 MB 的 SDK 加一堆商务流程完全划不来。第三类是开源神经网络 TTS比如 Coqui TTS、VITS 这类。中文效果在开源圈子里确实还可以但模型动辄几百 MB移动端 CPU 推理速度看设备脸色低端机上合成一句话可能要等好几秒体验很难接受。除非你有专门的模型压缩团队或者有 GPU 服务器做服务端合成否则不建议在纯端侧场景用它。第四类就是本文的主角 espeak-ng。它是 espeak 的升级分支用 C 语言实现库本体很小编译出的.so大概在 2-3 MB 左右语音数据目录也就十几 MB内存占用和 CPU 占用都极低在低端 Android 设备上也能流畅合成。它遵循 GPLv3 许可证商用的话要注意开源合规问题这是它唯一需要认真对待的坑后面我会细说。1.2 espeak-ng 的确定性是它最大的优势跟黑盒的云服务相比espeak-ng 的整个合成链路是可预测、可调试的。它内部是音素拼接式合成不是统计参数或神经网络所以你给它一段文本它什么时候开始返回音频块、返回多少字节的 PCM 数据这些行为都很稳定。我在代码里打开日志可以看到每个回调拿到的样本数也可以精确计算出一段文本的播放时长。这种确定性在做硬件联动、字幕同步、音频缓存这类需求时非常有用。另外espeak-ng 的架构特别适合 Android 集成。它本身支持通过espeak_Initialize指定数据目录通过espeak_Synth同步或异步合成文本然后通过回调把 16bit PCM 数据交给你。你不需要理会音频采集、设备枚举那些破事拿到 PCM 之后想怎么处理都行——直接用 AudioTrack 播放、编码成 WAV 文件、或者流式传给录音机接口做后续处理完全自主可控。1.3 它不适合谁音质党可以直接跳过 espeak-ng。它的中文发音带有明显的电子合成味有点像早期的 GPS 导航语音播报短句、口令、通知类文本还好如果要朗读大段文学作品听感确实一般。另外它的吐字清晰度虽然不错但韵律和情感完全谈不上。如果你的场景是对外面向终端用户的高频语音播报建议还是老老实实接云 TTS 或者商业离线 SDK。espeak-ng 的合理位置是轻量工具型集成不是消费级语音体验。2. 交叉编译与 SO 库准备最容易被卡住的一步选完型之后第一步就是把 espeak-ng 编译成 Android 能用的动态库。这一步坑比较密集包括 NDK 版本、编译参数、数据目录路径很多人就是在这里放弃的所以我单独用一整章讲清楚。2.1 环境准备与源码获取我本地的环境是 macOSAndroid Studio 自带 NDK 25.2.9519653。你需要先确认 NDK 已经通过 SDK Manager 装好并记录下它的绝对路径比如~/Library/Android/sdk/ndk/25.2.9519653。然后是源码git clone https://github.com/espeak-ng/espeak-ng.git cd espeak-ngespeak-ng 的构建系统已经支持 CMake这比老 espeak 的 autotools 省心不少。我的拉取版本大概在 1.51 或更新的 commit不同的子版本在 CMake 配置项上略有差异但整体流程是一致的。2.2 用 NDK Toolchain 进行交叉编译我不建议直接把 espeak-ng 源码塞进 Android 工程的 CMakeLists 里用add_subdirectory编译虽然可行但会导致整个应用工程构建变慢而且出现问题不好定位。更稳妥的做法是预编译好各个 ABI 的.so文件像对待一个普通第三方库那样集成。针对 arm64-v8a 的编译命令如下export ANDROID_NDK_HOME$HOME/Library/Android/sdk/ndk/25.2.9519653 cmake -S . -B build-android-arm64 \ -DCMAKE_TOOLCHAIN_FILE$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake \ -DANDROID_ABIarm64-v8a \ -DANDROID_PLATFORMandroid-23 \ -DCMAKE_BUILD_TYPERelease \ -DBUILD_SHARED_LIBSON cmake --build build-android-arm64 --target espeak-ng -j8重点是-DBUILD_SHARED_LIBSON确保产出的是.so而不是静态库文件。编译完成之后在build-android-arm64/src/目录下会找到libespeak-ng.so。armeabi-v7a、x86_64 同理把ANDROID_ABI换掉重新跑一遍即可。目前主流设备基本是 arm64-v8a如果你想覆盖老设备再编个 armeabi-v7a我的建议是至少两个 ABI 都编上避免在老旧机顶盒、车载设备上报错闪退。2.3 构建产物里最关键的 espeak-ng-data 目录这一步是大部分教程都没讲清楚的重点。espeak-ng 的引擎本体只是一个动态库它真正发音时需要读取一套名为espeak-ng-data的数据文件里面包含了lang、voices、phontab、intonation等等。在 PC 上安装 espeak-ng 之后它会默认安装到/usr/share/espeak-ng-data这样的目录但在 Android 上文件系统路径是不可控的你必须把这份数据打包进 App并在代码里告诉引擎数据目录在哪。我在编译完成后执行了一下make install到一个临时目录或者直接从源码目录的espeak-ng-data子目录里拷贝整个数据目录两种方法拿到的数据是一样的。关键是要保证整个目录结构完整不要只拷一半espeak-ng-data/ ├── lang ├── voices ├── phontab ├── intonation └── ...把这个目录放进 Android 工程app/src/main/assets/下面后续在 App 启动时拷贝到私有目录然后初始化时传给espeak_Initialize。2.4 两种集成方案预编译产物与 CMake 源码构建这里给你两个方向参考取决于你后续要不要改动 espeak-ng 的 C 源码。第一种是我推荐的做法预编译.so放进app/src/main/jniLibs/arm64-v8a/和app/src/main/jniLibs/armeabi-v7a/Gradle 会自动把它们打入 APKSystem.loadLibrary(espeak-ng)就能加载。优点是构建快、问题少JNI 层完全由自己控制。缺点是你如果想修改 espeak-ng 底层源码每次都要回到本地重新编译再拷贝。第二种是把 espeak-ng 源码作为 Git submodule 放进工程在 app 的 CMakeLists 里add_subdirectory。优点是可以共享构建流程不需要手动拷贝产物缺点是每次构建都要重新编译 espeak-ng速度慢且对 NDK 版本敏感。我个人在正式项目里用的是第一种简单可控出了问题也好回退。另外提醒一句如果你用 CMake 源码方式记得检查最终 APK 里是否包含了espeak-ng-data。很多朋友在电脑上跑通之后APK 里没有数据目录运行时就报错找不到中文语音这个坑我放在第 5 章展开讲。3. JNI 封装与播放链路从调用 API 到听到声音库和数据的准备工作完成之后进入纯代码阶段。espeak-ng 的 C API 接口并不多核心只有四个espeak_Initialize、espeak_SetSynthCallback、espeak_Synth、espeak_Terminate理解这几个函数之后封装并不复杂。3.1 Java 侧接口设计我设计了一个很薄的 Java 层让调用方只需要关心init、speak、stop、release四个动作。native 方法全部用包名com.example.espeak做前缀方便 JNI 查找public class EspeakNgTts { static { // 注意加载顺序依赖库先加载 System.loadLibrary(espeak-ng); System.loadLibrary(espeakng_jni); } public interface Callback { void onAudio(byte[] pcm); // 16-bit PCM 数据 void onDone(); // 本次合成结束 } private final ExecutorService executor Executors.newSingleThreadExecutor(); private long sampleRate; public native long nativeInit(String dataParentDir, Callback callback); public native void nativeSpeak(String text, int rate, int pitch, int volume); public native void nativeStop(); public native void nativeRelease(); private AudioTrack audioTrack; public void init(final String dataParentDir, final Callback callback) { executor.execute(() - { sampleRate nativeInit(dataParentDir, callback); if (sampleRate 0) { throw new RuntimeException(espeak-ng init failed); } }); } public void speak(String text, int rate, int pitch, int volume) { executor.execute(() - nativeSpeak(text, rate, pitch, volume)); } }这里所有的 native 调用都在同一个单线程池中执行因为espeak-ng 的合成流程在同步模式下会阻塞当前线程直到合成结束放到同一线程可以天然规避并发调用问题也避免了在 Android 主线程里做耗时操作导致 ANR。3.2 native 层初始化与参数传递JNI 层核心在nativeInit里。espeak-ng 的espeak_Initialize的第一个参数是输出模式我建议用AUDIO_OUTPUT_RETRIEVAL也就是不自己播而是通过回调把 PCM 数据返回来。第二个参数是缓冲区大小传 0 表示用默认值。第三个参数就是数据目录的父目录要的是espeak-ng-data所在的上一级目录。#include jni.h #include string.h #include espeak-ng/speak_lib.h static JavaVM *g_jvm NULL; static jobject g_callback NULL; static jmethodID g_method_on_audio NULL; static jmethodID g_method_on_done NULL; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { g_jvm vm; return JNI_VERSION_1_6; } static int synth_callback(short *wav, int numsamples, espeak_EVENT *events) { if (wav NULL) { JNIEnv *env; (*g_jvm)-AttachCurrentThread(g_jvm, env, NULL); (*env)-CallVoidMethod(env, g_callback, g_method_on_done); return 0; } JNIEnv *env; (*g_jvm)-AttachCurrentThread(g_jvm, env, NULL); jbyteArray audio (*env)-NewByteArray(env, numsamples * 2); jbyte *bytes (*env)-GetByteArrayElements(env, audio, NULL); memcpy(bytes, wav, numsamples * 2); (*env)-ReleaseByteArrayElements(env, audio, bytes, 0); (*env)-CallVoidMethod(env, g_callback, g_method_on_audio, audio); (*env)-DeleteLocalRef(env, audio); return 0; } JNIEXPORT jlong JNICALL Java_com_example_espeak_EspeakNgTts_nativeInit(JNIEnv *env, jobject thiz, jstring data_parent_dir, jobject callback) { const char *dir (*env)-GetStringUTFChars(env, data_parent_dir, NULL); int sr espeak_Initialize(AUDIO_OUTPUT_RETRIEVAL, 0, dir, 0); (*env)-ReleaseStringUTFChars(env, data_parent_dir, dir); if (sr 0) { return 0L; } if (callback ! NULL) { g_callback (*env)-NewGlobalRef(env, callback); jclass clazz (*env)-GetObjectClass(env, callback); g_method_on_audio (*env)-GetMethodID(env, clazz, onAudio, ([B)V); g_method_on_done (*env)-GetMethodID(env, clazz, onDone, ()V); } espeak_SetSynthCallback(synth_callback); return (jlong) sr; }这里有个非常重要的细节synth_callback会运行在哪个线程取决于 Java 调用nativeSpeak所在线程。因为我在 Java 侧用单线程池回调其实还是发生在同一个 native 调用线程里所以用AttachCurrentThread没问题。但如果你把nativeSpeak拆分到 ThreadPool 的不同线程回调所在线程就会漂移必须用g_jvm做一次跨线程环境获取这也是要在JNI_OnLoad里保存JavaVM*的原因。3.3 nativeSpeak 的同步合成机制espeak_Synth在AUDIO_OUTPUT_RETRIEVAL模式下会一直阻塞到当前文本合成完毕期间通过回调把 PCM 数据一块一块地送出来。所以 Java 侧的执行逻辑应该是speak方法进线程池阻塞调用 native一轮合成完成后返回再等下一次调用。这样设计的好处是调用方不需要考虑竞态和状态机想停就nativeStop把当前合成中断掉。JNIEXPORT void JNICALL Java_com_example_espeak_EspeakNgTts_nativeSpeak(JNIEnv *env, jobject thiz, jstring text, jint rate, jint pitch, jint volume) { const char *utf8 (*env)-GetStringUTFChars(env, text, NULL); espeak_SetParameter(espeakRATE, rate, 0); espeak_SetParameter(espeakPITCH, pitch, 0); espeak_SetParameter(espeakVOLUME, volume, 0); espeak_Synth(utf8, strlen(utf8) 1, 0, POS_CHARACTER, 0, espeakCHARS_UTF8 | espeakENDPAUSE, 0, NULL); (*env)-ReleaseStringUTFChars(env, text, utf8); }有几个参数需要解释一下。strlen(utf8) 1是让引擎知道文本以 NUL 结尾POS_CHARACTER表示文本位置类型按字符计算适合中文标志位espeakCHARS_UTF8告诉引擎输入是 UTF-8 编码espeakENDPAUSE会在句末增加停顿这对中文朗读的节奏感很重要不要省略。3.4 AudioTrack 播放与缓冲细节回调拿到的 PCM 是 16bit 单声道数据采样率就是espeak_Initialize返回的那个值。我在 Java 回调里第一次接收到音频数据时再初始化 AudioTrack按实际采样率创建Override public void onAudio(byte[] pcm) { if (audioTrack null) { int minBuf AudioTrack.getMinBufferSize((int) sampleRate, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT); audioTrack new AudioTrack.Builder() .setAudioAttributes(new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build()) .setAudioFormat(new AudioFormat.Builder() .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .setSampleRate((int) sampleRate) .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) .build()) .setBufferSizeInBytes(minBuf * 2) .setTransferMode(AudioTrack.MODE_STREAM) .build(); audioTrack.play(); } audioTrack.write(pcm, 0, pcm.length); }这里要注意一点AudioTrack 的 buffer 如果太小写入速度跟不上合成速度时会出现丢掉音频区块的问题听起来就是一顿一顿的。我实测用minBuf * 2作为 buffer 大小比较稳妥既能保证连续播放又不会因为 buffer 过大导致停止响应延迟。在onDone回调里做stop和release一轮合成结束之后把 AudioTrack 清掉避免上一个文本的残留声音串到下一段。4. 中文发音的秘密语言代码、音色与听感调优espeak-ng 默认的英文发音还行中文这块就有点特殊了如果你什么都不设置它可能用英文音素强行读中文那效果真的没法听。所以中文这一步必须单独说。4.1 zh、cmn、zhy 语言代码的差异espeak-ng 的数据目录里中文相关的语言文件分布在espeak-ng-data/lang和espeak-ng-data/voices中。你可能听过几种叫法zh、cmn、zhy。它们其实是不同历史时期和不同标准下的命名espeak-ng 里对普通话的态度是优先使用cmn因为这是 ISO 639-3 标准的官话代码espeak-ng 对它支持最完善。而zh是老版本 espeak 沿袭下来的兼容别名zhy则主要出现在一些方言变体中。我的建议是初始化之后在代码里做两段式设置先cmn失败再回落zhif (espeak_SetVoiceByName(cmn) ! EE_OK) { espeak_SetVoiceByName(zh); }注意espeak_SetVoiceByName的返回值要检查。常见的一种错误是调用者感觉已经设置成功了但实际因为数据目录没加载这个调用一直是失败的于是它一直用默认语音读中文效果自然很糟糕。4.2 中文朗读的听感调参经验espeak-ng 的默认参数是为英文设置的直接拿到中文上会显得语速偏快、吐字发紧。我的实测调整方案是这样的参数默认值中文建议值说明espeakRATE175135-160中文语速再快就很糊放慢 20% 左右听感舒服很多espeakPITCH5050-55音调稍微抬高一点中文的声调会更明显espeakVOLUME100100-180如果你的播放链路整体偏低可以适当提升这个表里的数值单位容易让人困惑其实espeakRATE的单位是每分钟单词数对中文来说就是个大概的语速档位不需要深究直接按值调就行。espeakPITCH的范围是 0 到 9950 是中间线。我在做短句播报时喜欢把语速调到 150 左右播报口令、验证码、站名这类短文本很清楚如果是长文朗读场景140 以下会更耐听。这个完全看你的产品调性没有标准答案。4.3 进阶玩法把 espeak-ng 封装成系统 TTS 服务如果你不想在自己的 App 里写播放逻辑而是想把 espeak-ng 做成一个全局可用的语音引擎比如让阅读 App这类应用直接调用它那就需要走 Android 的TextToSpeechService路线。espeak-ng 官方仓库里其实带了 Android 工程示例espeak-ng/android目录下有一个EspeakService继承了系统的TextToSpeechService抽象类实现了onSynthesizeText、onIsLanguageAvailable、onLoadLanguage等回调。把 espeak-ng 数据目录放进这个 Service 的私有目录初始化时把数据路径指过去然后在系统设置里把默认 TTS 引擎改成Espeak大部分支持 TTS 的 App 就能直接使用。我实际在阅读类 App 里试过它能把一段正文切成多句文本逐次调用合成接口整本书朗读问题不大。唯一的短板仍然是音质但作为本地免费引擎已经是非常出色的方案了。这一步如果你感兴趣可以基于官方 Android 工程改它已经把 JNI 层和 Service 层的框架搭好了省掉不少重复工作。5. 实战踩坑记录五条排查链路帮你少走弯路这一章是我最想写的内容。espeak-ng 的 API 其实不难难的是那些不报错、或者报错信息完全没有提示意义的问题。我整理出五条我实际踩过、也帮别人排查过的坑每一条都是一个完整的排查链路你可以照着这个思路去定位自己遇到的问题。5.1 运行时找不到 espeak-ng-data 的完整排查现象espeak_Initialize返回 -1 或一个异常值中文语音完全不可用。排查链路先用日志确认传入路径到底是什么。我在代码里打过日志发现当时传入的是getFilesDir().getAbsolutePath()也就是/data/data/com.example.app/files。如果espeak-ng-data是放在这个目录下那路径是没问题的。但很多朋友会在 assets 里放一个espeak-ng-data目录然后忘了在启动时拷贝——espeak-ng 在 Android 上只能从真实文件系统读取数据assets 里的文件对它来说是不存在的。根因assets 目录不是普通文件系统路径。官方 API 或者一些老教程只说设置数据路径但没说清楚 Android 的 assets 必须先解压到 files 目录才能用。解决方案App 启动时执行一次拷贝把整个 assets 里的espeak-ng-data复制到context.getFilesDir()/espeak-ng-data然后初始化时传context.getFilesDir().getAbsolutePath()。注意拷贝过程要递归创建目录不能只拷贝文件。拷贝完成后再调用 nativeInit。/data/data/com.example.app/files/ ├── espeak-ng-data/ │ ├── lang/ │ ├── voices/ │ ├── phontab │ └── ...5.2 JNI 中文乱码的根因与修复现象Java 层传字符串你好合成出来却变成乱码或者无效的哼哼声。排查链路先确认 Kotlin/Java 字符串在 JNI 里拿到的是什么编码。GetStringUTFChars返回的是修改版 UTF-8而 espeak-ng 期望的是标准 UTF-8。普通中文文本在这两者之间的差异通常不大但遇到特殊字符、生僻字、组合字符时就会出问题。我当时用一个包含 emoji 和中文标点的字符串测试发现部分字符被错误解析成多个音素听起来就是乱念。根因修改版 UTF-8 会把 U0000 编码为0xC0 0x80也会对代理对做特殊处理espeak-ng 的 UTF-8 解析器不认识这种编码导致个别字符乱掉。解决方案最稳妥的做法不是用GetStringUTFChars而是在 JNI 里把 Java 的String转成byte[]以 UTF-8 编码后传给 native 层byte[] bytes text.getBytes(StandardCharsets.UTF_8); nativeSpeakBytes(bytes, rate, pitch, volume);在 JNI 侧直接用GetByteArrayElements拿到字节数组再把长度传进去。这样彻底绕开了修改版 UTF-8 的问题实测用 emoji、中文、日文混合字符串都不会乱。5.3 合成卡顿与 ANR线程模型问题现象在界面上点朗读按钮之后整个页面卡住几秒然后系统弹出 ANR。排查链路一看日志卡点就发生在 nativeSpeak 调用里完全符合espeak_Synth同步阻塞的特性。我当时第一版代码是直接在onClick回调里调speak()等于在主线程里跑完整段合成当然会卡死。根因espeak-ng 的合成流程是 CPU 密集型长文本合成耗时从几百毫秒到数秒不等不能放在主线程。解决方案必须用后台线程或线程池执行 nativeSpeak而且强烈建议用单线程池。还有一个隐蔽的问题如果你在回调里做 UI 操作比如更新进度条记得切回主线程因为合成回调默认运行在后台线程。AudioTrack 的写入没有线程限制但 UI 刷新必须走主线程。5.4 低音质与杂音采样率和缓冲配置现象声音能出来但有明显的爆音、卡顿或类似机器噪声。排查链路先确认 AudioTrack 的采样率有没有用对。espeak_Initialize的返回值就是实际采样率一般在 Linux/Android 下是 22050。如果你在 Java 侧写死 44100AudioTrack 会把 22050 的 PCM 数据按 44100 播放听感就是音调变高、速度加快听起来特别别扭。另外如果 AudioTrack 的 buffer 太小也会出现断续声。根因两类问题混在一起。一类是采样率写死导致的重采样错误一类是 buffer 不足导致的丢块。解决方案采样率一定用espeak_Initialize的返回值不要写死。AudioTrack buffer 大小用getMinBufferSize乘 2。如果还有杂音检查回调里拷贝 PCM 的字节数是不是numsamples * 2有的人写成了numsamples直接少拷一半数据播放出来的就是严重的噪声。5.5 不同 Android 版本与 CPU 架构的兼容性现象在开发机上一切正常发布测试包之后用户的机器上闪退崩溃信息指向System.loadLibrary。排查链路崩溃日志一般会写dlopen failed: library libespeak-ng.so not found或者unexpected e_machine。前一种常见于只放了 arm64-v8a 的 so但用户设备是老的 32 位系统加载不到对应 ABI后一种是把 x86 的 so 跑到 arm 设备上或者是 NDK 版本太老导致的 ELF 格式问题。根因JNI 库的 ABI 不匹配APK 里缺少目标设备的.so。解决方案至少同时编 arm64-v8a 和 armeabi-v7a 两个 ABI。如果你的测试机全是 arm64很难发现 32 位设备的兼容问题。另外用相对新的 NDK 版本重新编译一次老 NDK 编出来的 so 在部分 Android 14/15 的 ROM 上可能因为依赖了过时的 libc 符号而加载失败。关于开源合规与最后一点小建议前面提到 espeak-ng 是 GPLv3 许可证。如果你的 App 是个人项目、内部工具、或者开源项目直接集成没有问题但如果是商业闭源 App想要直接把 espeak-ng 的代码和你的业务代码揉在一个进程里发行就要认真评估 GPL 的传染性问题。比较稳妥的做法是把 TTS 能力做成一个独立的 Module 或独立 App通过进程隔离或系统 TTS 服务的方式与主应用解耦降低合规风险。我不是法务具体怎么操作还是要咨询专业人士但这个坑确实要提前知道别等产品上线了才着急。我在实际项目里使用这套方案已经大半年了启动速度快、内存稳定、不依赖网络也不需要维护任何账号体系确实符合离线开箱即用这个定位。如果你只想快速验证建议先做一个小 Demo把 so 文件和数据目录加进去用几百行代码把朗读功能跑通然后再考虑音频焦点、服务化封装、缓存优化这些进阶的事情。espeak-ng 的 API 设计得非常直白留给开发者自己发挥的空间很大熟悉之后你会觉得老牌开源库那种克制而高效的设计风格其实很有味道。
返回列表