sherpa-onnx + ZipVoice 端侧声音克隆:Flutter 中的完整 TTS 方案

录 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 同时接收两组输入:

  1. 待合成文本的 token 序列
  2. 参考音频的 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 优化。最终放弃,原因记录如下。

遇到的问题

  1. INT8 量化精度崩溃:MNN 在麒麟 710 上对 INT8 ONNX 的反量化路径与 onnxruntime 有差异,输出范围扩大约 10 倍,合成全是杂音
  2. FP32 模型太大:换用 FP32 后精度正常,但 decoder 从 125MB 涨到 478MB,内存占用翻倍
  3. 长文本性能不如 onnxruntime:短文本(10 字)MNN 确实更快,但 200 字长文本实测 ~300 秒,比 onnxruntime 的 240 秒还慢
  4. 多 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 的关键经验:

  1. ZipVoice + sherpa-onnx 是目前 Flutter 端侧 TTS 的最佳组合------模型小、集成简单、3 秒即可克隆
  2. 必须用独立 Isolate------FFI 阻塞 + native 库冲突两个问题只有 Isolate 隔离能同时解决
  3. WAV 解析不能偷懒------扫描 RIFF chunk 而非硬编码偏移
  4. numSteps=4 是红线------蒸馏版模型低于 4 步音质不可接受
  5. 边合成边播放是改善体验的关键------把 30 秒等待变成 3 秒首播
  6. 录音对齐 24kHz、控制在 3 秒------符合 ZipVoice 推荐,减少不必要的计算

项目完整代码已开源:

👉 github.com/qhvssonic/m...


附:相关资源

相关推荐
hahaha601624 分钟前
HLS高层次综合设计技巧--循环merge和循环split
人工智能·算法·计算机视觉
JAI科研25 分钟前
Deepseek Agent Harness教程(七) | Deepseek Harness不是一个内核加一堆插件
开发语言·人工智能·深度学习·算法·自然语言处理·transformer·vllm
2601_9673387127 分钟前
尚硅谷2026尚硅谷Java全栈+Python智能体教程
java·人工智能
alwaysrun29 分钟前
AI与电力类比:系统性风险与社会治理对比
人工智能
tuanxiang31 分钟前
在线测AI含量:批量内容生成的合规性排查踩坑记录
人工智能·深度学习·机器学习
Python大数据分析@35 分钟前
为什么codex和chatgpt合并?
人工智能
SEO_juper41 分钟前
实体SEO:从关键词到实体的2026搜索引擎与AI引用实战指南
人工智能·搜索引擎·seo·独立站·谷歌优化
集思广益的灰太狼42 分钟前
变频器启动致PLC数据异常?西门子G120配合滤波器EMC抑制方案
人工智能·算法·工控·emc·电磁兼容·变频器·西门子
luckystar513~1 小时前
自己动手编写skills:我让AI使用git更规范、合理
人工智能