如果你正准备在 Flutter 里做一款带“声音克隆”能力的离线文字转语音应用,也就是用户录几秒钟自己的声音,然后 App 就能用这个音色把文字读出来,那这套组合应该是当前社区里少见的、能完整跑通的端侧方案:sherpa-onnx 负责离线 TTS 推理,ZipVoice 负责端侧声音克隆,最终在 Flutter 层把它们拼成一条完整的语音合成链路。它不需要服务器,不产生 API 费用,数据也不会离开设备,适合阅读类 App、有声陪伴、无障碍播报这类对隐私和延迟敏感的场景。
这篇文章我会从方案选型开始讲,逐步拆解 Flutter 工程里怎么接 sherpa-onnx、怎么把 ZipVoice 的声音克隆输出喂给 TTS 引擎、怎么调听感、又在哪些环节容易翻车。全程以我们实际跑过的链路为主线,给的是可以直接对照落地的步骤,而不是原理堆砌。
1. 为什么是 sherpa-onnx + ZipVoice 的组合
1.1 端侧 TTS 和云 TTS 的本质差异
先明确一个核心问题:TTS 放端上做,到底图什么。最直接的是三点:一是离线可用,地铁里、飞机上、弱网环境都不影响;二是零成本,云 TTS 按字符收费,长时间朗读场景成本很可观;三是隐私可控,尤其涉及个人语音数据时,把参考音频传到云端本身就是一种风险。
但端侧 TTS 有一个老问题:模型够不够轻、推理够不够快。在手机上跑一个几百 MB 的神经网络模型,不能只看能不能加载,还得看首包延迟、CPU 占用、内存峰值和发热。sherpa-onnx 这个项目我用下来的感受是,它把基于 ONNX Runtime 的离线语音推理做得很完整,支持语音识别(ASR)、语音合成(TTS)、说话人识别等任务,并且针对手机端做过不少裁剪和优化。Flutter 侧也有对应的 Dart 绑定,不用自己从头写一堆 FFI 胶水代码。
传统 TTS 链路通常是“文本前端 -> 音素规整 -> 声学模型 -> 声码器”,不同模块可能由不同框架负责,串联起来很麻烦。sherpa-onnx 走的是端到端路线,模型文件给到它,文本进去,PCM 音频数据出来,中间环节被压缩到极致。这也是它能在 Flutter 里做成完整方案的原因。
1.2 ZipVoice 到底补上了哪一块
光有 TTS 还不够,因为普通 TTS 默认只有固定几个音色,更谈不上“用某个人的声音朗读”。ZipVoice 这类端侧声音克隆工具的定位,就是干这件事:输入一段几秒钟的参考语音,提取出说话人的音色特征(通常是一个高维向量,也叫说话人 embedding),然后把这个特征作为条件交给语音合成模型,让合成结果带上这个人的声音特点。
这里要强调一个容易误解的点:声音克隆并不是把参考音频剪碎再拼接,而是学习“这个人怎么说话”的声学特征,再用这些特征去驱动文本生成语音。ZipVoice 和 sherpa-onnx 的组合逻辑是:ZipVoice 负责端侧提取说话人特征,sherpa-onnx 负责用这个特征完成语音合成。听起来像是“两个模型串起来”,但真正落地时关键在特征对接,这个我在第 3 章会展开讲。
1.3 主流方案对比,选这套的理由
在 Flutter 里做 TTS,见过不少人一开始会走下面几条路:
| 方案 | 延迟 | 是否离线 | 音色可控 | 是否支持克隆 | Flutter 集成成本 |
|---|---|---|---|---|---|
| 系统自带 TTS | 低 | 是 | 差 | 不支持 | 低 |
| 云 TTS API | 中/高 | 否 | 中 | 部分支持 | 低 |
| 本地 VITS 自建推理 | 中 | 是 | 中 | 需要额外开发 | 高 |
| sherpa-onnx + ZipVoice | 低 | 是 | 高 | 支持 | 可控 |
系统自带 TTS 最大的问题是音色和语速在不同品牌手机上差异巨大,开发者没法保证所有用户听到的是一致的。云 TTS 虽然效果好,但每次合成都要网络请求,长文本场景延迟和费用都上去了。自己用 Python 把 VITS 模型包装成服务再让 Flutter 调,又退回了“伪端侧”的老路,而且移动端模型转换和推理优化是个大坑。
所以 shoot 下来,sherpa-onnx 做推理底座,ZipVoice 做音色提取,两者都在本地完成,是当前 Flutter 技术栈里比较务实的端侧 TTS + 克隆组合。它的核心价值是:你不需要自己搭一个推理服务,也不需要把用户语音样本上传到任何地方。
1.4 整体链路长什么样
用文字描述完整链路,大概是这样的:
参考音频录制(3 到 10 秒) -> ZipVoice 提取说话人 embedding -> 用户输入任意文本 -> sherpa-onnx 加载 VITS 类声学模型 -> 将 embedding 注入模型 -> 推理输出 PCM 音频数据 -> Flutter 播放器播放。
这里面的关键节点有两个:第一,ZipVoice 输出的 embedding 必须和 sherpa-onnx 里那个模型支持的输入格式对齐;第二,最终播放时 PCM 采样率要和播放器配置一致,否则声音会变调。后面章节的实操都是围绕这两个关键节点展开的。
2. Flutter 工程侧的整体接入思路
2.1 依赖选型和工程准备
Flutter 侧需要关心的依赖主要有三类:推理绑定、音频播放、文件路径处理。推理绑定可以用 sherpa-onnx 官方维护的 Dart 包,它底层通过 FFI 调用 C++ 库,封装了文本转语音的接口。音频播放我推荐 just_audio 或者 audioplayers,因为合成结果是一段 PCM 数据,播放器需要支持从内存数据流播放。文件路径处理用 path_provider,主要解决模型文件存放问题。
flutter pub add sherpa_onnx just_audio path_provider工程准备上,Android 端注意一下 minSdk 版本,建议 23 以上,NDK 和 CMake 也要装好,因为 sherpa-onnx 的 Android 构建依赖这些工具。iOS 端则要求最低版本 13 以上,同时注意 App Store 对本地推理应用没有特殊限制,按普通 App 提审即可。
2.2 模型资源怎么放
离线 TTS 模型包通常包含一个 .onnx 主模型文件,配套的 tokens.txt(字符到 token 的映射表)、lexicon.txt(词典)、dict 目录(中文分词相关资源)、espeak-ng-data(多语言音素规则数据)。这些文件加起来大小从几十 MB 到几百 MB 都有,不能直接当普通 Flutter asset 反复读取,否则每次启动都要触发系统资源复制,启动时间会非常难看。
建议的做法是:把模型文件声明在 pubspec.yaml 的 assets 里,首次启动时用 rootBundle 读取出来,写到应用私有目录(比如 getApplicationDocumentsDirectory),后续直接从磁盘路径加载。这样既保证安装包自带模型,又避免每次合成时都做一次资源 IO。
2.3 初始化 TTS 引擎
sherpa-onnx 的初始化逻辑非常直白,核心就是构建一个离线 TTS 配置对象,指定模型路径和配套资源路径。下面是一段可以对照运行的 Dart 代码:
import 'package:sherpa_onnx/sherpa_onnx.dart'; final ttsConfig = SherpaOnnxOfflineTtsConfig( model: SherpaOnnxOfflineTtsModelConfig( vits: SherpaOnnxOfflineTtsVitsModelConfig( model: '$modelDir/model.onnx', tokens: '$modelDir/tokens.txt', lexicon: '$modelDir/lexicon.txt', dictDir: '$modelDir/dict', dataDir: '$modelDir/espeak-ng-data', ), numThreads: 2, debug: false, ), ruleFsts: '$modelDir/phone.fst', ); final tts = SherpaOnnxOfflineTts(config: ttsConfig);注意几个容易踩的点:tokens.txt 必须和模型配套,换模型一定要换 tokens,否则中文会变成乱码或直接不出声。如果是纯中文 VITS 模型,很多开源模型并不强制需要 espeak-ng-data,但为了英文混读的效果,建议还是把 dataDir 配上。numThreads 在手机端不要开太大,2 到 4 线程就够,开多了反而增加调度开销。
2.4 为什么底层必须走 FFI,而不是纯 Dart
有朋友问过,能不能用 Dart 直接做神经网络推理。答案很现实:不能,至少现在不能。神经网络的算子库、内存管理、并行调度都是 C/C++ 层的强项,纯 Dart 做这类计算既慢又容易卡 UI。sherpa-onnx 的 Flutter 包本质上是把 C++ 推理引擎编译成 Android 的 .so 库和 iOS 的 .a 库,再通过 dart:ffi 暴露给 Dart 调用。这就是“Flutter 负责界面和业务,C++ 负责跑模型”的经典分工。
实际开发时,千万别在 UI isolate 里直接调用 TTS 合成接口。合成一长段文字可能要几百毫秒到几秒,如果不放到后台 isolate 或者至少用 compute 包裹,用户滚动页面时就能明显感觉到掉帧和卡顿。
3. 把 ZipVoice 的声音克隆串进 TTS 链路
3.1 参考音频的准备和处理
声音克隆的第一步是准备参考音频。ZipVoice 对输入音频是有基本要求的:格式最好是 16kHz 采样率、16bit、单声道的 WAV,时长控制在 3 到 10 秒之间。录音时注意无背景噪声,人声清晰,不要有音乐或混响。为什么一定要 16kHz?因为大部分语音模型训练时用的就是 16kHz 的采样率,过高采样率反而会导致频率特征分布和训练数据不一致。
时长也需要控制。太短的音频(比如 1 秒)提取不出稳定的说话人特征;太长的音频(比如 30 秒)又容易混入情绪波动和语气差异,导致提取出的 embedding 不够“纯粹”。我个人的经验是,4 到 5 秒的普通话陈述句效果最好,音素覆盖广,比如“今天天气不错,我们一起去公园散步吧”这种句子,包含的声母韵母足够丰富,能提取出稳定的音色特征。
如果录音设备出来的音频是 48kHz 或者 44.1kHz,需要在端侧做一次重采样。这一步可以用简单工具或音频库完成,目标就是把它变成 16kHz 单声道 WAV。
3.2 说话人特征注入的两种核心方式
这里讲整个方案里最关键的细节:ZipVoice 提取到的 embedding 怎么交给 sherpa-onnx。
从我调研和实际接触的情况看,当前主流开源 VITS 类模型有两种说话人控制方式。第一种是离散说话人 ID 加预训练 embedding 表,模型在训练时见过固定数量的说话人,每个 ID 对应一个高维向量。这种方式在克隆场景里需要做“映射”:把 ZipVoice 提取的向量去匹配最接近的说话人 ID,或者直接替换 embedding 表里对应位置的向量。第二种是连续说话人 embedding 作为条件输入,模型本身支持 zero-shot 克隆,训练时未见过的新声音可以直接通过输入 embedding 参与合成,不需要重新训练。
如果你的 sherpa-onnx 模型是第一种,打通 ZipVoice 的常规思路是:先制作一个“说话人映射文件”,把 ZipVoice 输出的 256 维或 512 维向量写入模型支持的 speaker 配置;如果是第二种,那链路更直接,把 ZipVoice 的输出转成 ONNX 需要的 tensor 格式,作为额外输入喂给模型即可。具体用哪种,需要先确认手里的模型是 “speaker-id 型 VITS” 还是 “zero-shot 型 VITS”。
3.3 在 Flutter 层封装一个克隆合成服务
为了不让业务代码被底层细节淹没,建议在 Flutter 层封装一个 VoiceCloneController。它对外暴露两个能力:一个是“设置当前用户音色”,接收一段参考音频,返回说话人 embedding;另一个是“用当前音色合成文本”,接收文字,返回 PCM 数据。这样 UI 层不需要关心 ZipVoice 还是 sherpa-onnx。
class VoiceCloneController { final SherpaOnnxOfflineTts _tts; final ZipVoiceService _zipVoice; Float32List? _currentEmbedding; VoiceCloneController(this._tts, this._zipVoice); Future<void> setSpeakerFromWav(Uint8List wavBytes) async { final embedding = await _zipVoice.extractEmbedding(wavBytes); _currentEmbedding = embedding; } Future<Uint8List> speak(String text) async { if (_currentEmbedding == null) { throw StateError('请先设置参考音频'); } // 这里需要将 embedding 转换为模型可接受的输入格式 final pcmBytes = await _tts.generateWithSpeakerFeature( text: text, speakerFeature: _currentEmbedding!, ); return pcmBytes; } }这段代码里的 generateWithSpeakerFeature 是一个抽象后的接口,真实调用方式取决于你使用的 sherpa-onnx 版本和具体模型。我在项目中是直接 fork 了官方 Flutter package,在 FFI 层加了一个“传入 float 数组作为说话人特征”的扩展方法,再编译出自己的 .so/.a 文件。过程不算复杂,但需要一点点 C++ 和 NDK 知识,这也是整条方案里技术门槛最高的部分。
3.4 播放链路和采样率匹配
合成出来的是 PCM 数据,不是文件,不能直接交给系统播放器播放。我使用的链路是:拿到 PCM 字节后,先封成 WAV 格式,或者直接作为原始 PCM 流交给 just_audio 的 StreamAudioSource 播放。封装 WAV 的好处是兼容性最好,很多播放器可以直接播放。需要留意的是,在写入 WAV 头时,采样率必须填 sherpa-onnx 合成时实际输出的采样率,常见的是 22050Hz、24000Hz 或 44100Hz。填错采样率,声音听起来就会变成“小熊说话”,也就是所谓的变速不变调。
final pcm = await controller.speak('你好,欢迎体验端侧声音克隆'); final wavBytes = _wrapPcmWithWavHeader( pcmData: pcm, sampleRate: tts.sampleRate, numChannels: 1, bitsPerSample: 16, ); await _audioPlayer.setAudioSource( StreamAudioSource.fromBytes(wavBytes), ); await _audioPlayer.play();播放器如果出现卡顿,可以检查 isDynamicSampleRate 之类的配置。不同插件对原始 PCM 的采样率处理策略不一样,能显式指定采样率就一定显式指定。
4. 听感调优与端侧性能打磨
4.1 影响克隆音色的核心参数
合成阶段有几个参数直接决定听感,不能只看模型傻跑。sherpa-onnx 的 VITS 配置里常见的有 noiseScale、noiseScaleW 和 lengthScale。用生活化类比解释:noiseScale 控制“情绪波动的剧烈程度”,值越大声音越有起伏,但过大就会像喝多了说话;noiseScaleW 控制“音节内部的随机抖动”,影响气息感和自然度;lengthScale 控制“语速的拉伸比例”,大于 1 是减慢,小于 1 是加快。
| 参数 | 作用 | 推荐范围 | 调太大后果 |
|---|---|---|---|
| noiseScale | 控制韵律随机性 | 0.6 到 0.8 | 声音发抖、不自然 |
| noiseScaleW | 控制音素级抖动 | 0.7 到 0.9 | 气息过重、咬字不清 |
| lengthScale | 控制整体语速 | 0.8 到 1.2 | 过慢拖沓,过快失真 |
不同的模型对同一组参数的反应差异很大,我通常先用默认参数合成一句长文本,再根据听感微调。注意不要为了追求“更像原声”而无脑调大 noiseScale,那会让克隆出来的声音变得“炸”。
4.2 首包延迟优化,重点是预热和缓存
端侧 TTS 的体验瓶颈往往是第一次合成特别慢。原因有两部分:一是模型首次加载需要解析结构、分配内存;二是操作系统的文件缓存还没生效。预热是解决首包延迟最有效的手段,App 启动后立刻在后台 isolate 合成一句非常短的文本,比如“准备”,让引擎先跑一遍。
另一个非常有用的策略是缓存 ZipVoice 的 embedding。同一个用户录制一次参考音频之后,embedding 基本是固定的,没必要每次合成都重新提取。把 embedding 序列化到本地文件,下次启动直接加载,能省掉 200 到 500 毫秒的预处理时间。
4.3 用量化模型和裁剪降低资源占用
对移动端来说,模型体积和运行时内存是硬指标。一个完整的 VITS 模型动辄 300 到 600MB,在低端 Android 机上很容易触发内存压力。优先选择 int8 量化的 ONNX 模型,体积通常能压缩到原来的四分之一到一半。量化带来的代价是音色细节可能略有损失,尤其是高频的气音和齿音,但多数场景下听感差异可接受。
模型裁剪是更激进的手段。如果 App 只需要中文单说话人,可以把多说话人模型切成单说话人版本,或者直接用单说话人训练好的 VITS 模型做底座,再把 ZipVoice 提取到的说话人特征映射进去。这个操作依赖具体模型结构,不适合零基础操作,但收益显著,内存占用能降 30% 以上。
4.4 多端部署的几个差异点
Flutter 的优势就是跨端,但端侧模型部署不能一套代码走天下。Android 端要打包 arm64-v8a 和 armeabi-v7a 两个架构的 .so,如果你的目标机是 x86 模拟器,还要单独处理 x86_64 的库。iOS 端要注意模拟器架构和真机架构不同,使用 pod 或打包静态库时容易踩架构不匹配的坑。Windows 端则要注意路径分隔符,模型路径里用反斜杠还是正斜杠在不同环境里可能有坑,统一用项目根目录拼路径更稳。
Web 端也可以通过 WASM 跑 sherpa-onnx,但受限于浏览器内存和单线程,目前体验远不如移动端流畅,做 Demo 演示可以,生产环境建议慎重。
5. 常见问题与排查实录
5.1 问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 合成中文变成乱码或不出声 | tokens.txt 和模型不匹配 | 换回配套的 tokens 文件 |
| 有声音但明显变调 | 播放器采样率和合成采样率不一致 | 用 tts.sampleRate 设置播放器 |
| 克隆音色不像原声 | 参考音频太长/太短/噪声大 | 换成 4 到 5 秒干净讲话录音 |
| 首次合成特别慢 | 模型未预热、大文件 IO | 启动时后台预热,合成短句 |
| 多次合成后内存上涨 | 每次合成新建对象未释放 | 复用 TTS 实例,避免重复初始化 |
| 部分 Android 机型崩溃 | 内存不足或架构未打包全 | 使用 int8 模型,检查 .so 架构 |
| 英文混读效果差 | 缺少 espeak-ng-data | 配置 dataDir,或使用支持中英混读的模型 |
| embedding 注入后声音没有变化 | 模型不支持该注入方式 | 确认模型是 zero-shot 还是 speaker-id 型 |
5.2 独家避坑心得
先说说参考音频这件事。我遇到过很多次用户录了 15 秒甚至 30 秒的音频,结果克隆出来的声音反而“四不像”。原因我说了,说话人 embedding 是整段音频的统计特征,时长太长会把多种语气和情绪混在一起,特征不再纯粹。实测下来,5 秒是最稳的区间,三句话左右,陈述语气,背景干净。
再有一个就是合成任务别在同一个 isolate 里无限堆积。如果用户快速触发多条朗读,建议做一个“当前任务取消 + 最新任务覆盖”的机制,否则音频回调会排队,表现为越来越慢,最后直接卡死。这里可以用 Dart 的 Future 链配合一个 generation token,每次开始新合成前使旧 token 失效,回调回来时判断 token 是否已经失效,失效就直接丢弃。
最后一个心得是关于工程排错的顺序。遇到音频问题,先用原始 PCM 数据写一个 WAV 文件分享到电脑上听,确认到底是合成侧的问题还是播放侧的问题,不要凭感觉改参数。像“声音嘶哑”“声音发闷”这类问题,大多数时候不是模型坏了,而是采样率、声道数或者播放配置出了偏差,把数据落盘检查是最快的定位手段。
这套方案后续还有一个很有趣的扩展方向:把同一套链路从“文本 -> 用户音色语音”升级成“对话 Agent + 用户音色语音”,也就是让聊天助手用自己的声音把回答内容读出来。技术上只需要在 speak 方法前面接一个大模型流式输出,中间加一层文本缓冲区控制切句时机,音频体验就能自然很多。只要模型文件和 embedding 对应关系维护好,这套底座可以支撑不止一个产品形态。