做过语音功能的人都知道,一个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-300ms | 150-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; Future<void> 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:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.INTERNET"/>iOS的话,在Info.plist里加:
<key>NSMicrophoneUsageDescription</key> <string>需要麦克风权限以录制你的声音用于生成个性化语音</string>2.3 ZipVoice声音克隆模块集成
ZipVoice目前提供的是一个跨平台SDK,Flutter调用时也是通过官方插件。它的核心API就两个:train和extractEmbedding,前者用于把录音训练成说话人模型,后者用于做实时特征提取。
在实际项目里,我一般直接用extractEmbedding就够了。原因是ZipVoice底层用的并不是那种需要长时间微调的大模型,而是预训练的说话人编码器,输入几秒音频就能输出一个固定维度的嵌入向量。它的优势在于快速、轻量,适合端侧场景。
初始化代码:
import 'package:zip_voice/zip_voice.dart'; final voiceCloner = ZipVoice( modelPath: 'models/zipvoice_encoder.onnx', embeddingDim: 256, sampleRate: 16000, ); Future<Float32List> 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用的gnustl,sherpa-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_vocab=305, spec_channels=1025, segment_size=8192, inter_channels=192, hidden_channels=192, filter_channels=768, n_heads=2, n_layers=6, kernel_size=3, p_dropout=0, resblock="1", 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_channel=512, upsample_kernel_sizes=[16, 16, 4, 4], n_speakers=82, gin_channels=256, ) model.load_state_dict(torch.load("model.pth", map_location="cpu")["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_version=17, )导出之后,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都要重新录一遍,产品基本就废了,所以缓存这一步很重要。
Future<void> saveSpeakerEmbedding(Float32List emb) async { final file = File('${appDocDir.path}/speaker_emb.json'); await file.writeAsString(jsonEncode(emb.toList())); } Future<Float32List?> loadSpeakerEmbedding() async { final file = File('${appDocDir.path}/speaker_emb.json'); if (file.existsSync()) { final list = jsonDecode(await file.readAsString()) as List<dynamic>; return Float32List.fromList(list.map((e) => (e as num).toDouble()).toList()); } return null; }3.3 Flutter端完整代码示例
把上面各个模块串起来,完整的一段“文本->指定音色语音”代码如下:
Future<Uint8List?> 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 List<int> 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'); Future<Uint8List?> get(String key) async { final file = File('${_cacheDir.path}/$key.wav'); if (file.existsSync()) return file.readAsBytesSync(); return null; } Future<void> put(String key, Uint8List data) async { if (!_cacheDir.existsSync()) _cacheDir.createSync(recursive: true); final entries = _cacheDir.listSync().whereType<File>().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) | 120ms | iOS上效果明显 |
这里要强调,线程数不是越大越好。四线程相比双线程只快了30ms左右,但CPU占用率几乎翻倍,温度上去之后会触发系统降频,反而更卡。我最终在所有设备上统一用双线程,换来了更稳定的温度表现。
另一个优化点是“模型预热”。第一次调用TTS时,模型加载和线程池初始化会额外消耗1秒左右。我的做法是在App启动后、用户进入语音页面之前,用一段默认文本提前调用一次tts.generate,把模型“暖”起来。这样等用户真正点击播放时,只剩纯合成耗时。
如果设备支持NNAPI或CoreML,可以尝试开启硬件加速。sherpa-onnx的配置里有enableNnapi和enableCoreML参数,我建议只在iOS上开CoreML,Android的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_type=QuantType.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,检查packagingOptions |
| iOS上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跑通,再逐步加入克隆能力。哪怕一开始模型效果不是最理想,先把链路走完,后面替换模型只是换文件的事,架构不会被推翻。