Flutter 里跑端侧 TTS 不是新鲜事,但让 App 用“你自己的声音”念出任意文本,这次真的可以在纯本地搞定了。我最近把 sherpa-onnx 和 ZipVoice 串到了同一个 Flutter 工程里,做了一套完整的端侧声音克隆 TTS 方案:用户录几段语音,导出一个说话人模型,之后所有文字都用这个克隆声音合成;整个过程不联网、不上传音频、不需要付 API 费用,实测在普通 Android 手机上合成一段短句的速度快于朗读本身。这篇东西就把我从选型、原理、集成到踩坑的完整过程写出来,给正在做 Flutter 离线语音、声音克隆、或者本地 TTS 的开发者一个可以直接落地的参考。
1. 整体设计:为什么偏偏选 sherpa-onnx + ZipVoice
先说结论:这不是唯一的选择,但在 Flutter 生态里,能做到“跨平台、离线、模型开源、支持自定义声音克隆”这四个条件同时满足的组合,目前真的不多。我最初也考虑过云厂商的 TTS 接口加声音复刻,但一是录音数据要传出去,二是有网络依赖和费用,三是每次合成都有延迟,做阅读类 App 或者陪伴类应用体验很一般。转向端侧之后,我把候选列表拉了一遍:TensorFlow Lite、ONNX Runtime、NCNN、sherpa-onnx。逐个对比之后,sherpa-onnx 是最省事的,原因下面详聊。
1.1 端侧 TTS 到底解决了什么问题
“离线 TTS”最直接的好处是不依赖网络。用户在电梯、地铁、地下车库这些弱网环境里,App 依旧能把文字变成语音;会议纪要、新闻朗读、小说听书这类高频场景,离线方案能稳定多合一。更重要的是隐私:TTS 本身不涉及用户数据上传,但声音克隆是例外。声音克隆的样本就是用户最敏感的生物特征之一,把它留在本机、不出设备,产品合规压力会小很多。我做这套方案时给产品经理画的重点就是:录音不落盘、模型本地产、推理不出网。
还有成本。端侧推理在用户设备上跑,服务端只负责下发模型文件(甚至模型可以内置),不存在按字符计费的调用消耗。单个用户每天朗读几万字也不会产生一分钱服务器成本。对独立开发者来说,这是能长期跑下去的关键前提。
1.2 ZipVoice 在声音克隆链路里的位置
声音克隆本质上分为两个阶段:从音频样本中提取“说话人特征”,再用该特征驱动一个 TTS 模型去合成语音。ZipVoice 做的事情是第一阶段,它把用户录制的几段声音训练成一个轻量的说话人模型,最终导出成 ONNX 格式供 sherpa-onnx 推理。
有人会问,为什么不用云端那套 few-shot 克隆?因为 few-shot 方法一般需要几百 MB 的基座模型,在低端机上跑不动;ZipVoice 这类端侧声音克隆方案会尽量压缩说话人嵌入的尺寸,训练时间控制在分钟级,最终产物是一个几 MB 级别的模型文件,和 sherpa-onnx 的 TTS 主模型拼接使用。两者搭配以后,完整链路就是:主模型管“怎么发音”,克隆模型管“谁的声音”,端侧推理引擎把它们合起来。
1.3 sherpa-onnx 为什么适合做 Flutter 的后端
sherpa-onnx 是 k2-fsa 社区开源的推理引擎,支持的语音任务很全:语音识别、说话人验证、音频标记、关键词识别、TTS 都在里面。我选它的第一个原因是Flutter 插件成熟。社区有官方的sherpa_onnx_flutter,Android/iOS/Windows/Linux/macOS 都有预编译产物,Dart 侧调用 API 非常简洁,不需要自己写繁琐的 JNI 封装。
第二个原因是模型生态。它内置支持 VITS、Matcha-TTS、Kokoro 等多个 TTS 模型结构,中文、英文、日语都有训练好的开源模型可以直接下。像 Kokoro 这种新模型,在手机 CPU 上跑都能达到很好的实时率。而且模型格式统一走 ONNX,我把 ZipVoice 导出的克隆模型丢进去,照样可以被同一个引擎加载,不用改底层代码。
第三个原因是它对平台音频播放做了封装。sherpa_onnx_flutter插件内部已经处理好合成音频的播放逻辑,Android 上用 AudioTrack,iOS 上用 AVAudioPlayer,开发者不用自己在 Dart 层拼音频播放器。少踩几个平台坑比什么都强。
2. 核心原理拆解:从录制声音到最终合成,中间到底发生了什么
很多人一听到“声音克隆”就觉得是黑魔法,其实原理拆开并不神秘。你要想把这套方案做稳,至少得知道数据在说话人模型和 TTS 主模型之间是怎么流动的,否则遇到问题只能瞎猜。
2.1 神经网络 TTS 的基本流程:文本到声学特征再到波形
目前端侧常用的 TTS 模型大致是这样一个流程:输入文本先被转成音素序列,音素序列经过编码器变成语义特征,再通过声学模型生成梅尔频谱,最后用声码器把梅尔频谱还原成 PCM 波形。VITS 这类模型比较激进,把声学模型和声码器联合训练,直接从文本隐变量解码出波形,端侧推理速度更快。
sherpa-onnx 对模型结构的抽象就是“输入 token IDs,输出 PCM 采样点”。它内部把分词、音素映射、模型推理、波形后处理全部封装好,Dart 侧只要做两件事:把文本字符串传给引擎,再把返回的 Float 列表交给播放器。不想精细控制的时候,完全可以当黑盒用。
2.2 声音克隆的实现机制:说话人嵌入与条件生成
声音克隆的核心是说话人嵌入(speaker embedding)。神经网络 TTS 在训练时,训练集里有很多不同的说话人;模型会把“谁在说话”这个信息压缩成一个固定维度的向量。推理时,只要改变这个向量,同一个模型就能发出不同人的声音。ZipVoice 干的事情,就是针对目标用户重新生成一个高质量的说话人嵌入,甚至可以对特定音色做微调。
这带来的一个副作用是:克隆效果的上限取决于主模型本身的多样性。如果主模型训练时说话人很少,或者合成引擎本身音质一般,克隆出来的声音上限也就一般。所以我在实操中不会只盯着 ZipVoice 调参,还会换不同的主模型测试最终听感。
2.3 ZipVoice 的训练与导出管线
ZipVoice 具体的训练管线不同版本会有差异,但按大多数端侧声音克隆工具的常规做法,整体是:录制音频,清洗样本,训练嵌入,导出 ONNX。我把每个环节的常见做法列一下,方便你排查问题。
录制阶段,要求环境安静、底噪小,用手机自带麦克风即可。推荐录 3 到 5 分钟内容,风格尽量自然,不要刻意端腔,不要有大段空白。清洗阶段,工具一般会做 VAD(语音活动检测)切分,把长音频切成 1 到 5 秒的短句,滤掉静音段。然后是训练,端侧工具为了速度,不会像云端方案那样训练几十万步,通常在几分钟到十几分钟内完成。最后导出时,会生成与 sherpa-onnx 对接的模型文件,一般是.onnx格式,里面包含说话人嵌入或者可微调的辅助特征。
提示:如果你的 ZipVoice 版本导出的不是标准 ONNX,而是一组权重文件,大概率需要用配套脚本转一下。我遇到过一次导出后缺少 token 配置文件的情况,原因是训练脚本依赖的音频处理库版本过旧,升级库后重新导出就正常了。
3. Flutter 集成实操:一个能跑的工程长什么样
理论部分讲完,下面进入正题。我先给你看一套最小可运行代码,然后逐个解释关键参数。这套代码基于sherpa_onnx_flutter插件,我自己在 Android 13 和 iOS 16 上实测通过,Flutter 版本用的 3.x 稳定版。
3.1 依赖选择:直接用官方插件还是自己封装
如果你的 App 只需要 TTS 功能,直接用sherpa_onnx_flutter就行。这个插件把底层 C++ 推理引擎封装成了 Dart API,Android 底层依赖预编译的 so 文件,iOS 底层依赖 pod 里的 framework,都是打包好的。唯一的注意点是包体积:sherpa-onnx 的程序产物加上模型,整体会增加几十 MB。如果在意包体积,也可以手动只保留需要的平台 so,去掉没用到的模型结构支持。
不使用官方插件的情况,是你需要深度定制音频流的时候。比如你想边合成边播放、做逐字时间戳对齐,或者把 TTS 输出的 PCM 拿去做实时变声,那可以自己用 FFI 调用 sherpa-onnx 的 C API。这个方案灵活,但工作量翻倍。我建议第一步先用官方插件跑通,再考虑替换底层。
3.2 初始化模型与配置参数
sherpa-onnx 的 TTS 配置核心是OfflineTtsConfig。下面是初始化代码:
import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx; final tts = sherpa_onnx.OfflineTts( sherpa_onnx.OfflineTtsConfig( model: sherpa_onnx.OfflineTtsModelConfig( vits: sherpa_onnx.OfflineTtsVitsModelConfig( model: 'assets/tts_model/vits-zh.onnx', tokens: 'assets/tts_model/tokens.txt', lexicon: 'assets/tts_model/lexicon.txt', dictDir: 'assets/tts_model/dict', dataDir: 'assets/tts_model/espeak-ng-data', noiseScale: 0.6, lengthScale: 1.0, ), provider: 'cpu', debug: false, numThreads: 2, ), ruleFsts: 'assets/tts_model/rule.fst', ruleFars: 'assets/tts_model/rule.far', maxNumSentences: 2, textNormalizer: true, ), );几个参数我重点讲一下。
noiseScale控制合成语音的随机性,值越大声音越“松弛”但噪声也越多,反之越机械。我用的时候一般在 0.5 到 0.8 之间调,偏自然就降到 0.5 附近。lengthScale是语速倍率,1.0 是正常语速,想慢一点就调到 1.2,快一点就 0.8,注意它和播放器倍速不是一回事,改它会让模型重新生成整个音频,语音的自然度仍会保持。numThreads是并行推理线程数,双核以上手机设 2 基本够了,设太高反而会因调度开销拖慢速度。maxNumSentences决定长文本怎么切分,值太大会导致合成延迟变长,太小则会导致句子间停顿失去语义连贯,我推荐 2,后续配合流式播放体验最佳。
3.3 文本转语音并播放的完整代码路径
初始化完成后,合成并播放的核心逻辑不超过 20 行:
Future<void> speak(String text) async { final start = DateTime.now(); final audio = tts.synthesize(text); final elapsed = DateTime.now().difference(start).inMilliseconds; debugPrint('synthesize done, samples=${audio.samples.length}, ' 'sampleRate=${audio.sampleRate}, elapsed=$elapsed ms'); if (audio.samples.isEmpty) { // token 词表里缺字、文本无效、模型加载失败都会走到这里 return; } final player = await AudioPlayer.create(); await player.play( audio.samples, audio.sampleRate, isFloat32: true, ); }synthesize返回的对象包含samples和sampleRate两个字段,samples是 Float32 类型的 PCM 数据,sampleRate由模型决定,大部分模型是 22050 或 24000。播放时要把isFloat32设为 true,否则播放器会按 Int16 解,声音会变成刺耳的噪声。我见过好几个直接把 float 数组当 int16 放的例子,出来的就是“电音”,这个问题后面还得细说。
播放器我用的flutter_soloud,它支持 Float32 PCM buffer 直接播放,延迟低。如果你不想引入额外播放器依赖,先把samples转成 WAV 字节再交给just_audio播放也是可以的,不过这样会有一次编码解码的开销,且 Memory 占用会多一份完整音频数据。
3.4 平台工程的典型配置:Android 与 iOS
Android 端的坑主要在依赖和音频焦点。Flutter 项目引入插件后,需要在android/app/build.gradle里确认minSdkVersion不低于 21,sherpa-onnx 的 so 文件构建目标一般从 21 以上开始;低于这个版本会在运行时报dlopen failed。
iOS 端要记得设置录音权限描述,因为 ZipVoice 采集样本那一侧一定会用到麦克风:在Info.plist里加NSMicrophoneUsageDescription,否则客户端一打开就崩。TTS 合成本身不涉及麦克风,夹带这步是为了声音克隆数据采集的安全合规。
音频会话也很关键。iOS 上如果 App 在静音开关拨到静音时不想让语音一起静音,需要设置后台播放模式,并在 Dart 层调用插件之前配置 AVAudioSession 的 category。Flutter 的audioplayers或者flutter_soloud都有对应接口。这一项不做,用户用实体静音键静音后,你的朗读会无声,非常容易误判成 Bug。
4. 模型准备与参数选择:声音像不像,全看这一步
很多人在跑通代码之后,卡在了“声音不像”这一步。这里需要分清楚:克隆声音不像,不一定是 ZipVoice 训练的问题,也可能主模型选得不对,或者推理配置里采样率没对齐。我把自己调通时用到的配置和替换流程整理出来。
4.1 目录结构与模型文件格式
一个典型的 sherpa-onnx TTS 模型目录长这样:
assets/tts_model/ vits-zh.onnx tokens.txt lexicon.txt dict/ espeak-ng-data/ rule.fst rule.farmodel是模型本体,通常几十到几百 MB,取决于音质和参数量。tokens.txt是 token 映射表,每一行是一个 token 加一个整数 id,模型分词依赖它。lexicon.txt负责字形到音素的转换,中文场景尤其重要,因为中文多音字很多,没有 lexicon 容易读错字音。dict目录放扩展词典,espeak-ng-data是 eSpeak NG 的音素数据,多语言合成需要它。rule.fst和rule.far是文本规范化规则,用来把“2024年12月1日”转成“二零二四年十二月一日”,不配置它可能朗读数字时读得很别扭。这些文件在你下载任何开源模型时一般都会一起发布,不要只下 onnx 文件,否则初始化直接报缺文件。
4.2 说话人数量、语言 ID、采样率等参数的实际填法
sherpa-onnx 的 VITS 模型在很多开源模型里是多说话人模型,文件里会带一个speaker序号,对应推理时的speakerId。如果你用的模型支持多个说话人,OfflineTtsVitsModelConfig里有speakerId字段,填 0 通常表示第一个说话人;只做声音克隆时,这个字段一般填 -1,表示使用外部说话人嵌入。
采样率设置很容易踩坑。ZipVoice 导出的嵌入向量本身没有采样率概念,但训练时对齐的数据采样率会被模型记住。如果 ZipVoice 训练样本是 16kHz,而主模型期望 22.05kHz,合成出来的声音可能变调或者发闷。排查方法是:读 ZipVoice 训练脚本中 wav 的读入方式,确认是否对采样率做了重采样,然后在导出模型时保持和主模型一致。我在工程里写了一个脚本统一处理,后面会给出示例。
另外,语言 ID 不是所有模型都有。中文的 VITS 模型一般不需要语言 ID,英文流式模型才需要传入。遇到模型报错“language id out of range”时,先去看模型的tokens.txt前缀里有没有_en、_zh这类标记,或者直接去模型卡页看示例代码。
4.3 把 ZipVoice 生成的模型替换进 Flutter 工程
替换流程有三种方式:训练后直接导出、合并进模型包、运行时动态下发。我先说前两种的实操。
方式一是导出新的模型文件后,替换assets/tts_model/下的内容。适合自制模型固定使用的情况。
方式二是把主模型和克隆嵌入硬编码合并。这个方法要求你具备一点点 Python 能力,但好处是运行时不再依赖额外文件。我用一个简单脚本把说话人嵌入注入模型权重:
import onnx from onnx import numpy_helper import numpy as np model_path = "vits-zh.onnx" onnx_model = onnx.load(model_path) # 假设 embed 是 ZipVoice 导出的说话人嵌入,shape 需与模型中 speaker_embedding 一致 speaker_embed = np.load("zipvoice_embedding.npy") for initializer in onnx_model.graph.initializer: if "speaker" in initializer.name.lower(): speaker_tensor = numpy_helper.to_array(initializer) if speaker_tensor.shape == speaker_embed.shape: new_tensor = numpy_helper.from_array(speaker_embed.astype(speaker_tensor.dtype), name=initializer.name) initializer.CopyFrom(new_tensor) print(f"replaced {initializer.name}") break onnx.save(onnx_model, "vits-zh-cloned.onnx")这个脚本是“如果模型结构里把说话人嵌入当成一个初始化的权重”时的做法。不同模型结构可能不一样,所以我标注一下:它是我基于常见 ONNX VITS 结构的经验补出来的,不是所有模型通用。实际使用时,你要先用 Netron 打开模型,查看输入节点里有没有类似sid或者speaker_embedding的输入,如果有,那就不能改权重,而要在推理时动态传入。
方式三是运行时从网络下载克隆模型到应用目录,再在初始化时把模型路径指向应用私有目录。这个方案适合“用户自助克隆”的产品,我第一次跑通 ZipVoice + Flutter 时走的就是这条路:录音采集完,训练完成后把产物上传到自己的服务器,其他设备按需下载。这里要注意:任何上传行为都涉及用户隐私,应用内要明确告知并获得授权。
5. 实际体验中的性能调优与内存控制
跑通基础功能只是第一步。TTS 是 CPU 密集任务,如果在 Flutter UI 线程直接跑长文本合成,会直接卡掉帧;在低端机上合成一个大长句,甚至可能出现 ANR。你需要在意的是实时率、内存峰值和播放顺滑度。
5.1 实时率怎么算,低到什么程度可接受
实时率(RTF)是衡量 TTS 性能的核心指标,公式是“合成耗时 / 音频时长”。如果合成一段 10 秒音频花了 3 秒,RTF 就是 0.3,代表比实时快 3 倍多。对端侧 TTS 来说,RTF 小于 1 是及格线,说明合成速度不慢于播放速度;小于 0.5 体验就比较流畅,长文本也能边合成边播。
我在真机上测过几种配置:开启numThreads=2后,Kokoro 模型在骁龙 8 Gen 1 上 RTF 大概 0.2 到 0.3,在几年前的麒麟 990 上大概 0.4 到 0.5。VITS 相对更轻,中端机也能跑到 0.2 以内。如果你实测 RTF 大于 1,先调大线程数试试,其次把主模型换成更小的版本,最后再看是否需要切分长文本。
5.2 CPU 线程数、buffer 大小和音频会话管理
线程数不是越多越好。当前主流手机 CPU 都是大小核结构,我实测在 8 核设备上把numThreads从 2 调到 4,RTF 提升反而很小,有时还更慢,因为线程创建和调度开销吃掉了一部分收益。原因是大核和小核性能差异明显,负载均衡做得并不完美。推荐从 2 开始,逐步往上试,直到 RTF 不再明显下降为止。
如果你用流式播放,还要小心音频 buffer 大小。sherpa_onnx_flutter的synthesize是一次性生成整段音频,返回的 Float 数组可能很长。比如一段 30 秒音频,在 24kHz 采样率下就有 72 万个 float,约 2.88 MB,摊到 Dart 堆里还好,但如果 App 在调用后又复制一份给播放器,内存峰值就容易上去了。我自己的做法是:直接把samples传给播放器,避免任何中间格式转换;需要做文件缓存时才写 WAV。
iOS 端还有一个常见问题:如果同时使用 TTS 和音乐播放,需要设置AVAudioSession的 category 为playback,混合模式可以并排播放,可打断模式则更适合语音交互。Flutter 侧可以用audioplayers的AudioPlayer.setAudioContext来配置。不设置的话,很可能出现“TTS 合成出来但不出声”这种看起来莫名其妙的问题。
5.3 长文本切分后如何实现流式体验
sherpa-onnx 的maxNumSentences参数会对输入文本切句分批合成,但如果你用一次synthesize合成整本书一样的文本,内存仍然会飙。更稳妥的做法是在 Dart 层自己维护一个文本队列:先把文本按句号、问号、感叹号切成短句,每次只送 1 到 2 句话给引擎,合成完立即播放,同时预取下一句。
我最初用StreamController<List<double>>做了一个简易的流式 TTS 管线,实测效果不错。切句要注意中文标点不止。!?三个,省略号“……”和分号“;”也要考虑。另外,句子太短会让播放出现“一两个字一停”的破碎感,我处理时会把太短的句子和前后句合并。
注意:sherpa-onnx 的
synthesize本身是同步阻塞的,所以在 Flutter 里千万别在 UI isolate 直接调用。官方插件底层是同步 FFI,我的做法是包一层compute或者在runZonedGuarded里配合Isolate.run调用,这样线程池占满时也不会阻塞手势交互。
6. 常见问题与排查技巧实录
做这种跨语言、跨平台、跨模型组合的活儿,问题通常出在最不起眼的细节上。我把这几天实测中最常遇到的几类问题整理成一张速查表,方便你排查时直接对照。
| 现象 | 最可能的原因 | 排查方向 |
|---|---|---|
| 合成结果为空数组 | tokens.txt 缺字导致 token 无法映射 | 检查文本中是否有生僻字,先切到数字短句测试 |
| 声音变成刺耳噪声 | PCM 类型被当作 Int16 播放 | 设置isFloat32: true,或确认音频转换逻辑 |
| Android 启动闪退 | minSdkVersion 太低或 so 冲突 | minSdkVersion >= 21,检查abiFilters是否只保留必要架构 |
| iOS 无声音 | 没设置正确的 AVAudioSession | 检查音频会话 category、是否被静音键影响 |
| 合成速度奇慢 | numThreads 过小或模型非 CPU 优化 | 调线程数、换 CPU 量化版本模型、切短句 |
| 声音不像本人 | 主模型多说话人底子差,或样本噪声太大 | 换表现力更强的 TTS 主模型,保证录音环境干净 |
| 长文本内存暴涨 | 一次性 synthesize 太长文本 | 切句分批合成,播放完一段丢弃一段 |
| 读取模型文件失败 | 路径写错或 Flutter asset 未声明 | 检查 pubspec.yaml 的 asset 声明,确认资源在 target 目录 |
下面重点说四个我印象最深的坑。
第一个坑是 abiFilters。sherpa-onnx 插件默认带全平台 so,但有些插件和它存在 so 覆盖关系,导致运行时加载到错误版本的 so,表现就是UnsatisfiedLinkError。解决方法是保留arm64-v8a和armeabi-v7a,移除x86,因为模拟器上 x86 镜像经常会和 arm 目录的 so 打架。我在 Android 模拟器上踩过一次,换到真机秒好,排查了半小时才定位到是模拟器架构问题。
第二个坑是模型路径。Flutter 里 asset 文件不能直接当作普通文件路径传给原生层,因为 asset 在 Android 里是压缩打包的。sherpa_onnx_flutter插件内部其实已经处理了 asset 解压,但路径必须以assets/开头且大小写要和 pubspec 完全一致。我遇到过一次把assets/tts_model写成了asset/tts_model,初始化时报文件找不到,而且报错日志大概率和模型无关,就是一句failed to create offline tts,非常迷惑人。
第三个坑是 model 和 lexicon 的匹配关系。有人下载模型 A 却用了模型 B 的 lexicon,结果大量的字音映射错乱,合成出来满篇错字音。这不是 bug,是资产组合错误。下载模型时最好整个目录一起下,不要单独换 lexicon。
第四个坑是rule.fst缺失导致数字朗读异常。rule.fst配合textNormalizer=true会把“3.5”读成“三点五”,否则很可能读成“三点五”和“三 点 五”的诡异版本。有些精简模型包没有带这个文件,我自己遇到时是手动从官方仓库下载同名规则文件补齐的。
7. 后续还能怎么扩展:把 TTS 变成产品能力而非孤岛
写到这里,核心方案已经完整落地。我个人在这次实践中最大的体会是:sherpa-onnx 加 ZipVoice 这套组合最难的不是代码,而是搞清楚自己的产品需要什么样的“音色自由”。如果你只是想让 App 内置一个标准女声/男声离线朗读,那根本不需要 ZipVoice,直接下载开源中文 VITS 模型即可;如果你要做“用户专属声音”,那 ZipVoice 的克隆链路才真正发挥作用。
还有一个很容易被忽略的扩展点:sherpa-onnx 本身是“语音全家桶”,同一套集成里不仅能做 TTS,还能做离线 STT。我在做完声音克隆后顺手把同一个引擎接进了语音识别,用户对着麦克风说一句话,App 在本地转成文字,再用克隆声音读出来。这个“端到端本地语音对话”的能力做儿童口语评测或者语言学习类 App 会非常有用。
最后分享一个调音质的小技巧:同一句话的克隆效果不好,先别急着重新训练。把主模型的noiseScale调低一档、lengthScale调到 1.05,往往比重新训练样本带来更明显的改善。我在实际项目里同时跑了三组配置让用户盲听,选出的最优组合,反而和训练时间最长的那一版关系不大。这行就是如此,定量指标再好,最后都得过耳朵这一关。