1. 为什么要在手机端跑语音模型
把语音识别和语音合成塞进手机,这件事放在三五年前,很多人的第一反应是"不现实"。云端API调用多省事,为什么要折腾端侧推理?但这两年情况变了。一方面,用户对隐私和响应速度的要求越来越高,录音上传到服务器再返回结果,这个链路天然带着延迟和信任成本;另一方面,端侧算力确实上来了,中端手机跑个量化后的语音模型已经不算吃力。
我这次做的项目,核心就是用sherpa-onnx这个推理框架,在 Android 上同时落地ASR(语音识别)和TTS(语音合成)两个能力。选它不是因为跟风,而是几个实际因素叠加的结果:它基于 ONNX Runtime,跨平台一致性好不容易出玄学问题;它自带完整的 Android 示例工程,省去了从零搭 JNI 桥接的痛苦;最关键的是,它把流式识别、非流式识别、VITS 系列 TTS 都封装成了相对统一的接口,模型换起来成本低。
这篇文章适合谁看?如果你是想在 App 里加语音功能但不想依赖云服务的 Android 开发者,或者你已经在用别的方案但被延迟、离线、包体积这些问题困扰,再或者你只是好奇端侧语音到底能做到什么程度,那接下来的内容应该对你有用。我会从整体设计思路讲到具体的模型选型、JNI 调用、参数调优,再到实际踩过的坑,尽量把每个决策背后的"为什么"说清楚。
需要提前说明的是,端侧语音不是银弹。它的识别准确率在嘈杂环境下确实不如云端大模型,TTS 的音色丰富度也有上限。但如果你追求的是"离线可用、响应快、数据不出设备",那这套方案目前是相当成熟的选择。
2. 整体方案设计与技术选型拆解
2.1 为什么是 sherpa-onnx 而不是其他方案
端侧语音推理这条路,可选的框架其实不少。我大致对比过几类:一类是各手机厂商自带的系统级语音服务,优点是集成简单,缺点是绑定平台、定制性差、很多能力不对外开放;一类是 TensorFlow Lite 生态里的语音模型,优点是 TFLite 在 Android 上部署成熟,缺点是语音相关的现成模型和工具链不如 ONNX 生态丰富;还有一类就是 sherpa-onnx 这种专门为语音任务打磨的推理框架。
sherpa-onnx 的几个关键优势,我在选型时是这么权衡的:
- 模型生态完整:它背后关联的项目提供了大量预训练模型,ASR 有 Zipformer、Paraformer、Whisper 等多种架构,TTS 有 VITS、Matcha 等,而且都转好了 ONNX 格式,拿来就能用。这一点非常重要,因为自己转模型格式往往是最耗时的环节。
- 流式支持到位:ASR 分流式和非流式,流式意味着可以边说边出字,体验上接近商业输入法。sherpa-onnx 对流式识别的支持是原生级别的,不用自己造轮子。
- 跨平台一致:同一套模型和 API,Android、iOS、桌面端都能跑,后续如果要扩展到其他端,迁移成本低。
- 纯离线:整个推理过程不依赖网络,这对隐私敏感场景是硬需求。
当然它也有代价。包体积会因为模型文件增大,首次加载模型有几百毫秒的耗时,这些后面会具体讲怎么优化。
2.2 ASR 和 TTS 的技术路线选择
ASR 这块,我最终选的是流式 Zipformer模型。原因很直接:我的使用场景需要实时反馈,用户说话时希望看到文字逐步浮现,而不是说完等一秒钟才出结果。流式模型的特点是它维护了一个解码状态,每次输入一小段音频就能输出对应的部分结果,延迟可以压到几百毫秒以内。
非流式模型(比如 Whisper)的优势是准确率通常更高,尤其是长句和复杂语境,但它需要拿到完整音频才能开始识别。如果你的场景是"录完一段话再转文字",那非流式更合适。我这里两个都集成了,让用户或业务层根据场景切换。
TTS 这块,我选的是VITS系列的模型。VITS 是端到端方案,输入文本直接输出音频波形,中间不需要单独的声学模型和声码器,部署链路短。它的音质在端侧模型里属于第一梯队,自然度不错,而且 sherpa-onnx 对它的支持很成熟。另一个考虑是 VITS 模型可以做到比较小的体积,量化后几十兆,对 App 包体积友好。
提示:模型选型没有绝对优劣,关键看场景。实时对话选流式 ASR,录音转写选非流式,追求音质选大模型,追求包体积选量化小模型。先明确场景再选模型,不要反过来。
2.3 整体架构分层
整个方案在 Android 上的架构,我分成了四层,从下往上依次是:
- 模型层:ONNX 模型文件 + tokens 词表文件,放在 assets 或外部存储,运行时加载。
- 推理层:sherpa-onnx 的 C++ 核心库,编译成
.so动态库,通过 JNI 暴露接口。 - JNI 封装层:Java/Kotlin 侧的封装类,管理模型生命周期、音频数据传递、结果回调。
- 业务层:App 的具体逻辑,比如录音、播放、UI 展示。
这样分层的好处是职责清晰。模型换了只动模型层,推理库升级只动推理层,业务逻辑基本不受影响。我见过一些项目把 JNI 调用和业务逻辑混在一起写,后期维护非常痛苦,强烈建议一开始就分好层。
3. 核心细节解析与实操要点
3.1 模型文件的准备与放置策略
模型文件怎么放,这个看似简单的问题其实有讲究。sherpa-onnx 的模型通常包含几个部分:ONNX 模型文件(.onnx)、词表文件(tokens.txt)、有时还有配置文件。ASR 流式模型一般是一个 encoder 加一个 decoder,TTS 则是单个模型加词表。
放置位置有两个选择:assets 目录或外部存储。
放 assets 的好处是打包进 APK,用户装完就能用,不依赖网络下载。坏处是 assets 里的文件不能直接用文件路径访问,需要先拷贝到应用私有目录。而且模型文件如果比较大,APK 体积会明显增加。
放外部存储(比如应用私有目录或 SD 卡)的好处是 APK 体积小,模型可以按需下载。坏处是需要处理下载、校验、版本管理这些额外逻辑。
我的做法是混合策略:小模型(比如轻量 TTS)直接放 assets,保证开箱即用;大模型(比如高精度 ASR)首次启动时从服务器下载到私有目录。这样既控制了初始包体积,又保证了功能完整。
拷贝 assets 到私有目录的代码大概是这样:
fun copyAssetFile(context: Context, assetPath: String, destFile: File) { if (destFile.exists() && destFile.length() > 0) return context.assets.open(assetPath).use { input -> FileOutputStream(destFile).use { output -> input.copyTo(output) } } }注意:拷贝大文件时一定要放在子线程,并且加个进度回调,否则用户会以为 App 卡死了。另外拷贝完成后建议做一次文件大小或哈希校验,避免拷贝中断导致模型损坏。
3.2 JNI 接口的封装与生命周期管理
sherpa-onnx 的 C++ 接口通过 JNI 暴露给 Java 层。核心的类通常有OnlineRecognizer(流式识别)、OfflineRecognizer(非流式识别)、OfflineTts(语音合成)等。每个类在 native 层都持有一个指针,Java 层需要负责创建和释放。
这里最容易出问题的是生命周期管理。native 对象不受 Java 垃圾回收管理,如果你创建了 recognizer 但忘记释放,就会内存泄漏。我的做法是在封装类里实现AutoCloseable接口,用use块或者显式close()来管理。
class AsrEngine(private val config: AsrConfig) : AutoCloseable { private var recognizerPtr: Long = 0 init { recognizerPtr = nativeCreateRecognizer(config) if (recognizerPtr == 0L) { throw IllegalStateException("Failed to create recognizer") } } override fun close() { if (recognizerPtr != 0L) { nativeDestroyRecognizer(recognizerPtr) recognizerPtr = 0 } } }另一个要点是线程安全。native 的 recognizer 通常不是线程安全的,多个线程同时调用会崩溃。我的做法是给每个引擎实例配一个单线程的 executor,所有识别请求都排队执行。这样既保证了安全,又避免了频繁创建销毁对象的开销。
3.3 音频数据的采集与格式转换
ASR 对输入音频有明确的格式要求,通常是16kHz 采样率、单声道、16-bit PCM。而 Android 的AudioRecord默认可能给你 44.1kHz 或 48kHz 的立体声数据,所以中间需要做重采样和声道转换。
采集这块,我用的是AudioRecord,配置如下:
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我取了最小值的两倍,是为了避免缓冲区溢出导致丢帧。AudioSource选MIC是通用选择,如果场景是近距离通话,VOICE_RECOGNITION可能会更好,它会做一些针对语音的预处理。
如果采样率不是 16kHz,就需要重采样。重采样算法有简单的线性插值,也有质量更高的多相滤波。端侧为了性能,一般用线性插值就够了,语音识别对这点精度损失不敏感。不过如果你的设备支持直接以 16kHz 采集,那就省了这一步,优先用设备原生支持的采样率。
流式识别时,音频数据是分块喂给 recognizer 的。块大小我一般取 100ms 左右,也就是 1600 个采样点。太小会增加调用开销,太大则延迟变高。
3.4 TTS 的文本预处理与音频播放
TTS 这块,输入是文本,输出是 PCM 音频。sherpa-onnx 的 TTS 接口通常返回一个浮点数组,表示音频波形,采样率由模型决定(VITS 常见的是 22050Hz 或 16000Hz)。
文本预处理是个容易被忽视的环节。中文 TTS 需要处理多音字、数字、英文混读等问题。sherpa-onnx 的模型本身对文本有一定处理能力,但如果你传入的是"2024年",它可能读成"二零二四"也可能读成"两千零二十四",取决于模型训练数据。我的做法是在传入前做一层文本规范化,把数字、符号转成期望的读法。
播放这块,拿到 PCM 数据后可以用AudioTrack播放:
val audioTrack = AudioTrack.Builder() .setAudioAttributes( AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build() ) .setAudioFormat( AudioFormat.Builder() .setEncoding(AudioFormat.ENCODING_PCM_FLOAT) .setSampleRate(sampleRate) .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) .build() ) .setBufferSizeInBytes(bufferSize) .setTransferMode(AudioTrack.MODE_STREAM) .build()用MODE_STREAM可以边合成边播放,降低首字延迟。如果 TTS 合成速度跟不上播放速度,可以先把整段合成完再播,代价是等待时间变长。
4. 实操过程与核心环节实现
4.1 环境搭建与依赖引入
先把基础环境搭起来。Android 项目里引入 sherpa-onnx,最直接的方式是用官方发布的 AAR 包,或者把源码作为子模块编译。我选的是 AAR 方式,省事。
在build.gradle里加依赖:
dependencies { implementation 'com.k2fsa.sherpa.onnx:sherpa-onnx:1.x.x' }如果官方仓库没有你需要的版本,也可以自己编译。编译需要 NDK 和 CMake,流程大致是:克隆源码,配置 CMake 工具链指向 Android NDK,执行构建脚本生成各架构的.so,再打包成 AAR。这个过程第一次会比较慢,但配好之后就很顺。
ABI 这块要注意,默认可能只编了arm64-v8a,如果你的 App 要支持老设备,需要加上armeabi-v7a。但每多一个架构,包体积就大一圈,建议根据实际用户设备分布决定。
4.2 ASR 流式识别的完整实现
流式识别的核心流程是:创建 recognizer → 创建 stream → 循环喂音频 → 取结果。
创建 recognizer 的配置:
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, modelType = "zipformer" ), decodingMethod = "greedy_search", enableEndpoint = true, rule1MinTrailingSilence = 2.4f, rule2MinTrailingSilence = 1.2f, rule3MinUtteranceLength = 20.0f )这里几个参数值得展开说。numThreads设成 2 是性能和功耗的平衡点,设太高在手机上会发热降频,反而变慢。decodingMethod用greedy_search是最快的,如果追求准确率可以用modified_beam_search,但延迟会高一些。
enableEndpoint是端点检测,这个非常关键。它决定了什么时候认为一句话说完了。三个规则分别是:检测到 2.4 秒静音、检测到 1.2 秒静音且已有识别结果、说话超过 20 秒强制断句。这些值需要根据实际场景调,比如朗读场景可以调长,对话场景可以调短。
喂音频和取结果的循环:
val stream = recognizer.createStream() while (isRecording) { val samples = readAudioChunk() // FloatArray stream.acceptWaveform(samples, sampleRate) while (recognizer.isReady(stream)) { recognizer.decode(stream) } val result = recognizer.getResult(stream) if (result.text.isNotEmpty()) { onPartialResult(result.text) } if (recognizer.isEndpoint(stream)) { onFinalResult(result.text) recognizer.reset(stream) } }这个循环里,isReady和decode的配合是流式识别的精髓。isReady判断当前是否有足够的数据可以解码,decode执行实际解码。内层 while 循环保证把所有可解码的数据都处理完。
4.3 TTS 合成的完整实现
TTS 的流程相对简单:创建引擎 → 传入文本 → 拿到音频。
val ttsConfig = OfflineTtsConfig( model = OfflineTtsModelConfig( vits = OfflineTtsVitsModelConfig( model = "$modelDir/model.onnx", tokens = "$modelDir/tokens.txt", lexicon = "$modelDir/lexicon.txt" ), numThreads = 2 ), maxNumSentences = 1 ) val tts = OfflineTts(ttsConfig) val audio = tts.generate("你好,欢迎使用语音合成", speed = 1.0f, speakerId = 0)generate返回的audio包含samples(FloatArray)和sampleRate。speed控制语速,1.0 是正常,小于 1 变慢,大于 1 变快。speakerId用于多说话人模型,单说话人模型传 0 即可。
maxNumSentences这个参数控制一次最多合成几个句子。设成 1 意味着逐句合成,首字延迟低但总耗时可能略长;设大一点可以批量合成,吞吐高但首字延迟高。实时对话场景建议设 1。
4.4 参数调优与性能实测
性能这块,我在一台中端机上做了实测,数据如下(仅供参考,不同设备差异较大):
| 任务 | 模型 | 线程数 | 耗时 |
|---|---|---|---|
| 流式 ASR 单次解码 | Zipformer 量化版 | 2 | 约 30-50ms |
| 非流式 ASR 10秒音频 | Paraformer | 2 | 约 400-600ms |
| TTS 合成 10 字 | VITS 量化版 | 2 | 约 200-400ms |
从数据看,流式 ASR 的实时性完全够用,TTS 的首字延迟也在可接受范围。如果觉得 TTS 慢,可以试试降低模型精度(int8 量化)或者减少线程数(有时候线程多了反而因为调度开销变慢)。
内存占用方面,流式 ASR 模型加载后大约占 100-200MB,TTS 大约 50-100MB。如果两个同时加载,低端机可能会吃紧。我的做法是按需加载,用完就释放,虽然重新加载有开销,但避免了内存压力。
实操心得:模型加载是个耗时操作,不要在 UI 线程做,也不要在每次识别时都重新加载。我的做法是在 Application 启动时异步预加载,或者用一个对象池管理引擎实例。
5. 常见问题与排查技巧实录
5.1 识别结果为空或乱码
这是最常见的问题,原因通常有几个。第一是音频格式不对,比如采样率不是 16kHz,或者传的是 short 数组但接口要 float 数组。排查方法是把音频数据存成 wav 文件,用工具听一下,确认格式正确。第二是模型文件路径错误,native 层找不到模型会静默失败。排查方法是检查文件是否存在、是否有读权限。第三是词表文件不匹配,用了 A 模型的 onnx 配了 B 模型的 tokens,结果就是乱码。
5.2 流式识别延迟高
如果发现识别结果出来得很慢,先看numThreads是不是设得太小。单线程在复杂模型上确实会慢。其次看音频块大小,如果一次喂太多数据,解码压力会集中。再就是看设备是否在省电模式,省电模式会限制 CPU 频率,推理速度会明显下降。
还有一个容易被忽视的点:日志输出。sherpa-onnx 默认会打不少日志,如果日志级别设得太高,IO 开销也会影响性能。生产环境建议把日志级别调低。
5.3 TTS 音频播放卡顿或杂音
杂音通常是因为音频格式不匹配。比如 TTS 输出的是 float 数组,但你用 16-bit PCM 的方式播放,就会产生噪音。确认AudioTrack的ENCODING_PCM_FLOAT和实际数据格式一致。
卡顿则可能是缓冲区太小。AudioTrack的 buffer size 如果小于一帧数据的大小,就会断断续续。建议 buffer size 至少能容纳 100ms 的音频数据。
5.4 内存泄漏与崩溃
native 对象忘记释放是内存泄漏的主因。排查方法是在关键路径加日志,观察 native 对象的创建和销毁是否配对。另外,如果多个线程同时操作同一个 stream,会直接崩溃。确保每个 stream 只在一个线程里使用。
下面是我整理的问题速查表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 识别结果为空 | 音频格式错误 / 模型路径错误 | 检查采样率、文件权限 |
| 识别结果乱码 | 词表与模型不匹配 | 确认 tokens 文件对应 |
| 延迟高 | 线程数不足 / 省电模式 | 调整 numThreads,检查电源状态 |
| TTS 杂音 | 音频格式不匹配 | 确认 PCM 编码类型 |
| 播放卡顿 | 缓冲区太小 | 增大 AudioTrack buffer |
| 崩溃 | 多线程操作同一 stream | 加锁或单线程化 |
| 内存泄漏 | native 对象未释放 | 检查 close 调用 |
5.5 包体积优化技巧
模型文件是包体积的大头。几个优化方向:量化,把 float32 模型转成 int8,体积能减到四分之一,精度损失通常在可接受范围;裁剪,如果只需要中文,可以去掉多语言模型里其他语言的部分;按需下载,大模型不打包进 APK,首次使用时下载。
我实测下来,一个中文流式 ASR 模型量化后大约 30-50MB,TTS 模型大约 20-40MB,加起来控制在 100MB 以内是可行的。如果还是嫌大,可以考虑只保留一个功能,或者用更小的模型架构。
6. 一些个人体会和后续扩展方向
这套方案跑通之后,我在实际使用中最大的感受是:端侧语音的瓶颈往往不在模型本身,而在工程细节。音频采集的稳定性、内存管理、线程调度、包体积控制,这些"脏活累活"才是决定体验的关键。模型选对了只是起点,把工程做扎实才能让用户觉得"好用"。
后续我打算尝试几个方向。一是多模型热切换,让用户根据场景在轻量模型和高精度模型之间切换,比如安静环境用大模型,嘈杂环境用小模型加降噪。二是自定义唤醒词,结合流式识别做一个本地的关键词检测,实现"说完即触发"的体验。三是模型增量更新,把模型文件做成可差分更新的形式,避免每次升级都让用户重新下载几十兆。
如果你也在做类似的事情,我的建议是先把最小可用链路跑通,别一上来就追求完美。先让识别能出字、合成能出声,再逐步优化延迟、准确率和体积。这个过程中你会遇到很多文档里没写的问题,但每解决一个,对整套系统的理解就深一层。端侧语音这条路,踩坑是常态,但踩完之后回头看,那些坑其实都是必经之路。