☰
Android端侧语音实战:sherpa-onnx集成ASR与TTS全解析
2026/10/22 15:46:10 网站建设 项目流程

1. 为什么要在手机端跑语音模型,而不是调云端接口

把语音识别和语音合成放到手机本地跑,这个念头最早来自一次很具体的尴尬。当时做一个离线场景的语音记录工具,用户在地铁、电梯、地下车库这些地方用,网络时断时续,云端接口要么超时要么返回半截结果,体验非常割裂。更麻烦的是,语音数据要上传到远端处理,涉及隐私的会议记录、个人备忘,用户天然会有顾虑。于是我开始认真考虑:能不能把 ASR(语音识别)和 TTS(语音合成)整套链路都塞进手机里,不依赖网络,数据不出设备。

sherpa-onnx就是在这个背景下进入视野的。它是一套基于 ONNX Runtime 的语音推理框架,覆盖了语音识别、语音合成、语音活动检测、说话人分离等能力,最大的特点是跨平台、纯本地推理、模型体积可控。Android 上它提供了预编译的 AAR 和完整的 JNI 封装,Java/Kotlin 层调用起来不算复杂。这篇内容就是把我从零把 sherpa-onnx 集成进 Android 项目、跑通 ASR 和 TTS 的完整过程拆开讲,包括模型怎么选、依赖怎么配、音频怎么喂、踩了哪些坑。

适合谁看?如果你是有一定 Android 开发基础、想给 App 加离线语音能力的开发者,或者在做端侧 AI 相关的东西,这篇会比较对路。完全没碰过 Android 的读者也能看懂原理部分,但实操章节需要一点工程基础。我不会只给一堆 API 调用,重点会放在为什么这么选、参数背后的逻辑、以及实测中那些文档里不会写的坑。

先说结论:手机端跑语音模型,今天已经完全可行。一个中等规模的流式 ASR 模型量化后大概几十 MB,TTS 模型也在几十 MB 量级,中端手机上实时率能跑到 0.1 到 0.3 之间(也就是处理 1 秒音频只要 0.1 到 0.3 秒),完全够用。下面进入正题。

2. sherpa-onnx 到底解决了端侧语音的哪些硬骨头

2.1 它不是一个模型,而是一套推理运行时

很多人第一次接触会误以为 sherpa-onnx 是某个具体的语音模型。其实不是。它更像是一个"模型容器 + 推理调度器",底层依赖 ONNX Runtime 做算子执行,上层把语音任务拆成几类标准接口:离线识别(非流式)、流式识别、语音合成、VAD、声纹等。你喂给它符合约定的 ONNX 模型文件,它负责前处理(特征提取)、推理调度、后处理(解码、拼接)。

这个设计的好处是模型和框架解耦。你可以用官方提供的预训练模型,也可以自己训练后导出 ONNX 塞进去。对 Android 来说,这意味着你不需要为每个模型单独写一套 JNI,框架层已经把音频输入、特征计算、结果回调这些通用逻辑封装好了。

2.2 端侧语音的三个核心难点,它是怎么绕过去的

端侧跑语音,绕不开三座大山:算力、内存、延迟。

算力方面,手机 CPU 和桌面 CPU 差距明显,GPU/NPU 又碎片化严重。sherpa-onnx 的策略是优先用 CPU + 量化模型。它支持 int8 量化,把原本 fp32 的权重压到 8 位整数,模型体积缩小约 4 倍,推理速度提升明显,精度损失在语音任务上通常可以接受。实测一个流式 Zipformer 模型量化后在中端机上单核就能实时推理。

内存方面,流式识别需要维护解码状态和特征缓存。sherpa-onnx 的流式接口设计成"喂一块、出一块",内部状态由 C++ 层管理,Java 层只持有句柄,避免了在 JVM 堆里堆积大块音频数据。这一点很关键,我见过有人把整段音频读进 byte 数组再一次性喂,结果长音频直接 OOM。

延迟方面,流式模型采用 chunk-based 推理,通常每 100ms 左右音频出一个中间结果,配合 endpoint 检测(静音判断)自动断句。这个机制让"边说边出字"成为可能,而不是等整段说完才出结果。

2.3 ASR 和 TTS 在框架里的定位差异

ASR 和 TTS 虽然都叫语音,但数据流向完全相反。ASR 是"音频进、文本出",TTS 是"文本进、音频出"。在 sherpa-onnx 里,这两条链路的 API 形态也不同:

维度ASR(识别)TTS(合成)
输入PCM 音频流/文件文本字符串
输出文本(含时间戳可选)PCM 音频采样
是否流式支持流式和非流式通常整句合成
典型模型Zipformer、Paraformer、WhisperVITS、Matcha
采样率16kHz 为主可配置,常见 22.05kHz
状态管理需要维护解码状态无状态,一次调用一次结果

理解这个差异很重要,因为它决定了你在 Android 层怎么组织代码。ASR 需要一个持续喂数据的循环,TTS 则是一次性调用拿结果。

3. 模型选型:不是越大越好,而是要匹配你的场景

3.1 流式还是非流式,先想清楚交互形态

选模型前先回答一个问题:你的用户是边说边看结果,还是说完再出结果?

如果是实时字幕、语音输入法、会议实时记录,必须用流式模型。流式模型的特点是推理时只依赖当前和历史的有限上下文,能持续输出中间结果。代价是精度通常略低于非流式,因为非流式模型能看到完整音频再做全局优化。

如果是录音转写、语音备忘录整理,非流式更合适,精度更高,实现也更简单——整段音频喂进去,等一个完整结果。

我的建议是:先上非流式跑通链路,再换流式优化体验。因为非流式的调试成本低很多,你能先确认音频格式、采样率、模型加载这些基础环节没问题,再去啃流式的状态管理。

3.2 中文场景下几个主流模型的实际表现

官方模型库里有不少选择,我挑几个中文场景常用的说说实测感受:

  • Zipformer 流式中文模型:体积小(量化后约 30-50MB),速度快,适合实时场景。缺点是对方言和口音较重的中文识别一般,专业术语需要热词增强。
  • Paraformer 非流式中文模型:精度明显更好,标点符号预测也内置了,适合转写。体积大一些,但非流式对速度要求没那么苛刻。
  • Whisper 系列:多语言能力强,中英混说场景表现好,但模型偏大,端侧跑小尺寸版本(tiny/base)精度会打折,适合对多语言有硬需求的场景。

选型时别只看 WER(词错误率)这个指标,还要看你的实际音频分布。安静环境下的标准普通话,小模型就够;嘈杂环境、远场拾音、带口音,就得往大模型走,或者加前端降噪。

3.3 量化版本的取舍:int8 到底损失多少

官方通常提供 fp32 和 int8 两个版本。int8 的优势前面说了,体积和速度都占优。但量化不是无损的,实测下来:

  • 在标准测试集上,int8 相比 fp32 的 WER 上升通常在 1-3 个百分点。
  • 在噪声、口音等困难样本上,差距会放大。
  • TTS 的 int8 量化对音质影响比 ASR 更明显,可能出现轻微机械感或音色变化。

我的经验是:ASR 优先用 int8,TTS 如果对音质敏感就用 fp32 或 fp16。手机存储和内存现在都不算紧张,TTS 模型本身也不大,没必要为了省那点体积牺牲听感。

4. Android 工程集成:从依赖到第一个能跑通的 Demo

4.1 依赖引入与 ABI 配置的坑

sherpa-onnx 的 Android 包通过 Maven 分发,在build.gradle里加依赖即可。但这里有个新手必踩的坑:ABI 过滤。

框架的 native 库支持 armeabi-v7a、arm64-v8a、x86、x86_64 等架构。如果你不做过滤,打包出来的 APK 会包含所有架构的 so 库,体积直接翻几倍。正确做法是在defaultConfig里指定需要的 ABI:

android { defaultConfig { ndk { abiFilters 'arm64-v8a', 'armeabi-v7a' } } }

真机基本都是 ARM,模拟器看你的宿主机架构。x86 系列现在用得少,除非你要在特定模拟器上调试,否则可以砍掉。

另一个坑是ONNX Runtime 的版本冲突。如果你的项目里已经有其他库依赖了 ONNX Runtime,版本不一致会导致 native 层符号冲突,表现为运行时崩溃或找不到符号。解决办法是用gradle dependencies排查,统一版本,或者用exclude排除重复依赖。

4.2 模型文件放哪:assets 还是外部存储

模型文件动辄几十 MB,放哪里是个工程决策。

放assets的好处是随 APK 一起分发,用户装完就能用,不需要额外下载。坏处是 APK 体积膨胀,而且 assets 里的文件不能直接被 native 层按路径读取,需要先拷贝到可访问的目录。

放外部存储(或应用私有目录)的好处是 APK 小,模型可以按需下载、动态更新。坏处是要处理下载、校验、版本管理。

我的做法是折中:基础模型放 assets 保证开箱即用,大模型或可选模型走下载。拷贝 assets 到私有目录时注意用流式拷贝,别一次性读进内存:

fun copyAsset(context: Context, assetName: String, destFile: File) { context.assets.open(assetName).use { input -> FileOutputStream(destFile).use { output -> input.copyTo(output, bufferSize = 8192) } } }

拷贝完成后把目标路径传给 native 层加载。注意拷贝只在首次或版本变化时做,别每次启动都拷,浪费时间和存储寿命。

4.3 初始化配置:采样率、线程数、解码方法

初始化识别器时有一堆参数,几个关键的:

  • 采样率:必须和模型训练时一致,中文模型基本都是 16000Hz。如果你录的是 44100Hz,必须先重采样,否则识别结果全是乱码。这是最常见的错误之一。
  • 线程数:numThreads控制推理用的 CPU 线程。设太高会和其他任务抢资源,设太低推理慢。中端机建议 2,高端机可以 4。实测超过 4 收益递减明显。
  • 解码方法:greedy_search最快,modified_beam_search精度略高但慢一些。实时场景用 greedy,转写场景可以上 beam search。
  • 热词/上下文:部分模型支持传入热词表,提升特定词汇的识别率。做垂直领域应用时这个很有用,比如医疗、法律术语。

初始化本身有耗时,几百毫秒到一两秒不等,取决于模型大小。别在主线程做初始化,否则启动就卡。建议放在后台线程,或者做成懒加载,用户第一次点语音按钮时才初始化。

5. 音频链路:从麦克风到识别结果的完整数据流

5.1 录音参数必须和模型对齐

Android 录音用AudioRecord,参数配置直接决定识别成败。核心参数:

val sampleRate = 16000 val channelConfig = AudioFormat.CHANNEL_IN_MONO val audioFormat = AudioFormat.ENCODING_PCM_16BIT val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)

三个要点:单声道、16 位、16kHz。模型要单声道,你就别录立体声;模型要 16 位 PCM,你就别用 float 编码。getMinBufferSize返回的是最小缓冲,实际用的时候建议取它的 2 到 4 倍,避免录音丢帧。

录音权限别忘了在 Manifest 里声明,Android 6.0 以上还要运行时申请。这些是基础,但每年都有人栽在这。

5.2 流式喂数据的节奏控制

流式识别的核心是"边录边喂"。典型循环是:从AudioRecord读一块 PCM,转成 float 数组(sherpa-onnx 的接口通常要 float 归一化到 [-1, 1]),喂给识别器,拿回当前结果。

这里有个节奏问题。喂太快,识别器处理不过来,数据积压;喂太慢,延迟高。经验值是每次喂 100ms 左右的音频,对应 16kHz 单声道就是 1600 个采样点。这个粒度和流式模型的 chunk 大小比较匹配。

PCM 16 位转 float 的公式是sample / 32768.0f。别用 32767,虽然差别极小,但用 32768 能保证对称性,避免直流偏移。

val floatSamples = ShortArray(n).map { it / 32768.0f }.toFloatArray()

注意这个转换有性能开销,长音频下map会产生大量临时对象。追求性能的话用预分配数组 + 循环赋值,减少 GC 压力。

5.3 端点检测:什么时候算"说完了"

流式识别如果一直不停,会一直输出中间结果,你需要一个机制判断用户说完了。这就是 endpoint 检测。

sherpa-onnx 的流式识别器内置了 endpoint 规则,基于静音时长判断。你可以配置rule1MinTrailingSilence、rule2MinTrailingSilence这类参数,控制多长的静音算一句话结束。默认值通常能用,但实际场景要调:

  • 用户说话停顿多(比如思考型表达),静音阈值要调大,否则一句话被切成好几段。
  • 用户语速快、连续说,阈值可以调小,响应更及时。

我一般把尾部静音阈值设在 0.8 到 1.2 秒之间,具体看场景。检测到 endpoint 后,调用 reset 重置识别器状态,准备下一句。

5.4 TTS 合成音频的播放与保存

TTS 相对简单:文本进去,float 音频数组出来,附带采样率。播放用AudioTrack,保存就写 WAV 文件。

写 WAV 要注意文件头。float 采样要先转回 16 位 PCM,再按 WAV 格式写 44 字节头 + 数据。头里要填对采样率、声道数、位深,否则播放器打不开。这个格式细节网上模板很多,但字节序容易错——WAV 是小端序,写多字节整数时要注意。

播放时AudioTrack的 buffer 大小和写入节奏也会影响听感。写太急会爆音,写太慢会卡顿。用阻塞式写入(WRITE_BLOCKING)配合合适的 buffer 通常最稳。

6. 实测踩坑记录:那些文档不会告诉你的问题

6.1 识别结果为空或乱码,八成是采样率

我遇到最多的问题就是识别出来是空的,或者一堆无意义字符。排查下来,90% 是采样率不匹配。要么录音用了 44100Hz 没重采样,要么 PCM 编码不是 16 位。验证方法很简单:把录到的音频存成 WAV,用电脑播放,如果声音变调(快放或慢放),就是采样率错了。

重采样如果自己写,用线性插值就够,但要注意边界处理。更稳的做法是录音时就直接用 16000Hz,从源头避免。

6.2 长音频内存暴涨的根因

前面提过,有人把整段音频读进内存再喂。我实测过,10 分钟 16kHz 16 位单声道音频约 19MB,转成 float 数组翻倍到 38MB,再加上识别器内部的特征缓存,很容易触发 OOM。

正确姿势是流式处理,喂完一块丢一块。非流式模型如果必须整段处理,也要控制音频长度,或者分段识别再拼接。分段拼接要注意边界处的词可能被切断,可以在分段时留一点重叠。

6.3 首次调用卡顿与模型预热

模型第一次推理往往比后续慢,因为要加载权重、初始化算子、预热缓存。如果用户第一次点按钮就卡一秒,体验很差。

解决办法是预热:App 启动后在后台线程用一小段静音或测试音频跑一次推理,把模型"热"起来。这样用户真正用时就是热路径,响应快很多。预热的代价是启动后多占一点 CPU,但通常可以接受。

6.4 多线程调用的线程安全问题

sherpa-onnx 的识别器实例不是线程安全的。如果你在多个线程同时调用同一个实例,会出各种诡异问题,甚至崩溃。正确做法是一个实例同一时间只被一个线程使用,或者用对象池管理多个实例。

TTS 同理。如果业务上需要并发合成,就创建多个实例,别共享。

7. 性能调优与体验打磨的实战思路

7.1 实时率与功耗的平衡

实时率(RTF)是衡量端侧语音性能的核心指标,等于处理耗时除以音频时长。RTF 小于 1 就是实时。实测中端机跑量化流式模型,RTF 大概在 0.1 到 0.3。

但 RTF 不是越低越好。一味加线程、提频率,功耗和发热上去了,手机烫手、掉电快,用户体验反而差。我的调优顺序是:先保证 RTF 稳定小于 0.5,再考虑降功耗。具体手段包括限制线程数、在息屏或后台时降频、用 VAD 过滤静音段减少无效推理。

VAD 这里特别值得说。用户说话有大量停顿和静音,如果全程都在跑识别,纯属浪费。加一个轻量 VAD 做前置过滤,只在检测到人声时才喂给识别器,能省不少算力。

7.2 模型按需加载与内存回收

不是所有功能都要常驻内存。如果 App 里 ASR 和 TTS 不是同时用,可以按需加载、用完释放。释放时要显式调用 native 的释放接口,别只置空 Java 引用,否则 native 内存不会回收。

监控内存可以用 Android Studio 的 Profiler,重点看 native heap。语音模型占的主要是 native 内存,Java heap 看不出来。

7.3 结果后处理:标点、数字、顺滑

原始识别结果通常没有标点,数字可能是汉字形式("一二三"而不是"123")。如果直接展示,可读性差。后处理可以做几件事:

  • 加标点:部分模型内置标点预测,没有的话可以用轻量标点模型,或者基于规则的简单断句。
  • 数字规整:把"二零二四"转成"2024",把"百分之五十"转成"50%"。规则 + 词典能覆盖大部分场景。
  • 顺滑:流式识别的中间结果会不断修正,展示时要做防抖,别让文字疯狂跳动。常见做法是只展示稳定部分,或者加一点延迟再渲染。

这些后处理看似小,但对最终体验影响很大。用户不会关心你模型多强,只会觉得"识别得准不准、读起来顺不顺"。

8. 我个人在实际集成中的几点体会

折腾完这一整套,有几个感受比较深。

第一,端侧语音的门槛已经从"能不能跑"变成"怎么跑得好"。框架和模型都成熟了,真正的功夫在工程细节:采样率对齐、内存管理、线程安全、功耗控制。这些没有捷径,只能一个个踩过来。

第二,别迷信大模型。我见过有人一上来就上最大的模型,结果手机跑不动、发热严重,体验还不如小模型。选型要匹配场景,实时场景小模型 + 热词增强,往往比大模型硬扛更实用。

第三,测试要用真实数据。实验室里安静环境跑得再好,到了地铁、商场、车里可能一塌糊涂。尽早拿真实场景的音频做测试,比后期调参有用得多。

最后分享一个小技巧:调试识别问题时,把喂给模型的音频同时存一份 WAV,出问题时用电脑上的工具复现。这样能快速区分是音频链路的问题还是模型本身的问题,比在手机上盲猜高效太多。这个习惯帮我省了大量排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询