断网也能用上“自己声音”的TTS,这件事听起来有点反直觉,但这两年端侧推理栈成熟之后,已经变成了一个可以稳定落地的方案。这个项目本质上是把sherpa-onnx当运行时、把ZipVoice当“声线提取器”,在Flutter应用里拼出一条完整的声音克隆链路:你录几秒钟参考音频,应用建模出音色特征,再输入任意文本,端侧直接合成出接近这个音色的语音。整个过程不需要服务器,不依赖网络,用户隐私也留在本地设备。
这篇文章不是标题党,也不是只贴一段官方 README。我接这套方案花了两周左右,踩了十几个坑,包括 ONNX Runtime 版本冲突、Flutter Asset 路径的坑、参考音频采样率不对导致的“克隆了个寂寞”、原生播放破音卡顿等等。我会把从技术选型、环境搭建、代码链路、参数调整到问题排查的完整过程拆开讲,适合正在做 Flutter 语音类 App、想把 TTS 和声音克隆离线化的开发者参考。
1. 为什么非要做“端侧声音克隆TTS”:痛点与选型逻辑
1.1 云端TTS的三个天花板
先说需求场景。我当时在做的是一款带“有声阅读”性质的工具 App,想让用户能用自己的声音读电子书。第一直觉是接云端大厂 TTS 的声音克隆能力,但实际调研一圈下来,三个问题直接劝退:
- 按字数计费。长期朗读场景下,用户一天读几万字,成本根本控不住。
- 音频要上传到服务端。用户录的参考音频是最敏感的生物信息,隐私合规压力不小。尤其当你只是一个小团队时,要解释“你的声音去了哪儿”这件事本身就麻烦。
- 网络依赖。地铁、地下车库、飞行模式这些场景下,云端 TTS 一断网就完全不可用,对“阅读工具”来说这属于致命体验缺陷。
当然云端 TTS 的专业效果确实强,多音色、情感控制、爆音抑制都做得很好。但如果我们把需求收敛成“听起来像同一个人、能离线跑、能用 Flutter 多端复用”,端侧方案其实已经够用。
1.2 sherpa-onnx 到底解决了什么问题
选sherpa-onnx,不是因为它名字洋气,而是因为它恰好卡在端侧语音推理的这个生态位上。
一句话介绍:它是一套基于 ONNX Runtime 的离线语音推理工具箱,支持语音识别、文本转语音、语音活动检测、说话人验证等能力。它把模型和运行时封装成非常干净的 C API,然后通过社区绑定覆盖了 Android、iOS、Windows、macOS、Linux、Web 甚至树莓派,这和 Flutter 的跨端目标高度一致。
更关键的是,它不绑定某个固定厂商模型。任何能导出成 ONNX 的 TTS 模型,只要把配套 tokens、lexicon、dict 准备好,理论上都能被它加载。这就给了我们这种“想接自定义克隆模型”的玩法一条活路。
对比其他几个常见路线:
| 方案 | 跨端能力 | 自定义模型友好度 | 社区活跃度 | 对 Flutter 的适配 |
|---|---|---|---|---|
| sherpa-onnx | 极强(移动/桌面/Web) | 高,ONNX 是通用格式 | 高 | 有官方 Dart/原生封装可参考 |
| Coqui TTS | 中,Python 生态为主 | 中 | 中 | 需要自建推理服务 |
| Piper TTS | 中,嵌入式路线 | 低,偏固定音色 | 中 | 需要写不少胶水代码 |
| 云端 TTS SDK | 各端都有 | 无 | 取决于厂商 | 简单但依赖网络和费用 |
如果拼“推理性能 + 部署灵活性 + Flutter 友好度”,sherpa-onnx 基本是当前端侧 TTS 方案里最顺的那条路。
1.3 ZipVoice 解决的是“音色即时克隆”问题
那么ZipVoice站在哪一环?
传统 TTS 要新增加一个音色,需要拿这个人的大量录音去做微调,训练时间以小时计。ZipVoice 做的事是把“说话人建模”压缩成一次轻量提取:给定一小段参考音频,它输出一组说话人embedding(也常被称为音色向量或声纹向量),然后在合成阶段用这组向量去影响声学模型的发音风格。
也就是说,它并不需要针对每个新用户重新训练模型。用户录一句 3 到 10 秒的话,应用就能获得对应的“音色条件”,这就是零样本(zero-shot)或少样本(few-shot)声音克隆范式。这个思路在端侧落地非常合适,因为移动设备不可能承载全量训练流程,但跑一次 embedder 推理完全没问题。
我这边实测的常见路径是:ZipVoice 负责生成音色编码,sherpa-onnx 负责加载带说话人条件输入的 VITS 系 TTS 模型。两个模型都转成 ONNX 格式后,Flutter 侧只需要做调度,不需要碰任何 Python 推理代码。
2. 端侧克隆管线到底长什么样:核心组成与数据流
2.1 三段式管线:参考编码 -> 条件TTS -> 声码器输出
完整链路并不是“一个模型吃进文字,吐出声音”那么简单。拆开看,它是三段各司其职:
- 参考音频编码段。输入是一段 WAV(要剪裁成合适长度),经过说话人 encoder,得到形状类似
[1, 256]或[1, 512]的浮点向量。具体维度取决于 ZipVoice 模型实现。 - 文本转语音条件生成段。把文本先正则化、分词并映射成音素 ID,然后送入 VITS 的声学模型部分。这个模型会利用第一段得到的说话人向量,对发音时长、基频、音色进行条件约束,输出线性谱或梅尔谱。
- 声码器输出段。把谱特征转成可播放的 PCM 波形。sherpa-onnx 侧的 VITS 模型通常把声学模型和 HiFi-GAN 声码器打包在同一个 ONNX 图里,所以对调用方来说,合成结果直接就是
float32的音频样本。
这种三段式中,真正支撑“零样本克隆”的机制就是音色向量作为额外条件输入。没有这个向量,模型只能发出训练集中某些说话人的声音;加了这个向量,合成的发音风格就会向参考音频靠近。
2.2 模型参与方:谁导出了谁
很多刚接触的人会把 sherpa-onnx 和 ZipVoice 理解成两个同类框架,其实不必混淆:
- sherpa-onnx 是纯运行时,负责加载 ONNX 模型、管理会话、暴露推理接口。
- ZipVoice 更像“模型方案 + 配套工具链”,它负责产出面向特定领域的中文音色克隆模型,并教会你如何把原始 PyTorch 权重导出成适合端侧的 ONNX。
实践中,先将 ZipVoice 的 encoder 和 VITS 模型各自 torch.onnx.export 成两个文件,再交给 sherpa-onnx 的 C API 加载。有一个更省事的做法:如果你的运行库足够新,也支持直接把“说话人 encoder + VITS + 声码器”拼成一个大的 ONNX 图。但我不太推荐这么做,理由后面会讲。
2.3 音色向量进入模型时的关键细节
从实现角度看,音色向量进入模型主要有两种方式:
- 方式 A:说话人向量拼接到文本编码器的输出特征中,类似
Concat(speaker_emb, text_feat)。 - 方式 B:通过
nn.Embedding的查表方式,但这里不是查预定义说话人 ID,而是用一个外部 speaker encoder 输出的向量直接替代 embedding 查询结果,再参与后续 affine transform。
不同版本模型对输入格式的要求并不完全一致。有些需要每次推理都输入参考音频,然后在算子图内部完成编码;有些则允许预先缓存“音色向量”文件,真正合成时直接加载向量文件。我建议优先用后者,因为它能把参考音频编码的时间开销从每次合成中拿掉,对于反复生成同一角色的多句文本可以省很多时间。
3. Flutter 工程落地的第一步:环境与原生依赖配置
3.1 版本选择与坑位提醒
先列出我这次项目稳定使用的版本组合,能少走很多弯路:
| 组件 | 推荐版本 | 备注 |
|---|---|---|
| Flutter | 3.19 及以上 | 主要用到 Dart 3 的 FFI 改进 |
| sherpa-onnx | 1.9.x 或更新的 1.10+ | 不同版本模型格式基本兼容,但 C API 结构体有微调 |
| ONNX Runtime | 1.15.x 或 1.16.x | 别盲目追新,新版本权限/so 冲突可能更多 |
| Android minSdk | 21+ | 低于 21 的 armv7 老设备跑模型会吃力 |
| CMake | 3.22.1 以上 | Flutter Android 插件默认仍用 CMake |
| Gradle | 7.5+ | 对应 Android Gradle Plugin 7.3+ |
这里重点提醒:sherpa-onnx 的 release 包通常会绑定一个比较保守的 ONNX Runtime 版本,如果项目里其它原生库同时引入更高版本的 ONNX Runtime,极易出现链接错乱。这个问题我放在第 5 节详聊,因为它的现象非常隐蔽。
3.2 原生依赖策略:方法通道还是 FFI
Flutter 侧跑原生推理有两条路线,我一开始选了 FFI 路线,后来实际落地时变成了“FFI + 原生方法通道”混合路线。简单分享下取舍逻辑:
- 方法通道(MethodChannel)路线:Kotlin/Swift 写封装,接收 Dart 传来的文本和参考音频路径,在原生层完成调用,再把 PCM 数据转为 Base64 或字节数组返回 Dart。优点是原生写起来顺手,而且可以直接使用 Android 的 AudioTrack 播放;缺点是每次大数据量跨通道传输有轻微性能损耗。
- FFI 路线:Dart 通过
dart:ffi直接调用 C 动态库,省去 Kotlin/Swift 这层胶水。优点是可以在 Dart 侧统一管理模型线程和内存,逻辑更集中,多端复用时不用分别写原生代码。缺点是如果你还需要播放、文件路径解析等原生 API,FFI 没有直接帮助,还是要借助 MethodChannel。
最终我建议不要盲目二选一。合理的做法是:模型加载、合成等计算密集型逻辑走 FFI,音频播放、录音权限走原生方法通道。这样既能用 Dart 做跨端逻辑复用,也能把对原生 API 的依赖限制在最小范围。
3.3 模型文件放哪里:别用 Flutter Asset 存大模型
这是我在早期集成时损失过一天时间的问题。
如果你使用flutter pub get之后把语言模型文件放进 Flutter 的 assets 目录,再在 Dart 里调用rootBundle.load('assets/model.onnx'),你拿到的是一段内存字节流。问题是:sherpa-onnx 的 C API 绝大多数接口接收的是模型文件的路径,而不是内存 Buffer。虽然也存在直接从内存加载的方式,但那是另一个层次的上层封装。
正确做法是:
- Android 端把模型放在
android/app/src/main/assets/tts/目录,通过原生 AssetManager 把文件释放到 App 私有目录,或者直接传递file:///android_asset/tts/model.onnx路径给 C 库。不同库对 asset 路径的支持不一,建议统一释放到context.getFilesDir()。 - iOS/macOS 端把模型放进 Bundle Resource,然后通过路径拼接获取。
- Windows/Linux 桌面端则使用相对路径或绝对路径,本地开发时直接编写 config 时填入。
如果你不想每次启动都释放大模型文件到私有目录,也可以做内存映射。但对于大多数应用场景,一次释放、后续复用句柄是性价比最高的方案。模型文件一般是几十到几百 MB,放在 Flutter Asset 里反而会让跨端路径处理变得很别扭。
4. 关键代码:声音注册到TTS推理的完整链路
4.1 一步不漏的录音与预处理
声音克隆的第一步不是调模型,而是拿到干净的参考音频。这里有一个几乎所有教程都会轻轻带过、但实际结果千差万别的环节:音频格式必须和模型训练时保持一致。
ZipVoice 这类模型在训练时,通常会把参考音频统一处理成 16kHz 或 24kHz 的单声道 PCM。你把一段采样率 44.1kHz 的立体声直接丢进去,模型虽然不会报错,但音色相似度会明显下降,因为特征分布已经偏移了。
我的参考音频处理管线如下:
- 通过 Flutter 录音插件录制用户声音,设置采样率 16000,单声道,位深 16bit。
- 避免用压缩格式保存。MP3、AAC 都会引入有损压缩噪音,静音段可能被平滑掉,后续编码器提取语义音色特征时会缺信息。优先保存为 WAV。
- 如果拿到的是一段较长录音,先做端点检测,把首尾静音切掉。预留 0.2 秒左右的前后 padding,避免过零截断造成脉冲。
- 统一音量到合理范围。参考音频太小声会导致 embedding 能量偏弱;太大则会削波,破坏音色细节。建议用
sox或ffmpeg做一次 normalize:
ffmpeg -i raw_ref.mp3 -ar 16000 -ac 1 -sample_fmt s16 -af loudnorm=I=-16:TP=-1.5 -f wav ref_16k_mono.wav这里不是要追求录音棚音质,而是要避免给 encoder 喂入与训练分布差异过大的“脏音频”。哪怕只是用户在手机前说一句“大家好,这是我用于声音克隆的参考音频”,只要保证环境安静、时长足够,效果基本可接受。
4.2 用 ZipVoice 提取说话人向量
在我的实际架构中,ZipVoice encoder 是单独加载的。Android 原生侧调用它时,核心逻辑可以抽象成:
// 伪代码,用于说明链路,真实 API 以你拉取的 ZipVoice/sherpa 版本为准 const char* encoder_model = "/data/user/0/com.example.app/files/tts/zipvoice_encoder.onnx"; const char* wav_path = "/data/user/0/com.example.app/files/ref.wav"; ZipVoiceEncoder* enc = ZipVoiceEncoderCreate(encoder_model); float* embedding = nullptr; int embedding_dim = 0; ZipVoiceEncoderRun(enc, wav_path, &embedding, &embedding_dim); // embedding 就是前端需要的音色向量 ZipVoiceEncoderFree(enc);整个过程在真机上大约是 30 到 100 毫秒,取决于 CPU 调度。得到向量后,我现在不会每次都重新提取。一个用户录完音,先把向量序列化到文件里,后续每次合成直接读取。生成的向量文件很小,256 维 float 也就是 1KB 左右。
序列化格式其实不必用 JSON,一个简单的 float32 little-endian binary 文件就够:
4 bytes: magic 'ZVEC' 4 bytes: dimension (int32) n * 4 bytes: float array这样后续无论是从 Dart 读、C++ 读还是从模型上层工具读,都非常方便。而且省去了 JSON 解析的开销,也避免浮点数被格式化成字符串后精度丢失的问题。
4.3 通过 sherpa-onnx 完成文本合成
接下来进入文本到语音的合成阶段。sherpa-onnx 的 C API 顶层抽象很干净,加载 VITS 模型并设置各项参数的流程大概是这样:
SherpaOnnxTtsConfig ttsConfig; memset(&ttsConfig, 0, sizeof(ttsConfig)); ttsConfig.model.vits.model = "/data/user/0/com.example.app/files/tts/zipvoice_vits.onnx"; ttsConfig.model.vits.tokens = "/data/user/0/com.example.app/files/tts/tokens.txt"; ttsConfig.model.vits.lexicon = "/data/user/0/com.example.app/files/tts/lexicon.txt"; ttsConfig.model.vits.dict_dir = "/data/user/0/com.example.app/files/tts/dict"; ttsConfig.model.num_threads = 2; ttsConfig.model.debug = 0; ttsConfig.model.provider = "cpu"; const SherpaOnnxTts* tts = SherpaOnnxCreateTts(&ttsConfig); if (!tts) { // 加载失败,检查模型路径和 tokens 是否匹配 return; } SherpaOnnxGeneratedAudio audio; memset(&audio, 0, sizeof(audio)); // 部分支持外部说话人向量的模型,需要先通过新的 API 把 speaker embedding 注入 float emb[256]; LoadZipVoiceEmbedding("speaker_vec.bin", emb, 256); audio = SherpaOnnxTtsGenerate(tts, "今天天气不错,适合出门散步。", 0, 1.0);需要注意SherpaOnnxTtsGenerate的参数里有一个sid和speed。如果你的模型是预训练多说话人模型,speed 可以调节语速,比如 1.0 表示原速,0.9 表示偏慢。而 sid 一般用于模型中预置的某个特定说话人;对于外部注入的说话人向量,很多版本没有直接暴露 embedding 输入参数字段,这时就必须去改 C API 的扩展,或者选择模型层已经支持外部 embedding 的版本。
合成成功后,audio.samples里是一串 float 值,范围大致在 -1.0 到 1.0 之间,audio.sample_rate是模型输出采样率,常见是 24000。后续播放需要做一次 float 到 int16 的转换,再交给播放器。
4.4 Flutter 侧的结构设计:一个 TtsEngine DTO
为了让 Flutter 界面调用时不碰原生细节,我定义了一个高层 Dart 类,类似下面这样:
class TtsEngine { /// 加载模型,初始化 native 资源 Future<void> initEngine({ required String encoderModelPath, required String ttsModelPath, required String tokensPath, required String lexiconPath, required String dictDir, }); /// 从参考 WAV 生成音色向量并缓存 Future<void> registerSpeaker(String speakerId, String refWavPath); /// 使用某个 speaker 合成文本,返回 PCM 字节 Future<Uint8List> synthesize(String text, String speakerId); }registerSpeaker会走入原生方法通道,调用 ZipVoice encoder 提取向量后存放在指定位置;synthesize则进入 FFI 层去跑 sherpa-onnx 的 TTS。这样一个设计让上层可以忽略底层到底是 CPU 还是 NNAPI,也方便后续替换成其他支持相同接口的引擎。
5. 接入过程中最折磨人的问题与排查过程
5.1 ONNX Runtime 版本冲突与符号找不到
现象描述:App 编译通过,运行时一调用 TTS 初始化,直接 crash,日志里有类似sherpa-onnx: symbol lookup error: undefined symbol或者找不到OrtGetApiBase之类的报错。
第一次遇到时我也很懵,因为单测模型在纯 C++ 工程里跑是好的,但一旦作为 Flutter plugin 合入完整 App,就开始各种崩。后来查了一遍依赖,发现项目里另一个第三方 SDK 自己带了另一个版本的 ONNX Runtime。两个动态库同时存在,libonnxruntime.so里的符号表发生了冲突。系统加载到哪个版本完全不可控。
排查链路如下:
- 使用
readelf -d app.so或 Android 的aapt dump badging查看产物里有哪些 so 文件。 - 使用
nm -D libonnxruntime.so | grep OrtGetApiBase看符号属于哪个动态库。 - 看 Gradle 依赖树,找到另一个引入 ONNX Runtime 的库。
“修复”并不是删除另一个 SDK,这样会打破业务功能。我这边最终采用的策略是改 CMake 链接顺序,并给 sherpa-onnx 的核心 so 做独立命名,然后在加载时通过System.loadLibrary指定完整路径,减少符号解析冲突。如果两个库都必须暴露同一组Ort符号,那么就只能统一它们的 ONNX Runtime 版本,或者对其中一个库做dlopen隔离加载,避免全局符号互相污染。
一个更省心的思路是:在你的 Flutter plugin 中不要直接引入第三方已经打包好的 onnxruntime,而是自己下载指定版本的 so 静态链接进 plugin 内部,让符号只暴露给 sherpa-onnx 需要的那部分代码。代价是包体积会变大,但这比线上崩溃好得多。
5.2 声音克隆出来的音色“像又不太像”:采样率与参考音频质量
第二个折磨人的问题是“声音出来以后总感觉差一口气,像参考说话人,但又有很强的机械感”。起初我以为是模型能力不行,后来排查后发现问题在输入参考音频的采样率。
ZipVoice 的 encoder 如果默认期望 16kHz,你喂进去 48kHz 的音频时,虽然引擎也能算,但频域信息已经错位。特别是高音区的泛音会被折叠到更低频段,导致模仿出来的声音发闷、少了透明感。这类问题最麻烦的点在于:不报错、不崩溃,你只会觉得“克隆效果不好”。
解决方式并不复杂:
- 把所有参考音频统一转成 16kHz 单声道 PCM WAV。
- 查看模型配置文件里的
sample_rate,如果 TTS 合成输出是 24kHz,别惊讶,这和 encoder 的 16kHz 并不冲突,编码器和声码器在不同采样率下工作完全正常。 - 不要用手机录音自带的降噪功能。很多手机的麦克风降噪会在背景噪声消除时把说话人的某些共鸣特征也抹掉,导致音色向量不稳定。
我也见过一些人拿在线合成声音当参考音频去克隆,这样的声音做出来必然不像真人。ZipVoice 提取的是音频里的声纹特质,从模型生成的音频再提取一次后,“二次压缩”会损失细节,效果会递推式打折。
5.3 Flutter 端播放爆音、卡顿与线程阻塞
项目初期,合成后的 PCM 数据我选择传回 Dart 层再播放。这在短句上没问题,但一旦读长章节,就暴露了两个问题:
- Dart 侧拿到的音频字节数组是几 MB 级别,跨原生隔离区的拷贝占了不少内存,GC 频繁触发导致播放卡顿。
- 合成过程是在原生线程同步进行的,如果这条原生线程和 UI 的调用时序没配合好,Dart Future 会长时间不返回,界面直接卡住。
最终我把“合成 + 播放”整体挪到了原生侧。Android 上用AudioTrack播放float32或转换后的 PCM,iOS 上用AVAudioPlayer配合 WAV 头部数据。Flutter 只需要拿到一个“已经播放完成/失败”的回调,这个回调通过 MethodChannel 发一个字符串事件就行。
这样改动之后,爆音几乎消失。剩余偶尔出现的“啪”声,多半是因为长文本被强制在中间切断导致的,不是解码问题。我的处理方式是把长文本按标点切分成子句,逐句合成逐句播放,句与句之间留出一百毫秒左右并做交叉淡化,听感会自然很多。
5.4 真机与模拟器的天壤之别:线程数和性能参数
如果在 Android 模拟器上调试性能,你会得到一个极其离谱的结论:一段 3 秒文本可能要花 8 秒合成。别急着给架构宣判死刑,这是模拟器 CPU 指令集和真实手机差异造成的,尤其是 x86 模拟器跑 ARM 翻译层时,ONNX Runtime 的线程调度会变得非常低效。
数据参考:
| 设备 | 线程数 | RTF(合成耗时/音频时长) | 备注 |
|---|---|---|---|
| PC Linux / 桌面 | 4 | 约 0.15 | 参考级,不代表移动端 |
| 中端 Android 手机 | 2 | 约 0.6 | 正常可接受 |
| 高端 Android 手机 | 2 | 约 0.3 | 基本无感 |
| x86 Android 模拟器 | 1 | 大于 3 | 仅用于功能验证,不用于性能判断 |
| iPhone 14 等 | 2 | 约 0.2 | CoreML 可能进一步降低 |
tuning 时不要一味把线程数调到 4。移动端 CPU 的调度还受散热、能耗限制影响。实测中 2 线程往往是最平滑的点。如果继续上调,CPU 频率会被内核压制,整体 RTF 反而可能恶化。桌面端则可以放宽到 4 甚至 8 线程。
6. 性能调优与效果校验:不能只看“听起来像”
6.1 客观指标怎么算:相似度、可懂度和实时率
声音克隆方案的验收不能停留在“好像有点像”的玄学层面。我在项目里给自己定了三个客观指标:
- 音色相似度(Speaker Similarity):用说话人验证模型提取参考音频和合成音频的 embedding,算余弦相似度。一般来说大于 0.7 算及格,0.8 以上算不错。但要注意,如果参考音频本身很短,embedding 的方差会很大,建议至少录 8 秒以上的有效语音再做客观对比。
- 可懂度(WER/CER):用 ASR 识别合成音频里的文本内容,和原始文本做字符错误率比对。端侧音频合成如果字错率超过 5%,说明部分音节已经糊了,需要检查是不是文本转音素这一步出了问题。
- 实时率(RTF):合成耗时与最终音频时长的比值。RTF 小于 1 表示合成速度大于播放速度,用户基本无感等待;大于 1 则表现为“等半天才出声”,体验很差。
我自己保留了一个小测试集,包含 20 句不同情绪和长度分布的中文文本,每次模型或配置调整后统一跑一遍,输出三组指标,再和上一版做 diff。有了这套流程,“不确定改了哪里变好了还是变差了”的困境就基本消失了。
6.2 一次完整的端侧合成日志样本
这里贴一次我在中端 Android 真机上跑出来的日志结构,可以作为性能调优时的参考:
[ZipVoice] load encoder model: 96ms [ZipVoice] load reference wav: ref_16k_mono.wav, samples=144000, duration=9.0s [ZipVoice] extract embedding: dim=256, cost=48ms [sherpa-onnx] load TTS model: 1480ms [sherpa-onnx] text: "今天天气不错,适合出门散步。" [sherpa-onnx] generate audio: samples=144000, sample_rate=24000, cost=890ms [sherpa-onnx] RTF = 890 / (144000/24000) = 0.148 [player] AudioTrack init: sampleRate=24000, channel=1, format=float [player] playback done, cost=5980ms如果你发现load TTS model时间特别长,可能不是模型问题,而是文件存放在慢速存储上且没有做文件页缓存预热。建议在 App 启动后先做一次模型文件的读取预热,后续加载耗时可以降低不少。
6.3 长文本切分与流式合成的取舍
理论上 sherpa-onnx 可以一次合成很长的文本,但端侧内存会很痛苦,用户等待时间也会变得不可控。我的做法是基于标点做文本切分:
- 按句号、问号、感叹号切成完整子句。
- 子句最长不超过 30 到 40 个字。
- 子句之间保留 60 到 120 毫秒空隙,让播放器连续播放。
如果后续需要更精细的流式听感,可以升级到“边合成边播放”的 pipeline:一个原生线程负责生成子句的 PCM,另一个线程把 PCM 写入 AudioTrack 的缓冲区。这样第一句播放的时候第二句已经开始合成,整体延迟能明显降低。流式方案对工程复杂度要求更高,因为它涉及多个线程的同步和队列入队逻辑。我的建议是,先把单句合成跑通、跑稳,再切分长文本;一上来就做流式,只会让问题排查加倍困难。
7. 这套方案后续还能怎么扩展
7.1 多角色有声朗读与形象音色库
声音克隆天然适配“多角色朗读”场景。你可以给一本书里的不同人物角色分别录一段参考音频,生成不同的音色向量文件。朗读时根据当前角色切换 speakerId。由于向量文件极小,即便一个 App 内置几十个音色,也几乎不占空间。
要做这一步,需要在上层设计一个 Speaker 管理器:
- speaker id 全局唯一。
- 每个 speaker 绑定一条参考音频记录、向量文件、以及“音色试听”链接。
- UI 上可以像选主题一样选声音。
这会极大提升应用的趣味性,尤其是面向儿童故事或有声小说阅读的产品。具体实现时,给多个角色生成连续朗读文本,需要格外注意标点和段落上下文的一致性,不要让不同角色在同一段落里横跳。
7.2 接一个离线 ASR 做成本地语音助手
既然 sherpa-onnx 支持 ASR,而它又能做 TTS,那一个离线语音助手的闭环就成立了。用户说话、本地识别成文本、本地响应逻辑、再通过 ZipVoice 音色合成出来,整条链路的隐私都在本地。
实际开发时,ASR 和 TTS 的模型加载和使用是两套独立 C API,但可以通过一个共同的线程池或者互斥锁协调,避免同时抢 CPU。移动设备上,如果 ASR 麦克风录音线程在跑,TTS 合成线程也在跑,容易造成输入音频断流。需要在录音输入端加入环形缓冲区和时限处理,确保音频数据不丢、不延迟。
7.3 桌面端与嵌入式扩展的“白嫖”价值
因为 sherpa-onnx 天生覆盖多端,你只要把模型文件和路径配置做好,同一套 Dart 代码可以轻松运行在 Windows/macOS/Linux 桌面上。我在调试阶段就是把 Android 侧逻辑抽成纯 Dart 接口,再用 Linux 桌面端编译和跑测试集,速度比真机方便得多。
再往后走,这套方案还能下沉到树莓派之类的小型设备,做仓库语音播报、前台导览机等。模型最小化后甚至可以塞进边缘盒子。如果你已经在用 Flutter 写跨端应用,这套方案的扩展成本其实很低。
最后提一句经验:这个项目最值得投资时间的不是“跑通模型”,而是“跑通后的参数和资源管理”。从一开始就设计好模型路径的释放机制、音频格式的统一校验、speaker 向量的缓存格式,后面做性能调优会顺手很多。真正难的不是调用一次 TTS,而是让它在不同设备、不同录音条件下都能稳定复现出可用的声音克隆效果。