ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android端侧语音交互实战:sherpa-onnx集成ASR与TTS全指南

Android端侧语音交互实战:sherpa-onnx集成ASR与TTS全指南

1. 端侧语音交互的现状与 sherpa-onnx 的切入点

把语音识别和语音合成塞进手机,这件事放在三五年前还属于“实验室里跑得动、真机上跑不动”的范畴。我最早接触端侧语音是在一个离线字幕工具的项目里,当时用某款云端 ASR 接口,网络一抖字幕就断,用户骂声一片。后来转向端侧方案,试过 TensorFlow Lite 的语音模型、也试过 PyTorch Mobile 的量化版本,踩了不少坑,直到遇到 sherpa-onnx,才算找到一个在 Android 上真正能落地的方案。

sherpa-onnx 是 k2-fsa 团队开源的一套端侧语音推理框架,底层基于 onnxruntime,支持流式和非流式 ASR、TTS、VAD、说话人分离等任务。它的核心价值在于:模型体积小、推理速度快、跨平台一致性好,而且提供了完整的 Android 预编译库和 Kotlin/Java API。换句话说,你不需要自己从 PyTorch 导出 ONNX、不需要手写 JNI 胶水层、不需要折腾 NDK 编译,直接拿 AAR 包就能在 Android 项目里跑起来。

这篇文章面向的是有一定 Android 开发基础、想在 App 里集成离线语音能力的开发者。不管你是想做语音输入法、离线字幕、语音助手,还是想做 TTS 朗读功能,sherpa-onnx 都能给你一条相对平滑的路径。我会从架构设计、模型选型、实操集成、性能调优、问题排查几个维度,把我在实际项目中积累的经验完整地分享出来。

2. 为什么选 sherpa-onnx 而不是其他端侧方案

2.1 端侧语音推理的三种主流路线对比

在决定用 sherpa-onnx 之前,我认真对比过市面上几条主流路线,这里把关键差异整理成表格,方便你根据自己的场景做判断。

方案底层引擎模型格式Android 支持流式 ASRTTS 支持集成难度
sherpa-onnxonnxruntimeONNX官方 AAR支持支持低
TensorFlow LiteTFLiteTFLite官方库需自行实现有限中
PyTorch MobilePyTorchTorchScript官方库需自行实现有限中高
云端 API服务端不涉及HTTP 调用支持支持低但依赖网络

从表格能看出来,sherpa-onnx 最大的优势是“开箱即用”——它把流式 ASR 和 TTS 这两块最难啃的骨头都封装好了,而且模型格式统一为 ONNX,社区里现成的模型也最多。TensorFlow Lite 和 PyTorch Mobile 虽然也是端侧方案,但流式推理的逻辑需要你自己写,TTS 的支持也不如 sherpa-onnx 成熟。

2.2 sherpa-onnx 的架构设计思路

sherpa-onnx 的架构可以分成三层来理解。最底层是 onnxruntime,负责实际的矩阵运算和算子执行;中间层是 sherpa-onnx 自己实现的语音处理逻辑,包括特征提取(fbank)、解码器(greedy search 或 beam search)、端点检测(VAD)等;最上层是各语言的 API 封装,Android 这边提供的是 Kotlin/Java 接口。

这种分层设计的好处是:模型和推理引擎解耦,你可以随时替换 ONNX 模型而不需要改代码;同时特征提取和解码逻辑用 C++ 实现,性能比纯 Java 实现高一个数量级。我在实测中对比过,同样的模型,sherpa-onnx 的 C++ 解码比用 Java 自己写的贪心解码快了将近 3 倍,尤其是在长语音场景下差距更明显。

2.3 模型选型的核心考量

sherpa-onnx 支持的 ASR 模型主要有几类:Paraformer(中文效果好)、Zipformer(中英混合强)、Whisper(多语言但体积大)、Conformer(通用)。TTS 模型则有 VITS、Matcha、Kokoro 等。选型时我一般看三个指标:模型体积、实时率(RTF)、识别准确率。

以中文场景为例,Paraformer 的小模型大概 70MB 左右,RTF 在骁龙 8 Gen 1 上能跑到 0.15 以下,也就是说 10 秒的语音 1.5 秒就能出结果。Zipformer 的中英混合模型大概 120MB,RTF 稍高但也在 0.3 以内。Whisper tiny 虽然只有 40MB,但中文识别准确率明显不如 Paraformer,而且不支持流式。所以如果你的场景是纯中文离线识别,Paraformer 是首选;如果需要中英混说,Zipformer 更合适。

注意:模型体积和准确率永远是一对矛盾。我见过有开发者为了追求小体积用了 Whisper tiny,结果中文识别错字率高达 15%,用户体验极差。建议在体积和准确率之间找平衡点,一般 70-150MB 是比较合理的区间。

3. Android 集成实操:从零到跑通第一个 Demo

3.1 环境准备与依赖引入

先说一下我的开发环境:Android Studio Hedgehog 以上、Gradle 8.x、minSdk 24、targetSdk 34、NDK 版本 25.2。sherpa-onnx 的 AAR 包对 NDK 版本有要求,太低会报链接错误,太高也可能有兼容问题,25.x 是比较稳的区间。

依赖引入有两种方式。第一种是直接从 Maven Central 拉取:

dependencies { implementation("com.k2fsa.sherpa.onnx:sherpa-onnx:1.10.0") }

第二种是手动下载 AAR 放到 libs 目录,适合需要定制编译或者内网环境的情况。我一般推荐第一种,版本管理方便,升级也简单。

引入依赖后,需要在build.gradle里加上 abiFilters,只保留 arm64-v8a 和 armeabi-v7a,这样能显著减小 APK 体积:

android { defaultConfig { ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") } } }

3.2 模型文件的放置与加载策略

模型文件不能放在 assets 里直接读,因为 onnxruntime 需要文件路径而不是 InputStream。我的做法是在 App 首次启动时把 assets 里的模型拷贝到filesDir下,之后直接从文件系统加载。拷贝逻辑要注意用后台线程,并且加一个版本标记,模型更新时重新拷贝。

fun copyModelIfNeeded(context: Context, modelName: String) { val targetDir = File(context.filesDir, "sherpa-models/$modelName") val versionFile = File(targetDir, "version.txt") val currentVersion = "1.0" if (versionFile.exists() && versionFile.readText() == currentVersion) return targetDir.mkdirs() context.assets.open("models/$modelName").use { input -> File(targetDir, "model.onnx").outputStream().use { output -> input.copyTo(output) } } versionFile.writeText(currentVersion) }

模型加载本身是同步的,但耗时可能到几百毫秒,建议放在 Application 启动时或者用懒加载。我实测 Paraformer 小模型加载大概 200ms,Zipformer 大概 400ms,TTS 的 VITS 模型大概 300ms。如果放在主线程会明显卡顿,一定要放到子线程。

3.3 流式 ASR 的完整调用流程

流式 ASR 的核心是“边录边识别”,需要把录音线程和识别线程分开。录音线程负责从 AudioRecord 读取 PCM 数据,识别线程负责把数据喂给 sherpa-onnx 的 stream 对象。

val config = OnlineRecognizerConfig( featConfig = FeatureConfig(sampleRate = 16000, featureDim = 80), modelConfig = OnlineModelConfig( transducer = OnlineTransducerModelConfig( encoder = "$modelDir/encoder.onnx", decoder = "$modelDir/decoder.onnx", joiner = "$modelDir/joiner.onnx" ), tokens = "$modelDir/tokens.txt", numThreads = 2, provider = "cpu" ), decodingMethod = "greedy_search" ) val recognizer = OnlineRecognizer(config) val stream = recognizer.createStream()

录音线程每次读到 1600 个采样点(100ms)就调用stream.acceptWaveform(samples, 16000),然后调用recognizer.isReady(stream)判断是否有可解码的帧,有的话就recognizer.decode(stream),再通过recognizer.getResult(stream)拿到当前识别结果。这套流程看起来简单,但有几个坑我踩过:一是采样率必须是 16000,录音时如果设备默认是 44100 需要重采样;二是 numThreads 不要设太大,2 线程在多数手机上已经够用,设 4 反而会因为线程调度开销导致延迟增加。

3.4 TTS 合成的参数调优

TTS 这边相对简单,核心是文本前端和声学模型的配合。sherpa-onnx 的 TTS 支持中英文混合,但需要模型本身支持。VITS 中文模型大概 60MB,合成一句话(10 个字左右)耗时在 200-400ms 之间。

val ttsConfig = OfflineTtsConfig( model = OfflineTtsModelConfig( vits = OfflineTtsVitsModelConfig( model = "$ttsModelDir/model.onnx", tokens = "$ttsModelDir/tokens.txt", lexicon = "$ttsModelDir/lexicon.txt" ), numThreads = 2 ), ruleFsts = "$ttsModelDir/rule.fst" ) val tts = OfflineTts(ttsConfig) val audio = tts.generate("你好,欢迎使用语音合成", sid = 0, speed = 1.0f)

speed 参数控制语速,1.0 是正常,0.8 偏慢,1.2 偏快。sid 是说话人 ID,多说话人模型才有意义。生成的 audio 是 FloatArray,需要转成 16bit PCM 再用 AudioTrack 播放。这里有个细节:AudioTrack 的 buffer size 要设够,太小会断音,我一般设成采样率的 0.5 秒对应的字节数。

4. 性能调优与真机适配经验

4.1 不同芯片平台的实测数据

我在几台不同芯片的手机上跑了同一套 Paraformer 流式模型,记录下 RTF 和首字延迟,数据如下:

芯片平台代表机型档位RTF首字延迟内存占用
骁龙 8 Gen 2旗舰0.08180ms180MB
骁龙 778G中端0.18320ms160MB
天玑 8100中高端0.12240ms170MB
骁龙 665入门0.45680ms140MB

从数据能看出来,旗舰机和中端机的差距主要在首字延迟上,RTF 差距反而不大。入门机(骁龙 665 这个级别)的 RTF 到了 0.45,意味着 10 秒语音要 4.5 秒才能出结果,体验就比较勉强了。所以如果你的 App 要覆盖低端机,建议用更小的模型,或者干脆只在中高端机上开启流式识别。

4.2 内存与线程的平衡策略

sherpa-onnx 默认用 2 线程,这个设置在中高端机上表现最好。我试过在骁龙 778G 上把 numThreads 调到 4,RTF 从 0.18 降到 0.15,但首字延迟反而从 320ms 涨到 380ms,原因是线程创建和同步的开销增加了。所以我的建议是:除非你的场景对吞吐量要求极高(比如批量处理录音文件),否则 numThreads 保持 2 就行。

内存方面,模型加载后会常驻内存,Paraformer 大概占 150-180MB。如果同时加载 ASR 和 TTS,内存占用会到 300MB 左右。在 4GB 内存的低端机上,这个占用已经比较危险了,可能触发系统杀后台。我的做法是 ASR 和 TTS 按需加载,用完就释放,虽然重新加载有几百毫秒开销,但能显著降低内存压力。

4.3 音频采集的坑与解决方案

Android 的 AudioRecord 在不同设备上行为不一致,这是端侧语音最头疼的问题之一。我遇到过几种典型情况:某些机型默认采样率是 48000 而不是 16000,需要手动重采样;某些机型在蓝牙耳机连接时采样率会变;还有的机型在录音时会有明显的底噪。

重采样我一般用线性插值,虽然音质不如 sinc 插值,但计算量小,对识别准确率影响可以忽略。底噪问题可以通过 VAD 过滤,sherpa-onnx 自带的 VAD 模型(silero-vad)效果不错,能在识别前把静音段切掉,既省算力又提高准确率。

提示:录音权限在 Android 6.0 以上需要动态申请,而且从 Android 11 开始,如果 App 在后台录音需要额外声明 foregroundServiceType。这些系统限制一定要提前处理,否则用户反馈“录不了音”你排查半天才发现是权限问题。

5. 常见问题排查与避坑指南

5.1 模型加载失败的几种典型原因

模型加载失败是最常见的问题,我整理了一个排查表:

报错信息可能原因解决方法
Failed to load model文件路径错误检查 filesDir 下文件是否存在
Invalid ONNX model模型文件损坏重新从官方仓库下载
Unsupported operatoronnxruntime 版本不匹配升级 sherpa-onnx 到最新版
Out of memory模型太大换小模型或降低 numThreads
Tokens file not foundtokens.txt 缺失确认模型目录包含所有文件

其中“Unsupported operator”这个坑我踩过最深。当时用了一个自己导出的 ONNX 模型,里面有个算子 onnxruntime 不支持,报错信息又很模糊,排查了一整天才发现是导出时用了 opset 17 而 onnxruntime 只支持到 15。后来我养成了一个习惯:导出模型后先用 onnxruntime 的 Python 版跑一遍,确认没问题再放到 Android 上。

5.2 识别结果异常的调试思路

识别结果异常一般分三种:完全没结果、结果乱码、结果延迟高。完全没结果通常是音频格式不对,检查采样率、声道数、位深是否匹配。结果乱码多半是 tokens.txt 和模型不匹配,或者模型本身是英文的却用来识别中文。结果延迟高则要看 RTF,如果 RTF 超过 0.5 就要考虑换模型或降 numThreads。

我一般会在调试阶段把每帧的识别结果打日志,观察是从第几帧开始出字的。正常情况下流式模型在 300-500ms 内应该出第一个字,如果超过 1 秒还没出,说明模型或配置有问题。

5.3 打包与混淆的注意事项

sherpa-onnx 的 AAR 里包含 native 库,混淆时要注意保留相关类。我在proguard-rules.pro里加了这几条:

-keep class com.k2fsa.sherpa.onnx.** { *; } -keep class ai.onnxruntime.** { *; }

另外,如果用了abiFilters只保留 arm64-v8a,在 32 位设备上会直接崩溃。虽然现在 32 位设备越来越少,但为了保险起见,建议同时保留 armeabi-v7a,APK 体积会增加大概 8MB,但兼容性更好。

6. 从 Demo 到产品的工程化建议

6.1 模型热更新的设计思路

模型文件不应该硬编码在 APK 里,否则每次换模型都要发版。我的做法是把模型放在 CDN 上,App 启动时检查版本号,有更新就下载到 filesDir。下载要用断点续传,模型文件动辄几十上百 MB,用户网络不稳定时很容易断。下载完成后要做 MD5 校验,防止文件损坏。

版本管理用一个 JSON 文件描述,包含模型名称、版本号、下载地址、MD5、大小等字段。App 端对比本地版本号和远端版本号,不一致就触发下载。这个机制我在实际项目里跑了半年多,更新过三次模型,用户侧无感知。

6.2 多场景下的资源调度

一个 App 里可能同时用到 ASR 和 TTS,甚至还有 VAD。如果全部常驻内存,低端机扛不住。我的策略是分优先级:VAD 常驻(模型小,才几 MB),ASR 按需加载(用户点录音按钮时加载,松开后延迟 30 秒释放),TTS 也是按需加载。这样峰值内存能控制在 250MB 以内,大多数设备都能接受。

释放模型时要注意调用recognizer.release()和tts.release(),否则 native 内存不会回收。我见过有开发者只置空引用,结果 native 内存泄漏,跑几次就 OOM 了。

6.3 用户体验层面的细节打磨

端侧语音和云端最大的区别是“没有网络等待”,所以用户体验的预期也更高。首字延迟超过 500ms 用户就会觉得卡,超过 1 秒就会觉得坏了。所以优化首字延迟比优化整体 RTF 更重要。我的做法是:录音开始后立刻喂数据,不要等缓冲区满;解码用 greedy search 而不是 beam search,虽然准确率略低但速度快很多;VAD 的静音阈值调低一点,让用户一开口就能触发识别。

另外,识别结果的展示也要注意。流式识别是逐字出结果的,如果每出一个字就刷新 UI,界面会闪得厉害。我一般用 100ms 的节流,每 100ms 更新一次显示,既流畅又不卡顿。

7. 我个人的一些实操体会

这套方案我在两个项目里落地过,一个是离线会议记录工具,一个是语音朗读助手。会议记录那个项目对准确率要求高,用的是 Zipformer 中英混合模型,120MB,在骁龙 870 上 RTF 0.15,体验很好。朗读助手对延迟敏感,用的是 Paraformer 小模型加 VITS,首字延迟控制在 300ms 以内,用户反馈“跟云端差不多”。

踩过的坑里,最深刻的是音频采集的兼容性问题。同一套代码在小米上正常,在华为上就没声音,排查后发现是华为某些机型默认采样率是 48000,而我没做重采样。后来加了一个采样率检测逻辑,如果设备默认不是 16000 就强制重采样,问题就解决了。所以我的建议是:不要假设设备行为一致,所有和硬件相关的参数都要做兼容处理。

还有一个经验是:模型不是越大越好。我一开始追求准确率用了大模型,结果低端机上卡得没法用。后来换成小模型,准确率只降了 2 个百分点,但流畅度提升了一倍。端侧场景下,流畅度往往比那一点点准确率更重要,毕竟用户能容忍偶尔错字,但容忍不了卡顿。

最后分享一个小技巧:如果你不确定该选哪个模型,可以先用 sherpa-onnx 官方提供的预编译 APK 跑一遍,里面内置了多个模型可以切换。实测哪个模型在你的目标机型上表现最好,再决定集成哪个。这样能省掉大量试错时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进