把语音模型塞进手机:sherpa-onnx 在 Android 上的 ASR / TTS

手机做语音功能,最省事的做法是调云端 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 权重,思路一致。


参考


离线语音这件事,最劝退的从不是写代码,而是模型效果体验和包体之间反复权衡,手机性能跑大模型本身就是吃力的事情(特别是在低端机上,并有适配问题),但是个别场景也不是不可取。

相关推荐
deli0074 小时前
DLA 枝晶生长实测:5000 个粒子集体停摆之后,分形维数收敛到 1.70
前端
IT_陈寒4 小时前
Redis卡顿的锅,这次真不是大key的错
前端·人工智能·后端
可乐鸡翅yeah_4 小时前
video.js 集成 hls.js 开发 M3U8 播放器,新手高频踩坑
开发语言·前端·javascript·后端·ecmascript·m3u8·音视频在线播放
程序猿追4 小时前
HarmonyOS 6 上做个极简浏览器:Web 组件 + 前进后退 + 加载进度
前端·华为·harmonyos
沐沐师4 小时前
Mybatis 框架教程
前端
三天不学习5 小时前
Tailwind CSS 快速入门(2026 版):从 v4 零配置上手,到「该不该用、怎么用好」的选型实战
前端·css·tailwind
明月_清风5 小时前
面对陌生的 GitHub 项目无从下手?这 4 个网站帮你快速读懂源码
前端·后端·github
htzyl2065 小时前
前端阶梯——第九章、颜色、文本与背景
前端·css
zhangzeyuaaa5 小时前
Ruby `require` 完全指南:从 `$LOAD_PATH` 到 `require_relative`
服务器·前端·ruby