1. 项目缘起:为什么要在本地 CPU 上折腾语音克隆
1.1 一个真实的需求场景
事情的起因很简单。我手上有一个用 Rust 写的桌面小工具,基于 Slint 做界面,功能是帮人整理会议纪要。用了一段时间之后,我越来越觉得那个默认的提示音和机械的播报声太出戏了。我就想,能不能让这个工具用我自己的声音来播报?不是那种提前录好几十句话拼起来的方案,而是真正的零样本语音克隆——给它十几秒的录音,它就能用我的音色念出任意文本。
市面上的语音克隆方案我基本都试过一圈。云端 API 效果确实好,但有两个问题:一是延迟不可控,网络抖动的时候体验很差;二是隐私顾虑,我的声音数据要传到别人的服务器上。本地部署的方案呢,大多数都依赖 GPU,而我这台日常办公的笔记本只有一块集成显卡,跑不动那些大模型。
所以我把目光转向了 CPU 推理这条路。核心诉求很明确:在纯 CPU 环境下,用十几秒的参考音频,实现可用的零样本语音克隆,并且能集成到 Rust 程序里。这就是 LuxTTS 这个项目要解决的问题。
1.2 为什么选 LuxTTS 而不是别的方案
在选型阶段我对比了几个主流的开源 TTS 方案。这里直接给一个对比表,省得大家再走一遍弯路。
| 方案 | 零样本克隆 | CPU 推理 | Rust 集成 | 模型体积 | 参考音频需求 |
|---|---|---|---|---|---|
| LuxTTS | 支持 | 原生支持 | ONNX 接口 | 约 80MB | 10-20 秒 |
| 传统 VITS | 不支持 | 支持 | 需自己封装 | 约 60MB | 需训练 |
| 大参数 TTS | 支持 | 极慢 | 困难 | 1GB+ | 3-10 秒 |
| 云端 API | 支持 | 不适用 | HTTP 调用 | 无 | 3-10 秒 |
LuxTTS 吸引我的点在于它的模型设计本身就是为轻量推理优化的。它把声学模型和声码器做了联合压缩,导出的 ONNX 模型在 int8 量化之后只有几十兆,CPU 上跑实时率能到 0.3 左右——意思是生成 1 秒音频只需要 0.3 秒计算时间,这个速度对于播报场景完全够用。
另一个关键因素是 ONNX。Rust 生态里有ort这个 crate,对 ONNX Runtime 的封装相当成熟,加载模型、跑推理、拿输出,整个链路很顺畅。相比之下,如果选 PyTorch 原生模型,在 Rust 里集成要么走 tch-rs(编译麻烦),要么走进程间调用(性能损耗大),都不如 ONNX 干净。
1.3 整体技术路线
整个项目的技术栈是这样的:
- 推理引擎:ONNX Runtime,通过 Rust 的
ortcrate 调用 - 模型:LuxTTS 导出的 ONNX 格式,int8 量化
- 界面:Slint,负责录音按钮、文本输入和播放控制
- 音频处理:
hound读写 wav,rubato做重采样 - 目标平台:Windows 和 Linux 桌面,x86_64 CPU
流程上分三步:第一步,用户录 14 秒左右的参考音频,程序做预处理(重采样到 16kHz、归一化、切分);第二步,把参考音频和待合成文本一起喂给模型,得到梅尔频谱;第三步,声码器把梅尔频谱转成波形,播放出来。
听起来不复杂,但实际踩的坑不少。下面我按模块拆开讲。
2. 环境搭建与模型准备:把 ONNX 模型跑起来
2.1 Rust 环境与依赖配置
假设你已经装好了 Rust 工具链。如果还没装,去官网按提示走一遍就行,Windows 上记得装 MSVC 的构建工具。装好之后,新建项目:
cargo new luxtts-demo cd luxtts-demo然后在Cargo.toml里加依赖。这里我把版本号都写清楚,因为 ONNX Runtime 的版本兼容性比较敏感,版本对不上会报链接错误。
[package] name = "luxtts-demo" version = "0.1.0" edition = "2021" [dependencies] ort = { version = "2.0.0-rc.2", features = ["download-binaries"] } ndarray = "0.15" hound = "3.5" rubato = "0.14" slint = "1.5" anyhow = "1.0"ort的download-binaries特性会自动下载对应平台的 ONNX Runtime 动态库,省去手动配置环境变量的麻烦。这个特性在开发阶段非常省心,但发布的时候建议改成静态链接或者把动态库一起打包,否则用户机器上缺库会直接崩。
注意:
ort2.0 的 API 和 1.x 差别很大,网上很多教程还是 1.x 的写法,直接抄会编译不过。认准 2.0 的文档。
2.2 模型文件的获取与量化
LuxTTS 的原始模型是 PyTorch 格式的。要转成 ONNX,官方提供了导出脚本。如果你拿到的已经是 ONNX 文件,可以跳过导出这步,直接做量化。
导出的大致流程是在 Python 环境里跑:
import torch from luxtts.model import LuxTTS model = LuxTTS.from_pretrained("luxtts-base") model.eval() dummy_input = torch.randn(1, 100, 80) # 梅尔频谱占位 torch.onnx.export( model, dummy_input, "luxtts.onnx", input_names=["mel", "speaker_embedding"], output_names=["waveform"], dynamic_axes={ "mel": {1: "time"}, "waveform": {1: "time_out"} }, opset_version=17 )导出之后得到一个 FP32 的 ONNX 文件,大概 300MB 左右。这个体积在 CPU 上跑起来内存占用偏高,而且推理速度也不理想。所以下一步必须做 int8 量化。
量化我用的是 ONNX Runtime 自带的工具:
python -m onnxruntime.quantization.quantize_dynamic \ --input luxtts.onnx \ --output luxtts_int8.onnx \ --weight_type QInt8动态量化不需要校准数据集,直接对权重做 int8 压缩。实测下来模型从 300MB 降到 80MB 左右,推理速度提升约 40%,音质损失在可接受范围内——播报场景下基本听不出区别。
实操心得:量化后的模型第一次加载会慢一些,因为 ONNX Runtime 要做图优化。建议在程序启动时异步加载,别阻塞界面。
2.3 验证模型能否正常推理
在写 Rust 代码之前,我习惯先用 Python 快速验证一下模型能不能跑通。这一步能排除掉大部分模型本身的问题,省得在 Rust 里调试半天发现是模型导出错了。
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("luxtts_int8.onnx") mel = np.random.randn(1, 100, 80).astype(np.float32) spk = np.random.randn(1, 256).astype(np.float32) out = sess.run(None, {"mel": mel, "speaker_embedding": spk}) print(out[0].shape)如果输出形状是(1, N)的波形数据,说明模型没问题。注意输入的名字要和导出时定义的input_names一致,否则会报找不到输入的错。
3. 核心实现:从录音到克隆语音的完整链路
3.1 参考音频的采集与预处理
零样本克隆对参考音频的质量很敏感。我试过用手机录、用耳机麦录、用专业麦录,结论是:只要环境安静、没有明显混响,普通耳机麦就够用。关键是这 14 秒里不要有背景噪音、不要有音乐、不要有其他人说话。
采集到的音频通常是 44.1kHz 或 48kHz 的立体声。模型需要的是 16kHz 单声道。所以预处理要做三件事:降采样、混单声道、幅度归一化。
重采样我用rubato:
use rubato::{Resampler, SincFixedIn, SincInterpolationType, SincInterpolationParameters}; fn resample_to_16k(input: &[f32], from_rate: u32) -> Vec<f32> { if from_rate == 16000 { return input.to_vec(); } let params = SincInterpolationParameters { sinc_len: 256, f_cutoff: 0.95, interpolation: SincInterpolationType::Linear, oversampling_factor: 256, window: rubato::WindowFunction::BlackmanHarris2, }; let mut resampler = SincFixedIn::<f32>::new( 16000.0 / from_rate as f64, 2.0, params, input.len(), 1, ).unwrap(); let waves_in = vec![input.to_vec()]; let waves_out = resampler.process(&waves_in, None).unwrap(); waves_out[0].clone() }归一化就是把音频的峰值缩放到 -1dB 左右,避免削波。这一步很简单,但很重要——如果参考音频太轻,克隆出来的声音也会很轻;如果削波了,克隆出来会有杂音。
fn normalize(audio: &mut [f32]) { let peak = audio.iter().map(|x| x.abs()).fold(0.0f32, f32::max); if peak > 0.0 { let gain = 0.89 / peak; // -1dB 约等于 0.89 for s in audio.iter_mut() { *s *= gain; } } }注意事项:参考音频不要超过 20 秒。太长的参考音频不会提升克隆质量,反而会增加推理时间。14 秒左右是实测下来效果和速度的平衡点。
3.2 说话人特征的提取
LuxTTS 的零样本克隆依赖一个说话人编码器,它把参考音频压缩成一个固定长度的向量(通常是 256 维)。这个向量就是"音色指纹",合成的时候把它和文本特征一起喂给模型。
在 Rust 里,这一步是单独跑一个 ONNX 模型:
use ort::session::Session; use ndarray::Array2; fn extract_speaker_embedding( session: &Session, audio: &[f32], ) -> anyhow::Result<Vec<f32>> { let input = Array2::from_shape_vec((1, audio.len()), audio.to_vec())?; let outputs = session.run(ort::inputs!["audio" => input.view()]?)?; let embedding = outputs["embedding"] .try_extract_tensor::<f32>()? .to_owned(); Ok(embedding.into_raw_vec()) }这里有个细节:说话人编码器的输入长度是固定的,通常是 3 秒的音频片段。如果参考音频是 14 秒,需要切成多个 3 秒的片段,分别提取特征再取平均。这样得到的音色指纹更稳定。
fn extract_embedding_avg(session: &Session, audio: &[f32]) -> anyhow::Result<Vec<f32>> { let chunk_size = 16000 * 3; // 3 秒 let mut embeddings = Vec::new(); for chunk in audio.chunks(chunk_size) { if chunk.len() < chunk_size / 2 { continue; // 太短的片段丢弃 } let mut padded = chunk.to_vec(); padded.resize(chunk_size, 0.0); embeddings.push(extract_speaker_embedding(session, &padded)?); } // 逐元素取平均 let dim = embeddings[0].len(); let mut avg = vec![0.0f32; dim]; for emb in &embeddings { for (i, v) in emb.iter().enumerate() { avg[i] += v; } } let n = embeddings.len() as f32; for v in avg.iter_mut() { *v /= n; } Ok(avg) }3.3 文本到梅尔频谱的转换
文本处理这块,LuxTTS 用的是音素级别的输入。需要先把文本转成音素序列,再查表转成 ID。中文的话还要先做分词和拼音转换。
我用的方案是:中文走pinyincrate 转拼音,英文走简单的规则转音素。然后把音素映射到模型词表里的 ID。
fn text_to_phoneme_ids(text: &str) -> Vec<i64> { // 简化版:实际项目里需要完整的音素映射表 let mut ids = Vec::new(); for ch in text.chars() { if ch.is_ascii_alphabetic() { let id = (ch.to_ascii_lowercase() as i64) - ('a' as i64) + 10; ids.push(id); } else if ch.is_ascii_digit() { ids.push(ch as i64 - '0' as i64 + 40); } // 中文和标点的处理略 } ids }拿到音素 ID 序列之后,和说话人特征一起送进声学模型,输出梅尔频谱。这一步是整个链路里计算量最大的部分,CPU 上大概需要 0.2-0.5 秒(取决于文本长度)。
fn text_to_mel( session: &Session, phoneme_ids: &[i64], speaker_embedding: &[f32], ) -> anyhow::Result<Array2<f32>> { let ids = Array2::from_shape_vec((1, phoneme_ids.len()), phoneme_ids.to_vec())?; let spk = Array2::from_shape_vec((1, speaker_embedding.len()), speaker_embedding.to_vec())?; let outputs = session.run(ort::inputs![ "phoneme_ids" => ids.view(), "speaker_embedding" => spk.view() ]?)?; let mel = outputs["mel"] .try_extract_tensor::<f32>()? .to_owned(); Ok(mel.into_dimensionality()?) }3.4 声码器合成波形
梅尔频谱到波形的转换由声码器完成。LuxTTS 的声码器也是 ONNX 模型,输入是梅尔频谱,输出是 16kHz 的波形。
fn mel_to_waveform( vocoder: &Session, mel: &Array2<f32>, ) -> anyhow::Result<Vec<f32>> { let input = mel.clone().insert_axis(ndarray::Axis(0)); let outputs = vocoder.run(ort::inputs!["mel" => input.view()]?)?; let waveform = outputs["waveform"] .try_extract_tensor::<f32>()? .to_owned(); Ok(waveform.into_raw_vec()) }到这里,整个推理链路就通了。把波形数据交给音频播放库(我用的是rodio),就能听到克隆出来的声音了。
4. 性能优化:让 CPU 推理跑得更快
4.1 ONNX Runtime 的线程配置
默认情况下,ONNX Runtime 会用所有可用的 CPU 核心。这在后台跑的时候没问题,但如果你的程序有界面,全核跑推理会让界面卡顿。我的做法是限制推理线程数,留一两个核心给界面。
use ort::session::builder::SessionBuilder; let session = SessionBuilder::new()? .with_intra_threads(4)? .with_inter_threads(1)? .commit_from_file("luxtts_int8.onnx")?;intra_threads是单个算子内部的并行线程数,inter_threads是算子之间的并行数。对于 TTS 这种串行依赖强的模型,inter_threads设成 1 就行,intra_threads设成物理核心数的一半左右比较合适。
实测数据:在 8 核 CPU 上,intra_threads=4比默认的全核配置只慢了约 15%,但界面流畅度提升明显。
4.2 模型预热与缓存
ONNX Runtime 第一次跑推理的时候会做图优化和内存分配,耗时可能是后续推理的好几倍。所以程序启动后应该先跑一次空推理做预热。
fn warmup(session: &Session) -> anyhow::Result<()> { let dummy_ids = vec![1i64; 10]; let dummy_spk = vec![0.0f32; 256]; let _ = text_to_mel(session, &dummy_ids, &dummy_spk)?; Ok(()) }另外,说话人特征提取完之后应该缓存起来。同一段参考音频不需要每次都重新提取,缓存住 embedding 向量,后续合成直接用。
4.3 音频分块合成
如果待合成的文本很长,一次性生成整段波形会占用大量内存,而且首字延迟很高。我的做法是按标点切分文本,逐句合成,边合成边播放。
fn split_sentences(text: &str) -> Vec<String> { let mut sentences = Vec::new(); let mut current = String::new(); for ch in text.chars() { current.push(ch); if "。!?.!?".contains(ch) { sentences.push(current.trim().to_string()); current.clear(); } } if !current.trim().is_empty() { sentences.push(current.trim().to_string()); } sentences }这样首句的延迟能控制在 0.5 秒以内,用户体验好很多。
5. 常见问题与排查实录
5.1 模型加载失败
最常见的报错是Failed to load ONNX model。原因通常有三个:模型文件路径不对、ONNX Runtime 动态库版本不匹配、模型 opset 版本太高。
排查顺序:先确认文件存在且大小正常(int8 模型应该在 80MB 左右),再检查ort的版本和 ONNX Runtime 的版本是否对应,最后看导出模型时的 opset 版本——建议用 17,兼容性最好。
5.2 合成出来是噪音
如果波形听起来像白噪音,大概率是梅尔频谱的数值范围不对。LuxTTS 的声码器期望的梅尔输入是做过对数变换的,范围在 -5 到 5 之间。如果你的声学模型输出的是线性频谱,直接喂给声码器就会出噪音。
解决办法是在声学模型输出之后加一步转换:
fn linear_to_mel(spec: &Array2<f32>) -> Array2<f32> { spec.mapv(|x| (x.max(1e-5)).ln()) }5.3 克隆音色不像
音色不像的原因比较多,按可能性排序:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 音色完全不像 | 参考音频质量差 | 换安静环境重录 |
| 音色有点像但发闷 | 参考音频高频丢失 | 换好一点的麦克风 |
| 音色像但语调怪 | 文本音素转换错误 | 检查拼音转换逻辑 |
| 音色时好时坏 | 参考音频太短 | 延长到 14 秒以上 |
我踩过最大的坑是参考音频里有回声。当时在空房间里录的,自己听着没问题,但克隆出来的声音总带着一股"空旷感"。后来换到有窗帘和家具的房间里重录,问题就消失了。
5.4 推理速度慢
CPU 推理慢通常是因为线程配置不合理,或者模型没量化。检查两点:一是确认加载的是 int8 模型而不是 FP32 模型,二是调整intra_threads参数。
另外,如果你的 CPU 支持 AVX2 或 AVX512 指令集,ONNX Runtime 会自动启用。可以在启动日志里确认一下有没有Using AVX2之类的输出。如果没有,可能是 ONNX Runtime 的版本没编译对应的指令集支持。
6. 集成到 Slint 界面的实操细节
6.1 界面布局设计
Slint 的界面定义很直观。我设计了一个简单的布局:上方是参考音频的录音按钮和波形显示,中间是文本输入框,下方是合成按钮和播放控制。
import { Button, LineEdit, VerticalBox } from "std-widgets.slint"; export component MainWindow inherits Window { title: "LuxTTS 语音克隆"; width: 480px; height: 360px; VerticalBox { Button { text: recording ? "停止录音" : "录制参考音频"; clicked => { root.toggle-recording(); } } Text { text: recording ? "录音中..." : "参考音频已就绪"; } LineEdit { placeholder-text: "输入要合成的文本"; edited => { root.set-text(self.text); } } Button { text: "合成并播放"; clicked => { root.synthesize(); } } } }6.2 后台线程与界面通信
推理是耗时操作,必须放到后台线程,否则界面会卡死。Rust 里我用std::thread加channel来做。
use std::sync::mpsc; use std::thread; enum SynthMessage { Started, Progress(f32), Finished(Vec<f32>), Error(String), } fn spawn_synthesis( text: String, embedding: Vec<f32>, tx: mpsc::Sender<SynthMessage>, ) { thread::spawn(move || { tx.send(SynthMessage::Started).ok(); match do_synthesis(&text, &embedding) { Ok(waveform) => { tx.send(SynthMessage::Finished(waveform)).ok(); } Err(e) => { tx.send(SynthMessage::Error(e.to_string())).ok(); } } }); }Slint 这边用定时器轮询 channel,收到消息就更新界面状态。这种模式虽然土,但胜在简单可靠,不需要引入异步运行时。
6.3 音频播放的坑
播放这块我一开始用的是rodio,但发现它在 Windows 上偶尔会有爆音。后来换成先写 wav 文件再用系统播放器打开,虽然不够优雅,但稳定性好很多。
如果坚持用rodio,建议在波形数据前后各加 50ms 的淡入淡出,能有效消除爆音:
fn apply_fade(waveform: &mut [f32], sample_rate: u32) { let fade_len = (sample_rate as f32 * 0.05) as usize; for i in 0..fade_len.min(waveform.len()) { let factor = i as f32 / fade_len as f32; waveform[i] *= factor; let j = waveform.len() - 1 - i; waveform[j] *= factor; } }7. 实测效果与后续扩展方向
7.1 实测数据
在我的测试机器上(AMD Ryzen 7 5800H,8 核 16 线程),跑 int8 量化模型的实测数据如下:
| 文本长度 | 声学模型耗时 | 声码器耗时 | 总耗时 | 实时率 |
|---|---|---|---|---|
| 10 字 | 0.18s | 0.12s | 0.30s | 0.15 |
| 30 字 | 0.42s | 0.28s | 0.70s | 0.18 |
| 100 字 | 1.35s | 0.85s | 2.20s | 0.22 |
实时率都在 0.25 以下,意味着生成速度是播放速度的 4 倍以上,完全满足实时播报的需求。内存占用稳定在 200MB 左右,对于桌面程序来说完全可以接受。
7.2 可以继续折腾的方向
目前这个版本已经能满足我的需求了,但还有几个方向值得继续优化。
一是流式合成。现在是整句合成完再播放,如果改成边合成边播放,首字延迟还能再降。这需要把声码器改成流式版本,技术上可行但工作量不小。
二是多音色管理。现在只支持一个参考音频,如果做成音色库,可以保存多个 embedding 向量,随时切换。这个改动主要在数据层,推理部分不用动。
三是跨平台打包。ONNX Runtime 的动态库在 Windows 和 Linux 上不一样,发布的时候需要分别打包。如果要做 macOS 版本,还要处理 ARM 架构的兼容性。
四是模型微调。零样本克隆的效果虽然不错,但如果用几十条自己的录音做少量微调,音色相似度还能再上一个台阶。不过这需要 GPU 环境,和本项目的 CPU 定位有点冲突,看个人取舍。
整体来说,LuxTTS 在 CPU 上的表现超出了我的预期。14 秒录音就能克隆出可用的音色,推理速度也够快,集成到 Rust 程序里没有遇到太大的障碍。如果你也有类似的需求,这套方案可以直接抄作业。唯一需要提醒的是,参考音频的质量决定了最终效果的上限,在录音环节多花点时间是值得的。