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

文章来源声明: 原文作者:libokaifa; 来源站点:掘金; 原文链接:https://juejin.cn/post/7694490288811540520; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

适合准备落地离线语音的 Android 团队直接抄作业:选型表、包体裁剪与线程治理清单可复用,能避开词典缺失、采样率不一致这类「不报错却没声音」的隐形故障。

手机做语音功能,最省事的做法是调云端 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,包体、内存、发热都要自己设计。


  1. 完整链路

流式 ASR 比批处理的 要快些。处理单元较小

流式 ASR 的数据流是这样的:

麦克风  (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 则简单得多:

文本 + 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/ 是最好的参考。


  1. 落地三步

3.1 拿到原生库

方案 A:下预编译库,生产首选

# 版本号务必用 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 一套,拷贝到工程对应目录:

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 会干净很多:

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:

dependencies {
    implementation files('libs/sherpa-onnx-1.13.8.aar')
}

3.2 选模型

模型都在 GitHub Releases 上,命名有规律:

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 两套权重,二选一即可,切勿两套同时入包:

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 目录与权限

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 决定,内部二选一,不能混用:

// 从 assets 读(模型随 APK 交付)
val recognizer = OnlineRecognizer(assetManager = assets, config = config)

// 从文件系统读(模型放在 filesDir / 下载目录),路径要绝对路径
val recognizer = OnlineRecognizer(assetManager = null, config = config)

怎么选?小模型(100 MB 以内)直接进 assets,装完即用;大模型走 filesDir + 首次启动释放,记得配一份完整性校验(文件大小或 MD5)。既保证离线可用,又不把安装包撑到几百 MB。

权限上加两个,其中录音权限必须运行时申请:

<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

后台或锁屏还要录音的话,得走前台服务,Android 10+ 还得声明类型:

<service
    android:name=".AsrService"
    android:foregroundServiceType="microphone"
    android:exported="false" />

混淆规则要保留 Kotlin 绑定类,不然 release 包一跑就崩:

-keep class com.k2fsa.sherpa.onnx.** { *; }
-keepclasseswithmembernames class * { native <methods>; }


  1. 流式 ASR:封装一个能用的引擎

sherpa-onnx 的 Android 示例没有内置录音类,录音和播放都用原生 AudioRecord / AudioTrack,全链路统一 16 kHz 单声道。这两个约定一旦不统一,症状是「乱码或单字堆积」,而且不会抛异常。

先把音频底座准备好:

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、端点规则都在这里定:

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
        ),
    )

解码循环是引擎的核心:

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
    }
}

接上录音就完整了。


  1. TTS:合成与播放

初始化和合成,VITS 模型的三个文件缺一不可:

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 模式把首包延迟压到最低:

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。

  1. VAD 与离线识别:什么时候该放弃流式

流式 Transducer 有个绕不开的短板:一句话中途改口要重算。如果业务更在意最终准确率(比如会议纪要),用 VAD 切句 + 离线模型识别会更稳。

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 都是构造即加载模型,初始化耗时不容忽视,同样要放后台线程。

主循环很短,喂音频、收段、逐段识别:

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 作为特例只在明确要「实时 + 高准确」时才值得多背一份模型的代价,否则包体和复杂度一起翻倍。


  1. 性能、包体与 NPU

性能优化的手段比较朴素,但每一条都有效:

手段做法收益
int8 量化用 `.int8.onnx`体积降 50% ~ 70%,RTF(实时率,耗时 ÷ 音频时长)明显下降
线程数`numThreads = 2`(低端)/ `3~4`(旗舰),不建议给满避免线程争抢导致掉帧
按需初始化进入语音页再 `init()`,别在 Application 里做冷启动少几百毫秒
预热空闲时合成或识别一句短文本首次交互延迟从「秒级」降到「百毫秒」
复用实例引擎全局单例,用完别 `release()`避免反复加载模型的 IO 和内存抖动
控制推理频率每累积 ≥ 100 ms 音频解一次降低 CPU 占用和发热
及时释放`stream.release()` / 丢弃 `GeneratedAudio`防 OOM

包体上,除了模型裁剪,ABI 也要收:

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 侧改动不大:

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 权重,思路一致。


参考


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