ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Flutter集成端侧TTS:sherpa-onnx+ZipVoice实现离线声音克隆

Flutter集成端侧TTS:sherpa-onnx+ZipVoice实现离线声音克隆 1. 方案选型为什么我把 TTS 从云端搬到了端侧做 Flutter 语音类应用的朋友应该都有同感TTS文本转语音这个环节选型一直很纠结。早期我直接调云厂商的合成接口音质确实好但延迟、隐私、成本三个问题始终绕不过去。后来换系统自带的离线引擎倒是省心了可那几款默认音色放在产品里实在没有辨识度用户一听就知道是机器在念稿子。再往后想用定制音色又得把录音上传到服务端训练费时费力不说合成时还必须联网体验直接打折扣。直到我把sherpa-onnx和ZipVoice组合起来在端侧跑通了带声音克隆能力的完整 TTS 链路上面的问题才算真正有了落地方案。先说结论这套方案支持离线合成、支持用几秒钟录音做声音克隆、延迟做到百毫秒级别、模型体积控制在几十 MB 以内并且是纯 Flutter 工程可集成的。说实话这个组合在同类开源方案里算是把音色定制和端侧部署这两件事兼顾得比较好的。为了说清楚我为什么这么选先放一张我当时做的对比表维度是延迟、隐私、成本、音色定制、离线可用性方案类型延迟隐私成本音色定制离线可用云端 TTS网络往返 排队通常 500ms 以上文本需上传敏感场景有顾虑按字符或调用量计费定制需训练周期长否系统离线 TTS较低但引擎调度开销不定本地处理较安全免费但音色受限不支持或支持有限是sherpa-onnx ZipVoice 端侧方案首句冷启动约几百 ms后续 100~300ms全部本地推理一次开发成本无持续费用少样本克隆几秒录音即可是从表里能看出来端侧方案的主要优势是隐私和离线。但真正让我定下这套组合的不只是这两点而是sherpa-onnx 本身就内置了 VITS 等神经网络 TTS 模型的推理能力ZipVoice 则补上了说话人特征提取这一环两者配合可以做到输入一段文本 一段参考录音直接输出克隆音色的语音。这比传统的拼接式克隆需要大量录音做音库或者云端微调式克隆需要把数据交给第三方都要轻量得多。在具体选型上我还对比过其他几个方向在 Flutter 里写 Dart 层 TTS或者用 Rust 重写推理逻辑。这个方案理论上可行但 TTS 模型推理是重计算场景Dart 层的性能不适合跑神经网络Rust 重写又需要额外维护一套 FFI 绑定开发成本高且不划算。用 TensorFlow Lite / PyTorch Mobile 直接加载 TTS 模型。能跑但 TTS 链路并不只是一次前向推理还涉及词法分析、文本转音素、声码器等多个模块用通用推理框架自己拼链路工作量不是一般的大。sherpa-onnx 已经把这套链路封装好了属于开箱即用。找现成的 Flutter TTS 插件只做合成不做克隆。很多插件只封装了系统引擎或单一云端接口音色固定没法满足让产品有辨识度这个需求。ZipVoice 本身也提供了端侧说话人特征提取模型支持 5~10 秒的参考音频提取 embedding和 sherpa-onnx 的 VITS 模型配合时只需把这个 embedding 作为说话人 id 或条件向量注入无需重新训练模型。这正是我需要的姿势。所以最后定下来的技术栈就是Flutter 做 UI 和业务逻辑原生层Android/iOS集成 sherpa-onnx 和 ZipVoice通过 MethodChannel 暴露给 Dart 调用。整套链路完全离线适用于需要自定义音色的语音助手、有声书工具、导航播报这类场景。这篇文章我把完整方案和踩过的坑都整理出来。2. 核心原理TTS 链路和声音克隆到底是怎么跑的2.1 sherpa-onnx 的 TTS 合成链路sherpa-onnx 是 k2-fsa 社区出的端侧推理框架底层基于 ONNX Runtime。它的 TTS 模块支持多种模型结构最常用的是 VITS 和 VITS2。以 VITS 为例一条完整的合成链路大致是文本输入 → 文本前端中文分词、拼音转换→ 文本编码器 → 声学模型生成中间特征→ 声码器HiFi-GAN→ PCM 音频输出。你可能会问这和用 ONNX Runtime 直接跑一个模型有什么区别区别就在于这些组件是有依赖关系的单独跑通一个 ONNX 模型不代表能合成出音频。sherpa-onnx 把前端、声学模型、声码器、流式/非流式输出都封装成了统一接口你在代码里只需要传一句话它就能返回可播放的音频数据。实际集成时我优先选的是VITS 中文模型int8 量化版。原因很直接原始 FP32 模型体积在 200MB 以上量化后能压到 60~80MB在端侧可以接受int8 推理在移动端 CPU 上速度能比 FP32 快 2~3 倍。sherpa-onnx 官方发布的预训练模型里有不少是已经量化好的直接下下来就能用。2.2 ZipVoice 的声音克隆机制ZipVoice 是一个端侧声音克隆工具主打少样本声音克隆。它的核心逻辑是用说话人编码器把参考音频转换成一个固定维度的说话人向量也叫 speaker embedding这个向量代表了声音的音色特征包括音高范围、共振峰分布、说话习惯等。推理阶段把这个向量和其他条件一起送入声学模型模型生成的语音就会带有参考音频的音色。这么做的好处是省去了传统克隆方案里的录制大量语料 训练音库过程5~10 秒的干净录音就能得到一个可用的克隆效果。不过这里要提醒一句少样本克隆不等于完全复刻。它的相似度取决于参考音频的质量、说话人本身的稳定性、以及模型对音色的捕捉能力。如果参考录音里有背景噪音、混响或者说话人情绪波动很大克隆出来的音色会明显偏离。我自己实测下来安静环境下录 5 秒中文朗读中音区域相似度很高但语调和停顿点会有些模型化的感觉不是 100% 复读机式还原。2.3 端侧部署的几个硬指标在 Flutter 里集成这套方案有几个硬指标是不能忽视的模型体积VITS 中文模型 ZipVoice 编码器加起来大概 80~120MB。如果要控制 App 体积可以把模型放在服务端首次启动下载或者放到外部存储目录而非 assets 里。内存占用加载模型后常驻内存大约 200~400MB视模型和量化程度而定。如果应用本身已经很大需要慎重评估。首句冷启动延迟模型加载到内存、初始化推理引擎这个过程会消耗几百毫秒到一两秒。解决方案是页面启动后预加载模型并跑一次非常短的静默合成来做预热。设备兼容性sherpa-onnx 对 Android 的 .so 和 iOS 的 .xcframework 都有预编译产物但不同架构arm64、x86_64、armeabi-v7a需要分别打包。Flutter 工程里通常只需要保留 arm64真机和 x86_64模拟器两种。3. 环境准备Flutter 里怎么把两个原生库接进来3.1 集成方案选型原生插件还是 FFIFlutter 集成 C/C 库主要有两条路一是写平台插件Android 用 AAR 或源码iOS 用 XCFramework 或源码通过 MethodChannel 通信二是用 dart:ffi 直接在 Dart 层调用 C 接口。我最后选的是平台插件方式原因有三个sherpa-onnx 官方已经提供了 Android 和 iOS 的预编译库ZipVoice 也类似直接集成比自己搞 FFI 绑定省事。TTS 推理涉及音频播放和文件操作原生层处理这些更方便Dart 层只需要拿 PCM 或 WAV 数据。FFI 虽然免去了 MethodChannel 的序列化开销但动态库路径管理、生命周期控制、崩溃隔离都需要自己解决在成熟的库面前没必要重复造轮子。具体结构是Flutter 层负责录音上传/本地管理、文本输入、界面展示、音频播放。MethodChannel负责初始化模型、提取说话人向量、合成文本三个核心方法的调用。原生层Android 用 Kotlin sherpa-onnx AAR ZipVoice SDKiOS 用 Swift XCFramework。3.2 Android 端的配置细节Android 端的配置有几个关键点踩过坑才记得住第一步导入依赖在android/app/build.gradle里加入 sherpa-onnx 的 AAR 依赖以及 ZipVoice 的 SDK 依赖。需要特别注意的是如果两个库都打包了 ONNX Runtime要保证版本一致否则会出现java.lang.NoSuchMethodError这类运行时崩溃。第二步ABI 过滤Flutter 默认会打包多种 ABI 的 .so但端侧推理库对 x86 架构支持往往不完善。我一般在build.gradle里做 ABI 过滤android { defaultConfig { ndk { abiFilters arm64-v8a, armeabi-v7a, x86_64 } } }这里建议保留 x86_64 是为了方便模拟器调试。如果你的测试机全是真机只留arm64-v8a也行。第三步模型文件放置模型文件不建议直接塞进 assets因为 Flutter 的 assets 机制会把文件打包进 APK不利于热更新和模型替换。我采用的是首次启动从应用私有目录读取或者从服务端下载后写入 filesDir的方式val modelDir File(context.filesDir, models) if (!modelDir.exists()) { // 从 assets 复制或从网络下载 }3.3 iOS 端的配置细节iOS 端的配置主要集中在 XCFramework 合并和资源加载上第一步制作 XCFramework如果你拿到的是 ZipVoice 的源码或 .a 静态库需要先用xcodebuild -create-xcframework把模拟器架构和真机架构合并成 XCFramework。如果直接用未合并的 .framework 会经常遇到 building for iOS Simulator, but linking in object file built for iOS 的报错。第二步Podfile 配置在ios/Podfile里注意关闭 Bitcode。现在 Xcode 对 Bitcode 的要求已经放松但第三方库未必支持所以通常会在 Podfile 里加上post_install do |installer| installer.pods_project.targets.each do |target| target.build_configurations.each do |config| config.build_settings[ENABLE_BITCODE] NO end end end第三步模型加载路径iOS 的沙盒机制和 Android 不同模型文件如果放在 Bundle 里可以通过Bundle.main.path(forResource:ofType:)获取路径如果是从网络下载需要写入Application Support目录并且注意 iCloud 备份排除避免被系统清理。3.4 Flutter 侧的统一接口设计原生层接好后Flutter 侧我封装了三个方法保持接口简单直接class VoiceCloneTts { static const _channel MethodChannel(voice_clone_tts); /// 初始化模型返回是否成功 static Futurebool init({ required String ttsModelPath, required String zipVoiceModelPath, }) async { return await _channel.invokeMethod(init, { ttsModelPath: ttsModelPath, zipVoiceModelPath: zipVoiceModelPath, }); } /// 从参考音频提取说话人向量 static FutureListdouble extractSpeakerEmbedding({ required String refAudioPath, }) async { final result await _channel.invokeMethodListdynamic( extractSpeakerEmbedding, {refAudioPath: refAudioPath}, ); return Listdouble.from(result ?? []); } /// 合成文本返回音频文件路径 static FutureString synthesize({ required String text, required Listdouble speakerEmbedding, }) async { return await _channel.invokeMethodString(synthesize, { text: text, speakerEmbedding: speakerEmbedding, }); } }这里有个设计细节speaker embedding 为什么不直接传路径而是传Listdouble因为在实际业务中参考音频可能是一次性的但说话人向量需要反复使用。我通常会把第一次提取的向量存到本地数据库里下次直接加载向量不用重新跑编码器启动速度能快不少。这个接口设计也为后面加多音色管理功能留了余地。4. 实操从录音到让 App 用你的声音说话4.1 参考录音的采集规范声音克隆的第一步是采集参考音频。这一步的质量直接决定最终效果很多朋友觉得随便录一段就能克隆其实是个误区。我整理了一套录音标准时长建议 5~20 秒太短提取的说话人向量不稳定太长会有多余静音和噪声干扰。环境安静房间避免空调声、键盘声、风扇声等稳态噪声。距离麦克风离嘴 15~30cm避免近讲效应导致低频过重。内容读一段包含不同声调的中文段落覆盖说话人的自然语调变化。格式16kHz 采样率、16bit、单声道的 WAV 是最稳妥的。如果拿到的录音是 44.1kHz 的 MP3需要先转成 WAV 再送入特征提取。如果是 Flutter 侧直接录音建议用record插件设置采样率 16000、单声道、PCM 格式输出避免二次转码。4.2 完整流程录音 → 特征提取 → 合成 → 播放我把完整流程拆成了一个可复刻的步骤序列步骤 1初始化模型在应用启动或进入语音合成页面时调用初始化接口。尽量提前做因为模型加载是最耗时的环节。我实测过一个 80MB 的 VITS 模型在骁龙 8 系列上加载大约需要 600~900ms在 A 系列芯片上也类似这个耗时不适合放在用户点击合成按钮之后。步骤 2提取说话人向量用户录完参考音频后调用extractSpeakerEmbedding。这个接口内部会跑一次 ZipVoice 的编码器前向推理耗时通常在 100~300ms可以接受。提取到的向量建议序列化存到本地数据库比如 Flutter 的 sqflite 或 Hive下次启动直接读取。步骤 3合成指定文本这一步是核心。调用synthesize传入文本和 speaker embedding。原生层拿到数据后会调用 sherpa-onnx 的 TTS 接口完成文本前端、声学模型、声码器三个步骤最终返回一个 WAV 文件路径或者 PCM 字节数组。我当时的 Kotlin 核心代码大致是这样class TtsModelWrapper { private var tts: OfflineTts? null private var zipVoiceEncoder: ZipVoiceEncoder? null fun init(ttsModelPath: String, zipVoiceModelPath: String): Boolean { val config OfflineTtsConfig( model OfflineTtsModelConfig( vits OfflineTtsVitsModelConfig( model $ttsModelPath/vits_zh_origin.onnx, tokens $ttsModelPath/tokens.txt, lexicon $ttsModelPath/lexicon.txt, dictDir $ttsModelPath/dict, dataDir $ttsModelPath/espeak-ng-data, ), numThreads 2, debug false, provider cpu, ), ruleFsts $ttsModelPath/rule.fst, ruleFars $ttsModelPath/rule.far, ) tts OfflineTts(config) zipVoiceEncoder ZipVoiceEncoder($zipVoiceModelPath/encoder.onnx, 2) return tts ! null zipVoiceEncoder ! null } fun extractEmbedding(refAudioPath: String): FloatArray { val wav readWavAsFloatArray(refAudioPath) return zipVoiceEncoder!!.encode(wav, 16000) ?: throw RuntimeException(extract failed) } fun synthesize(text: String, embedding: FloatArray): String { val audio tts!!.synthesize(text, sid 0, speakerEmbedding embedding) val path File(context.cacheDir, tts_${System.currentTimeMillis()}.wav) writeWavFile(path, audio.samples, audio.sampleRate) return path.absolutePath } }步骤 4播放音频Flutter 侧拿到 WAV 文件路径后可以用just_audio或audioplayers播放。如果原生层返回的是 PCM 字节可以先写成本地 WAV 文件再交给播放器这样处理成本最低。4.3 参数选择与效果调优这里说几个参数层面的细节numThreads我设为 2。线程太少推理慢线程太多移动端 CPU 发热且性能反降。实测单线程和双线程在高端芯片上差距约 30%但四线程相对双线程几乎没有提升反而功耗更高。sid不是所有模型都支持多说话人。如果用的是单说话人 VITS 模型sid 固定传 0 即可。多说话人模型可以通过 sid 切换预设音色但这时候 speaker embedding 默认不生效或者需要特别处理得看具体模型的训练方式。speed语速调节sherpa-onnx 的 TTS 支持语速参数范围我记得是 0.5~2.0 左右。但注意语速调得太大音质会有明显下降尤其克隆音色会更明显。我建议保持默认值 1.0真正的语速控制放在文本端做比如缩短句子、增加停顿符号听起来更自然。文本归一化中文文本里如果有数字、英文、特殊符号一定要预处理。sherpa-onnx 的规则 FST 能处理一部分但像VIP 会员第 3 期这种混排文本我通常会自己做一次数字转中文、英文转拼音的预处理效果比直接丢给模型好很多。4.4 效果复盘我和原声的对比体验当时做完第一版我用自己的录音克隆了一版语音说同一段台词对比原声和合成声音色相似度中频部分基本一致低频胸腔共鸣和高频气息部分有明显压缩感。自然度短句子比长句子好长句子在句尾音调上会暴露机械感。情感表达平淡文本表现尚可带情绪起伏的文本基本无法还原。这个预期需要提前管理好。端侧少样本克隆解决的是音色归属感问题它能让人一听就知道这是你的声音但做不到完全复刻你的说话习惯和情绪。如果产品宣传文案里承诺100% 还原真人声音后期一定会被用户锤。5. 常见问题与排查技巧实录5.1 Android 编译报错和运行时崩溃Q1aar 依赖冲突导致无法编译这个最常见。sherpa-onnx 和 ZipVoice 都带了 ONNX Runtime版本不一致时 Gradle 直接报 duplicate class。解决办法是使用 resolutionStrategy 强制指定一个版本configurations.all { resolutionStrategy { force com.microsoft.onnxruntime:onnxruntime-android:1.17.1 } }Q2模型初始化成功但合成时崩溃日志里看到JNI DETECTED ERROR IN APPLICATION这通常是因为把FloatArray从 Kotlin 传给了 C 层但数组生命周期没管理好。sherpa-onnx 的 JNI 接口要求 speaker embedding 以float[]形式传入且要保证在合成过程中不被 GC 回收。如果用的是kotlin.collections.ListFloat然后转数组一定要确认转出来的是FloatArray而非ArrayFloat否则跨语言调用时会 crash。Q3Release 包合成结果和 Debug 不一致大部分情况是混淆导致的。需要把模型路径、JNI 接口相关的类加入 keep 规则-keep class com.k2fsa.sherpa.onnx.** { *; } -keep class com.zipvoice.** { *; } -keepclasseswithmembernames class * { native methods; }5.2 iOS 链接错误和资源加载问题Q4真机编译报building for iOS Simulator, but linking in object file built for iOS这是典型的 XCFramework 没有包含 simulator 架构导致的。如果用源码编译的静态库要确认 build 命令同时支持arm64 x86_64。最稳妥的做法是直接下载官方或第三方发布的 XCFramework避免自己合并但漏掉架构。Q5模型放在 App Bundle 里第一次加载不到iOS 的 Bundle 路径区分大小写模拟器大小写不敏感但真机敏感。检查路径时最好用Bundle.main.url(forResource:withExtension:)来定位不要手写字符串路径。另外如果模型文件很大注意 Xcode 默认不会把 resources 目录下的子目录递归复制到 Bundle需要确认Copy Bundle Resources里包含了模型文件。5.3 合成质量相关Q6合成音频有电流声或爆破音注意音频数据是否发生了 int16 的溢出。模型输出的 audio samples 范围是 [-1, 1] 的浮点数写 WAV 时转 int16 需要做 clamp不 clamp 会出现明显的破音。参考这块代码fun floatToPcm16(samples: FloatArray): ByteArray { val bytes ByteArray(samples.size * 2) for (i in samples.indices) { val v (samples[i] * 32767).toInt().coerceIn(-32768, 32767) bytes[i * 2] (v and 0xff).toByte() bytes[i * 2 1] ((v shr 8) and 0xff).toByte() } return bytes }Q7克隆音色和人声差异很大最可能的原因是参考音频不符合要求。我建议按和模型训练集采样率一致的标准处理sherpa-onnx 官方中文 VITS 模型是 16kHz 采样率如果你用 44.1kHz 的录音先降采样到 16kHz 再提取嵌入向量。另外参考音频不要过长10 秒以内最佳因为ZipVoice的编码器通常有最大有效时长超出部分可能被截断或加窗加出问题。Q8合成首句慢后续恢复正常这是模型加载 显式初始化的问题。可以在应用启动时调用一次很短的合成比如合成一个句号把模型跑热。如果你做的是语音助手最好在进入主界面后就在后台线程预热用户真正需要合成时延迟就低了。5.4 性能与包体积优化Q9模型太大安装包爆炸有两个方向一是用量化模型。sherpa-onnx 官方模型仓库里通常有int8版本优先选二是模型从服务端下发。把模型放到 OSS/CDN首次启动后台下载再写入私有目录。这样安装包只保留一个必要的精简模型用于兜底用户量上来后还能通过下发新模型提升效果不用强制升级 App。Q10合成时内存飙高低端机直接被杀VITS 类模型在生成波形时会分配比较大的临时缓冲区。建议在原生层限制numThreads同时避免并发合成。如果需要连续合成多句话最好做成队列一次只跑一个任务。我遇到过一个案例同一时间发起 5 句合成中端机上内存直接冲上 1.5GB优化成串行队列后内存峰值降到 500MB 以内。6. 还可以往哪些方向扩展整套方案跑通之后其实是可以在产品上继续加东西的。我只说两个我已经实践过的方向因为它们的收益很直接。第一个方向是多音色管理。ZipVoice 的特征提取能力天然支持一个模型对应多个说话人向量的场景。我做了个简单的声音列表功能用户录完一段声音之后把 speaker embedding 存到数据库里给它起个名字比如我的温柔女声我的播音腔之后在 TTS 界面点一下就能切换。整个切换过程不涉及重新加载模型只是换一个 embedding耗时从上一次提取时的 100~300ms 降到了近乎零。第二个方向是和本地数据库做配套。如果你做的是个人语音助手这类产品可以把用户的配置、历史合成记录、参考音频路径和 embedding 都存进 sqflite 或 Hive。这样 App 重启后用户不需要重新录音、重新提取向量直接就能进入读一段文字的界面。数据库里的语音记录还可以做成本地搜索不依赖云端。再往后如果你不满足于中文单语种sherpa-onnx 的 TTS 还支持英文、日文、粤语等模型格式和调用方式基本一致。ZipVoice 是否支持多语种需要看你的模型文件选择。但总体思路是特征提取 声学模型 声码器解耦换语种时只需要换对应的声学模型文件和文本前端资源。我在实际跑这套方案时最大的体会有两点。第一端侧 TTS 的难点不全在推理性能上反而在工程集成和音频数据处理这些看起来不起眼的地方。很多坑不是模型跑不起来而是 JNI 内存管理、大小端转换、ABI 过滤这些细节没处理好。第二少样本声音克隆的效果上限很大程度上取决于参考音频质量。与其花大量时间调参、换模型不如先把自己的录音环境弄干净十分钟的安静录音比折腾一天的模型参数都有效。如果你也想在 Flutter 里接一套离线声音克隆 TTS希望这份记录能帮你把路走得更顺一点。
返回列表