news 2026/10/22 15:45:56

Android端侧语音实战:sherpa-onnx实现ASR与TTS全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android端侧语音实战:sherpa-onnx实现ASR与TTS全解析

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 上的架构,我分成了四层,从下往上依次是:

  1. 模型层:ONNX 模型文件 + tokens 词表文件,放在 assets 或外部存储,运行时加载。
  2. 推理层:sherpa-onnx 的 C++ 核心库,编译成.so动态库,通过 JNI 暴露接口。
  3. JNI 封装层:Java/Kotlin 侧的封装类,管理模型生命周期、音频数据传递、结果回调。
  4. 业务层: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秒音频Paraformer2约 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. 一些个人体会和后续扩展方向

这套方案跑通之后,我在实际使用中最大的感受是:端侧语音的瓶颈往往不在模型本身,而在工程细节。音频采集的稳定性、内存管理、线程调度、包体积控制,这些"脏活累活"才是决定体验的关键。模型选对了只是起点,把工程做扎实才能让用户觉得"好用"。

后续我打算尝试几个方向。一是多模型热切换,让用户根据场景在轻量模型和高精度模型之间切换,比如安静环境用大模型,嘈杂环境用小模型加降噪。二是自定义唤醒词,结合流式识别做一个本地的关键词检测,实现"说完即触发"的体验。三是模型增量更新,把模型文件做成可差分更新的形式,避免每次升级都让用户重新下载几十兆。

如果你也在做类似的事情,我的建议是先把最小可用链路跑通,别一上来就追求完美。先让识别能出字、合成能出声,再逐步优化延迟、准确率和体积。这个过程中你会遇到很多文档里没写的问题,但每解决一个,对整套系统的理解就深一层。端侧语音这条路,踩坑是常态,但踩完之后回头看,那些坑其实都是必经之路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 15:45:27

微信小程序家庭理财管理系统:从记账到毕业论文的全栈实战

简介:基于微信小程序与 Java 后端构建的家庭理财管理系统毕业设计资源,面向计算机专业学生和初学小程序开发的读者。系统以家庭为单位管理日常收支,实现了用户注册登录、后台管理、工资管理、记账本、贷款管理、理财分析等模块,并…

作者头像 李华
网站建设 2026/10/22 15:45:27

SQL面试考点全拆解:窗口函数、JOIN与索引优化实战

不管是校招还是社招,只要岗位和数据沾边,SQL面试题基本是躲不掉的关卡。我这些年面试别人、也被别人面过,最大的感受是:SQL题看起来千变万化,实际上考点非常集中。很多人刷题爱往偏题怪题上使劲,结果真正上…

作者头像 李华
网站建设 2026/10/22 15:44:31

微信小程序停车场预约系统毕设包:从源码到跑通的完整指南

简介:这是一套面向计算机相关专业在校学生的微信小程序停车场预约管理系统完整项目,适用于毕业设计、课程设计、大作业及初期项目立项演示,也可供想入门小程序与后端开发的学习者参考。项目代码完整、功能验证通过,涵盖小程序端页…

作者头像 李华
网站建设 2026/10/22 15:44:23

Ultrafast模式解析:延迟分层如何实现与定价

1. 从“快”被单独定价说起:一个被忽视的产品信号大多数人看到“Ultrafast 模式”这个词,第一反应是“哦,又提速了”。但如果你把这件事只理解成一次性能优化,那就错过了它真正有意思的地方。真正值得琢磨的不是“快了多少”&…

作者头像 李华
网站建设 2026/10/22 15:40:00

x86保护模式:现代操作系统运行的硬件基石

1. 为什么“保护模式”不是CPU的“安全模式”,而是整个现代操作系统运行的基石? 很多人第一次看到“保护模式”这个词,下意识会联想到Windows里的“安全模式”——按F8进的那个蓝屏界面。但x86 CPU的保护模式和它毫无关系。它既不防病毒&…

作者头像 李华
网站建设 2026/10/22 15:39:36

AI Coding质量治理:工程规范、契约评审与风险驱动测试

1. 这不是又一个“AI写代码”的热闹,而是工程团队正在悄悄重建的防线最近在几个技术社区里刷到不少标题带“AI Coding”的分享,点进去一看,八成是教你怎么用某款工具三分钟生成一个计算器——很酷,但离真实项目差着好几层防火墙。…

作者头像 李华