
做端侧语音识别和合成的人都知道跑通一个离线ASR/TTS系统最麻烦的不是模型训练而是把模型塞进手机里。既要考虑二进制体积、内存占用又得处理音频流、线程调度、权限、不同安卓机型的ABI兼容。sherpa-onnx 是我最近在 Android 项目里用的主力推理框架它基于 onnxruntime把语音识别和语音合成统一到一套 API 下离线就能跑。这篇文章我把从零接入、踩坑到调优的完整过程记录下来给想省时间的同行做个参考。sherpa-onnx 不是什么新概念包装它就是把常用的语音模型比如 streaming 的 paraformer、zipformer、conformer以及 TTS 的 vits、matcha 系列提前转成 onnx 格式再通过一层 JNI 封装暴露给 Android 调用。你不需要懂 onnxruntime 底层细节也不需要自己写特征提取只要准备好模型文件、知道几个关键 API就能在手机上实现不间断的实时识别和自然语音合成。适合做离线语音助手、会议录音转写、导航播报这类场景的开发者。1. 为什么偏偏是 sherpa-onnx离线语音落地的现实考量1.1 云方案和纯端侧方案的抉择早期语音功能基本都是走云端的手机采集音频上抛服务器识别完再把文本传回来。这种方案开发最快但有几个问题是绕不过去的第一网络的稳定性和延迟不可控地铁上、地下车库、电梯里直接哑火第二音频隐私数据全过服务器很多事情没法解释第三成本跟着并发走日活上来之后开支很可观。于是越来越多产品要求“端侧识别、端侧合成”数据不出设备。纯端侧的方案从技术栈上筛选下来无外乎两条路要么用各家平台的原生能力比如系统自带的离线语音优点是省事缺点是模型不透明、长度限制、自定义能力弱要么就是自己带一个推理引擎把开源模型跑起来。后者看着工程量大但好处是可控——模型、词典、热词、唤醒词全都捏在自己手里。sherpa-onnx 走的就是第二条路而且它把很多脏活累活都做了。1.2 它到底解决了什么问题用过一些其他端侧推理框架的人应该能体会有的框架只做 ASRTTS 还要另找一套有的框架对 Android 支持停留在“能用”水平封装粗糙调用起来要手动维护大量 native 层代码。sherpa-onnx 给我的直观感受是“封装得像个正经 SDK”——它有清晰的 JNI API支持流式和非流式识别TTS 侧支持多音色模型还附带音频播放逻辑。你只需要在 Kotlin/Java 层调用几个类就能跑通全流程。从架构上看它底层是 onnxruntime本身对 ARM 平台优化做得不错。同时它对模型做了归一化处理同样是中文识别你可以换 zipformer、paraformer 甚至 sense-voiceAPI 层几乎不用改。这种“模型可插拔”的特性在实际项目里太重要了——模型迭代和框架升级不再互相绑架。1.3 和 TFLite / MNN 对比时的取舍在选型阶段我其实也对比过 TFLite 和 MNN。TFLite 社区大、算子兼容好但语音模型经常涉及动态 shape、跳层解码TFLite 转换时得手动算子映射踩坑很痛。MNN 性能也好但它更偏向通用 CV 推理ASR 的流式解码和 TTS 的声码器部分没有现成配套。sherpa-onnx 不一样它把特征提取fbank、推理、解码、声码器整条链路都打通了而且直接给出流式识别接口省掉你研究“窗口移动怎么和模型输入对齐”的麻烦。当然它也有代价——框架本身是 C 写的集成后 APK 体积会明显增加特别是如果你带多个模型的话。所以后面章节我会专门讲怎么选模型、怎么裁剪尽量平衡体积和效果。2. Android 工程接入与模型准备别把时间花在依赖上2.1 Gradle 依赖和 ABI 配置接入第一步是把 SDK 放进项目。目前 sherpa-onnx 在 Android 上提供两种方式一种是直接把源码里的 android 模块导入工程另一种是引用预编译的 AAR 包。我强烈建议用 AAR省去自己编 CMake 的时间。在你的 app 模块 build.gradle 里加依赖就好注意不要只加主包要把对应的动态库打包进去。dependencies { // 这是模拟的结构实际版本号以仓库发布为准 implementation com.example.ltd:sherpa-onnx-android:1.10.0 }这里容易忽略的是 ABI 过滤。默认 Android 工程支持 armeabi-v7a、arm64-v8a、x86、x86_64如果你不做限制APK 会包含四套 so 文件体积直接爆炸。真实手机基本都用 arm64-v8a建议这样配置android { defaultConfig { ndk { abiFilters arm64-v8a } } }模拟器调试时再加 x86_64但发布包一定要过滤。还有一个细节如果你的工程里其他库也用了 soABI 不一致会导致运行时崩溃所以统一用 arm64-v8a 最省心。2.2 模型文件结构与下载管理sherpa-onnx 不直接读 .pt 或者 .ckpt它要的是转换好的 .onnx 文件通常配套一个 YAML 或者 JSON 的配置文件。以流式识别模型为例一般会看到这样几个文件encoder.onnx编码器把音频特征变成高维表示decoder.onnx解码器结合上下文输出 token 概率joiner.onnx只在前缀束解码中需要tokens.txt词表每个 token 对应一个 ID还有一个 metadata 文件记录采样率、特征维度等信息我自己习惯把模型统一放在 assets/models/ 目录下首次启动再复制到 app 私有目录。为什么不直接用 assets 路径因为 native 层读 assets 需要 Android AssetManagersherpa 虽然支持但效率不如直接读文件路径而且容易碰到中文文件名乱码问题。初期用 assets 直接读做验证没问题正式项目建议拷贝到 filesDir。拷贝逻辑不复杂但是要处理“首次启动复制、之后检查是否存在”的判断fun ensureModelFiles(context: Context): Boolean { val modelDir File(context.filesDir, models) if (!modelDir.exists()) modelDir.mkdirs() val assetsDir models val fileNames arrayOf(encoder.onnx, decoder.onnx, tokens.txt, config.yml) var allExist true for (name in fileNames) { val target File(modelDir, name) if (!target.exists()) { allExist false break } } if (allExist) return true // 从 assets 拷贝 context.assets.list(assetsDir)?.forEach { name - context.assets.open($assetsDir/$name).use { input - val output File(modelDir, name) output.outputStream().use { fileOut - input.copyTo(fileOut) } } } return true }这个函数的重点是检查逻辑要放在异步任务里因为几十 MB 的模型在低端机上复制可能要好几秒放在主线程就直接 ANR 了。3. ASR 流式识别代码实战从麦克风音频到文字输出3.1 初始化识别器与配置参数初始化是 ASR 最关键的一步很多奇怪的报错都从这里冒出来。sherpa-onnx 的 Android 接口中核心类是 OfflineRecognizer 和 StreamingRecognizer我们做实时识别用 StreamRecognizer 更合适不同版本类名可能有差异这里以实际 API 为准。初始化时要指定模型目录和推理设置。先看配置参数的例子val config sherpa.onnx.OfflineRecognizerConfig() config.model.encoder $modelDir/encoder.onnx config.model.decoder $modelDir/decoder.onnx config.model.tokens $modelDir/tokens.txt config.model.numThreads 2 config.feat.sampleRate 16000 config.feat.featureDim 80sampleRate 一般是 16000因为大部分训练数据都是 16k 采样。featureDim 看模型配置常用 80。有一个坑是 numThreads别一味追求大ASR 模型在手机上的推理瓶颈经常是内存带宽线程数设成 2 往往比 4 还稳尤其是实时流式场景线程太多反而增加调度开销。初始化完成后还要准备一个 AudioRecord 来抓麦克风 PCM 数据。这里采样率必须是 16000声道数 1位深 16bit这是模型训练时最通用的格式。如果拿到的音频直接是 44.1k/双声道先重采样转格式否则识别率会惨不忍睹。val minBuffer AudioRecord.getMinBufferSize( 16000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT ) val audioRecord AudioRecord( MediaRecorder.AudioSource.VOICE_RECOGNITION, 16000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, minBuffer * 2 )这里用 VOICE_RECOGNITION 而不是 MIC可以避免录音时的回声消除导致某些厂商特殊处理实测下来识别稳定性更好。3.2 流式处理的循环与实时解码流式识别不是“录完整个句子再识别”而是每隔几十毫秒往识别器里丢一段音频识别器内部累积特征并增量输出结果。核心循环长这样val stream recognizer.createStream() val buf ShortArray(1600) // 每包 100ms 音频 var isRecording true while (isRecording) { val read audioRecord.read(buf, 0, buf.size) if (read 0) { recognizer.acceptWaveform(stream, buf, read) while (recognizer.isReady(stream)) { recognizer.decode(stream) } val text recognizer.getResult(stream).text // 回调 UI 更新识别结果 runOnUiThread { resultTextView.text text } } }看起来简单实际执行中要处理几个问题。第一解码线程和录音线程不能混在一起。AudioRecord.read 是阻塞调用放在循环里没问题但是 getResult 和 decode 会占用 CPU如果和 UI 更新放一起低端机会肉眼可见地掉帧。我的做法是单独开一个 HandlerThread 专门跑这个循环UI 更新通过 post 回主线程。第二流式识别的结果可能是“部分结果”也就是句子中间会反复修改。所以要区分“最终结果”和“临时结果”一般用 isFinal 字段判断或者根据端点检测结果来切割。有些版本有 isFinal处理方式类似val result recognizer.getResult(stream) if (result.isFinal) { // 这句话已经好了追加到完整文本末尾 fullText.append(result.text) // 重新创建 stream 开始下一句 stream.reset() }第三要记得控制内存。如果长时间开着流不 reset中间状态的 token 序列会一直增长内存和耗时都会上涨。用户每说完一句话就应该 reset或者至少每隔一段时间 reset 一次。3.3 端点检测和静音过滤真实场景里用户说话不会像在安静录音室一样前后总有杂音、停顿。sherpa-onnx 支持 VAD 吗严格说它不是内置 VAD 模块但你可以通过音频能量判断来切分。一个简单有效的办法计算每帧音频的 RMS均方根低于阈值就认为是静音连续 N 帧静音就认为句子结束。var silentFrames 0 while (isRecording) { read audioRecord.read(buf, 0, buf.size) if (read 0) continue recognizer.acceptWaveform(stream, buf, read) recognizer.decode(stream) val rms calculateRms(buf, read) if (rms 300) { // 阈值需要按实际环境调 silentFrames if (silentFrames 30) { val result recognizer.getResult(stream) if (result.text.isNotBlank()) { onFinalResult(result.text) } stream.reset() silentFrames 0 } } else { silentFrames 0 } }这个阈值要靠真机调不同手机麦克风增益差距很大我在某款设备上阈值 300 合适换一台千元机就得 500 以上。更省心的方案是直接用摄像头或 VoiceInteraction 的系统 VAD但会额外增加权限复杂度。按我实际经验能做一层简单静音切分就够用了复杂的双阈值 VAD 留给场景特殊的团队。4. TTS 离线合成实战让手机开口说话4.1 模型选择与初始化TTS 部分的模型很多sherpa-onnx 支持 vits、matcha、kokoro 等多种。我以 vits 中文模型为例模型文件一般长这样model.onnx语音合成主模型输入文本 token 和音素时长等tokens.txt文本到 ID 的映射表也可能有 lexicon.txt用来做中文拼音转换初始化 TTS 比 ASR 简单核心类通常是 TextToSpeech构造时传入模型路径和配置val ttsConfig sherpa.onnx.OfflineTtsConfig() ttsConfig.model.vits $modelDir/tts/model.onnx ttsConfig.model.tokens $modelDir/tts/tokens.txt ttsConfig.model.lexicon $modelDir/tts/lexicon.txt ttsConfig.model.numThreads 1 // sampleRate 一般为 22050 或 24000看模型 ttsConfig.rule.fsmnVad true val tts sherpa.onnx.OfflineTts(ttsConfig)注意 TTS 的模型采样率通常不是 16k而是 22050 或 24000。你合成完成后得到的 PCM 采样率就是模型自身的采样率播放前要告知 AudioTrack。如果采样率设置错声音会整体变调——听起来就像开了倍速。4.2 长文本切分与合成管线TTS 接口本身简单调用一行代码就能拿到音频val result tts.speak(你好欢迎使用离线语音合成) val samples result.samples val audioTrack AudioTrack.Builder() .setAudioAttributes( AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build() ) .setAudioFormat( AudioFormat.Builder() .setSampleRate(tts.sampleRate) .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) .build() ) .setBufferSizeInBytes(samples.size * 2) .setTransferMode(AudioTrack.MODE_STATIC) .build() audioTrack.write(samples, 0, samples.size) audioTrack.play()但如果文本有几百个字直接这样合成会让主线程卡死几秒甚至十几秒。所以我建议把长文本拆分成句子逐句合成、逐句播放这样还可以做到“首句延迟低”的效果。切分点按中文标点。也可以按最大长度硬切。我实现过一个简单切分函数效果还算稳定fun splitText(text: String, maxLen: Int 50): ListString { val result mutableListOfString() val delimiters 。 var start 0 var lastDelimiter -1 for (i in text.indices) { if (text[i] in delimiters) lastDelimiter i if (i - start maxLen) { val cut if (lastDelimiter start) lastDelimiter 1 else i result.add(text.substring(start, cut).trim()) start cut lastDelimiter -1 } } if (start text.length) result.add(text.substring(start).trim()) return result.filter { it.isNotBlank() } }切分之后用一个队列按次序合成。合成放后台线程播放完上一段再合成下一段。这样整个长文的响应节奏很自然也不会有一次性合成占用大内存的问题。4.3 Audiobook 场景的语速/音调控制sherpa-onnx 的 TTS 接口通常支持 speed 参数常见的是 0.8~1.2。如果是做有声书或导航播报语速需要做轻微调整。我实测 speed1.0 在短提示音上没问题但长段落听起来偏快一般设成 0.9 左右更自然。音调控制不那么直接有些模型支持 pitch有些不支持。如果不是特别需要建议不要硬调。你更需要注意的是“文本归一化”——数字、英文、单位要先转成正常中文发音。比如“3.45GB”直接读会乱七八糟最好把它变成“三点四五吉字节”。这块可以塞进文本预处理但别太激进否则“8360”这类数字可能被转成“八千三百六十”而不是“八三六零”看场景决定。5. 模型选型与量化经验小模型也能出好效果5.1 怎么根据场景选 ASR 模型sherpa-onnx 官方仓库和社区里有多种中文模型我简单分成两类流式streaming和非流式。流式模型适合做“边说边出结果”的输入法、语音搜索延迟低非流式模型适合“录音完成后再转写”的会议纪要场景准确率通常更高。我一开始图省事直接用了非流式模型做实时识别结果发现每次要等用户停顿超过一定时间才能出完整结果交互体验很差。后来换成流式 zipformer 系列200ms 左右就能看到首字句子中间的单词也能增量显示体验完全不一样。但如果你的场景是电话录音转写那非流式模型更合适因为你可以把整段录音丢进去一次搞定。还要注意模型的词表大小和热词能力。有些模型自带 bigram能提升成语、专有名词的识别但体积也上去了。用之前可以在本地跑一批典型句子量化指标只作参考最终以主观试听为准。5.2 int8 量化与体积、速度权衡onnx 模型一般有 float32、float16、int8 三种精度。服务器可以用 float32手机端必须考虑体积。同一套 zipformer 模型float32 版本可能有 90MBfloat16 降到 45MBint8 可以到 23MB 左右。对 APK 安装包而言这差距是天壤之别。int8 量化会有准确率损失但语音模型在 int8 下的表现往往比图像模型乐观因为语音特征本身抗噪性比较强。我实测一个小型中英文混合模型float32 词错率 4.8%int8 大概 5.6%对绝大多数工具类应用完全能接受。真正要注意的是量化后某些算子不兼容比如某些 conformer 里的 attention 层在 onnxruntime 移动端可能跑不了你需要回到模型转换阶段查一遍算子支持清单。我自己的经验是开始不要直接下载预量化模型先用 float16 跑通全流程、确认精度满意再切 int8。如果 int8 有明显指标劣化优先检查 tokens.txt 是否匹配、有没有设置正确的 featureDim 和 sampleRate而不是立刻换回 float32。5.3 TTS 模型的体积取舍TTS 模型也不小单是一个 vits 模型就可能有 50MB 以上。为了减小体积可以选更小的声码器或使用多说话人合一的模型。sherpa-onnx 的 matcha 系列比 vits 更轻量但发音质量略有差距适合播报类不强调音色的场景。做导航语音、通知播报用 matcha 足够做有声书、客服机器人还是上 vits 大模型。另外TTS 模型不需要所有说话人音色全带你可以从模型转换阶段就去掉多余 speaker embedding只保留你需要的音色。这个操作通过修改模型导出脚本来做能砍掉不少参数但需要你对原 model 结构有一定了解。如果懒得折腾就按下一节的内存优化思路来缓存模型实例。6. 典型问题排查手册这些坑我替你踩过了6.1 加载 so 文件失败项目接入后一启动就报UnsatisfiedLinkError大概率是 ABI 不匹配。比如你只装了 arm64-v8a 的 so但测试机是 32 位应用模式自然找不到库。解决办法是确认设备的 CPU 架构在 build.gradle 里显式声明 abiFilters。也有时候是因为多个 module 混用不同 ABI导致 lib 目录被覆盖用 APK Analyzer 检查最终的 lib 目录结构最直接。也可以不开防混淆的keep规则sherpa-onnx 的 Java 类一般不会因为混淆出问题但保险起见建议在 proguard-rules.pro 里加上-keep class com.sherpa.onnx.** { *; }6.2 点击麦克风后识别结果一直是空的这个问题我排查过很多次最常见的根源是录音权限和音频源冲突。确认 AndroidManifest.xml 里加了RECORD_AUDIO权限并且运行时也动态申请了。第二检查代码里是否同时有别的模块在录音比如音乐播放器或者摄像头这会导致 AudioRecord 拿不到数据。第三检查工程里是不是有音频焦点抢占如果另一个应用抢走了音频焦点你的 AudioRecord 可能静默。如果都没问题在 AudioRecord 的 callback 里打印 read 的字节数如果一直是 0说明硬件层就没出数据。偶尔有手机需要重启才能恢复这属于系统固件问题代码上没解。6.3 TTS 合成出来了但播放没有声音先别急着怀疑 TTS 模型先确认 AudioTrack 的采样率、声道数、位深是否匹配。很多模型输出的是 mono PCM但 AudioTrack 设置成 stereo音量会变成双倍、也可能直接不出声。检查 sampleRate 是否用的是tts.sampleRate而不是写死 44100。另外AudioTrack 的 buffer size 太小也会丢数据写成 samples.size * 2 时要注意 samples 是 ShortArraysize 是 short 数量乘以 2 是对的别乘以 4。还有一种情况是静音检测把大量音频块当成静音忽略了。如果你自己做了静音判断在播放长文本时中途停顿超过阈值就会把后段文本丢掉。所以 TTS 的静音检测要和 ASR 分开TTS 只需要在整句结束后做切割。6.4 识别准确率突然下降这个现象很有迷惑性。模型没换、参数没动但准确率时高时低。我遇到过的原因是麦克风通路在某次电话、视频会议后没有正确恢复导致采集到的音频采样率错乱。你可以在每次恢复录音前重置 AudioRecord 的 AudioSource或者干脆在录音 session 结束时 release 掉重建。还有一种原因是手机开启了“语音增强”部分高版本系统默认给 VOICE_RECOGNITION 加 DSP 处理导致波形与训练数据分布不同。这种情况下可以改用 UNPROCESSED 音频源不过兼容性不一定好建议多机型测试。6.5 常见问题速查表为了方便排查我把高频问题整理成表格按出现概率排列现象可能原因快速处理UnsatisfiedLinkErrorABI 不匹配检查 abiFilters 和 APK lib 目录录不到音频权限未授予 / 音频焦点冲突检查权限、log 打印 read 返回值识别结果乱码tokens.txt 与模型不匹配重新下载整套模型文件识别延迟大numThreads 过高或模型非流式调小线程数改用流式模型TTS 没有声音AudioTrack 参数错误确认 sampleRate 与声道TTS 声音变调采样率不匹配用 tts.sampleRate 设置播放器应用卡死模型路径不存在或 IO 阻塞异步加载模型文件放置到 filesDir内存持续增长Stream 没 reset每句话结束后调用 reset7. 性能优化与后续扩展思路7.1 内存复用与单例模式ASR 和 TTS 的实例都是重量级对象内部有模型权重和中间状态。每次重新 new 一个实例会重复加载模型、申请内存非常伤。正确的做法是做成单例整个应用生命周期只初始化一次之后反复调用。我见过有人每次进页面就 new 一个 ASR退出页面不释放结果内存暴涨直接 OOM。至少要确保 onDestroy 里调用 release 接口。object VoiceEngine { private var recognizer: OfflineRecognizer? null private var tts: OfflineTts? null fun init(context: Context) { // 这里检查文件、创建实例全程线程安全只跑一次 } fun release() { recognizer?.release() tts?.release() recognizer null tts null } }如果你内存吃紧可以把 TTS 和 ASR 实例分开使用完 TTS 就释放使用前再懒加载。但从用户角度首次合成或识别前会卡一下体验不如常驻。7.2 低性能机型的降级策略不是每台手机都是旗舰尤其很多线下设备是五六年前的机子。针对这类设备我建议打一个“性能配置开关”。根据设备可用内存和 CPU 核心数决定加载 int8 还是 float16 模型以及解码线程数。比如内存小于 4GB用 int8 模型加单线程内存大于 6GB可以用 float16 加双线程。这个策略可以用一个小工具类来封装。另外在 UI 层面还要做降级识别过程中如果 CPU 占用率一直过高就减少 AudioRecord 每次读入的长度比如从 1600 改成 800相当于变相减小解码窗口。实测这样会稍微增加误识别但能保证界面不卡死。实时识别场景流畅度比 1% 的准确率重要得多。7.3 热词和自定义词典的接入思路sherpa-onnx 本身支持 hotwords 和 lexicon 定制。做导航播报的人应该会遇到地名识别难题比如“犄角旮旯”不加热词直接识别容易断错。你可以把常用地名、专有名词放进热词 list在初始化时传进去。不同版本 API 略有区别大概是这样config.hotwords 犄角旮旯\n某某小区 config.hotwordsScore 2.0实际效果相当于解码时对这些词做了偏置能明显提升特定场景的召回。注意热词别加太多否则模型会被带偏普通词反而识别不准。我一般控制在 100 条以内并且只放用户真正高频关注的词。7.4 结合系统能力的扩展方向端侧 ASR/TTS 跑通后可以往更多方向扩展。比如接入无障碍服务做全局字幕结合摄像头做多模态交互或者把识别结果和语义槽位结合做一个完全离线的语音助手。我最近在做一个项目把导航指令合成和语音识别组合起来用户直接说“去某某站”系统离线解析并播报整个链路完全不上网隐私和延迟都很有优势。还有一点sherpa-onnx 支持流式识别与文本到音频同步输出你可以基于流式结果做“边说边显示”这在 TTS 和字幕场景中都很有价值。如果你需要做麦克风阵列、噪声抑制则要再叠加前后端信号处理但核心的 ASR/TTS 链路已经稳了。我在实际集成中体会最深的是别把时间浪费在重新造轮子上。sherpa-onnx 把语音链路封装得相当完善你只要做好工程上的文件管理、音频采集和生命周期控制剩下的工作重心应该放在业务逻辑和交互体验上。比如识别结果如何上屏、TTS 播报如何打断再唤醒这些才是真正拉开体验差距的地方。如果你也有端侧语音落地的需求照着这篇文章的思路走先从最小的模型跑通 demo再根据真机表现调参数会比一上来就追求大模型稳得多。最后提醒一句模型文件要记得做版本管理最好连同配置参数一起打标签不然换模型后某天突然识别率下降你都不知道是哪次代码改动引起的。