1. 项目缘起为什么要在本地 CPU 上折腾语音克隆1.1 一个真实的需求场景事情的起因很简单。我手上有一个用 Rust 写的桌面小工具基于 Slint 做界面功能是帮人整理会议纪要。用了一段时间之后我越来越觉得那个默认的提示音和机械的播报声太出戏了。我就想能不能让这个工具用我自己的声音来播报不是那种提前录好几十句话拼起来的方案而是真正的零样本语音克隆——给它十几秒的录音它就能用我的音色念出任意文本。市面上的语音克隆方案我基本都试过一圈。云端 API 效果确实好但有两个问题一是延迟不可控网络抖动的时候体验很差二是隐私顾虑我的声音数据要传到别人的服务器上。本地部署的方案呢大多数都依赖 GPU而我这台日常办公的笔记本只有一块集成显卡跑不动那些大模型。所以我把目光转向了 CPU 推理这条路。核心诉求很明确在纯 CPU 环境下用十几秒的参考音频实现可用的零样本语音克隆并且能集成到 Rust 程序里。这就是 LuxTTS 这个项目要解决的问题。1.2 为什么选 LuxTTS 而不是别的方案在选型阶段我对比了几个主流的开源 TTS 方案。这里直接给一个对比表省得大家再走一遍弯路。方案零样本克隆CPU 推理Rust 集成模型体积参考音频需求LuxTTS支持原生支持ONNX 接口约 80MB10-20 秒传统 VITS不支持支持需自己封装约 60MB需训练大参数 TTS支持极慢困难1GB3-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读写 wavrubato做重采样目标平台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.0ort的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_version17 )导出之后得到一个 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 单声道。所以预处理要做三件事降采样、混单声道、幅度归一化。重采样我用rubatouse rubato::{Resampler, SincFixedIn, SincInterpolationType, SincInterpolationParameters}; fn resample_to_16k(input: [f32], from_rate: u32) - Vecf32 { 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::ResultVecf32 { 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::ResultVecf32 { 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) - Veci64 { // 简化版实际项目里需要完整的音素映射表 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::ResultArray2f32 { 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: Array2f32, ) - anyhow::ResultVecf32 { 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_threads4比默认的全核配置只慢了约 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) - VecString { 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: Array2f32) - Array2f32 { 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(Vecf32), Error(String), } fn spawn_synthesis( text: String, embedding: Vecf32, tx: mpsc::SenderSynthMessage, ) { 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 5800H8 核 16 线程跑 int8 量化模型的实测数据如下文本长度声学模型耗时声码器耗时总耗时实时率10 字0.18s0.12s0.30s0.1530 字0.42s0.28s0.70s0.18100 字1.35s0.85s2.20s0.22实时率都在 0.25 以下意味着生成速度是播放速度的 4 倍以上完全满足实时播报的需求。内存占用稳定在 200MB 左右对于桌面程序来说完全可以接受。7.2 可以继续折腾的方向目前这个版本已经能满足我的需求了但还有几个方向值得继续优化。一是流式合成。现在是整句合成完再播放如果改成边合成边播放首字延迟还能再降。这需要把声码器改成流式版本技术上可行但工作量不小。二是多音色管理。现在只支持一个参考音频如果做成音色库可以保存多个 embedding 向量随时切换。这个改动主要在数据层推理部分不用动。三是跨平台打包。ONNX Runtime 的动态库在 Windows 和 Linux 上不一样发布的时候需要分别打包。如果要做 macOS 版本还要处理 ARM 架构的兼容性。四是模型微调。零样本克隆的效果虽然不错但如果用几十条自己的录音做少量微调音色相似度还能再上一个台阶。不过这需要 GPU 环境和本项目的 CPU 定位有点冲突看个人取舍。整体来说LuxTTS 在 CPU 上的表现超出了我的预期。14 秒录音就能克隆出可用的音色推理速度也够快集成到 Rust 程序里没有遇到太大的障碍。如果你也有类似的需求这套方案可以直接抄作业。唯一需要提醒的是参考音频的质量决定了最终效果的上限在录音环节多花点时间是值得的。