录 3 秒妈妈的声音,手机离线克隆音色朗读故事。端侧 TTS 完整技术方案。 项目已开源:github.com/qhvssonic/m...
一、为什么选 ZipVoice
做端侧声音克隆 TTS,可选方案不多。对比几个主流选择:
| 方案 | 克隆方式 | 参考音频需求 | 端侧可行性 | 模型大小 |
|---|---|---|---|---|
| CosyVoice | 需要微调或长录音 | 10-30 秒 | 模型太大,不适合端侧 | 2GB+ |
| VITS | 不支持零样本克隆 | 需要 fine-tune | 可以但无克隆能力 | ~100MB |
| ZipVoice-Distill | 零样本克隆 | 3 秒即可 | ✅ 模型小,推理快 | ~186MB |
ZipVoice 的优势在于:
- 3 秒参考音频就能克隆音色,用户门槛极低
- INT8 量化后模型只有 186MB(encoder 5.5MB + decoder 125MB + vocoder 54MB)
- 有 sherpa-onnx 官方 Flutter 插件,集成成本低
- Flow Matching 4 步生成,速度与质量平衡好
二、ZipVoice 的工作原理
一句话从文本变成声音,经过五个阶段:
scss
"小兔子住在森林里"
↓ ① Tokenization(espeak-ng 音素化)
token ID 序列 [352, 241, 302, ...]
↓ ② Encoder(文本编码 + 音色对齐)
text_condition (帧级别条件向量)
↓ ③ Decoder × 4 步(Flow Matching 迭代生成)
mel 频谱
↓ ④ Vocoder(vocos,频谱→频域复数)
STFT 复数表示 (mag, x, y)
↓ ⑤ ISTFT(反傅里叶变换)
波形 → WAV 文件
声音克隆是怎么实现的
ZipVoice 不需要额外的"克隆模型"。它的 Encoder 同时接收两组输入:
- 待合成文本的 token 序列
- 参考音频的 mel 频谱 + 对应文本的 token 序列
Decoder 在做 Flow Matching 时,把参考音频的 mel 拼接在生成区域前面:
ini
speech_condition:
[参考音频 mel, 568 帧] [待生成区域, 568 帧]
↑ ↑
音色锚点 模型要生成的部分
模型被训练成"让生成段的音色与参考段保持一致"。所以只要 3 秒参考音频就够------模型从中提取音色特征,然后"延续"这种音色到新的文本内容。
为什么只需要 4 步
ZipVoice 使用 Flow Matching(流匹配)而非传统扩散模型。Flow Matching 通过 Rectified Flow 把噪声到目标的路径设计成近似直线,使得少量步数就能逼近目标。
再通过蒸馏(distillation),把原本 16-32 步的推理压缩到 4 步等效效果。这就是模型名字里 "distill" 的含义。
工程实测:
- numSteps=4:音质清晰,音色还原准确(推荐值)
- numSteps=3:偶有微弱杂音,可接受
- numSteps=2:明显电流杂音,不可用
- numSteps=1:完全不可识别
蒸馏版模型的速度场是为 4 步优化的,增加到 8 步并不会带来更好的效果。
三、Flutter 集成方案
3.1 sherpa-onnx Flutter 插件
sherpa-onnx 提供了官方 Flutter package(sherpa_onnx),底层通过 FFI 调用 C++ 推理引擎。
yaml
# pubspec.yaml
dependencies:
sherpa_onnx: ^1.13.2
初始化 ZipVoice TTS:
dart
final config = sherpa.OfflineTtsConfig(
model: sherpa.OfflineTtsModelConfig(
zipvoice: sherpa.OfflineTtsZipVoiceModelConfig(
encoder: '$modelDir/encoder.int8.onnx',
decoder: '$modelDir/decoder.int8.onnx',
vocoder: '$vocoderPath',
tokens: '$modelDir/tokens.txt',
lexicon: '$modelDir/lexicon.txt',
dataDir: '$modelDir/espeak-ng-data',
),
numThreads: 4,
),
);
final tts = sherpa.OfflineTts(config);
3.2 为什么必须用 Isolate
sherpa-onnx 的 FFI 调用是同步阻塞的------tts.generateWithConfig() 会阻塞当前线程直到合成完成。在天玑 9500 上合成 200 字需要约 30 秒,如果在主 Isolate 运行会冻结整个 UI。
更麻烦的是,sherpa-onnx 的 initBindings() 加载 native 库时会与 Flutter 的 raster 线程产生 mutex 冲突(pthread_mutex_lock on destroyed mutex),在某些设备上直接 crash。
解决方案:持久化 Worker Isolate。
dart
class LocalTtsService {
Isolate? _workerIsolate;
SendPort? _workerSendPort;
/// App 启动时预热:启动 Isolate + 加载模型
Future<bool> warmUp({required String modelDir, ...}) async {
_workerIsolate = await Isolate.spawn(_ttsWorkerMain, receivePort.sendPort);
// 发送 loadModel 命令...
}
/// 合成:通过 SendPort 发命令给 Worker
Future<String?> synthesize({required String text, ...}) async {
_workerSendPort!.send(_WorkerCommand(type: _CmdType.synthesize, ...));
// 等待结果...
}
}
Worker Isolate 在 App 生命周期内常驻,模型只加载一次(约 2 秒),后续合成零冷启动。
为什么不用一次性 Isolate? 实测中发现一次性 Isolate 销毁时,sherpa-onnx FFI 的 native 资源回收与 Isolate 清理产生竞争,导致 pthread_mutex_lock on destroyed mutex 崩溃。持久化 Isolate 避免了反复创建/销毁的问题。
3.3 WAV 参考音频解析
ZipVoice 需要把参考音频的 PCM 采样数据传给模型。需要先解析 WAV 文件头获取采样率、位深、数据偏移。
不能硬编码 44 字节偏移 。Android 的 record 包录制的 WAV 文件在 fmt chunk 后可能插入 LIST、fact 等扩展 chunk,实际 data offset 可能是 80、120 甚至更多字节。如果固定按 44 字节读,会把 chunk 元数据当成音频数据,听起来就是"开头有电流声"。
正确做法是按 RIFF 规范扫描 chunk:
dart
int pos = 12; // 跳过 RIFF header + WAVE
while (pos + 8 <= bytes.length) {
final chunkId = String.fromCharCodes(bytes.sublist(pos, pos + 4));
final chunkSize = byteData.getUint32(pos + 4, Endian.little);
if (chunkId == 'fmt ') {
// 读取 sampleRate, bitsPerSample, numChannels
} else if (chunkId == 'data') {
dataOffset = pos + 8;
dataLength = chunkSize;
break;
}
pos = pos + 8 + chunkSize + (chunkSize & 1); // chunk 对齐
}
四、性能调优
4.1 参考音频长度
ZipVoice 官方推荐参考音频 < 3 秒。实测发现参考音频越长,Decoder 的计算量越大(因为要拼接到 speech_condition 中),但对音色质量的提升微乎其微。
端侧版本录音引导设计为 3-5 秒,引导文本:"从前有座大山山里住着小白兔"。要求一口气读完,不要停顿------连贯的语音比带停顿的更容易提取稳定的音色特征。
4.2 录音采样率对齐
ZipVoice 的 vocoder 输出 24kHz 音频。如果参考录音是 16kHz,sherpa-onnx 内部会做重采样,但这会引入额外的延迟和可能的质量损失。
直接把录音采样率设为 24kHz,与模型对齐,省去重采样步骤。
4.3 线程分配
天玑 9500 全大核设计(8 核),MNN LLM 配置 4 线程,TTS(onnxruntime)也配 4 线程。两组各占 4 核,实现 LLM 和 TTS 的真并行(边生成故事边合成语音时互不争抢)。
dart
const int kTtsNumThreads = 4;
4.4 性能实测数据
| 指标 | 麒麟710(2018 中端) | 天玑9500(2025 旗舰) | 提升 |
|---|---|---|---|
| 模型加载 | ~2s | < 1s | 2x |
| 200 字合成 | ~240s(4 分钟) | ~30s | 8x |
| 合成速率 | 1.2 s/字 | 0.14 s/字 | 8.5x |
| 音频采样率 | 24kHz | 24kHz | --- |
麒麟 710 上的 1.2 秒/字是硬件瓶颈(CPU 无 FP16/SME2 指令),无法通过软件优化显著改善。但"5 年前的中端机仍可稳定运行"本身也是端侧方案鲁棒性的体现。
五、边合成边朗读
TTS 合成即使在旗舰设备上也需要数十秒,为了改善体验,实现了按句切分的流水线播放。
5.1 切分策略
按句号切分文本,每段 ≥ 15 字(太短的段合并到下一段)。这是为了保证每段有足够的音素上下文,避免合成出的音频在段与段之间音色不连贯。
5.2 播放流程
markdown
第一段文本(~15 字)→ TTS 合成(~2-3s)→ 立即播放
↓ 播放期间(~5s)后台合成第二段
第一段播完 → 第二段已就绪 → 无缝衔接播放
↓ ...
最后一段播完 → 合并全部段为完整 WAV → 跳转播放页
天玑 9500 上首段出声延迟约 3-4 秒(15 字 × 0.14 秒/字 ≈ 2 秒合成 + 模型初始化开销),后续段在播放期间合成完毕,用户感知不到等待。
5.3 音频队列管理
用 just_audio 播放器管理音频队列。每段合成完写入单独的 WAV 文件,播放器按顺序播放。全部播完后调用合并函数把多个 WAV 拼接成一个完整文件(简单地拼接 PCM 数据 + 重写 WAV header)。
六、MNN TTS 后端的尝试与放弃
开发过程中尝试过用 MNN 替换 onnxruntime 作为 TTS 后端,期望获得更好的 ARM 优化。最终放弃,原因记录如下。
遇到的问题
- INT8 量化精度崩溃:MNN 在麒麟 710 上对 INT8 ONNX 的反量化路径与 onnxruntime 有差异,输出范围扩大约 10 倍,合成全是杂音
- FP32 模型太大:换用 FP32 后精度正常,但 decoder 从 125MB 涨到 478MB,内存占用翻倍
- 长文本性能不如 onnxruntime:短文本(10 字)MNN 确实更快,但 200 字长文本实测 ~300 秒,比 onnxruntime 的 240 秒还慢
- 多 native 库析构冲突:MNN + sherpa-onnx + kaldi-native-fbank 三个 .so 的 C++ 全局对象析构顺序不可控,进程退出时偶发 SIGABRT
结论
sherpa-onnx(onnxruntime 后端)+ INT8 量化模型是目前最稳定的端侧 TTS 方案。MNN 在 LLM 推理上优势明显(SME2 加速),但在 ONNX TTS 模型上还需要更多优化。
详细的 MNN TTS 移植踩坑记录见项目仓库。
七、音色管理
支持多个音色档案(妈妈、爸爸、奶奶等),每个档案包含:
- 参考音频 WAV 文件(24kHz,3-5 秒)
- 对应的引导文本(用于 ZipVoice 的 prompt_text)
- 昵称和创建时间
元数据存储在本地 SQLite,音频文件存在 App 私有目录。切换音色时按 voiceId 精确查询,确保选中正确的参考音频。
八、总结
端侧声音克隆 TTS 的关键经验:
- ZipVoice + sherpa-onnx 是目前 Flutter 端侧 TTS 的最佳组合------模型小、集成简单、3 秒即可克隆
- 必须用独立 Isolate------FFI 阻塞 + native 库冲突两个问题只有 Isolate 隔离能同时解决
- WAV 解析不能偷懒------扫描 RIFF chunk 而非硬编码偏移
- numSteps=4 是红线------蒸馏版模型低于 4 步音质不可接受
- 边合成边播放是改善体验的关键------把 30 秒等待变成 3 秒首播
- 录音对齐 24kHz、控制在 3 秒------符合 ZipVoice 推荐,减少不必要的计算
项目完整代码已开源:
附:相关资源
- ZipVoice 论文:arXiv:2506.13053
- sherpa-onnx 仓库:k2-fsa/sherpa-onnx
- Flow Matching 论文:arXiv:2210.02747
- MNN 仓库:alibaba/MNN