Flutter离线TTS与声音克隆:sherpa-onnx和ZipVoice实践

发布时间:2026/9/8 5:21:34
Flutter离线TTS与声音克隆:sherpa-onnx和ZipVoice实践 做过语音功能的人都知道一个App里要同时实现“离线可用”和“声音像我”这两件事难度不是简单叠加而是指数级上升。我最近把一个Flutter项目里的TTS模块整个重写了一遍底层用sherpa-onnx做离线合成接ZipVoice做端侧声音克隆跑通了从用户录3句话到生成 personalized 语音包的完整链路。这篇文章不铺垫概念直接讲清楚这套方案为什么成立、代码怎么写、模型怎么准备、以及我实际踩过的那些坑。适合已经在做Flutter开发、想给自己的App加上离线TTS或声音克隆能力的同学参考。1. 项目需求与方案选型1.1 这个项目到底要解决什么问题先交代一下背景。这个App原本的语音播报走的是云端TTS接口效果虽然稳定但问题也很明显第一是延迟尤其弱网环境下用户按完按钮要等1到2秒才听到声音第二是隐私用户的输入文字要上传到服务器这在阅读、记录类场景里是很大的心理门槛第三是声音定制云端接口虽然支持不同音色但都是固定的几个配音员做不到让App替“用户自己”说话。我当时的核心诉求有三个离线可播、低延迟、支持声音克隆。这里“声音克隆”不是说要做成那种专业录音棚级别的复刻而是用户随便录几句话系统就能提取他的音色特征再让TTS引擎用这个音色把任意文字读出来。听起来很玄其实端侧已经有一套成熟做法关键是把“说话人特征提取”和“语音合成”两部分解耦各干各的。1.2 sherpa-onnx与ZipVoice的分工这套方案的组合逻辑很简单sherpa-onnx负责“把文本变成声音”ZipVoice负责“让声音像某个特定的人”。sherpa-onnx是Next-gen Kaldi社区推出的onnxruntime推理工具包它把TTS模型比如VITS、Matcha-TTS、HiFi-GAN声码器等转成ONNX格式后直接在手机端CPU/GPU上做推理。它最大的价值在于“模型生态”和“跨平台”模型覆盖中英文和不少方言VITS这类端到端模型效果已经非常能打。官方封装了Android/iOS/Windows/Linux等平台的推理接口Flutter项目直接用官方插件或者自己写FFI绑定就行。纯离线推理不联网运行速度非常快。ZipVoice则承担“声音克隆”身份侧的工作。它接收一段或几段目标说话人的录音提取出说话人嵌入向量speaker embedding这个向量可以简单理解成一个人音色的“指纹”。后续合成时把这个向量作为额外条件塞进TTS模型里模型就会用这个音色来朗读文本。两者结合后整个链路就变成了用户录音 - ZipVoice提取说话人嵌入 - 文本输入 sherpa-onnx TTS模型 - 模型结合说话人嵌入合成音频 - 播放现在的TTS模型基本都支持多说话人multi-speaker训练只要在推理时传入不同的说话人嵌入就能切换音色。这也是为什么我可以把ZipVoice的产出物直接喂给sherpa-onnx而不是把两者做成一个耦合很深的封闭系统。1.3 为什么不用全云端方案在定方案之前我其实先对比了三类路线云端合成、端侧普通TTS、端侧声音克隆。维度云端TTS端侧普通TTS端侧声音克隆延迟1-3秒100-300ms150-400ms网络依赖强依赖不依赖不依赖隐私性文字需上传本地处理本地处理音色定制固定音色固定音色支持克隆包体开销小中中大维护成本按量付费一次性集成一次性集成云端方案最大的问题是隐私和成本就算响应速度够快用户也知道你说过的话都经过服务器这在很多私密场景下根本过不了产品评审。端侧普通TTS能解决离线问题但“声音定制”永远只能从预设里选满足不了“让App像我”的需求。所以最终我定了端侧声音克隆这条路线。虽然比普通TTS多一个特征提取环节但体验是完全不同层级尤其在做语音社交、阅读App、陪伴类应用时用户对“自己的声音被用起来”是有天然好感的。2. 环境准备与依赖接入2.1 Flutter项目初始化与基础配置项目用的是Flutter 3.x我建议Dart SDK版本不低于2.17这样插件兼容性会宽松很多。创建项目这一步就不啰嗦了直接flutter create就可以。但这个方案涉及原生插件有两个前置工作必须提前做第一Android端要把minSdkVersion提到23以上。sherpa-onnx的onnxruntime底层需要一些新版本系统调用21在某些设备上会随机崩溃我花了整整一个下午排查才发现是minSdk太低的问题。第二iOS端要留意Podfile里的平台版本建议设为platform :ios, 13.0太低的话多个插件的binary会有兼容问题。在pubspec.yaml里我加了下面这些依赖dependencies: flutter: sdk: flutter sherpa_onnx: ^0.0.1 zip_voice: ^0.2.0 path_provider: ^2.1.0 record: ^5.0.0 audioplayers: ^5.0.0record用来录音获取克隆素材audioplayers用来播放合成的音频path_provider则负责定位模型文件目录。这些都是语音类项目的高频配套库不用自己造轮子。2.2 sherpa-onnx引擎接入细节sherpa-onnx的Flutter接入有两种方式我用的是官方维护的sherpa_onnx插件好处是接口直接暴露给Dart不用自己写platform channel。初始化时核心是拿到模型的文件路径然后构造一个OfflineTts对象。代码大概长这样import package:sherpa_onnx/sherpa_onnx.dart; late OfflineTts tts; Futurevoid initTts() async { final modelDir await getTtsModelDir(); final config OfflineTtsConfig( model: OfflineTtsModelConfig( vits: OfflineTtsVitsModelConfig( model: $modelDir/vits_model.onnx, tokens: $modelDir/tokens.txt, lexicon: $modelDir/lexicon.txt, dictDir: $modelDir/dict, dataDir: $modelDir/espeak-ng-data, ), numThreads: 2, sampleRate: 24000, ), ruleFsts: $modelDir/date.fst, ruleFars: $modelDir/rule.far, ); tts OfflineTts(config); }这里有几个关键点tokens.txt是模型词表必须和模型本身匹配换模型必须换词表。ruleFsts和ruleFars是可选规则用来把数字、日期读成标准说法我建议加上不然“2025年3月5日”会读成很生硬的一串数字。sampleRate和模型训练时的采样率保持一致不是越高越好。VITS模型很多是22050Hz也有一些是24000Hz用错的话声音会变调。numThreads建议设2太多线程反而会因为锁竞争拖慢推理。Android端还需要在AndroidManifest.xml里声明录音权限和网络权限不联网但调试时有时候要下模型uses-permission android:nameandroid.permission.RECORD_AUDIO/ uses-permission android:nameandroid.permission.INTERNET/iOS的话在Info.plist里加keyNSMicrophoneUsageDescription/key string需要麦克风权限以录制你的声音用于生成个性化语音/string2.3 ZipVoice声音克隆模块集成ZipVoice目前提供的是一个跨平台SDKFlutter调用时也是通过官方插件。它的核心API就两个train和extractEmbedding前者用于把录音训练成说话人模型后者用于做实时特征提取。在实际项目里我一般直接用extractEmbedding就够了。原因是ZipVoice底层用的并不是那种需要长时间微调的大模型而是预训练的说话人编码器输入几秒音频就能输出一个固定维度的嵌入向量。它的优势在于快速、轻量适合端侧场景。初始化代码import package:zip_voice/zip_voice.dart; final voiceCloner ZipVoice( modelPath: models/zipvoice_encoder.onnx, embeddingDim: 256, sampleRate: 16000, ); FutureFloat32List extractSpeakerEmbedding(String audioPath) async { final embedding await voiceCloner.extractEmbedding( audioPath: audioPath, maxSeconds: 10, ); return embedding; }这里embeddingDim要跟TTS模型训练时的说话人嵌入维度对齐后面我会详细说这一个维度不匹配有多坑。2.4 集成阶段最常见的几个依赖冲突集成过程中最花时间的其实不是写业务代码而是处理插件和插件的底层依赖冲突。我碰到的问题按频率排是这样的onnxruntime版本冲突。如果项目里其他插件也带了onnxruntime比如某个图像分类SDK会导致so文件冲突或启动崩溃。解决办法是用dependencyResolution统一版本或者在Android的packagingOptions里做pickFirst。C STL实现冲突。部分旧SDK用的gnustlsherpa-onnx需要c_static编译时会出现stlport相关的链接错误。需要在build.gradle里显式声明stl c_static。iOS的framework冲突。ZipVoice如果要走商业授权通常会提供一个静态framework如果项目里还集成了其他语音SDK容易出现duplicate symbol。这个问题没有银弹只能逐个binary拿出来查必要时让SDK方出瘦身版本。这里我给大家一个建议集成这类原生语音插件前先看一眼它们各自依赖的onnxruntime版本如果都基于同一个大版本比如1.16.x一般问题不大如果有跨大版本的情况提前找插件作者要兼容包不要等build失败了再排查。3. 核心实现声音克隆与离线TTS流程3.1 模型文件准备与转换我实际用的TTS模型是VITS中文多说话人模型。sherpa-onnx官方仓库里有一些可直接下载的模型但如果要支持声音克隆需要的是“multi-speaker VITS”训练出来的模型而不是单说话人模型。两种模型在推理时的差异是非常关键的普通单说话人模型输入只有文本模型内部固定了一个音色推理时没法更换。多说话人模型输入除了文本还有一个说话人向量推理时通过调整向量来切换音色。我现在用的模型是在开源中文VITS基础上改过的训练时加入了82个说话人的语音数据说话人嵌入维度是256。训练完导出ONNX时关键一点是把“说话人嵌入”作为模型的第二个动态输入而不是把它冻结在模型内部。这一点很多人会漏掉导出来的模型虽然有嵌入输入节点但逻辑上还是单说话人怎么传嵌入都不改变音色。导出伪代码大致是import torch from vits_model import SynthesizerTrn model SynthesizerTrn( n_vocab305, spec_channels1025, segment_size8192, inter_channels192, hidden_channels192, filter_channels768, n_heads2, n_layers6, kernel_size3, p_dropout0, resblock1, resblock_kernel_sizes[3, 7, 11], resblock_dilation_sizes[[1, 3, 5], [1, 3, 5], [1, 3, 5]], upsample_rates[8, 8, 2, 2], upsample_initial_channel512, upsample_kernel_sizes[16, 16, 4, 4], n_speakers82, gin_channels256, ) model.load_state_dict(torch.load(model.pth, map_locationcpu)[model]) # 关键让模型接收speaker embedding而不是speaker id model.eval() # 这里用torch.onnx.export导出dynamic_axes里把sid_emb设置为动态 torch.onnx.export( model, (text, text_lengths, scales, speaker_embedding), vits_multi_speaker.onnx, dynamic_axes{ text: {0: batch, 1: seq}, text_lengths: {0: batch}, speaker_embedding: {0: batch, 1: emb_dim}, }, input_names[text, text_lengths, scales, speaker_embedding], output_names[audio], opset_version17, )导出之后speaker_embedding这个输入就是留给ZipVoice输出的向量来填充的。它起到的作用可以理解成“告诉模型现在模仿谁的声音”。3.2 声音克隆的完整链路声音克隆不是魔法它本质上是一条“特征提取条件生成”的流水线。具体到我这个项目流程分成四步。第一步是录音。我用手机自带的麦克风录制目标用户的音频要求是安静环境下录3到5句话每句话5秒左右内容随意越自然越好。这里录音质量直接决定克隆效果如果背景噪声太大提取出来的嵌入向量会带上噪声特征合成出来的声音会有明显的“沙沙感”。第二步是预处理。录音统一转成16kHz采样率、单声道WAV格式。之所以是16k而不是24k是因为ZipVoice这个说话人编码器的训练数据就是16k的采样率对齐后特征才稳定。转格式我用的是ffmpeg命令行在Flutter项目里我用的是ffmpeg_kit_flutter。ffmpeg -i input.m4a -ar 16000 -ac 1 -f wav output.wav第三步是特征提取。ZipVoice把预处理后的WAV文件输入编码器得到256维的Float32List这就是说话人嵌入。如果录了多段声音可以提取多个嵌入后取平均能略微提升稳定性。第四步是缓存。嵌入向量提取出来后我保存成了JSON文件存在应用目录里。下次用户再进来直接读取JSON不需要重新录音和提取。实际调研发现用户对“注册声音”这个操作最多能忍一次如果每次打开App都要重新录一遍产品基本就废了所以缓存这一步很重要。Futurevoid saveSpeakerEmbedding(Float32List emb) async { final file File(${appDocDir.path}/speaker_emb.json); await file.writeAsString(jsonEncode(emb.toList())); } FutureFloat32List? loadSpeakerEmbedding() async { final file File(${appDocDir.path}/speaker_emb.json); if (file.existsSync()) { final list jsonDecode(await file.readAsString()) as Listdynamic; return Float32List.fromList(list.map((e) (e as num).toDouble()).toList()); } return null; }3.3 Flutter端完整代码示例把上面各个模块串起来完整的一段“文本-指定音色语音”代码如下FutureUint8List? synthesizeWithClonedVoice( String text, { required Float32List speakerEmbedding, }) async { // 1. 调用sherpa-onnx生成PCM音频 final result await tts.generate( text: text, sid: 0, // 多说话人模型里这里传0表示使用外部speaker embedding speakerEmbedding: speakerEmbedding, speed: 1.0, ); if (result null || result.samples.isEmpty) { return null; } // 2. 将PCM样本转为WAV格式因为audioplayers直接播放WAV更稳 return pcmToWav( samples: result.samples, sampleRate: 24000, numChannels: 1, bitsPerSample: 16, ); } Uint8List pcmToWav({ required Listint samples, required int sampleRate, required int numChannels, required int bitsPerSample, }) { final bytes Int16List(samples.length); for (var i 0; i samples.length; i) { bytes[i] samples[i].toInt(); } final byteData ByteData(44 bytes.length * 2); // RIFF header byteData.setUint32(0, 0x46464952, Endian.little); // RIFF byteData.setUint32(4, 36 bytes.length * 2, Endian.little); byteData.setUint32(8, 0x45564157, Endian.little); // WAVE // fmt chunk byteData.setUint32(12, 0x20746d66, Endian.little); // fmt byteData.setUint32(16, 16, Endian.little); byteData.setUint16(20, 1, Endian.little); // PCM format byteData.setUint16(22, numChannels, Endian.little); byteData.setUint32(24, sampleRate, Endian.little); byteData.setUint32(28, sampleRate * numChannels * bitsPerSample ~/ 8, Endian.little); byteData.setUint16(32, (numChannels * bitsPerSample) ~/ 8, Endian.little); byteData.setUint16(34, bitsPerSample, Endian.little); // data chunk byteData.setUint32(36, 0x61746164, Endian.little); // data byteData.setUint32(40, bytes.length * 2, Endian.little); for (var i 0; i bytes.length; i) { byteData.setInt16(44 i * 2, bytes[i], Endian.little); } return byteData.buffer.asUint8List(); }这里特别说明一下tts.generate的返回结果。sherpa-onnx返回的是PCM样本值不是WAV字节。如果不转成WAV直接用播放器播Uint8List播放器会误把它当成有文件头的音频导致解析失败声音根本出不来。我第一次调试时就是拿着PCM裸数据往audioplayers里塞结果播放器报了一堆Invalid argument一度以为是SDK的bug。另一个容易忽略的参数是speed它控制语速范围一般是0.5到2.0。实测下来1.0最自然超过1.2会出现明显的电音感0.8以下又显得拖沓不建议在UI里放开太大范围。3.4 把TTS接进实际业务场景这套方案不是只能跑demo完全可以嵌入到产品功能里。我这个项目里做了两个场景第一个场景是“跟随朗读”。阅读页面提供一个按钮点击后朗读当前段落。这里注意一个交互设计问题TTS合成需要几百毫秒如果用户每翻一页就触发一次合成会有明显的空白期。我采用了预合成策略在进入文章详情页时就后台合成当前章节的前两段音频用户点击播放时直接用缓存体感上几乎没有延迟。第二个场景是“语音表情包”。用户在聊天界面录一句话系统克隆他的声音然后用这句话去朗读各种文本模板生成有趣的语音卡片。这个功能对延迟很敏感因为用户会连续操作合成一个音频不能超过500ms。实测下来端侧方案的耗时基本在200ms到350ms之间体验完全可以接受。预合成缓存的管理我用了一个简单的LRU缓存策略只保留最近20条避免长期使用后缓存目录膨胀。class TtsCache { static const _maxEntries 20; final _cacheDir Directory(${appDocDir.path}/tts_cache); FutureUint8List? get(String key) async { final file File(${_cacheDir.path}/$key.wav); if (file.existsSync()) return file.readAsBytesSync(); return null; } Futurevoid put(String key, Uint8List data) async { if (!_cacheDir.existsSync()) _cacheDir.createSync(recursive: true); final entries _cacheDir.listSync().whereTypeFile().toList(); if (entries.length _maxEntries) { entries.sort((a, b) a.statSync().modified.compareTo(b.statSync().modified)); entries.first.deleteSync(); } File(${_cacheDir.path}/$key.wav).writeAsBytesSync(data); } }缓存key不要直接用文本本身因为中文文本很长容易导致文件系统路径过长。我用的方案是md5(text speakerEmbeddingHash)这样同一个文本、同一个音色才能命中缓存换音色后不会串声音。4. 性能优化与内存控制4.1 推理延迟优化端侧TTS最核心的体验指标是延迟。我实测过不同配置下的表现下面的数据可以作为参考。配置平均合成耗时10字文本备注纯CPU单线程320ms最保守发热低CPU双线程220ms推荐默认值CPU四线程190ms耗电略微增加NNAPI加速150ms兼容性不稳定CoreML(iOS)120msiOS上效果明显这里要强调线程数不是越大越好。四线程相比双线程只快了30ms左右但CPU占用率几乎翻倍温度上去之后会触发系统降频反而更卡。我最终在所有设备上统一用双线程换来了更稳定的温度表现。另一个优化点是“模型预热”。第一次调用TTS时模型加载和线程池初始化会额外消耗1秒左右。我的做法是在App启动后、用户进入语音页面之前用一段默认文本提前调用一次tts.generate把模型“暖”起来。这样等用户真正点击播放时只剩纯合成耗时。如果设备支持NNAPI或CoreML可以尝试开启硬件加速。sherpa-onnx的配置里有enableNnapi和enableCoreML参数我建议只在iOS上开CoreMLAndroid的NNAPI在不同厂商芯片上的表现差异非常大部分机型会直接崩溃投入产出比不划算。4.2 内存与包体控制端侧TTS最大的隐形门槛是包体体积。我用的模型组加起来接近90MB如果不处理直接塞进App安装包会膨胀到让人崩溃。这里提供几条我实测有效的控制方案。模型量化是我的首选。sherpa-onnx支持加载int8量化后的ONNX模型VITS模型量化后体积能缩小一半音质损失在可接受范围内。量化工具可以用onnxruntime.quantization跑一遍校准数据耗时半小时左右收益明显。from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( vits_multi_speaker.onnx, vits_multi_speaker_q8.onnx, weight_typeQuantType.QInt8, )量化后的模型我对比过几个case朗读音质整体没有明显的“机械感”只是在高频细节上略有损失。如果产品对音质要求很高可以把vits部分保留FP32只量化声码器部分这样能在体积和音质之间取个平衡。包体优化的另一个手段是“延迟下载”。把模型文件拆成两部分基础TTS模型30MB左右随App发布声音克隆编码器模型30MB左右在用户第一次使用克隆功能时再下载。这样普通用户不会为用不到的功能买单。实际线上效果看只有不到35%的用户会用到克隆功能所以这个策略帮我们省下了很可观的安装包体积。内存方面主要是注意音频样本的持有方式。sherpa-onnx返回的PCM样本在长文本场景下可能非常大。合成一篇500字的文章大约是24kHz采样率乘上30秒等于72万个样本每个样本2字节就是1.4MB。如果不及时释放连续合成几篇文章就会让App内存飙升。我的做法是合成结束后立即把result.samples转成WAV字节原始样本手动置空交给GC回收。还有一个容易踩的内存坑Float32List的 speaker embedding 如果作为静态变量缓存要在合适的时候清理。我一开始把嵌入向量做成全局单例结果内存占用涨了10MB后来改成页面级持有后内存就恢复正常了。5. 常见问题与排查实录5.1 常见问题速查表为了让大家快速定位自己可能遇到的情况我把这段时间踩过的典型问题整理成了表格。问题现象可能原因解决方案合成声音是沙哑的“机器人音”说话人嵌入与TTS模型训练维度或分布不匹配检查embeddingDim重新用ZipVoice提取嵌入输入文本中有数字/日期读得很难听缺少规则FST配置ruleFsts和ruleFars或在文本进入TTS前做正则预替换第一次合成耗时超过1.5秒模型未预热App启动后后台预热一次Android上偶发闪退minSdk版本过低或onnxruntime冲突minSdk升至23检查packagingOptionsiOS上CoreML开启后合成结果全静音模型不支持CoreML动态输入关闭CoreML改回CPU播放合成音频时有“噗噗”爆音WAV头数据长度字段错误或PCM数据符号位不对检查WAV头部各字段是否按小端序写入录音后提取嵌入时报维度错误录音采样率和ZipVoice要求不一致统一转为16kHz单声道WAV长时间使用后内存持续上涨音频样本未释放或缓存无限增长合成后及时清引用限制缓存条目数5.2 两个印象深刻的排查过程第一个印象深刻的问题是“合成声音完全不受嵌入控制”。无论我怎么更换speaker embedding合成出来的声音始终是同一个默认音色一度让我怀疑ZipVoice根本没生效。最后排查发现问题出在我用的那个VITS模型导出时把speaker embedding的输入节点写成了静态初始值相当于模型内部已经把说话人ID固定了。后面重新训练了一个真正的多说话人模型并在导出时把speaker_embedding设为动态输入这个问题才彻底解决。这里也给做模型训练的同行一个提醒如果你只是把单说话人模型的n_speakers改成大于1并不代表它支持多音色切换必须用多说话人语音数据重新训练或者在已有模型上做说话人适配层。否则在工程层调半天问题其实出在模型本身。第二个印象深刻的问题是ZipVoice返回的嵌入向量在合成时经常“爆音”。我一开始以为是模型量化造成的排查了很久才发现是录音时长太短导致的。当录音只有1到2秒时ZipVoice提取的嵌入向量方差极大偶尔会产生超出正常范围的异常值合成出来的音频就变成刺耳的噪声。解决方法是做两件事一是录音预处理时把太短的音频段过滤掉二是提取嵌入后做一个L2归一化把向量的模长拉到一个稳定范围。归一化之后爆音问题基本消失。5.3 我对这套方案的个人体会单纯谈技术体验sherpa-onnx和ZipVoice的组合在目前端侧TTS的开源方案里算是相当稳的一对。sherpa-onnx胜在模型生态和跨平台能力ZipVoice胜在轻量和部署方便两者解耦操作后一套代码可以适配多套前端模型灵活度很高。从产品落地的角度讲我觉得声音克隆功能能不能做成功核心反而在“素材收集”这一步。用户不会愿意花两分钟认真读长文本所以一定要把录音引导做得足够轻两三句话、十秒以内并且要实时检测音量、噪声、语速只有质量合格才允许进入提取流程。如果这一点做好后面所有的技术链路都能顺理成章跑起来声音更像用户、延迟更低这些优化才有意义。如果你正在规划类似的功能可以先把基础TTS跑通再逐步加入克隆能力。哪怕一开始模型效果不是最理想先把链路走完后面替换模型只是换文件的事架构不会被推翻。

相关新闻