1. 为什么要在手机端跑语音模型把语音识别和语音合成塞进手机这个念头最早来自一个很实际的需求我手上有个离线笔记类的工具项目用户在地铁、电梯、山区这些没网的地方也想用语音转文字而且录音内容涉及个人隐私不想往云端传。一开始我试过调云端接口延迟先不说光是网络抖动就够让人抓狂一段十秒的录音有时候要等三四秒才出结果弱网环境下直接超时。后来把目光转向端侧推理试了几个方案最后落在 sherpa-onnx 上原因后面细说。sherpa-onnx 是 k2-fsa 团队做的一套端侧语音推理框架底层是 onnxruntime上面封装了流式和非流式的 ASR、TTS、VAD、说话人分离等能力支持 Android、iOS、Windows、Linux、macOS 甚至树莓派。它最大的特点是模型全部以 ONNX 格式分发推理不依赖 PythonC 核心加各语言绑定Android 上直接通过 JNI 调用。换句话说你不需要在手机里塞一个 Python 解释器也不需要联网模型文件拷进去就能跑。这篇文章面向的是想在 Android 上落地离线语音能力的开发者不管你是做输入法、会议记录、无障碍工具还是智能硬件配套 App只要涉及“手机本地跑 ASR/TTS”这里面的选型思路、集成步骤和踩坑经验都能直接参考。我会从整体设计讲到具体实现包括模型怎么选、JNI 怎么接、音频怎么喂、性能怎么调尽量把每个决策背后的理由说清楚而不是只丢一堆代码。需要提前说明的是端侧语音模型和云端大模型在效果上有差距尤其是远场、噪声、口音这些场景。但它的优势也很明确零延迟、零网络依赖、数据不出设备。你要做的是根据场景取舍而不是指望它全面碾压云端。下面进入正题。2. 整体方案设计与选型思路2.1 为什么是 sherpa-onnx 而不是其他方案端侧语音推理这条路能选的方案其实不多。我大致列一下当时对比过的几个方向方案优点缺点是否选用云端 API效果好、模型大依赖网络、隐私风险、按量计费否TensorFlow Lite 自训练生态成熟需要自己训练和转换、语音模型门槛高否PyTorch Mobile灵活包体积大、语音模型支持少否whisper.cpp识别效果好模型偏大、TTS 缺失、流式支持弱部分参考sherpa-onnxASR/TTS/VAD 全套、流式、模型小、跨平台文档偏工程化、中文资料少是选 sherpa-onnx 的核心理由有三点。第一它把 ASR 和 TTS 都覆盖了我不用为了两个能力引入两套框架JNI 层只维护一份。第二它原生支持流式识别这对实时字幕、语音输入这种场景是刚需whisper.cpp 在这方面要自己改。第三模型体积可控最小的中文流式模型量化后只有几十兆塞进 APK 或者放 assets 都行。2.2 端侧 ASR 和 TTS 的技术路线ASR 这边sherpa-onnx 支持两类模型流式streaming和非流式offline。流式模型用 zipformer 或 conformer 结构边说话边出字延迟低但精度略低非流式模型用 paraformer 或 whisper 结构等整段说完再识别精度高但需要等。我的做法是两者都集成短指令用流式长录音用非流式后面会讲怎么切换。TTS 这边sherpa-onnx 支持 VITS、Matcha、Kokoro 等结构。VITS 是端到端模型音质自然模型大小中等Matcha 基于 flow matching推理更快Kokoro 是较新的轻量模型多语言支持好。中文场景我最终选了 VITS 的中文模型原因是音质稳定、社区模型多、调参空间大。2.3 模型格式与推理引擎的关系所有模型都是 ONNX 格式推理引擎是 onnxruntime。这里有个关键点onnxruntime 在 Android 上有多个版本CPU 版、NNAPI 版、GPU 版。sherpa-onnx 默认用 CPU 版因为语音模型的算子对 NNAPI 支持不完整强行用反而可能回退到 CPU 还多一层开销。我的实测是 CPU 版加多线程就够了后面性能章节会给出具体数据。模型文件一般包含三部分encoder、decoder、joiner流式或者 encoder、decoder非流式外加 tokens.txt 词表。TTS 则是 model.onnx 加 tokens.txt 加 lexicon.txt如果有。这些文件打包进 assets 或者首次启动时下载取决于你的包体积预算。3. 环境准备与工程集成3.1 Android 工程的基础配置先说你需要的环境Android Studio 最新稳定版、NDK建议 r25 以上、CMake 3.22 以上、JDK 17。sherpa-onnx 的 Android 预编译包是 AAR 形式直接放到 app/libs 下然后在 build.gradle 里加依赖。android { defaultConfig { ndk { abiFilters arm64-v8a, armeabi-v7a } } } dependencies { implementation files(libs/sherpa-onnx.aar) }这里有个坑sherpa-onnx 的 AAR 里包含了 native so如果你同时引入了其他也带 onnxruntime 的库可能会符号冲突。我遇到过和某个图像推理库冲突的情况解决办法是统一用 sherpa-onnx 自带的 onnxruntime把另一个库的 so 排除掉。3.2 模型文件的放置策略模型放哪里直接影响 APK 体积和首次启动体验。三种方案全部打包进 assetsAPK 会大几十兆但用户装完就能用适合对体积不敏感的工具类 App。首次启动下载APK 小但首次使用要联网违背了离线初衷适合模型可选、用户能接受的场景。按需下载加本地缓存折中方案基础模型内置高级模型下载。我选的是第一种因为目标用户就是冲着离线来的装完不能用等于白装。具体做法是在 assets 下建 models 目录把 encoder.onnx、decoder.onnx、joiner.onnx、tokens.txt 放进去首次启动时拷贝到 filesDir因为 onnxruntime 从 assets 直接读会有性能问题拷贝到内部存储后加载更快。fun copyAssetDir(assetDir: String, destDir: File) { val assets context.assets.list(assetDir) ?: return destDir.mkdirs() for (name in assets) { val src $assetDir/$name val dest File(destDir, name) if (context.assets.list(src)?.isNotEmpty() true) { copyAssetDir(src, dest) } else { context.assets.open(src).use { input - dest.outputStream().use { output - input.copyTo(output) } } } } }注意拷贝操作要放在子线程几十兆的文件在主线程拷贝会直接 ANR。我一般用 WorkManager 或者协程的 Dispatchers.IO。3.3 JNI 接口的调用方式sherpa-onnx 的 Java API 封装得比较完整核心类是OnlineRecognizer和OfflineRecognizerTTS 是OfflineTts。以流式识别为例初始化大概是这样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, provider cpu ), decodingMethod greedy_search ) val recognizer OnlineRecognizer(config)numThreads这个参数很关键后面性能章节会专门讲。provider保持 cpu 就行别折腾 NNAPI。4. 核心细节解析与实操要点4.1 音频采集的参数选择端侧语音的第一道关是音频采集。Android 上录音用 AudioRecord参数必须和模型匹配否则识别率会断崖式下跌。sherpa-onnx 的模型基本都要求 16kHz、单声道、16bit PCM。如果你用默认的 44.1kHz 采集再重采样不仅多一层开销还可能引入混叠。val sampleRate 16000 val channelConfig AudioFormat.CHANNEL_IN_MONO val audioFormat AudioFormat.ENCODING_PCM_16BIT val minBuffer AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat) val bufferSize maxOf(minBuffer, sampleRate * 2) // 至少一秒的缓冲 val recorder AudioRecord( MediaRecorder.AudioSource.VOICE_RECOGNITION, sampleRate, channelConfig, audioFormat, bufferSize )AudioSource的选择有讲究。VOICE_RECOGNITION会启用系统的降噪和自动增益适合语音输入MIC是原始信号适合做 VAD 或者需要保留环境音的场景VOICE_COMMUNICATION针对通话优化延迟低但可能过度处理。我实测下来语音转文字用VOICE_RECOGNITION效果最稳。4.2 流式识别的数据喂入节奏流式识别的核心是“边录边喂”。AudioRecord 读到的 PCM 数据要转成 float 数组然后调用recognizer.acceptWaveform(samples, sampleRate)再调recognizer.decode()触发解码最后用recognizer.getResult()拿文本。val samples ShortArray(bufferSize / 2) val read recorder.read(samples, 0, samples.size) if (read 0) { val floatSamples FloatArray(read) { samples[it] / 32768.0f } recognizer.acceptWaveform(floatSamples, sampleRate) while (recognizer.isReady()) { recognizer.decode() } val text recognizer.getResult().text if (text.isNotEmpty()) { runOnUiThread { resultText.text text } } }这里有个节奏问题不要每读一小块就 decode那样 CPU 会被频繁唤醒。我的做法是累积到 100ms 左右的数据再喂一次既保证延迟可接受又降低解码频率。实测 100ms 的块大小在流畅度和功耗之间平衡最好。4.3 TTS 的文本预处理与播放TTS 这边sherpa-onnx 的OfflineTts接收文本返回一个GeneratedAudio对象里面是 float 采样和采样率。中文文本需要先做分词和注音模型自带的 lexicon 会处理但数字、英文、特殊符号要提前归一化。val tts OfflineTts(ttsConfig) val audio tts.generate(今天天气不错, 0, 1.0f) // audio.samples 是 FloatArrayaudio.sampleRate 是采样率播放的时候如果采样率不是 44.1kHz要用 AudioTrack 指定采样率别用 MediaPlayer它不支持任意采样率。我一般把 TTS 输出的 float 转成 16bit PCM再用 AudioTrack 的 STREAM 模式播放。实操心得TTS 生成是同步阻塞的长文本会卡住调用线程。一定要放到子线程生成完再切回主线程播放。另外生成的音频可以缓存起来同样的文本第二次直接播缓存省 CPU。4.4 VAD 的引入时机VAD语音活动检测在端侧非常有用它能判断用户什么时候开始说话、什么时候说完从而决定何时启动识别、何时结束。sherpa-onnx 自带 VAD 模型基于 silero-vad。引入 VAD 的好处是省电。没有 VAD 的话麦克风一直开着识别器一直跑电量掉得飞快。有了 VAD静音时只跑一个轻量的 VAD 模型检测到人声才唤醒识别器。我的实测是带 VAD 的方案比不带 VAD 省电约 40%。val vadConfig VadModelConfig( sileroVad SileroVadModelConfig( model $modelDir/silero_vad.onnx, threshold 0.5f, minSilenceDuration 0.5f, minSpeechDuration 0.25f ), sampleRate 16000 ) val vad VoiceActivityDetector(vadConfig, bufferSizeInSeconds 30f)threshold是判定阈值0.5 是默认值噪声大的环境可以调到 0.6安静环境可以降到 0.4。minSilenceDuration是判定“说完了”的静音时长0.5 秒比较符合中文说话习惯太短会截断句子太长会显得反应慢。5. 实操过程与核心环节实现5.1 从零搭建一个可运行的 Demo我按最小可用路径走一遍。新建 Android 项目minSdk 设 24因为 sherpa-onnx 的 so 最低支持到 24。把 AAR 放进 libs模型放进 assets/models。第一步写一个 ModelManager 负责拷贝模型和提供路径object ModelManager { private const val MODEL_DIR models fun ensureModels(context: Context): File { val dest File(context.filesDir, MODEL_DIR) if (!dest.exists() || dest.listFiles()?.isEmpty() ! false) { copyAssetDir(context, MODEL_DIR, dest) } return dest } }第二步封装 RecognizerHolder懒加载识别器避免每次识别都重新初始化object RecognizerHolder { private var recognizer: OnlineRecognizer? null fun get(context: Context): OnlineRecognizer { return recognizer ?: synchronized(this) { recognizer ?: createRecognizer(context).also { recognizer it } } } }第三步写录音和识别的循环用协程管理生命周期fun startRecognition(scope: CoroutineScope, onResult: (String) - Unit) { scope.launch(Dispatchers.IO) { val recognizer RecognizerHolder.get(context) val stream recognizer.createStream() val recorder createAudioRecord() recorder.startRecording() val buffer ShortArray(1600) // 100ms while (isActive) { val read recorder.read(buffer, 0, buffer.size) if (read 0) { val samples FloatArray(read) { buffer[it] / 32768.0f } stream.acceptWaveform(samples, 16000) while (recognizer.isReady(stream)) { recognizer.decode(stream) } val text recognizer.getResult(stream).text if (text.isNotEmpty()) { withContext(Dispatchers.Main) { onResult(text) } } } } recorder.stop() recorder.release() } }这套代码跑起来对着手机说话屏幕上就能实时出字。第一次跑通的时候延迟大概在 300ms 左右后面调优能压到 200ms 以内。5.2 非流式识别的接入方式流式适合实时字幕但如果你要识别一段已经录好的音频文件非流式模型精度更高。接入方式类似只是换成OfflineRecognizer一次性把整段音频喂进去。val config OfflineRecognizerConfig( modelConfig OfflineModelConfig( paraformer OfflineParaformerModelConfig( model $modelDir/paraformer.onnx ), tokens $modelDir/tokens.txt, numThreads 2 ) ) val recognizer OfflineRecognizer(config) val stream recognizer.createStream() stream.acceptWaveform(samples, 16000) recognizer.decode(stream) val text recognizer.getResult(stream).text非流式模型我选的是 paraformer中文效果好模型大小适中。whisper 系列虽然多语言强但中文场景下 paraformer 的性价比更高尤其是短音频。5.3 TTS 的完整播放链路TTS 从文本到声音中间有几个环节容易出问题。文本归一化、模型推理、音频格式转换、播放。我写了一个 TtsPlayer 类把这些串起来class TtsPlayer(private val context: Context) { private val tts: OfflineTts by lazy { val config OfflineTtsConfig( model OfflineTtsModelConfig( vits OfflineTtsVitsModelConfig( model $modelDir/tts.onnx, tokens $modelDir/tokens.txt, lexicon $modelDir/lexicon.txt ), numThreads 2 ) ) OfflineTts(config) } fun speak(text: String) { val audio tts.generate(text, 0, 1.0f) val pcm FloatArrayToShort(audio.samples) val track AudioTrack.Builder() .setAudioFormat( AudioFormat.Builder() .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .setSampleRate(audio.sampleRate) .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) .build() ) .setBufferSizeInBytes(pcm.size * 2) .setTransferMode(AudioTrack.MODE_STATIC) .build() track.write(pcm, 0, pcm.size) track.play() } }MODE_STATIC适合短音频一次性写入再播放延迟低。长文本用MODE_STREAM边写边播避免内存占用过大。5.4 参数调优的实测数据我在一台中端机骁龙 7 系上做了几组对比数据如下配置流式识别延迟CPU 占用内存占用numThreads1450ms18%120MBnumThreads2280ms32%135MBnumThreads4260ms55%160MBnumThreads2 VAD290ms22%140MB结论很清晰numThreads2是甜点再加线程收益递减但功耗上升。带 VAD 的方案虽然延迟略高但 CPU 占用明显下降长时间使用更划算。TTS 这边VITS 模型生成一句 10 字的中文耗时约 200ms采样率 22050Hz。Matcha 模型能压到 120ms但音质略逊。如果你的场景对延迟敏感比如语音助手Matcha 更合适如果追求音质VITS 是首选。6. 常见问题与排查技巧实录6.1 识别结果为空或乱码这是最常见的问题原因通常有三个。第一采样率不匹配模型要 16kHz 你喂了 44.1kHz识别出来全是乱码。排查方法是打印实际采集的采样率和模型配置对比。第二音频格式不对模型要 float 归一化到 [-1, 1]你喂了原始 short结果也是乱的。第三tokens.txt 路径错误识别器加载了错误的词表。避坑技巧初始化识别器后先喂一段已知的测试音频确认能出正确文本再接入实时录音。这样能把问题隔离在模型加载阶段而不是录音阶段。6.2 首次识别特别慢第一次调用识别时onnxruntime 要做图优化和内存分配耗时可能是后续的 5 到 10 倍。解决办法是在 App 启动后、用户还没点录音之前先跑一次空音频或者短音频预热。我的做法是在 Application 的 onCreate 里起一个后台协程喂 1 秒静音数据把识别器“热”起来。6.3 长时间运行后内存增长如果每次识别都新建 stream 而不释放内存会持续增长。stream 用完必须调stream.release()识别器本身可以复用。另外AudioRecord 的 buffer 不要开太大按需分配用完释放。try { // 识别逻辑 } finally { stream.release() }6.4 TTS 播放有杂音或断断续续杂音通常是采样率不匹配或者 PCM 转换时溢出。float 转 short 的时候要 clamp 到 [-32768, 32767]否则会回绕产生爆音。fun FloatArrayToShort(samples: FloatArray): ShortArray { return ShortArray(samples.size) { (samples[it] * 32767).coerceIn(-32768f, 32767f).toInt().toShort() } }断断续续则是 AudioTrack 的 buffer 太小或者写入速度跟不上播放速度。用MODE_STREAM时buffer 至少设成采样率的两倍。6.5 常见问题速查表现象可能原因排查方向识别为空采样率不匹配检查 AudioRecord 和模型配置识别乱码音频未归一化确认喂入的是 float [-1,1]首次慢图优化未预热启动时预热一次内存增长stream 未释放检查 release 调用TTS 爆音PCM 转换溢出加 clampTTS 断续buffer 太小增大 AudioTrack buffer功耗高无 VAD引入 VAD 降低唤醒频率6.6 模型选择的经验之谈最后说模型选择。中文流式识别zipformer 系列是首选模型小、速度快、精度够用。非流式识别paraformer 中文效果最好whisper 适合多语言但模型大。TTS 中文VITS 音质最自然Matcha 速度最快Kokoro 多语言支持好但中文模型还在完善。模型不是越大越好。我试过一个 200MB 的大模型识别精度确实高一点但加载慢、内存占用大中低端机上体验很差。最后换回 60MB 的量化模型精度差距在可接受范围内体验反而更好。端侧场景体积、速度、精度三者要平衡别一味追求精度。另外模型文件建议做 MD5 校验防止拷贝过程中损坏导致加载失败。这个坑我踩过一次排查了半天才发现是模型文件不完整。7. 性能优化与功耗控制7.1 线程模型的调整sherpa-onnx 内部用 onnxruntime 的线程池numThreads控制的是算子级并行。但 Android 上还有一层就是你的录音线程和解码线程。我的做法是把录音和解码放在同一个 IO 协程里避免线程切换开销。如果解码耗时超过录音间隔就加大录音 buffer让解码有更多时间。7.2 模型量化与算子优化ONNX 模型可以量化成 int8体积缩小到四分之一推理速度提升明显。sherpa-onnx 官方提供的模型很多已经是量化版。如果你自己转换模型用 onnxruntime 的量化工具注意保留语音相关的敏感算子不量化否则精度掉得厉害。7.3 按场景切换模型不是所有场景都需要流式。语音输入法需要实时出字用流式录音转文字可以等说完再转用非流式。我的 App 里做了个开关用户选“实时模式”就加载流式模型选“高精度模式”就加载非流式模型。这样既满足不同需求又不会同时加载两套模型占内存。7.4 后台与前台的生命周期管理语音功能在后台运行时Android 会限制 CPU 和网络。如果你的 App 需要在后台持续监听要用前台服务加通知。但要注意长时间后台录音很耗电用户可能不买账。我的做法是后台只保留 VAD检测到人声才唤醒识别并且加一个超时自动停止。8. 一些个人体会这套方案我在两个项目里落地过一个是离线笔记工具一个是智能硬件的配套 App。最大的感受是端侧语音的门槛不在模型本身而在工程细节。采样率、线程、内存、功耗每一个都能让你调半天。sherpa-onnx 把模型推理这层封装得很好但音频链路和生命周期管理还是得自己写。另一个体会是别指望端侧模型能处理所有场景。远场、多人对话、强噪声这些还是云端模型的强项。端侧的价值在于隐私、延迟和离线可用把这三点做好用户就会买单。我见过有人硬要把端侧模型做到云端效果结果模型大到手机跑不动得不偿失。最后分享一个小技巧调试阶段把识别中间结果和耗时打到 Logcat用adb logcat实时看比在 UI 上显示更直观。尤其是排查延迟问题时能看到每一步的耗时分布很快就能定位瓶颈。