1. 为什么要在手机本地跑语音模型
把语音识别和语音合成塞进手机,这件事放在三四年前,很多人的第一反应是"不现实"。云端API调用多方便,为什么要折腾端侧?但这两年情况变了。一方面,用户对隐私和响应速度的要求越来越高,录音上传到云端再等结果,那个延迟感在实时对话场景里是致命的;另一方面,端侧算力确实上来了,中端手机跑个量化后的小模型已经不怎么吃力。
我最初接触 sherpa-onnx 是因为一个离线字幕的需求。场景很简单:用户录一段视频,希望在本地生成字幕文件,不想联网,也不想把音频传到任何服务器。试过几个方案之后,sherpa-onnx 是少数能在 Android 上把 ASR 和 TTS 都跑通、而且部署相对干净的选择。它基于 onnxruntime,模型是 ONNX 格式,跨平台一致性很好,Android、iOS、桌面端用的是同一套推理后端,这对维护多端项目的人来说省心不少。
这篇文章面向的是有一定 Android 开发基础、想把语音能力落到端侧的开发者。如果你正在纠结"端侧语音到底能不能用""模型怎么选""Android 上怎么集成",下面这些内容应该能帮你少走一些弯路。我会从整体设计思路讲起,然后拆解模型选型、集成步骤、参数调优,最后把我在实际项目里踩过的坑整理出来。
需要先说明一点:sherpa-onnx 本身是一个推理框架和模型集合,它不负责帮你训练模型,也不提供现成的云端服务。你要做的是选一个合适的预训练模型,把它和 onnxruntime 一起打包进 APK,然后在 Android 层写好音频采集、推理调用和结果回调。听起来步骤不多,但每一步都有细节。
2. 整体方案设计与技术选型思路
2.1 端侧语音方案的核心矛盾
端侧跑语音模型,本质上是在三个维度上做权衡:模型精度、推理速度、包体积。这三个指标互相拉扯,你不可能同时把三个都拉满。
云端方案之所以精度高,是因为可以用几百兆甚至上G的大模型,算力无限。端侧不行,一个中端手机的可用内存和算力有限,APK 体积也不能太夸张。所以端侧方案的第一件事就是接受"精度会有损失",然后在这个前提下尽量把体验做好。
sherpa-onnx 的思路是用 ONNX Runtime 做推理引擎,模型经过量化和结构优化,把参数量压到几十兆甚至几兆的级别。它的 ASR 模型有流式和非流式两种,TTS 模型也有多种声学模型和声码器组合。选型的时候,你要先明确自己的场景是"实时"还是"离线整段"。
2.2 流式与非流式的选择逻辑
流式 ASR 的特点是边说边出结果,延迟低,适合实时字幕、语音助手这类场景。它的代价是模型结构更复杂,通常用 chunk-based 的编码器,精度会比非流式略低一点。非流式 ASR 是等你说完一整段再识别,精度更高,适合录音转写、会议纪要这种场景。
我个人的经验是:如果交互场景对延迟敏感(比如语音输入法、实时翻译),优先选流式模型;如果是"录完再处理"的场景,非流式模型更划算,精度和稳定性都更好。sherpa-onnx 里流式模型通常带streaming字样,非流式则没有,选模型的时候看文件名就能区分。
TTS 这边相对简单一些,主要看音色和推理速度。sherpa-onnx 支持的 TTS 模型有 VITS、Matcha 等,VITS 音质好但推理稍慢,Matcha 更快但音色选择少一些。如果你的场景是"读一段文字",对实时性要求不高,VITS 是更稳妥的选择。
2.3 为什么选 ONNX Runtime 而不是其他推理框架
Android 上能用的推理框架不少,TensorFlow Lite、NCNN、MNN 都有人用。sherpa-onnx 选 ONNX Runtime 有几个实际好处:
- 模型生态统一:ONNX 是模型交换格式,PyTorch、TensorFlow 训练的模型都能转过来,不用为每个框架单独适配。
- 算子覆盖全:语音模型里有些特殊算子(比如某些归一化层),ONNX Runtime 的支持比较完整,不容易遇到"算子不支持"的尴尬。
- 跨平台一致:同一份模型在 Android、iOS、Linux 上行为一致,调试的时候可以在桌面端先验证,再往手机上搬。
当然它也有代价,ONNX Runtime 的库体积不算小,Android 上大概会增加几兆到十几兆的 APK 体积,具体取决于你编译时保留的算子。这个后面在集成部分会细说。
2.4 整体架构分层
一个典型的端侧语音应用,架构上可以分成四层:
| 层级 | 职责 | 关键组件 |
|---|---|---|
| 音频采集层 | 麦克风采集、重采样、分帧 | AudioRecord、Oboe |
| 推理层 | 模型加载、前向计算 | ONNX Runtime、sherpa-onnx JNI |
| 业务逻辑层 | 结果拼接、标点、状态管理 | Kotlin/Java 业务代码 |
| 展示层 | UI 渲染、交互反馈 | Compose/View |
这个分层的好处是每层职责清晰,音频采集和推理解耦,方便单独调试。比如你怀疑是采集有问题,可以先把音频存成文件,用桌面端工具验证,排除推理层的问题。
3. 模型选型与资源准备
3.1 ASR 模型怎么挑
sherpa-onnx 官方提供了一批预训练模型,命名规则一般是语言-类型-数据集-参数量。比如sherpa-onnx-streaming-zipformer-bilingual-zh-en就是流式、Zipformer 结构、中英双语的模型。
选模型的时候重点看这几个参数:
- 参数量:参数量越大精度越高,但推理越慢、体积越大。中端手机建议选 20M 以下的模型,旗舰机可以上到 50M 左右。
- 是否流式:前面说过,看场景。
- 语言覆盖:单语模型通常比多语模型精度高,如果你的场景只用中文,别选双语模型。
- 量化方式:int8 量化能把体积压到 fp32 的四分之一左右,精度损失通常在可接受范围内。
我实测下来,一个 30M 左右的 int8 量化流式模型,在中端骁龙 7 系芯片上,单帧推理大概在 20-40ms,基本能满足实时字幕的需求。如果换成 fp32,推理时间会翻倍,中端机就有点吃力了。
3.2 TTS 模型的组成
TTS 比 ASR 复杂一点,它通常由两部分组成:声学模型和声码器。声学模型负责把文本转成声学特征(比如梅尔频谱),声码器负责把特征转成波形。
sherpa-onnx 的 TTS 模型包里一般会包含这两个文件,外加一个tokens.txt和lexicon.txt。选型的时候要注意:
- 声学模型和声码器要配套,不能混用。
- 中文模型需要 lexicon 或者前端文本处理,英文模型通常用字符级 token。
- 有些模型支持多说话人,文件里会有 speaker 相关的配置。
3.3 模型文件的组织方式
把模型放进 Android 项目,有两种常见做法:
- 放 assets 目录:打包进 APK,首次启动时拷贝到内部存储。优点是分发简单,缺点是 APK 体积大。
- 首次启动下载:APK 里只放一个空壳,模型从服务器下载。优点是 APK 小,缺点是需要网络,且要处理下载失败的情况。
我一般推荐第一种,因为端侧语音的核心卖点就是"离线可用",如果首次启动还要联网下载模型,体验就打了折扣。如果模型实在太大,可以考虑把 ASR 和 TTS 拆成两个可选模块,用户按需下载。
模型文件在 assets 里的目录结构建议这样组织:
assets/ models/ asr/ encoder.onnx decoder.onnx joiner.onnx tokens.txt tts/ model.onnx vocoder.onnx tokens.txt lexicon.txt这样结构清晰,加载的时候按目录读取就行。
4. Android 集成实操步骤
4.1 引入 sherpa-onnx 的 Android 库
sherpa-onnx 提供了预编译的 Android AAR,可以直接通过 Maven 引入。如果你用的是较新的 Gradle,在build.gradle.kts里加:
dependencies { implementation("com.k2fsa.sherpa.onnx:sherpa-onnx:1.x.x") }版本号以官方发布为准。如果预编译库不满足需求(比如你要裁剪算子减小体积),可以自己从源码编译。自己编译的话需要 Android NDK 和 CMake,流程稍微长一点,但能精确控制保留哪些算子。
注意:自己编译时,ONNX Runtime 的算子裁剪要小心。语音模型里常用的 LSTM、GRU、LayerNorm 这些算子如果被裁掉,模型加载会直接报错。建议先用完整算子集跑通,再逐步裁剪。
4.2 音频采集的正确姿势
Android 上采集音频,AudioRecord是最基础的选择。关键参数是采样率、声道数和位深。sherpa-onnx 的模型通常要求16kHz、单声道、16bit的 PCM 数据,所以采集的时候最好直接按这个格式来,避免后面重采样。
val sampleRate = 16000 val channelConfig = AudioFormat.CHANNEL_IN_MONO val audioFormat = AudioFormat.ENCODING_PCM_16BIT val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) val audioRecord = AudioRecord( MediaRecorder.AudioSource.MIC, sampleRate, channelConfig, audioFormat, bufferSize * 2 )bufferSize建议取最小值的两倍,避免采集过程中出现 underrun。采集线程里用read()把数据读到一个 short 数组,然后喂给推理接口。
这里有个容易忽略的点:麦克风采集的音频通常有直流偏移和底噪,直接喂给模型会影响识别率。建议在采集后加一个简单的高通滤波,去掉低频噪声。如果不想自己写滤波,可以在模型输入端做归一化,效果也还行。
4.3 模型加载与推理调用
sherpa-onnx 的 Android 接口是 JNI 封装的,调用方式大致是这样:
val config = OnlineRecognizerConfig( featConfig = FeatureConfig(sampleRate = 16000, featureDim = 80), modelConfig = OnlineModelConfig( transducer = OnlineTransducerModelConfig( encoder = "models/asr/encoder.onnx", decoder = "models/asr/decoder.onnx", joiner = "models/asr/joiner.onnx" ), tokens = "models/asr/tokens.txt", numThreads = 2, provider = "cpu" ), decodingMethod = "greedy_search" ) val recognizer = OnlineRecognizer(config) val stream = recognizer.createStream() // 采集循环里 stream.acceptWaveform(samples, sampleRate) while (recognizer.isReady(stream)) { recognizer.decode(stream) } val result = recognizer.getResult(stream)numThreads建议设成 2,设太多反而会因为线程调度开销导致延迟增加。provider在 Android 上一般用cpu,除非你的设备支持 NNAPI 且模型能完整跑在 NPU 上,否则用 CPU 更稳。
4.4 TTS 的调用流程
TTS 的调用比 ASR 简单,输入文本,输出音频:
val ttsConfig = OfflineTtsConfig( model = OfflineTtsModelConfig( vits = OfflineTtsVitsModelConfig( model = "models/tts/model.onnx", lexicon = "models/tts/lexicon.txt", tokens = "models/tts/tokens.txt" ), numThreads = 2 ) ) val tts = OfflineTts(ttsConfig) val audio = tts.generate("你好,这是一段测试文本", speed = 1.0f)generate返回的是 float 数组,采样率通常是 22050 或 16000,具体看模型配置。播放的时候用AudioTrack就行,注意采样率要和模型输出一致,否则声音会变调。
4.5 线程模型与内存管理
端侧推理一定要放在独立线程里,不能阻塞主线程。我的做法是开一个HandlerThread专门做推理,采集线程把音频数据通过队列传给它,推理结果再通过回调发回主线程更新 UI。
内存管理方面,ONNX Runtime 的 session 创建比较耗时,不要每次推理都新建。正确的做法是应用启动时创建一次,全局复用。stream 对象可以每次会话新建,用完记得释放,否则会内存泄漏。
提示:sherpa-onnx 的 JNI 对象需要手动释放,Kotlin 里可以用
use {}或者try-finally确保释放。我见过不少项目因为忘记释放 stream,跑久了就 OOM。
5. 参数调优与性能优化
5.1 影响识别率的关键参数
ASR 的识别率不只取决于模型,特征提取的参数也很关键。sherpa-onnx 的FeatureConfig里有两个参数要特别注意:
- sampleRate:必须和模型训练时一致,通常是 16000。如果采集是 44100,要先重采样。
- featureDim:通常是 80,表示梅尔滤波器的数量。这个值要和模型匹配,改错了模型直接报错。
解码方法上,greedy_search最快但精度一般,modified_beam_search精度更高但慢一些。实时场景用 greedy,离线转写可以用 beam search。
5.2 延迟优化的几个手段
延迟是端侧语音最敏感的指标。我总结下来,优化手段主要有这几个:
- 减小 chunk 大小:流式模型的 chunk 越小,出结果越快,但精度会略降。一般 320ms 的 chunk 是延迟和精度的平衡点。
- 降低采样率:如果模型支持 8kHz,用 8kHz 采集能减少一半的数据量,推理更快。但 8kHz 音质差,识别率也会降。
- int8 量化:量化后推理速度通常能提升 30%-50%,精度损失在 1%-3% 左右。
- 多线程:
numThreads设成 2 通常最优,设成 4 在小核上反而更慢。
5.3 包体积控制
APK 体积是很多团队关心的指标。sherpa-onnx 加上模型,很容易让 APK 增加几十兆。控制体积的办法:
- 模型量化:int8 量化能把模型体积压到四分之一。
- 算子裁剪:自己编译 ONNX Runtime,只保留用到的算子。
- ABI 过滤:只保留
arm64-v8a,去掉armeabi-v7a和x86,能省不少体积。现在主流手机都是 arm64,兼容性影响不大。 - 模型按需下载:如果实在太大,把 TTS 模型做成可选下载。
我做过一个对比,一个包含流式 ASR 和 VITS TTS 的 APK,不做任何优化大概 80MB,做了 int8 量化和 ABI 过滤后能压到 35MB 左右。
5.4 功耗与发热
端侧推理是耗电大户,长时间跑语音识别,手机发热明显。优化思路:
- 按需推理:不要一直开着麦克风,用 VAD(语音活动检测)判断有没有人说话,没人说话就暂停推理。
- 降低推理频率:流式模型不需要每帧都推理,可以隔几帧跑一次。
- 动态调整线程数:检测到设备温度高时,降低
numThreads。
VAD 这块 sherpa-onnx 也提供了模型,可以配合使用。实测下来,加上 VAD 之后,待机功耗能降一半以上。
6. 常见问题与排查实录
6.1 模型加载失败
这是最常见的问题,报错信息通常是Failed to load model或者Invalid model file。排查顺序:
- 检查模型文件是否完整拷贝到了内部存储,assets 里的文件不能直接用路径访问,要先拷贝出来。
- 检查模型路径是否正确,Android 的文件路径和桌面端不一样。
- 检查模型是否和推理库版本匹配,不同版本的 sherpa-onnx 对模型格式要求可能不同。
我踩过的坑:assets 里的模型文件如果超过 1MB,用
AssetManager.open()读取时可能被压缩,导致 ONNX Runtime 解析失败。解决办法是在build.gradle里加androidResources { noCompress += listOf("onnx") },禁止压缩 onnx 文件。
6.2 识别结果乱码或为空
如果模型加载成功但识别结果是乱码,通常是 tokens 文件不匹配。tokens 文件定义了模型输出的索引到字符的映射,用错了模型就输出乱码。
如果结果为空,检查音频数据是否正常。可以把采集的音频存成 wav 文件,用桌面端工具播放确认。常见原因是采样率不对或者音频全是静音。
6.3 推理速度慢
推理慢的原因可能有很多,按这个顺序排查:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 首次推理慢 | 模型首次加载、JIT 编译 | 预热一次,忽略首次结果 |
| 持续慢 | 线程数设置不当 | 调整为 2 |
| 特定设备慢 | CPU 架构差异 | 检查是否走了 arm64 |
| 越来越慢 | 内存泄漏 | 检查 stream 是否释放 |
6.4 TTS 声音异常
TTS 输出声音异常,常见的有几种:
- 变调:采样率不匹配,检查播放时的采样率和模型输出是否一致。
- 断断续续:AudioTrack 缓冲区太小,增大 buffer size。
- 杂音:声码器输出有截断,检查音频数据是否做了归一化。
6.5 常见问题速查表
| 问题 | 排查方向 | 快速验证 |
|---|---|---|
| 模型加载失败 | 文件完整性、路径、版本 | 用桌面端加载同一模型 |
| 识别乱码 | tokens 文件 | 检查 tokens 和模型是否配套 |
| 识别为空 | 音频数据 | 存 wav 文件播放确认 |
| 推理慢 | 线程数、量化、ABI | 对比不同配置的耗时 |
| 声音异常 | 采样率、缓冲区 | 检查 AudioTrack 配置 |
| 内存泄漏 | stream 释放 | 用 Profiler 观察内存曲线 |
7. 一些实操心得
做端侧语音这段时间,有几个体会比较深。
第一,不要一上来就追求大模型。我一开始选了个 100M 的模型,精度确实好,但中端机跑起来卡顿明显,用户体验反而差。后来换成 30M 的量化模型,精度降了一点点,但流畅度上来了,整体体验更好。端侧场景下,"够用且流畅"比"极致精度"更重要。
第二,音频采集的质量决定上限。模型再好,如果采集的音频有噪声、有削波,识别率也上不去。花点时间在采集端做滤波和增益控制,收益比换模型大。
第三,测试要覆盖真实场景。实验室里安静环境跑得好,不代表在地铁、商场里也能用。我建议在开发阶段就录一批真实场景的音频做测试集,别等到上线才发现问题。
第四,VAD 是省电的关键。没有 VAD 的语音应用,麦克风一直开着,电量掉得飞快。加上 VAD 之后,只有检测到人声才启动推理,功耗能降一大截。
第五,模型更新要有机制。端侧模型打包在 APK 里,更新起来麻烦。建议设计一个模型版本管理机制,允许通过下载更新模型文件,这样后续优化模型不用重新发版。
最后分享一个小技巧:调试 ASR 的时候,可以把采集的音频和识别结果同时打日志,格式化成音频时间戳 -> 识别文本,这样能直观看到哪段音频识别错了,方便定位是采集问题还是模型问题。这个日志在排查问题时特别有用。