VoxCPM
Mizuoto 中 VoxCPM 引擎的实现:VoxCPM2、1.5 和 0.5B,MiniCPM-4、局部 DiT 与 AudioVAE,按上游锁定的库版本构建的分词器和文本规范化,哪些做法与上游完全一致,带 CUDA 图的逐块解码,全量与 LoRA 微调,以及对照结果。
OpenBMB 的无离散分词语音合成(OpenBMB/VoxCPM,Apache-2.0,代码与权重):逐块(patch)生成连续的语音潜变量,中间没有离散编码。代码:mizuoto/engines/voxcpm。Mizuoto 0.1 没有 VoxCPM,所以参考实现是上游自己的包,在同一个 Mizuoto 完整链路中经适配器运行,库版本与上游锁定的一致(transformers 5.3.0、tokenizers 0.22.2、wetext 0.1.2、inflect 7.5.0)。在 CPU 上以相同种子运行时,每个组件和每个请求在 bf16(上游的默认)和 fp32 下都与它完全相同,两个训练器得到的权重也逐位相同。面向用户的说明见引擎详解。
变体与文件
| 变体 | 目录模型 | 参数量 | 音频 | 音色方式 |
|---|---|---|---|---|
v2 | voxcpm/2 | 2B | 输入 16 kHz,输出 48 kHz | 零样本、带参考文本的克隆(参考 + 续写)、无参考文本的克隆(参考)、按描述设计、对克隆音色加指令 |
v1_5 | voxcpm/1.5 | 0.6B | 44.1 kHz | 零样本、以续写方式克隆(需要参考文本) |
v1 | voxcpm/0.5b | 0.5B | 16 kHz | 零样本、以续写方式克隆(需要参考文本) |
模型代际由 config.json 的 architecture 决定(缺省为 voxcpm);1.5 是第一代中使用 44.1 kHz VAE 的模型。模型目录包含 config.json、model.safetensors(否则 pytorch_model.bin)、audiovae.pth(否则 audiovae.safetensors)、tokenizer.json 和 tokenizer_config.json。音色缓存保存提示(左侧补齐)和参考(右侧补齐)的 VAE 潜变量,与上游 build_prompt_cache 的做法相同。
精度:auto 采用上游的选择,即配置中的 bf16,适用于包括 CPU 在内的所有设备,Apple MPS 上用 float32;engines.voxcpm.dtype 可覆盖。VAE 始终以 float32 运行。
工作原理
文本 token 和音频块共用一个序列。每一块中,文本语义语言模型(带 LongRoPE 和静态 KV 缓存的 MiniCPM-4)和较浅的残差声学语言模型产生两个隐状态;局部 DiT(双向运行的 MiniCPM-4,作为条件流模型,约 10 个欧拉步,带无分类器引导)根据它们和上一块预测下一块潜变量;局部编码器把这一块重新嵌入语言模型;停止头决定是否继续。AudioVAE 是类似 DAC 的因果卷积编解码器,使用 Snake 激活,负责解码潜变量(v2 带采样率条件,把 16 kHz 潜变量解码为 48 kHz 音频)。
| 模块 | 内容 |
|---|---|
config.py | 把 config.json 当作数据读取,带上游 pydantic 的默认值 |
minicpm.py | MiniCPM-4:RMSNorm、LongRoPE、GQA SDPA、静态 KV 缓存、muP 残差 |
model.py | 标量量化器、局部编码器、局部 DiT(v1 和 v2)、CFM 的欧拉采样器、提示序列、PatchDecoder 和生成循环 |
vae.py | AudioVAE v1 和 v2(因果卷积、Snake、采样率条件)以及 v2 的有状态流式解码器 |
text.py | 按 transformers 5.3 的方式构建的分词器、上游对多字汉字 token 的拆分、文本规范化 |
engine.py | VoxCpmEngine、LoadedVoxCpm:精度、音色方式、音色缓存、重试、流式 |
train.py、sft.py | VoxCpmTrainer(方法 full、lora)及其计算 |
assets/wetext/ | wetext 0.1.2 的中文(带 remove_erhua)和英文文本规范化 FST(7.7 MB) |
共享模块:runtime/ops.py、runtime/loading.py(折叠权重归一化)、runtime/graphs.py、nn/dsp/resample.py(librosa 调用方式下的 soxr;torchaudio 的 sinc)、nn/dsp/wav.py、nn/dsp/tiles.py、text/normalize/wetext.py(其 version="0.1" 的步骤)、runtime/compile.py。
与上游一致的做法
- 精度。 与上游一样,先转换模型的精度再拷入检查点,所以 LongRoPE 表(以 float32 构建)随之舍入为 bf16。
- 注意力使用 SDPA,
enable_gqa,张量连续:预填充时为因果注意力,局部模块中为双向注意力,逐步生成时在显式布尔掩码下对静态缓存计算。上游对缓存的全部位置计算(超出当前长度的部分为零并被掩码);在 CPU 上 Mizuoto 只算到当前长度之后第一个 256 的倍数(minicpm.attend_span),掩码保留。被掩码的位置只会加上精确的零,而 CPU 上的 SDPA 在任何 64 的倍数处截断都给出相同的结果位(以 bf16 和 fp32 对 3100 以内的每个位置检查过),所以结果完全相同,VoxCPM2 在 CPU 上快 1.5 倍。在 GPU 上则与上游一样对整个缓存计算:这一步走 SDPA 的 math 路径,其矩阵向量乘和 softmax 按键的长度划分求和,所以短于整个缓存的任何截断都不能给出相同的结果位(在 CUDA 上以 bf16、fp16 和 fp32 对 3100 以内的每个位置检查过)。 - 随机性。 CFM 的噪声是推理时唯一的抽样,每块一次
randn,使用模型的精度。每次尝试都从一个以其种子初始化的新生成器抽样,与上游torch.manual_seed设置进程级随机流的位置相同。没有种子时,从请求的随机流中取一个 32 位种子(上游:torch.seed()),并在实际参数中报告。 - 坏例重试。 非流式结果若每个文本 token 对应
retry_badcase_ratio_threshold块或更多,就用种子 + 1 重新生成,最多retry_badcase_max_times次;块数上限为min(tokens × ratio + 10, max_len)。applied.retries记录重新生成的次数。 - 提示。 VoxCPM2 用
librosa.load(sr=16000)(soxr)读取音频;0.5B 和 1.5 用torchaudio.load和torchaudio.functional.resample。续写提示在左侧补齐到整块,参考在右侧补齐。参考文本与待合成文本之间不加空格拼接,与上游相同。 - 文本。 换行和连续空白变成一个空格,需要时再做文本规范化。VoxCPM2 的描述或指令变成
(…)前缀,并去掉其中的括号,与上游网页演示相同;请求同时有两者时用,连接。 - 分词器是 transformers 5.3 的
LlamaTokenizer从词表构建出来的那个,忽略文件自带的流水线:没有 normalizer,Metaspace使用prepend_scheme="always"且不拆分,BPE 中没有未知 token,配置中的特殊 token 按 id 顺序添加(词表中没有的 token 取下一个空闲 id,所以 VoxCPM2 的<|im_end|>是 73442,而不是文件中的 73440)。直接使用tokenizer.json时,以空白开头或位于特殊 token 之后的文本会得到不同的 id。随后上游的包装把多字汉字 token 拆成单字;词表之外的字为<unk>。 - 文本规范化采用 wetext 0.1.2 的做法:在标注前和每个 FST 之后去掉首尾空白,开启
remove_erhua时也处理含“儿”但不含数字的文本。它的 FST 与 wetext 0.0.4(CosyVoice 的共享资源)和 WeTextProcessing(IndexTTS)的都不同,所以随引擎提供。数字由inflect读出;Mizuoto 的 7.3.1 与上游的 7.5.0 在语料上给出相同的词。 - 解码。 VoxCPM2 从开头去掉解码出的上下文块(提示的最后
min(3, n)块);第一代只要有提示音频就去掉2 × patch × hop个采样,与上游一样不管提示多长。流式时每块输出一段:VoxCPM2 经有状态的因果解码器,第一代则解码最后三块并保留最新的一块。 - Snake 按上游的顺序计算上游的运算,是一个普通函数(上游用 TorchScript 编译它:见有意的差异);权重归一化用
torch._weight_norm折叠,即上游钩子在每次前向时做的调用,并在 VAE 运行的设备上进行(GPU 的归约与 CPU 给出的比特不同)。
有意的差异
- 未提供:用 ZipEnhancer 对参考音频降噪(
denoise;一个 ModelScope 流水线外加第二个模型,上游默认关闭)、时间戳(stable-ts)、上游在 CUDA 上的torch.compile(optimize)以及 vLLM 服务。 - 长文本由 Mizuoto 切分;上游不切分。
- VoxCPM2 带参考文本克隆时同时使用参考和续写,与上游网页演示相同(
reference_prefix,默认开启)。上游的 Python 接口只在调用方传入reference_wav_path时才加参考。 compute.compile包装局部 DiT。在 CPU 上以 bf16 运行时没有收益,而且它的浮点差异可能改变语音结束的位置(VoxCPM-0.5B:56 320 对 55 040 个采样);默认关闭。- Snake 不再编译为 TorchScript。 上游的 TorchScript
snake在 CUDA 上从某个形状的第二次调用起被融合,相差最多 4.8e-7:一个请求的数值取决于进程见过的形状(第一个请求与之后的不同),融合器还在默认设备上编译,使工作在cuda:1上的进程在cuda:0上打开 256 MB 的上下文。作为普通函数,同样的运算以 eager 方式运行:在 CPU 上(从不融合)与原来相同,也与上游在某个形状上的第一次调用相同,每个请求都一样,VAE 慢约 1 %。
显存与速度
阶段:VAE 为音色提示编码并负责解码;模型(kind="sequence";局部编码器和 DiT 标为 hot,因为它们每块要运行多次)负责生成;流式时两者同时在位。开启 compute.oom_fallback 时,若 VAE 显存不足,就按 256 个潜变量帧一块分块解码。
一块包括 DiT 的欧拉步(每步一对引导计算)、局部编码器、停止头和两个语言模型各一步:约 6 000 个小内核,外加三次等待设备(停止标志,以及每个语言模型从 Python 整数拷贝的位置)。model.PatchDecoder 在固定张量上运行一块,只等待设备一次,用于读取停止标志。语言模型在读取标志之前就前进一步(上游先读标志,在前进之前就停止);它们不抽样,生成结束时缓存也会丢弃,所以各块完全相同。缓存满时,在上游那一步会报错的地方同样报错。
开启 compute.cuda_graphs(NVIDIA,模型完整位于设备上,compute.compile 关闭)时,一块就是一个 CUDA 图,按步数和引导值各一个(那里的注意力跨度是整个缓存);DiT 的噪声从 CUDA 图自己的生成器抽取,该生成器每块从请求的生成器载入并写回。此时只要保留 CUDA 图,语言模型的静态缓存就随模型保留(这是请求本来就需要的显存),在权重移动或模型卸载时释放。真实的 0.5B 和 1.5 在有无 CUDA 图时音频相同,每个请求快 1.6–1.9 倍。
快速模式(加载选项 attention_span,默认关闭):在 GPU 上每一步也只关注缓存中已填充的块,与 CPU 上一样,而不是全部 8192 个位置(GPU 上只有关注整个缓存才精确,这使 VoxCPM 在 G1 中慢了 1.4–1.7 倍)。在 RTX 3090 上:VoxCPM2 每个请求 2.13 → 1.26 秒(×1.69),1.5 0.95 → 0.68 秒(×1.40);那里的音频是同一请求的另一次生成,CPU 上完全相同;输出中会注明(applied.attention_span)。安装了 FlashAttention 时,compute.attention = fast 通过注意力内核达到同样的效果(×1.64 和 ×1.17)。
训练
方法 full 和 lora(train.py,计算在 sft.py),即上游的 train_voxcpm_finetune.py 及其按模型的方案(conf/<model>/*.yaml),留为 0 的超参数由方案填入。不需要 datasets、transformers、argbind 或 tensorboard。阶段:prepare(按 VAE 的采样率读取音频,读法与 datasets 相同:soundfile 的 float64 和 librosa.resample 的 soxr;文本的 token;max_batch_tokens 过滤)、train、export。
- 按上游的顺序抽样,使用以 42 播种的私有生成器(上游的
Accelerator用 42 给每个生成器播种):重放构建时的默认初始化(先 VAE,再模型,然后是用 Kaiming 初始化的 LoRAA,其值被保留;其余由检查点覆盖);每次遍历数据加载器时抽基础种子和采样器的种子;每次前向时抽 CFM 的掩码、条件噪声(即使比例为 0 也会抽)、r、t和z,以及 LoRA 的 dropout 掩码。在 CPU 上一个生成器负责全部抽样,与上游的默认生成器相同;在 CUDA 上前向的抽样来自设备生成器。 - 精度。 在 CUDA 上与上游相同:float32 权重,bf16 自动混合精度,带梯度缩放器。其他设备全程 float32:上游用它的配置无法在 CUDA 以外训练(前向把输入转成配置中的 bf16,而权重仍是 float32),所以 CPU 上的参考实现是配置
config.json为 float32 的上游。 - 保持上游的做法:每段音频在右侧补齐到整块;停止标签在最后一块,损失只算音频位置;每个微批次的损失除以累积步数;关闭裁剪时梯度按 1e9 裁剪;记录的学习率是下一步的。
- 导出。 全量:
model.safetensors(float32,上游的键名),与基础模型的配置、VAE 和分词器放在一起。LoRA:基础权重(文件系统允许时用硬链接)加lora_weights.safetensors和lora_config.json,在引擎加载时应用,与上游的VoxCPM(lora_weights_path=…)相同;上游从不合并它们。没有 3–15 秒的音频时,预览以零样本方式朗读。 training.memory:对 MiniCPM 各层、局部编码器和 DiT 做梯度检查点,结果完全相同(重放 LoRA dropout 和 CFM 的抽样);VAE 的解码器不放到设备上;对 LoRA,low以自动混合精度的类型存放冻结的线性层权重(CUDA),lowest从内存流式载入它们(在 CPU 上结果相同)。
对照结果
- 组件,每个模型在 bf16 和 fp32 下:分词器和文本规范化在 420 条文本上,VAE 的潜变量和解码;然后是各种方式的完整请求(中英文的零样本和克隆、只用参考、只用续写、设计、带指令的克隆音色、文本规范化、强制重试、有无音色的流式,包括分块长度)。全部完全相同。
- 完整链路,27 个用例(每种音色方式、文本规范化、强制重试、流式、多段、其他步数和引导值、通过
engines.voxcpm.dtype设为 fp32):音频和行为(实际参数、警告、音色方式、分段、流式分块长度)全部完全相同。 - 训练:VoxCPM-0.5B 全量、VoxCPM-0.5B LoRA 和 VoxCPM2 LoRA 在每一步都与上游脚本相同(两项损失、梯度范数、学习率),保存的权重也相同。每个导出结果无论由上游还是 Mizuoto 加载,朗读结果都相同。
- 契约套件全部通过(三个模型共 18 项测试)。
- 在 GPU 上(RTX 3090,2026-10-19,与上游的包对照,每个用例在新进程中运行):语言模型、局部编码器、DiT 和潜变量都相同;把 Snake 像上游那样脚本化(仅作实验)后,v1.5 fp32 零样本、v2 零样本和 v2 克隆用例都逐采样相同。按发布的做法(Snake 即时执行)与上游相差 72–76 dB,与上游在不同进程历史下与自己的差别相当(v1.5 fp32 零样本为 74.5 dB;一个 v2 克隆甚至长度都变了)。
限制
- 没有参考音频降噪,也没有时间戳。
- 在 CUDA 上,进程中的第一个请求解码其潜变量(潜变量相同)时,结果位与之后的请求略有不同;之后的请求彼此完全一致。
- VoxCPM2 的全量微调约需 40 GB 内存。