手机做语音功能,最省事的做法是调云端 API。最近在技术选择无网状态下的技术选型,离线方案。真正上手之后会发现,离线语音的难点不在「调用一个 SDK」,而在模型选型、包体控制和线程治理这三件事上。
这篇记录的是我把 sherpa-onnx 的 ASR 和 TTS 完整塞进 App 的过程,包含可运行的 Kotlin 代码和一份踩坑清单。技术栈是 sherpa-onnx 1.13.8 + ONNX Runtime 1.28.2,目标平台 arm64-v8a / armeabi-v7a,全流程无网络请求。
1. 为什么是 sherpa-onnx
因为「模型能完整进 APK、不用自己写推理」的方案。
| 维度 | 表现 |
|---|---|
| 离线 | 基于 next-gen Kaldi + ONNX Runtime,推理全在本地 |
| 体积 | 运行时约 7 MB 级(不含模型),模型按需选,最小中文流式模型 14 M |
| 延迟 | 流式 Transducer 首字延迟在 200--500 ms 量级,能撑实时字幕 |
| 能力 | ASR(流式 / 非流式)、TTS、VAD、标点、说话人识别与分离,接口统一 |
| 接口 | C / C++ / Java / Kotlin / Swift / Dart / Go / Rust / C# / JS 等 12 种 |
| 加速 | 以 CPU 为主,另支持 Qualcomm QNN/HTP、RKNN、Ascend 等 NPU |
它的定位很关键:不是云 API 的客户端,而是把语音模型搬进手机里的推理引擎。
得先有个预期:离线不等于免费。运行时本身只有 7 MB 出头,但模型动辄几十上百 M,包体、内存、发热都要自己设计。
2. 完整链路
流式 ASR 比批处理的 要快些。处理单元较小
流式 ASR 的数据流是这样的:
text
麦克风 (AudioRecord 16kHz / mono / PCM16)
│ ShortArray → FloatArray,归一化到 [-1, 1]
▼
OnlineStream.acceptWaveform(samples, 16000) 内部按 chunk 累积特征
▼
OnlineRecognizer.decode(stream) 每 100 ms 调一次
├─ isReady(stream) 为 true 时 getResult() 取部分结果
└─ isEndpoint(stream) 为 true 时一句话说完,提交最终结果
▼
OnlineRecognizerResult(text, tokens, timestamps)
▼
标点恢复(可选)→ UI 上屏 / 送 LLM
TTS 则简单得多:
text
文本 + sid(说话人)
▼ OfflineTts.generate(text, sid, speed),或 generateWithCallback 流式回调
GeneratedAudio(samples: FloatArray, sampleRate),samples 归一化在 [-1, 1]
▼
AudioTrack(STREAM 模式,边合成边播)
sherpa-onnx 的 Android 示例工程在 android/ 目录下有 17 个,想快速验证效果可以直接装预编译 APK;想看 API 用法,kotlin-api-examples/ 是最好的参考。
3. 落地三步
3.1 拿到原生库
方案 A:下预编译库,生产首选
bash
# 版本号务必用 Releases 页面的最新 tag
wget https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.13.8/sherpa-onnx-v1.13.8-android.tar.bz2
tar xvf sherpa-onnx-v1.13.8-android.tar.bz2
解压出来每个 ABI 一套,拷贝到工程对应目录:
text
app/src/main/jniLibs/arm64-v8a/ # libonnxruntime.so + libsherpa-onnx-jni.so
app/src/main/jniLibs/armeabi-v7a/
app/src/main/jniLibs/x86_64/ # 仅模拟器调试需要
两个 .so 拷进去就完事。具体体积别信我给的数字------未 strip 的安装包体积会明显大于运行时实际占用,直接用第 7 节的 Analyze APK 实测。RKNN 机型记得下 -android-rknn 后缀的包。
方案 B:打成 AAR,团队项目推荐
多个模块各自拷 .so 很容易出现版本漂移,打成 AAR 会干净很多:
bash
tar xvf sherpa-onnx-v1.13.8-android.tar.bz2
# 把四个 ABI 的 .so 拷进 android/SherpaOnnxAar/sherpa_onnx/src/main/jniLibs/<ABI>/
# 这一步必须先做,否则 gradle 不会把 .so 打进去
cd android/SherpaOnnxAar
./gradlew :sherpa_onnx:assembleRelease
# 产物:./sherpa_onnx/build/outputs/aar/sherpa_onnx-release.aar
业务工程直接依赖本地 AAR:
groovy
dependencies {
implementation files('libs/sherpa-onnx-1.13.8.aar')
}
3.2 选模型
模型都在 GitHub Releases 上,命名有规律:
text
ASR : .../releases/download/asr-models/<模型名>.tar.bz2
TTS : .../releases/download/tts-models/<模型名>.tar.bz2
VAD : .../releases/download/asr-models/silero_vad.onnx
标点 : .../releases/download/punctuation-models/<模型名>.tar.bz2
拉取慢的话,HuggingFace 上有镜像仓库(csukuangfj/sherpa-onnx-*)。
ASR 怎么选,先看这张表:
| 场景 | 推荐模型 | 语言 | 特点 |
|---|---|---|---|
| 实时中英混说(最常用) | sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20 |
中 / 英 | Transducer,实时性好,官方示例默认模型 |
| 低端机 / 长续航 | sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23 |
中 | 约 14 M,Cortex-A7 也能跑 |
| 多语种 / 方言 | sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09 |
中 / 粤 / 英 / 日 / 韩 | 非流式,带 ITN,短音频强 |
| 整句准确率优先 | sherpa-onnx-paraformer-zh-int8-2025-10-07 |
中 | 非流式 Paraformer |
| 纯中文高精度 | sherpa-onnx-zipformer-ctc-zh-int8-2025-07-03 |
中 | 非流式 CTC,体积小速度快 |
我的取舍原则有三条:
流式选 Transducer,非流式选 Paraformer / SenseVoice。 Whisper、SenseVoice 这类非流式模型要「模拟流式」,必须配 VAD 分段,体验会打折。
一律优先 int8 量化权重 (文件名带 .int8.onnx),体积和速度双赢,只有在高端机上追精度才上 fp32。
Transducer 是三文件结构(encoder / decoder / joiner),只有 encoder 大,通常只量化 encoder,decoder 和 joiner 保持 fp32。
下载完先裁剪,这一步对包体影响很大。模型包里往往同时存在 fp32 和 int8 两套权重,二选一即可,切勿两套同时入包:
bash
cd sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20
# 示例音频、脚本、说明文档:与推理无关
rm -rf test_wavs
rm -f *.sh README.md
# BPE 词表:该双语模型不需要
rm -f bpe.model bpe.vocab
# 走 int8 方案:删掉 fp32 三件套
rm -f encoder-epoch-99-avg-1.onnx
rm -f decoder-epoch-99-avg-1.onnx
rm -f joiner-epoch-99-avg-1.onnx
裁剪原则就一句:推理链路用不到的,一律不进包。
TTS 侧的选择比 ASR 少,但有三个必须知道的点:
| 模型 | 语言 | 说话人 | 文件构成 |
|---|---|---|---|
vits-zh-hf-fanchen-C |
中文 | 187 | model.onnx + lexicon.txt + tokens.txt(+ date.fst / number.fst) |
vits-icefall-zh-aishell3 |
中文 | 174 | 同上 |
matcha-icefall-zh-baker |
中文 | 1 | 声学模型 + 声码器 + lexicon.txt + tokens.txt |
kokoro-multi-lang-v1_1 |
中英 | 103 | model.onnx + voices.bin + tokens.txt |
kitten-nano-en-v0_2-fp16 |
英 | --- | 极小体量,适合低端机 |
lexicon.txt 是硬门槛。中文 VITS 和 Matcha 靠词典做文本到音素的映射,缺词典直接不出声,而且是那种「不报错、就是没声音」的情况,最难查。
date.fst / number.fst 负责文本正规化,让「3 月 5 日」读成「三月五日」。省掉也能跑,但数字会读得像在念代码。
多说话人靠 sid,generate(text, sid) 里的 sid 就是说话人索引,换音色不用重建引擎。
VAD 建议用 silero_vad.onnx(windowSize = 512)或更轻的 ten-vad.onnx(windowSize = 256)。VAD 决定「什么时候算一句话结束」,直接决定断句质量和延迟,不要因为「模型本身有 endpoint」就省掉它------遇到长句没有静音,endpoint 会迟迟不触发。
3.3 目录与权限
text
app/src/main/
├── assets/
│ ├── silero_vad.onnx
│ ├── sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20/
│ │ ├── encoder-epoch-99-avg-1.int8.onnx
│ │ ├── decoder-epoch-99-avg-1.onnx
│ │ ├── joiner-epoch-99-avg-1.onnx
│ │ └── tokens.txt
│ └── sherpa-onnx-vits-zh-hf-fanchen-C/
│ ├── model.onnx
│ ├── lexicon.txt
│ └── tokens.txt
└── jniLibs/arm64-v8a/
├── libonnxruntime.so
└── libsherpa-onnx-jni.so
Kotlin API 同时支持两种加载方式,由是否传 assetManager 决定,内部二选一,不能混用:
kotlin
// 从 assets 读(模型随 APK 交付)
val recognizer = OnlineRecognizer(assetManager = assets, config = config)
// 从文件系统读(模型放在 filesDir / 下载目录),路径要绝对路径
val recognizer = OnlineRecognizer(assetManager = null, config = config)
怎么选?小模型(100 MB 以内)直接进 assets,装完即用;大模型走 filesDir + 首次启动释放,记得配一份完整性校验(文件大小或 MD5)。既保证离线可用,又不把安装包撑到几百 MB。
权限上加两个,其中录音权限必须运行时申请:
xml
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
后台或锁屏还要录音的话,得走前台服务,Android 10+ 还得声明类型:
xml
<service
android:name=".AsrService"
android:foregroundServiceType="microphone"
android:exported="false" />
混淆规则要保留 Kotlin 绑定类,不然 release 包一跑就崩:
proguard
-keep class com.k2fsa.sherpa.onnx.** { *; }
-keepclasseswithmembernames class * { native <methods>; }
4. 流式 ASR:封装一个能用的引擎
sherpa-onnx 的 Android 示例没有内置录音类,录音和播放都用原生 AudioRecord / AudioTrack,全链路统一 16 kHz 单声道。这两个约定一旦不统一,症状是「乱码或单字堆积」,而且不会抛异常。
先把音频底座准备好:
kotlin
object SherpaAudio {
const val SAMPLE_RATE = 16000
const val CHUNK_SAMPLES = 1024 // 每次读 1024 个采样点
fun createAudioRecord(): AudioRecord {
val minBuf = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
return AudioRecord(
MediaRecorder.AudioSource.VOICE_RECOGNITION,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
maxOf(minBuf, CHUNK_SAMPLES * 2 * 4),
)
}
/** ShortArray → FloatArray,归一化到 [-1, 1] */
fun shortsToFloats(shorts: ShortArray): FloatArray =
FloatArray(shorts.size) { shorts[it] / 32768.0f }
}
引擎的配置部分,模型路径、numThreads、端点规则都在这里定:
kotlin
fun buildRecognizerConfig(modelDir: String): OnlineRecognizerConfig =
OnlineRecognizerConfig(
featConfig = FeatureConfig(sampleRate = SherpaAudio.SAMPLE_RATE, featureDim = 80),
modelConfig = OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/encoder-epoch-99-avg-1.int8.onnx",
decoder = "$modelDir/decoder-epoch-99-avg-1.onnx",
joiner = "$modelDir/joiner-epoch-99-avg-1.onnx",
),
tokens = "$modelDir/tokens.txt",
numThreads = 2,
provider = "cpu",
modelType = "zipformer2",
),
enableEndpoint = true,
decodingMethod = "greedy_search",
endpointConfig = EndpointConfig(
rule1 = EndpointRule(false, 2.4f, 0f),
rule2 = EndpointRule(true, 1.4f, 0f),
rule3 = EndpointRule(false, 0f, 20f), // 强制最长 20 s
),
)
解码循环是引擎的核心:
kotlin
class SherpaAsrEngine(private val assets: AssetManager) {
private var recognizer: OnlineRecognizer? = null
private var stream: OnlineStream? = null
var onPartial: (String) -> Unit = {}
var onFinal: (String) -> Unit = {}
/** 必须放在后台线程:初始化耗时几百 ms 到数秒 */
fun init(modelDir: String) {
recognizer = OnlineRecognizer(assetManager = assets, config = buildRecognizerConfig(modelDir))
stream = recognizer!!.createStream()
}
fun acceptSamples(samples: FloatArray) {
val r = recognizer ?: return
val s = stream ?: return
s.acceptWaveform(samples, SherpaAudio.SAMPLE_RATE)
while (r.isReady(s)) { // 有增量结果就刷 UI
onPartial(r.getResult(s).text)
}
if (r.isEndpoint(s)) { // 一句话说完,提交并重置
onFinal(r.getResult(s).text)
r.reset(s)
}
}
fun release() {
stream?.release(); recognizer?.release()
stream = null; recognizer = null
}
}
接上录音就完整了。
5. TTS:合成与播放
初始化和合成,VITS 模型的三个文件缺一不可:
kotlin
class SherpaTtsEngine(private val assets: AssetManager) {
private var tts: OfflineTts? = null
fun init(modelDir: String) {
tts = OfflineTts(assetManager = assets, config = OfflineTtsConfig(
model = OfflineTtsModelConfig(
vits = OfflineTtsVitsModelConfig(
model = "$modelDir/model.onnx",
lexicon = "$modelDir/lexicon.txt", // 缺了不会有声音
tokens = "$modelDir/tokens.txt",
lengthScale = 1.0f, // < 1 更快,> 1 更慢
),
numThreads = 2,
),
ruleFsts = "", // 可填 "$modelDir/date.fst,$modelDir/number.fst"
))
}
fun synth(text: String, sid: Int = 0, speed: Float = 1.0f): FloatArray =
tts!!.generate(text, sid, speed).samples
/** 边合成边回调:返回 1 继续,0 结束 */
fun synthWithCallback(text: String, onChunk: (FloatArray) -> Int) =
tts!!.generateWithCallback(text = text, sid = 0, speed = 1.0f) { onChunk(it) }
fun sampleRate(): Int = tts!!.sampleRate()
fun speakerCount(): Int = tts!!.numSpeakers()
}
长文本一次性合成会让用户干等。generateWithCallback 能在合成过程中分块吐数据,配合 AudioTrack 的 STREAM 模式把首包延迟压到最低:
kotlin
fun play(tts: SherpaTtsEngine, text: String) {
val sr = tts.sampleRate()
val mono = AudioFormat.CHANNEL_OUT_MONO
val pcmFloat = AudioFormat.ENCODING_PCM_FLOAT
val format = AudioFormat.Builder().setEncoding(pcmFloat).setSampleRate(sr).setChannelMask(mono).build()
val track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build()
)
.setAudioFormat(format)
.setBufferSizeInBytes(AudioTrack.getMinBufferSize(sr, mono, pcmFloat))
.setTransferMode(AudioTrack.MODE_STREAM).build()
track.play()
// 在 sherpa 的合成线程里阻塞写 AudioTrack,自然形成背压
tts.synthWithCallback(text) { samples ->
var offset = 0
while (offset < samples.size) {
val n = track.write(samples, offset, samples.size - offset, AudioTrack.WRITE_BLOCKING)
if (n < 0) break
offset += maxOf(n, 1)
}
1
}
track.stop(); track.release()
}
ENCODING_PCM_FLOAT 需要 API 21+。要兼容更低版本,用 FloatArray → ShortArray 转 16 bit 再写 ENCODING_PCM_16BIT。
6. VAD 与离线识别:什么时候该放弃流式
流式 Transducer 有个绕不开的短板:一句话中途改口要重算。如果业务更在意最终准确率(比如会议纪要),用 VAD 切句 + 离线模型识别会更稳。
kotlin
val vad = Vad(
assetManager = assets,
config = VadModelConfig(
sileroVadModelConfig = SileroVadModelConfig(
model = "silero_vad.onnx",
threshold = 0.5f,
minSilenceDuration = 0.25f,
minSpeechDuration = 0.25f,
windowSize = 512, // silero 用 512,ten-vad 用 256
maxSpeechDuration = 5.0f,
),
sampleRate = 16000,
numThreads = 1,
),
)
val offline = OfflineRecognizer(
assetManager = assets,
config = OfflineRecognizerConfig(
modelConfig = OfflineModelConfig(
senseVoice = OfflineSenseVoiceModelConfig(
model = "$modelDir/model.int8.onnx",
useInverseTextNormalization = true,
),
tokens = "$modelDir/tokens.txt",
numThreads = 3,
),
decodingMethod = "greedy_search",
),
)
Vad与OfflineRecognizer都是构造即加载模型,初始化耗时不容忽视,同样要放后台线程。
主循环很短,喂音频、收段、逐段识别:
kotlin
vad.acceptWaveform(samples, 16000)
if (vad.isSpeechDetected()) { /* 说话中,UI 可以显示「正在听」 */ }
vad.flush() // 收尾,把尾部残留吐出来
while (!vad.empty()) {
val seg = vad.front() // SpeechSegment(start, samples)
vad.pop()
val stream = offline.createStream()
stream.acceptWaveform(seg.samples, 16000)
offline.decode(stream)
val result = offline.getResult(stream)
println("${result.lang} ${result.text}") // 已带 ITN;lang / emotion 视模型而定
}
三种方案的取舍很清楚:
| 方案 | 延迟 | 准确率 | 适合 |
|---|---|---|---|
| 流式 Transducer | 低(首字 200--500 ms) | 中 | 实时字幕、语音输入 |
| VAD + 离线 Paraformer / SenseVoice | 高(等一句说完) | 高 | 纪要、质检、二次校对 |
| 两遍(2Pass:流式 + 离线重打分) | 低 | 高 | 既有实时性又要准确率 |
这三行不是三选一的死选项,但也别指望它们互相替代。按业务诉求挑一条主线,2Pass 作为特例只在明确要「实时 + 高准确」时才值得多背一份模型的代价,否则包体和复杂度一起翻倍。
7. 性能、包体与 NPU
性能优化的手段比较朴素,但每一条都有效:
| 手段 | 做法 | 收益 |
|---|---|---|
| int8 量化 | 用 .int8.onnx |
体积降 50% ~ 70%,RTF(实时率,耗时 ÷ 音频时长)明显下降 |
| 线程数 | numThreads = 2(低端)/ 3~4(旗舰),不建议给满 |
避免线程争抢导致掉帧 |
| 按需初始化 | 进入语音页再 init(),别在 Application 里做 |
冷启动少几百毫秒 |
| 预热 | 空闲时合成或识别一句短文本 | 首次交互延迟从「秒级」降到「百毫秒」 |
| 复用实例 | 引擎全局单例,用完别 release() |
避免反复加载模型的 IO 和内存抖动 |
| 控制推理频率 | 每累积 ≥ 100 ms 音频解一次 | 降低 CPU 占用和发热 |
| 及时释放 | stream.release() / 丢弃 GeneratedAudio |
防 OOM |
包体上,除了模型裁剪,ABI 也要收:
groovy
android {
defaultConfig {
ndk {
abiFilters 'arm64-v8a', 'armeabi-v7a' // 只留真机;调试模拟器时加 'x86_64'
}
}
splits {
abi {
isEnable = true
reset()
include 'arm64-v8a', 'armeabi-v7a'
isUniversalApk = false // 渠道包通常不需要 universal apk
}
}
}
打完包用 Android Studio 的 Build → Analyze APK 逐项确认:lib/ 下每个 ABI 只有两个 .so;assets/ 里没有 test_wavs/、.sh、README.md 和 fp32/int8 双份权重;模型没有重复打进 assets/ 和 jniLibs/。
关于运行时体积,官方文档给的数据是整个运行时约 7.2 MB,其中 libonnxruntime.so 约 5.8 MB,而且用的是 ONNX Runtime 的 Full build 。对包体敏感的话有两条路:换 ONNX Runtime 的 Mobile / 裁剪版;或者评估 sherpa-ncnn(官方数据运行时约 1.6 MB)。
模型分发策略:
| 策略 | 做法 | 适用 |
|---|---|---|
| 全内置 | 模型直接进 assets | 包体可接受,要求装完即用 |
| 首次释放 | assets 里放压缩包,首次启动解压到 filesDir | 压缩率高,省安装包 |
我这边走的是第二种。这个 App 的定位不是游戏,安装包往上堆一个量级就会劝退一批下载用户,所以宁可多一次解压,也要压住体积。
Android 10+ 分区存储:解压到
context.filesDir(内部存储)即可,不用处理 MediaStore 权限。
高端机上想再压延迟和功耗,可以走 QNN / HTP,Kotlin 侧改动不大:
kotlin
val config = OnlineRecognizerConfig(
modelConfig = OnlineModelConfig(
transducer = OnlineTransducerModelConfig(
encoder = "$modelDir/libencoder.so",
decoder = "$modelDir/libdecoder.so",
joiner = "$modelDir/libjoiner.so",
qnnConfig = QnnConfig(
backendLib = "libQnnHtp.so",
systemLib = "libQnnSystem.so",
contextBinary = "$modelDir/encoder.bin,$modelDir/decoder.bin,$modelDir/joiner.bin",
),
),
tokens = "$modelDir/tokens.txt",
provider = "qnn",
modelType = "zipformer",
),
)
配套有三个要求:libQnnHtp.so 和 libQnnSystem.so 要手动拷到 jniLibs/arm64-v8a/;模型必须用 android-aarch64 后缀的版本;首次启动会做 context 编译,可能慢到几十秒 ,务必给启动页留时间窗,并把 .bin 持久化下来复用。RK3588 用 provider = "rknn" + .rknn 权重,思路一致。
参考
- 仓库:github.com/k2-fsa/sher...
- 官方文档:k2-fsa.github.io/sherpa/onnx...
- Android 构建:k2-fsa.github.io/sherpa/onnx...
- ASR 模型:github.com/k2-fsa/sher...
- TTS 模型:github.com/k2-fsa/sher...
- Kotlin API 源码:
sherpa-onnx/kotlin-api/*.kt - NDK
-g问题:github.com/android/ndk...
离线语音这件事,最劝退的从不是写代码,而是模型效果体验和包体之间反复权衡,手机性能跑大模型本身就是吃力的事情(特别是在低端机上,并有适配问题),但是个别场景也不是不可取。