资讯详情

资讯详情

端侧语音识别与合成落地实践:基于sherpa-onnx的Android离线方案

语音识别和语音合成在绝大多数人印象里都是要联网才能用的能力。确实很长一段时间里我也默认把 ASR / TTS 当成云端服务来调录音传上去等待返回再处理结果。直到一个必须完全离线运行的功能需求砸过来我才意识到把语音模型真正塞进手机里并不是什么异想天开而是一套有成熟工具链的工程方案。这套方案的核心引擎我用的是 sherpa-onnx。它让我在 Android 上同时跑通了离线语音识别和离线语音合成而且效果能直接用在产品里。这篇文章就把我整个落地过程的思路、代码和踩坑记录整理出来。如果你正准备做端侧语音能力或者只是好奇手机本地能不能跑动现在这些语音模型往下看应该能省不少时间。文章里不涉及复杂的算法推导更多是从工程集成角度讲清楚怎么做、为什么这么做、哪些坑不能踩。1. 端侧语音识别与合成我为什么放弃云端方案1.1 云端方案的三座大山很多人第一反应是语音识别这种事直接接云服务不就行了稳定性有保证模型效果也更好。这个说法在联网环境下没毛病。可我遇到的实际场景偏偏不允许这么干。第一个问题是延迟。云端识别从录音开始算要把音频数据传到服务器服务器等整个句子说完再做解码再把文本回传。一进一出网络里走的不仅是数据还叠加了排队和处理时间。即使不考虑带宽一次完整识别的往返延迟也常常在 300 到 800 毫秒之间。对于交互性不强的应用还能接受可一旦要做类似“边说边出字幕”的效果这个延迟就非常尴尬。端侧识别可以把处理压缩在设备内部识别完一段音频后拿到文本的时间基本等于模型推理时间。第二个问题是隐私和合规。语音里包含的内容可能非常敏感医疗记录、商务会议、个人日记甚至孩子的声音。如果录音要传出去无论厂商怎么承诺“不留存”心理上和技术上都很难做到完全安心。客户端本地处理音频文件不出设备这是本质上的区别。对一些企业内部工具、隐私敏感应用来说离线能力几乎是硬性要求。第三个问题更朴素离线场景下根本连不上网。飞机上、地下室、偏远地区还有那种专门采购的、不开放网络权限的定制设备。云端方案在这些地方直接失效。项目上要的是“任何情况下都能用”不是“大多数情况下能用”。所以问题变成能不能在手机上直接跑一个足够好的模型答案是可以而且不只是理论可行是已经有开箱即用的开源方案。1.2 sherpa-onnx 到底解决了什么问题sherpa-onnx 是一个基于 ONNX Runtime 的端侧语音推理库。它不负责训练模型也不只支持一种模型而是把已经训练好的语音识别和语音合成模型转换为 ONNX 格式然后再通过统一的接口供移动端调用。换句话说它解决的是“模型落地”这一步。我最早接触它的时候项目已经有训练好的模型但卡在怎么在 Android 上跑起来。直接拿训练框架部署不现实体积太大依赖太重手写 C 推理又复杂用第三方服务又违背离线需求。sherpa-onnx 最吸引我的地方是它把底层推理封装好了对外暴露的接口很稳定支持 ASR、TTS还有 VAD、说话人嵌入等周边能力。开发者只需要准备好标准模型文件关注业务逻辑就行。它支持 Android、iOS、Linux、Windows甚至还覆盖嵌入式设备。对 Android 开发来说可以直接集成 AAR 包也可以通过源码编译出自己需要的 so 库。模型方面支持 zipformer、paraformer、sensevoice 等识别架构也支持 vits 这种合成架构。模型文件经过量化后体积和运行速度都能达到移动端可用水平。1.3 适合端侧语音的场景边界端侧方案当然不是万能钥匙。它有自己的能力边界需要在技术选型时想清楚。拿识别来说端侧模型要兼顾体积和算力词表和模型容量很难跟云端超大模型比。复杂方言、背景噪音很大的环境、超长的自由对话端侧识别率会明显不如云端。更大的限制是内存和发热。手机端模型推理需要 CPU 或 NPU 参与长时间高负载运行会导致发热降频所以“扛住偶尔一两句识别”和“连续录音一小时”是完全两码事。反过来如果需求是固定场景下的命令词识别、实时字幕、语音播报、本地语音助手端侧方案完全够用。我记得当时把模型跑通后对着手机说一句“打开闹钟”从录音结束到文本上屏只用了不到 200 毫秒这个体验已经很接近本地应用的体感了。语音合成方面端侧模型生成的音色已经能做到自然可听完全能满足导航播报、提示音、内容朗读这类需求。所以我的建议是先别问端侧行不行先列出自己的场景约束。只要对延迟、隐私、离线有硬指标端侧就是值得优先考虑的路线。2. Android 端项目设计与模型选型2.1 先想清楚需求和指标动手集成之前我其实花了不少时间在“需求定义”上。最忌一上来就找一个最大最全的模型塞进去结果包体爆炸手机还带不动。我当时的需求是这样的Android 设备上做一个离线语音助手用户说话之后本地识别成文字再根据文字内容触发动作最后用语音合成播放回复。应用要求不联网AR 产品经理给的响应指标是“用户说完最后一个字后屏幕反馈时间不超过 300 毫秒”。模型必须在普通中端手机上跑得动发热不能太夸张。这里有两个容易被忽略的技术点一个是“用户说完最后一个字”不等于“音频采集结束”。语音识别需要先判断用户是否说完这有端点检测的逻辑。另一个是识别出的文字要能稳定匹配到预先定义的动作词否则会经常误触发。这两点在模型选型和参数配置时都要考虑进去。2.2 ASR 模型的选择思路sherpa-onnx 官方仓库里提供了很多已经转换好的模型可以直接下载使用。模型格式上识别模型一般包含一个.onnx文件和一个tokens.txt词表文件。真正要花心思的是选哪种架构。我对比过 zipformer、paraformer、sensevoice 这些常见架构。简单说paraformer 偏向离线整句识别对长句效果更好但要求整段音频都到手后才能开始解码不适合流式场景。zipformer 更擅长流式识别可以做到边说边出结果延迟更低。sensevoice 在多语种和热词上有优势不过体积通常更大。由于我的交互场景要求边说话边给实时结果我最终选择了基于 zipformer 的流式中文模型。模型大小也需要认真看。同样的架构有 fp32 版本和 int8 量化版本。量化后的识别模型体积只有几十到两百兆识别字准率损失往往在可接受范围内。我在中端测试机上试过量化模型加载时间在 1 到 3 秒单句识别 CPU 占用可以压到很低。对产品来说包体和内存要优先省反正量化模型效果够用。还要考虑词表。中文识别模型的 tokens 文件通常是几千个汉字和子词如果应用里有固定的人名、地名或专业术语光靠模型本身容易识别错。sherpa-onnx 支持热词和规则干涉我最后在模型之外又加了一层自定义规则把特定词手动映射成目标文本实际效果提升远超预期。2.3 TTS 模型的选择思路语音合成模型我用的主要是 vits 架构。sherpa-onnx 里的 vits 模型一般会拆成四个文件模型本体.onnx、词表tokens.txt、词典lexicon.txt、以及特殊符号和音素信息所在的数据目录。少一个都加载不了这点后面还会细说。选 TTS 模型不能只看音色好不好听还要看采样率和中文发音质量。官方模型仓库里有很多中文 vits 模型采样率有 22050 Hz 也有 24000 Hz音色从单人发音到多说话人都有。多说话人模型会多一个说话人 ID 参数调用时要用指定音色。我选了一个训练比较充分的中文模型声音自然度能接受延迟也很低。vits 模型体积一般比识别模型小不少通常几十兆。在功能上合成一句十几个字的回复耗时在几百毫秒到一秒钟之间。和云端几十种音色、超强韵律控制相比肯定有差距但对一个助手类应用来说这种本地音色已经能给用户带来“手机在与自己对话”的感觉了。2.4 模型与包体的平衡方案所有模型都塞进 APK 是最简单的方式但也最粗暴。我当时算过识别模型加合成模型加必要的词典总大小超过 200 兆。对于一个功能单一的应用这个包体显然太臃肿。于是我们采用了“APK 内只放最小占位模型 首次启动后台下载完整模型”的方案安装包控制在 20 兆左右用户首次联网启动时从自己的服务器拉取模型文件缓存到应用专属目录。这样做的好处不仅是包体小还方便以后更新模型而不用重新发版。代价是下载模块要自己做还要处理断点续传和校验。如果应用不能联网那就只能把模型放 assets 里或者引导用户通过本地文件导入。两种路线没有谁绝对好完全取决于产品约束。做技术选型时要记住模型文件本质上就是数据资产应该跟代码分离管理。3. 把 ASR 和 TTS 接进 Android代码级接入过程3.1 工程前置准备这一步看起来简单但很多人一上来就卡住。我用的集成方式是下载官方预编译好的 AAR 包把sherpa-onnx.aar放进工程的app/libs目录然后在build.gradle里声明implementation files(libs/sherpa-onnx.aar)AAR 包内部已经包含 Android 各架构的 so 库。如果你的设备只支持arm64-v8a建议在defaultConfig里显式声明ndk { abiFilters arm64-v8a }只保留这一个架构能让 APK 体积少掉一半以上。如果还需要兼容老设备再酌情加入armeabi-v7a。不建议同时打包x86模拟器架构否则调试时虽然便利发布时却会无谓增加体积。工程里还需要申请录音权限在AndroidManifest.xml中加入uses-permission android:nameandroid.permission.RECORD_AUDIO /动态权限申请也要在启动时完成我也因为忘记动态申请而出现过“授权后不回调”的诡异问题。这些都是 Android 基础工程常识但放到语音集成场景里哪怕一个小配置错误都可能被误判成“sherpa-onnx 不行”。模型文件建议放在filesDir或cacheDir下。如果放 assets要注意不能直接用路径加载需要先拷贝到可访问的目录。我用的是首次启动时从服务器下载下载完成后把模型路径传给识别器。3.2 流式识别模块接入sherpa-onnx 的 Android API 中流式识别的核心概念是OnlineRecognizer和OnlineStream。前者负责加载模型和上下文后者代表一条需要被识别的话音流。每当麦克风采集到音频数据就把 PCM 采样数据送进 stream然后循环解码最终从 stream 上取结果。我整理的接入代码大致如下关键步骤我都加了注释// 初始化识别器全局只做一次 OnlineModelConfig modelConfig new OnlineModelConfig(); modelConfig.setModel(asrModelPath); // zipformer/q8.onnx modelConfig.setTokens(tokensPath); // tokens.txt modelConfig.setNumThreads(4); OnlineRecognizerConfig recognizerConfig new OnlineRecognizerConfig(); recognizerConfig.setModelConfig(modelConfig); recognizerConfig.setEnableEndpointDetector(true); OnlineRecognizer recognizer new OnlineRecognizer(recognizerConfig); OnlineStream stream recognizer.createStream(); // 录音数据回调里把 short[] 音频数据送入流 recognizer.acceptWaveform(stream, samples); // 拉取当前解码结果 while (recognizer.isReady(stream)) { recognizer.decode(stream); } String text recognizer.getResult(stream).getText().trim();有一段需要特别说明我也在这里栽过跟头送给识别器的音频采样率必须和模型要求的一致。大部分中文 ASR 模型要求 16 kHz也就是每秒钟 16000 个采样点。如果你的录音器用的是 44.1 kHz 或 48 kHz必须先重采样到 16 kHz再转成short[]。否则识别结果会出现大量乱码或直接为空。另外流式识别最好“边说边送”。不要等用户说完一整段再一次性把长音频塞进去否则会失去实时性。麦克风采集间隔通常设置为 20 到 40 毫秒一帧每帧音频都调用一次acceptWaveform。3.3 离线合成模块接入TTS 的接入比 ASR 简单不少。核心类是OfflineTts。初始化时把 vits 模型相关文件配好然后调用speak方法返回合成音频。我一般再把音频交给AudioTrack播放。代码如下OfflineTtsModelConfig modelConfig new OfflineTtsModelConfig(); modelConfig.setVitsModel(ttsModelPath); // vits.onnx modelConfig.setVitsLexicon(lexiconPath); // lexicon.txt modelConfig.setVitsTokens(tokensPath); // tokens.txt modelConfig.setVitsDataDir(dataDir); // data 目录 OfflineTtsConfig ttsConfig new OfflineTtsConfig(); ttsConfig.setModel(modelConfig); OfflineTts tts new OfflineTts(ttsConfig); // 合成 GeneratedAudio audio tts.speak(你好我已为你打开离线模式); short[] samples audio.getSamples(); int sampleRate audio.getSampleRate();拿到samples后就可以用系统AudioTrack播放。要注意sampleRate不一定是 44100必须从GeneratedAudio里读取而不是自己硬编码。我之前偷懒写死了 24000结果换了一个模型后播放声音变得又闷又抖排查半天才意识到是采样率对不上。vits 模型还支持调节语速和音调调用时传入参数即可。正常语速设 1.0作提示音时可以稍微加快一点避免机器人感过重。3.4 容易忽略的配置细节sherpa-onnx 的配置项不少大部分用默认值就能跑。不过下面几个细节我建议集成时一定确认第一模型文件和 tokens 文件必须配对。不同模型之间的 tokens 完全不一样拿 A 模型的 token 文件去加载 B 模型结果基本是胡言乱语。第二初始化和释放要配对。Android 的 JNI 层对象在不用时要显式释放否则 native 内存会一直占着。第三别把识别和合成放在同一个线程。识别是 CPU 密集任务合成也会有突发耗时如果都塞在主线程容易卡 UI。我通常让录音和识别跑在HandlerThreadTTS 则使用线程池。模型所在路径也不要有中文和特殊字符。有些设备的文件系统对路径处理不一致中文路径会导致 so 库读取失败。这个坑很低级但遇到过的人都知道它有多隐蔽。4. 性能调优如何让模型在手机上跑得更稳4.1 线程数与解码策略怎么定sherpa-onnx支持设置推理线程数这个参数直接影响 CPU 占用和实时率。理论上线程数越多推理越快但移动端 CPU 核心数有限线程一多反而会因为调度和功耗发热导致性能下降。我测试下来设置 4 个线程是大多数中端机型的甜点位。追求极致流畅时可以试着动态调整设备温度低时用 4 线程温度高时降到 2 线程。流式识别的解码节奏也需要控制。每次decode调用都会做一部分推理如果过于频繁CPU 会持续满载如果间隔太长又会让“边说边出字”变卡顿。我的经验是每收到 10 到 20 帧音频后再调用一次decode既可以保持流畅度又能避免无意义空转。对于非实时场景比如一次识别一段预先录制好的音频文件解码策略可以更激进直接把整段音频送入流然后循环decode直到取完结果。实时交互场景则要反过来尽量用小步长解码换来更平滑的结果输出。4.2 模型加载与内存控制模型加载最怕的是“每次使用都重新 new”。语音模型动辄几十兆上百兆解析 ONNX 文件、加载参数、初始化上下文整个流程耗时可能达到秒级。如果每次识别、每次合成前都加载一次用户根本没法用。正确做法是把OnlineRecognizer和OfflineTts作为应用级单例持有。项目启动时异步加载等加载完成后对外暴露 ready 状态。识别和合成过程中不要再创建底层引擎。我的实现里App 启动时用一个后台任务预热引擎用户真正触发语音时引擎早已就绪响应速度就非常快。内存方面也要有控制意识。加载一个识别模型再加载一个合成模型Java 堆可能只有几十兆的浮动但 Native 内存会明显上涨。如果模型文件特别大建议用small model或量化版。还有一个基础但重要的点tokens.txt、lexicon.txt这些辅助文件加载进内存后也不会自动释放尽量不要频繁替换模型文件否则容易触发系统层面的内存压力。4.3 生命周期与实例复用Android 组件有明确的生命周期语音引擎的创建和释放最好和Application绑定而不是和某个Activity绑定。如果把引擎放在Activity里每旋转一次屏幕、每进入一次页面都会重新加载模型用户会明显感到卡顿和发热。我推荐的模式是在Application.onCreate里初始化引擎并持有引用Activity只需要判断引擎是否 ready如果没有 ready就显示一个加载提示并等待全局回调。应用退出时再在Application.onTerminate注意只有模拟器会回调或自己封装的生命周期接口里释放引擎。有一个细节值得注意录音时要使用单例的AudioRecord实例不要每次启动都重新创建和释放。频繁初始化和释放AudioRecord会引入 200 到 500 毫秒的启动延迟再加上模型初始化很容易超过需求里的 300 毫米指标。4.4 提升识别和合成质量的土办法模型本身有上限但工程层面还能再榨出一些效果。识别方面我的做法是设置热词。sherpa-onnx 支持在识别配置里加入热词列表和权重比如把应用内的功能名词全部加入权重设到 5 左右。实测中专业术语和自定义词的正确率能提升明显。音频质量方面手机自带的麦克风采集数据会受环境影响。如果产品的使用场景在车内或户外建议在送入识别器之前先做降噪处理。sherpa-onnx 本身有 VAD语音活动检测可以过滤掉静音片段配合降噪算法识别准确率会有可感知的提升。TTS 提升则主要靠缓存。相同文本不重复合成先把音频结果缓存到内存或磁盘。像“好的”“收到”这类高频提示语提前合成好播放时几乎没有延迟。另外vits 模型有些多说话人模型支持不同音色在播报新闻、温馨提示、错误反馈时可以切换不同声音产品体验会活泼很多。5. 实操中踩过的坑与排查手记5.1 so 库加载失败第一个坑是UnsatisfiedLinkError。报错大概长这样java.lang.UnsatisfiedLinkError: dlopen failed: library libsherpa-onnx-jni.so not found原因基本都是 AAR 包没有正确包含所需架构的 so 库或者是abiFilters过滤掉了真机架构。我在一个 32 位的老测试机上跑过结果因为只打了arm64-v8a而崩溃。解决办法有两个要么扩大abiFilters要么放弃 32 位设备。现在 64 位中端机已经普及建议只保留arm64-v8a既省空间又减少兼容负担。如果是自己编译 so 库还要注意 NDK 版本。sherpa-onnx 官方推荐的 NDK 版本和工程用的版本不一致时编出的 so 库可能无法加载报错信息很隐晦往往要去读logcat里更底层的dlopen原因。5.2 识别不出有效内容我遇到的最诡异问题是初始化正常、模型加载正常、录音也在进行但getResult永远是空字符串或者偶尔出一个单字。排查了半天最后发现是录音器采样率和模型要求不一致。我最初用的是 48 kHz 录音模型要求 16 kHz导致送入引擎的音频频率不对语音识别器根本“听”不明白。第二个常见原因是acceptWaveform传入的数组长度不是采样率帧对齐的长度。sherpa-onnx 的流式接口对每帧数据长度并不严格但如果你的数据一次性塞得太少比如只有几十个采样点会导致模型内部状态更新不及时结果看起来就跟死掉一样。建议每次送入 320 个采样点16 kHz 下正好 20 毫秒或 640 个采样点。还有一个小概率原因模型文件本身损坏。下载过程中断或者文件被不完整写入也会导致识别结果不可靠。这种情况下就要做文件 MD5 校验或者重新下载。5.3 合成语音播放异常TTS 的常见问题集中在播放环节。一个是播放杂音通常是因为AudioTrack的采样率没有和GeneratedAudio.getSampleRate()对齐。另一个是某句话合成时间特别长甚至卡住 UI多半是在主线程直接调用了speak。TTS 合成虽然比识别快但一句十几个字的文本也可能需要几百毫秒加上网络或文件IO容易超过用户忍受范围。正确做法是放到子线程或者用一个队列顺序合成。还有一次我在初始化时少传了dataDir导致所有文本合成出来都像在说乱码。vits 模型和数据目录关系紧密缺少音素统计文件模型就不知道怎么发音。检查模型文件是否齐全是和“检查代码有没有报错”同样重要的事情。5.4 常见问题速查表我把下面这些问题整理成一张速查表集成时可以直接对照排查。现象可能原因解决方式so 库找不到abiFilters 不含真机架构只保留 arm64-v8a 或增加对应架构识别结果全为空采样率不匹配将录音重采样到模型要求的 16 kHz识别结果乱码tokens 文件和模型不匹配从相同模型目录获取 tokens.txt模型加缓慢使用未量化的 fp32 模型换 int8 量化模型合成音频变调AudioTrack 采样率写死从 GeneratedAudio 获取真实采样率合成文本乱读vits 数据目录缺失补全 lexicon.txt 和 data 目录内存持续上涨每次使用都 new 引擎改为单例加载应用退出时释放点击录音无反应动态权限没有申请检查 RECORD_AUDIO 运行时授权这张表解决了我项目里九成以上的问题。剩下的一成基本是设备兼容或模型本身瑕疵需要针对具体机型和日志再单独分析。6. 写在最后我的几点真实体会整套流程跑下来我的最大感受是端侧语音方案比想象中成熟也比想象中挑细节。成熟的是工具链sherpa-onnx 已经把推理、格式转换、资源管理都封装好了不是那种需要从头造轮子的状态。挑细节的是模型选择、参数配置、音频链路任何一个地方不按模型规格来效果都会打折扣。在实际操作中我最后悔的是没有一开始就做“基线测试”。应该先选一个公开的中文模型用官方 demo 跑通端到端流程记录延迟、CPU 和内存再逐步替换成自己的模型。这样即使出了问题也知道究竟是模型的问题还是集成的问题排查起来不会眉毛胡子一把抓。如果你正在规划移动端离线语音功能我建议把优先级的顺序定为先确认场景到底有多依赖离线再选一个尽量小的量化模型跑通全链路最后才去抠热词和音色这些体验细节。不要在第一天就纠结音色是不是真人级、识别率是不是百分之百。先让“从麦克风到文本、从文本到语音”这条路彻底走通后面优化都是水到渠成的事。如果你已经用 sherpa-onnx 跑通了基础版可以再往下扩展。它不只支持 ASR 和 TTS还支持 VAD、降噪、说话人识别甚至可以作为更复杂本地语音助手的地基。这些能力组合在一起能撑起很多原本需要云端才能实现的产品形态。对一个手持设备来说把语音模型装进手机意味着产品可以在任何角落独立对话这种价值值得多花一点时间去打磨。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →