早些年做语音产品,总觉得离线识别和合成是稀罕事,直到真把 sherpa-onnx 跑在 Android 上,才意识到本地 ASR / TTS 的门槛可以压得非常低。用云端方案当然省事,但网络一抖就抓瞎,还会带来延迟和隐私问题。这阵子我重新把模型塞进手机里,基于 sherpa-onnx 做了一套完整流程,从 ASR 到 TTS 都离线可用,识别快、合成稳,彻底摆脱了服务器依赖。这篇博客就聊聊整个处理过程中最关键的工程决策和实操细节,包括模型怎么选、Android 工程怎么配、音频流怎么喂、推理结果怎么用,以及那些真机调试时才暴露出来的坑。适合准备给 App 增加离线语音能力的移动端开发,也对做边缘推理的语音工程师有参考价值。
1. 为什么值得把 ASR/TTS 模型搬到本机:三个绕不开的现实问题
1.1 云端语音方案的隐性成本
很多团队一开始都倾向于用云端 API,因为接入简单,识别率也漂亮。但只有当用户开始在高铁上、地下车库、弱网环境里使用你的功能时,问题才会集中爆发。丢包、延迟、超时都会让原本流畅的对话变得断断续续,用户录了一段话却迟迟得不到识别结果,体验非常糟糕。
云端的网络请求还会带来流量消耗。大部分语音识别 API 需要上传完整音频,短则几秒钟,长则一两分钟,按录音时长计费。如果产品里大量使用语音输入,这笔流量费会实实在在压在用户身上,而 TTS 功能如果要每次播放都从网络拉取合成音频,同样会产生重复流量和延迟等待。
隐私也是一个敏感点。在某些场景下,语音内容会涉及账号密码、身份证号、地址等个人信息,一旦用户意识到自己的录音要传到服务器做处理,心理门槛就会抬高,甚至直接放弃使用。把这些能力搬回本机,录音从头到尾不离开设备,是实打实的产品卖点,尤其适合健康、金融、会议记录等对数据敏感的场景。
1.2 sherpa-onnx 在边缘推理生态里的位置
有人会问,ONNX Runtime 本身就能跑模型,为什么还要引入 sherpa-onnx?答案在于所有语音相关的脏活累活都有人替你做了。语音识别不是丢一个音频进去就能出文本的简单过程,它至少包含特征提取、声学模型、解码器三个环节,还涉及流式处理、端点检测、标点恢复;语音合成同样需要处理音素、时长、音高和最终波形生成。直接用 ONNX Runtime,你得自己实现这些算法,还要处理不同模型格式之间的兼容问题。
sherpa-onnx 是一个专门面向移动端和嵌入式设备的语音推理工具包,它把上面这些模块统一封装成稳定的 API。你只需要配置好模型路径,调用一个识别或合成方法,剩下的采样率变换、特征提取、解码流程全部在内部处理。它同时支持 ASR 和 TTS,底层模型可以是多种类型的 ONNX 模型,换模型时不需要改业务代码,这在实际项目中省去大量调试时间。
它还有一个好处是运行时依赖干净。不需要额外安装 Python 环境,Android 端只引入一个动态库和几个 Java/Kotlin 接口,甚至纯 C 语言的项目也能直接用。对比某些需要捆绑数百兆底层框架的方案,sherpa-onnx 对 APK 体积的影响相对可控,更适合嵌入式形态的产品。
1.3 本地推理带来的产品形态变化
把模型塞进手机后,最大的感受是产品逻辑变得非常干净。过去一个语音按钮,需要先检查网络、获取 token、回调结果,每一步都可能出错;现在按下按钮,本地立刻开始监听麦克风,识别结果实时上屏,TTS 合成也能在几百毫秒内开始播音,所有流程都在一个进程里闭环。
这也让应用可以做成真正的离线工具。比如一个语音助手类 App,在没有网络的情况下依然可以完成语音唤醒、口令识别、播报回复,这在车载导航、户外运动、教育学习等场景非常有用。还能把 TTS 结果缓存下来,频繁重复播放的固定内容直接从缓存读取,连实时合成的时间都省掉。
值得一提的是,移动端设备算力这些年提升很快,哪怕只是中端手机,跑一个中等大小的语音模型也能在 CPU 上拿到不错的实时率。sherpa-onnx 本身就针对 ARM 平台做了不少性能优化,配合量化模型,基本能做到“实时率小于 1”,也就是处理一秒钟的音频耗时不到一秒,体验上接近云端水平。
2. Android 工程集成前的准备:依赖、模型文件与项目骨架
2.1 Gradle 依赖注入与最低版本要求
在动手写代码之前,先把工程环境理顺。我通常在 Android Studio 里新建一个原生项目,然后通过 Maven Central 引入 sherpa-onnx 的 Android 版依赖。核心依赖坐标是io.github.k2-fsa:sherpa-onnx,包含预编译的动态库和 Java 接口层。具体版本号可以在仓库页面查最新稳定版,建议选你项目里验证过的版本,不要每次启动都用 nightly。
build.gradle 里大概是这样:
dependencies { implementation 'io.github.k2-fsa:sherpa-onnx:1.x.x' }如果希望兼顾 TTS 和 ASR,这一款依赖已经覆盖。项目的最低 SDK 版本建议不低于 24,因为较旧版本在 JNI 库加载和音频接口上会有兼容性问题。目标 SDK 建议跟随当前主流版本,不需要额外开任何敏感权限,麦克风权限照常申请,但所有推理都在本地完成后,网络权限甚至可以不申请,这对隐私审核非常有利。
依赖包会自动把 so 文件打包到 APK。默认支持 arm64-v8a、armeabi-v7a 和 x86_64 三种架构。调试时建议优先用 arm64-v8a 的真机,x86_64 架构主要用于模拟器,但模拟器跑语音模型会比较吃力,性能数据也不具参考性。
2.2 模型文件下载和放置策略
模型是另一块重头戏。sherpa-onnx 社区提供了很多现成的 ONNX 模型,涵盖中文、英文、中英文混合等。不要一上来就下载最大的模型,先看自己实际场景是命令词识别、连续大段听写,还是多语种混合场景。
下载下来的模型通常是一整个二进制文件,后缀为.onnx,大小从几十 MB 到几百 MB 不等。TTS 模型还要区分声学模型和声码器,某些模型需要两个文件同时配置。把模型的路径填进配置之前,务必先确认模型支持的采样率,常见的是 16kHz,但部分 TTS 模型要求 22.05kHz 或 44.1kHz,格式不匹配时合成出来的音频会出现音调扭曲或明显噪声。
放置位置建议使用 Android 的 assets 目录,这样打包时比较简单,但是 APK 体积会显著增加。我习惯的做法是把模型编译成下载资源,在 App 首次启动时从服务端拉取并解压到私有目录(比如getFilesDir()下的models文件夹),避免每次升级都重新打包大文件。解压过程要放在后台线程,用ModelLoader管理进度,确保完成前不触发启动页跳转。
2.3 初始化配置类的核心参数
sherpa-onnx 的初始化核心是几个 config 类,分别对应特征提取、模型信息、解码器。以 ASR 为例,需要设置FeatureConfig中的采样率,通常为 16000;DecoderConfig中指定识别模型文件路径、tokens 文件路径,以及可选的热词文件。tokens 文件是普通文本,列出模型使用的全部字符标记,缺少这个文件时启动会直接报错。
还有一个容易忽略的参数是enable_endpoint_detection,开启后识别器会在检测到停顿后自动结束一句话,适合按句输出的交互场景。在流式识别中还要区分StreamingRecognizer和普通的OfflineRecognizer,前者适合边说边出结果,后者适合一次性喂入整段音频。初始化代码放在 Application 的onCreate()里做预加载,这样用户进入语音页时模型已经常驻内存,基本无等待。
3. ASR 实时识别:从麦克风数据流到上屏文本的完整链路
3.1 获取麦克风 PCM 数据并喂给识别器
Android 上标准做法是使用AudioRecord拿到原始 PCM 数据。配置采样率 16000、声道单声道、编码格式ENCODING_PCM_16BIT,创建成功后用一个循环线程不停读取音频缓冲区。
关键点在于读取到的字节数组必须转换成浮点数组再馈入 sherpa-onnx 的识别器。转换时把每两个字节合成一个 16 位整数,再除以 32768 归一化到[-1.0, 1.0]。我最初直接用字节流喂进去,识别结果全是乱码,就是因为跳过了归一化。这个步骤看似简单,却是整个链路里最容易翻车的地方。
代码骨架大致如下:
int bufferSize = AudioRecord.getMinBufferSize(16000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); audioRecord = new AudioRecord(MediaRecorder.AudioSource.VOICE_RECOGNITION, 16000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSize); short[] rawBuffer = new short[2048]; float[] floatBuffer = new float[2048]; // 循环读取并喂给 recognizer.acceptWaveform()AudioSource.VOICE_RECOGNITION经过系统预处理,对语音识别友好,但要提醒的是部分设备在用它时增益较低,如果官方测出的识别率偏低,可以换用MIC并自己加前置增益。
3.2 流式识别与结果回调
sherpa-onnx 的流式接口设计很像手写输入法:创建识别器独立实例,持续调用acceptWaveform()把音频片段加入流中,然后按你需要的时间间隔取出当前识别结果。我的策略是每 200ms 取一次临时结果,实时上屏;当端点检测触发或用户松手时,调用finalResult()拿到完整句子。
需要特别注意的是,识别器的所有操作必须在同一个线程上进行,不能在多个线程里并发调用同一个StreamingRecognizer。否则会出现内部状态错乱,结果忽多忽少。通常的做法是单独开一个音频线程,循环读麦克风、循环喂数据、在回调里通过 Handler 切换到主线程更新 UI。
回调里拿到的结果是一段文本,但实际接口还会提供一个时间戳信息。如果你做的是字幕或点读场景,可以利用这个信息精确定位每个字对齐的音频位置。不过默认配置下时间戳不一定开启,需要配置enable_timestamps并传入一个数组来接收。
3.3 半句识别与端点检测的联动
端点检测是提升交互感的重要环节。如果不开启它,用户录完一句话后系统会一直等后续音频,直到用户手动停止。开启后,识别器会分析到语音结束的静音段,自动终止当前句子并返回结果,整个交互就像按下对讲机松开自动发送一样。
静音阈值参数值得反复调。默认阈值在安静环境下不错,但如果在车上或嘈杂的户外,容易提前切断句子尾部。我通常把静音时长阈值从 1.2 秒提高到 1.8 秒,同时开启rule1、rule2、rule3三组组合规则,这样在噪音环境下也能较稳定地在完整句子后断句。调参时用录好的真实场景音频反复测试,不要依赖测试人员和你说“大概可以”。
4. TTS 离线合成:从文本到扬声器的完整链路
4.1 TTS 配置与模型初始化
TTS 部分同样由配置类驱动,核心是OfflineTtsConfig,里面需要指定声学模型路径和声码器路径。某些新模型把声学模型和声码器融在一个文件里,那么只需要配置一个模型路径;老式 VITS 类模型则通常直接生成最终音频,无需额外声码器。
合成之前还要设置说话人 ID。有的模型内置多个音色,比如在不同 ID 下生成不同性别的成人音或童音。即使是单音色模型,也要显式传入 ID 为 0,跳过默认值可能会触发边界异常。初始化 TTS 对象同样是重量级操作,建议放在启动阶段做完,不要每次进入页面时重新创建。
代码示例:
val ttsConfig = OfflineTtsConfig( model = OfflineTtsModelConfig( vits = OfflineTtsVitsModelConfig( model = "$modelDir/tts.onnx", tokens = "$modelDir/tokens.txt", speakerId = 0 ) ) ) val tts = OfflineTts(ttsConfig)4.2 合成过程的控制与长文本分段
synthesize()方法接受文本输入,返回一个音频采样数组。实际使用中不要一次性丢一大段文本进去,推理时间会成倍增长,而且某些模型对输入长度有上限,超出后报错或截断。把文本按标点切分成长度适当的句子,逐句合成,再按顺序播放,这是最稳妥的方案。
切分逻辑不能只按逗号和句号。有一次我按句子切分后,数字串被切成两半,合成结果把“2024年”读成了“二十零二十四年”。建议在分段上加上正则规则,比如连续数字中间不断开,地名和单位前后位置尽量保留在同一个片段里。虽然这些细节看起来琐碎,但直接决定合成音频听上去会不会像机器在“掰字”。
合成后的音频数组一般以 16 位 PCM 格式返回。播放前需要确定采样率是多少,通常与模型输出的采样率一致,用AudioTrack播放时设置同样的采样率和格式。如果播放出来音调不对,十有八九是采样率设置错了。
4.3 AudioTrack 播放与打断控制
播放 TTS 结果我推荐直接用AudioTrack,而不是MediaPlayer,因为它省掉写临时文件的步骤,直接把字节数组写成 PCM 流。初始化时设为MODE_STREAM,然后不断调用write()写入合成数据。合成是耗时操作,播放是异步操作,需要设计一个生产消费队列。
用户可能随时打断播放,比如语音助手说完话后用户立刻说了新的指令。这种情况下要停止合成线程,清空播放队列,再让AudioTrack执行pause()和flush()。如果不清理队列,新的语音会被旧内容覆盖或重叠播放,体验会变得一团糟。我踩过这个坑后,专门封装了一个TtsPlayer单例,统一管理播放任务、打断信号和释放资源。
还有一个细节:AudioTrack的输出声音默认是经过系统音效处理的,如果设置了会调,合成语音在下一句开始前容易出现爆音。建议在播放过程中调用AudioManager.requestAudioFocus申请短暂焦点,并关闭其它音效。实测下来声音干净很多。
5. 模型选择与压缩策略:不要只看体积,要看三段式指标
5.1 不同 ASR 模型的实测对比
模型选型在整个项目中影响最大,也最容易只凭直觉判断。社区里能跑的中文模型不少,比如基于 Paraformer 架构的中文小模型、基于 Zipformer 的通用模型、以及一些满足特殊需求的流式模型。它们的差异不仅体现在识别准确率上,还体现在实时率和内存占用上。
我自己的对比经验可以整理成一张表:
| 模型类型 | 体积范围 | 中英文支持 | 实时率(手机 CPU) | 适合场景 |
|---|---|---|---|---|
| Paraformer 小模型 | 50MB 左右 | 中文为主 | 0.5 左右 | 命令词、短句识别 |
| Zipformer 通用模型 | 150MB 以上 | 中英混合 | 0.8 左右 | 大段听写、语音记录 |
| 流式小模型 | 30MB 左右 | 特定领域 | 0.3 左右 | 实时按键对话 |
这里说的实时率是指处理一秒音频所需的时间,小于 1 意味着能跟上说话速度。实际项目中如果用户手机性能偏中低端,就算实时率超过 1.2,也还是勉强能用的,但会有越来越大的延迟积累,长时间说话以后结果明显落后。
5.2 量化模型和裁剪策略
ONNX 模型可以用量化工具压缩,常见的是 int8 动态量化。量化后的模型体积通常缩小 3 到 4 倍,推理速度也能提升一到两倍;代价是识别率会轻微下降,中文场景可能把相近读音的字搞得含糊不清。实际测试中,如果原本准确率在 94% 以上,量化后掉到 91% 左右,在非专业场景下基本可接受。
裁剪策略要克制。我见过有人把模型里所有的嵌入层和注意力头都减掉,结果识别率直接崩盘。比较稳妥的做法是只移除确定多余的输出分支,比如不使用标点恢复功能时,可以把对应的后处理分支关闭,或者把热词列表压缩到最精简。更有效的优化方向是调整解码器参数,比如把 beam 宽度从 5 减到 3,能省下不少计算量,直观感受不明显。
5.3 多语言混合需求的针对性处理
中英文混合识别是个深坑。早期我选用一个只训练过中文的模型,然后让系统识别少量英文,结果“OK”被识别成“哦克”,“Wi-Fi”变成“歪饭”。后来换了专门做过中英混合微调的模型,情况才好起来。选模型时别只盯着备注里的“支持中文”三个字,要看它是否明确提到“中英混说”或“code-switch”。
如果你只需要固定英文指令,更简单的办法是不换整套模型,而是使用热词表。把高频词组直接写入 hotwords 文件,并调高热词权重,识别器会把相近结果强行拉向热词。热词表的缺点是容易误伤,把“快手”写成热词后,可能把“快受”“快守”都拉过去,所以权重不能设得太离谱。
6. 真机调试中的高频问题与排查链路
6.1 识别结果全是乱码或空白,先从采样率查起
这是最高频的问题,没有之一。现象是麦克风录了好几个字节进识别器,最后返回的字符串却是空,或者偶尔冒出一个无法理解的乱码。排查链路要按顺序走:先确认配置里的采样率是 16000;再确认AudioRecord构造时采样率也是 16000;然后打日志,确认每次acceptWaveform()里浮点数组的最大绝对值在 0.1 到 0.9 之间。如果值长期低于 0.05,说明输入增益太小,模型直接听不见;如果值接近 1.0 且频繁削顶,说明有噪音干扰。
有一次我在自定义 ROM 的测试设备上遇到问题,格式化后录音文件用系统播放器听完全正常,但喂给模型就是空结果。最后发现是这个设备把AudioRecord底层的音频路由到了麦克风阵列的远场波束,导致原始 PCM 相位异常。解决办法是改采样源为UNPROCESSED,绕过设备的降噪和波束处理,识别率立刻恢复。
6.2 模型加载即闪退或 so 找不到
闪退问题常见于只打包了 arm64 而测试机是老旧的 armeabi-v7a 机型。你在 Android Studio 里看安装是真装上了,但一调用 JNI 就崩溃,logcat 提示library not found for xxx.so。解决办法是检查 APK 里lib目录是否包含目标架构的 so 文件,或者干脆在abiFilters里显式指定支持的架构。
还有一个被忽略的问题是压缩对齐。如果模型文件放在 assets 里,系统在加载时可能把它当成二进制资源直接映射,而某些模型文件数据超过 2GB 时需要注意分区问题。在实测中,发现某个 300MB 模型在低内存手机上加载时会被系统直接杀死,因为同时加载了 ASR 和 TTS 两个大模型。后来调整策略:ASR 模型初始化后先不预热,等用户进入输入页再加载;TTS 模型则延迟到第一次需要播报时初始化,错开峰值内存。
6.3 内存占用居高不下的排查
sherpa-onnx 模型加载后内存分成三块:模型本身占用的参数空间、内部特征缓冲、运行时临时张量。参数量化到 int8 后内存下降明显,但运行时临时张量依然可能几十兆。性能分析工具可以抓分配到 Java 堆之外的 native 内存,你会发现模型对象本身并不算在常规堆统计里。
如果App整体内存压力大,一个实用技巧是给识别过程设置“会话式释放”:当用户结束一次识别后,调用recognizer.free()释放当前流式的内部状态;经常使用的模型对象可以保留,但不再实际持有激活流,这样能把峰值内存降下来。释放操作要在音频线程停止之后再调用,避免刚释放又被写入数据导致崩溃。
6.4 UI 卡顿和回调线程纪律
识别结果回调发生在音频线程,绝对不能在回调里直接刷新TextView。每次识别出临时结果时,我用runOnUiThread或 Handler 把文本送到主线程,再更新界面。有一次我图省事直接在回调里调了TextView.setText,偶尔能出一次结果,但多几次后就会伴随 ANR,因为 Android 主线程被跨线程访问破坏。
TTS 播放线程同理。播放进度更新、结束回调都要统一切回主线程。这不是 sherpa-onnx 特有的问题,而是所有音视频处理项目里最常见的纪律问题。宁可多写两个 Handler,也不要让线程安全问题在下一次崩溃里等着你。
7. 从“能跑”到“好用”:进阶优化和可扩展方向
7.1 冷启动预加载与模型预热
模型加载时间通常是 TTS/ASR 页面最大的等待来源。一个简单的预热方案是在 Application 启动后切入后台线程,把核心模型实例创建出来,但不调用任何推理。预热还有一个隐藏作用:让操作系统提前分配足够连续内存,避免真正调用时发生明显 GC。实测中,预加载后进入语音页面速度从 1.5 秒左右降到 0.2 秒以内,体验完全是两个档位。
要注意的是,Application 级预加载会占用全局内存,如果App还有地图、视频等大内存模块,需要做好权衡。对于内存吃紧的设备,可以做一个开关,只有用户打开过语音页面才在后台缓存模型实例,下次再进直接命中。
7.2 把 ASR/TTS 封装成系统级能力
当模型在应用内稳定工作后,可以尝试进一步扩展。比如把 ASR 封装成一个前台服务,配合通知栏常驻,这样别的页面也能随时调用语音输入;也可以定义一个全局的 TTS 服务,统一管理朗读队列,让阅读类、消息播报类界面共享同一个语音引擎。
这种封装对代码结构要求更高,需要把模型实例、音频线程、播放控制器全部交给单例管理,同时处理好生命周期边界。我在项目中用MediaSession做播放控制,系统媒体面板可以直接暂停合成语音,用户锁屏后也能通过耳机键打断播报,体验接近系统级语音服务。
7.3 结合 VAD 与语义纠错的延展思路
sherpa-onnx 自带的基础能力足够做很多玩法,但要做成智能助理,还需要叠加自己的逻辑。加入 VAD 端点检测后,用户不需要一直按住说话按钮,语音助手可以像对讲机一样自动判断开始和结束,这个层级的体验已经相当接近专业语音产品。
识别结果上屏后,可以再接一层语义理解。本地跑一个小型语义分类模型,把转写文本映射到“打开音乐”“关闭屏幕”“设置提醒”等意图,再分发给不同的业务模块。整个过程全部离线完成,不需要把原始录音发出去,既快又保护隐私。这已经是从“塞进手机”到“变成手机能力”的进阶地图了,也是我认为未来所有语音应用都会走向的形态。