VibeVoice 的架构总述:LLM 加扩散头这条路线怎么走
翻语音模型的仓库,最容易看懵的地方是”语音 token”这个词。文本那边的 token 是词表里的整数,一查就明白;语音这边说”tokenizer”,你以为也是一个码本,结果翻到代码里发现返回的是一个带均值和标准差的对象。VibeVoice 就是这样一个例子,它的仓库文档 docs/vibevoice-tts.md 在架构一节里把自己拆成三块:一个 Qwen2.5 底座的 LLM、一对连续语音 tokenizer(acoustic 与 semantic)、一个 diffusion head。这篇就顺着源码把这三块的分工走一遍,看清楚音频在哪一步变成了什么、又是在哪一步被接回文本序列的。
先把状态说在前面,免得读到一半误会。仓库 README 顶部按日期排的更新记录里写明:2025-09-05,微软因发现有与既定意图不符的使用方式,基于负责任 AI 原则从该仓库移除了 VibeVoice-TTS 代码。现在仓库里确实还有 vibevoice/modular/modeling_vibevoice.py,但它的首行注释写明是从社区 fork github.com/vibevoice-community/VibeVoice 复制回来的;vibevoice/modular/__init__.py 的 __all__ 只导出 Streaming 系列的六个符号,TTS 的类不在其中;README 的 Overview 表格里,VibeVoice-TTS-1.5B 那一行的 Quick Try 标的是 Disabled,docs/vibevoice-tts.md 的 Installation and Usage 一节只有一句 “Disabled due to widespread misuse.”。所以下面讲的是代码结构,不是”照着做就能生成语音”的教程。
第一块:连续 tokenizer 把音频压成什么
入口在 vibevoice/modular/modular_vibevoice_tokenizer.py,两个模型类分别是 VibeVoiceAcousticTokenizerModel 和 VibeVoiceSemanticTokenizerModel。区别在代码里写得很直白:acoustic 那个类同时构造了编码器和解码器(有 encode 也有 decode),semantic 那个类的注释写明”with only encoder for semantic tokens”,只有 encode。也就是说,只有 acoustic 那一路负责把 latent 还原回波形。
编码器 TokenizerEncoder 的下采样是一串 SConv1d,每一层的 stride 直接取自配置里的 encoder_ratios,并且类里自己算了一个 self.hop_length = np.prod(self.ratios)。配置类 VibeVoiceAcousticTokenizerConfig 里 encoder_ratios 的默认值是 [8, 5, 5, 4, 2, 2],VibeVoice-1.5B 模型卡随附的 config 里也是这一组;另一边,vibevoice/processor/vibevoice_processor.py 取音频采样率时的兜底值是 24000。把这两处放在一起:连乘得到 3200,24000 除以 3200 正好是 7.5——这就是 README 的 Overview 和 docs/vibevoice-tts.md 里都写着的 7.5 Hz 超低帧率。数字能对上,说明文档里那句话在代码里是有出处的,不是宣传口径。以上这些都是仓库当前代码与模型卡里的取值,随版本可能变动。
真正让它区别于码本式 tokenizer 的是返回类型。encode 返回的是 VibeVoiceTokenizerEncoderOutput,类注释写明它表示”a Gaussian distribution with fixed variance”,对象上挂着 sample(dist_type=...) 和 kl()。acoustic 的 encode 在构造这个输出时把 std=self.fix_std 一并传了进去,semantic 的 encode 只传 mean。对应到配置:acoustic 侧的 std_dist_type 是 gaussian、fix_std 是 0.5,semantic 侧是 none 和 0。所以”连续 tokenizer”这个说法在这里是字面意思——没有查表,没有离散码本,出来的就是一串浮点向量,采样与否由 std_dist_type 决定。
另外一处值得留意的是卷积层的形态。两个 tokenizer 的配置里 causal 默认都是 True,与之配套,SConv1d 和 SConvTranspose1d 各自写了 _forward_streaming 与 _forward_non_streaming 两条路径,forward 的签名上带着 cache、sample_indices、use_cache、is_final_chunk 这几个参数。缓存对象就是同文件里的 VibeVoiceTokenizerStreamingCache,它的类注释一句话把定位说清楚了——“Cache for streaming convolution, similar to KV cache in attention”,并且每个卷积层是靠 layer_id 这个属性在缓存字典里对号入座的。换句话说,这套 tokenizer 从设计上就允许分块喂音频,而不是必须一次性把整段读进来。这里只陈述代码里写了什么,至于分块与否在实际运行中带来什么差别,我们没有跑过,不做任何判断。
第二块:latent 怎么接回文本序列
桥梁是 vibevoice/modular/modeling_vibevoice.py 里的 SpeechConnector,结构简单到一眼看完:fc1 把输入维度映射到 LLM 的 hidden_size,中间过一层 LlamaRMSNorm,再过一个 fc2。VibeVoiceModel.__init__ 里建了两个:
self.acoustic_connector = SpeechConnector(config.acoustic_vae_dim, lm_config.hidden_size).to(dtype)
self.semantic_connector = SpeechConnector(config.semantic_vae_dim, lm_config.hidden_size).to(dtype)
到了 VibeVoiceForConditionalGeneration.forward 里,两路的结果是相加后写回词嵌入的:x[acoustic_input_mask] = speech_all_connect_features[speech_masks] + semantic_speech_all_connect_features[speech_masks]。acoustic_input_mask 标出的是序列里哪些位置属于语音,这些位置上的嵌入被换成语音 latent 投影后的向量,其余位置仍是文本嵌入。LLM 那一侧因此完全不需要知道语音是怎么来的,它拿到的还是一条定长向量序列。
forward_speech_features 这个函数本身也分了两条支路,由 speech_type 决定:取 "audio" 时走 acoustic_tokenizer.encode,从波形现场编码;取 "vae" 时则不走编码器,把传进来的张量 reshape 成 (batch, -1, vae_dim),再按 acoustic_tokenizer.fix_std / 0.8 这个值加一层高斯噪声。也就是说,这条支路收的输入已经是 latent 而不是波形;至于官方是否推荐这样预先算好 latent 再喂进来,仓库里我们没有找到相关说明,这里只陈述这条分支的入参形态。还有一条容易忽略的兜底分支:speech_tensors 为 None 时,函数会造一个全零的 (1, 1, vae_dim) 张量过一遍 acoustic_connector 再返回,避免下游拿到空值。
有两个细节值得单独记一下。第一,acoustic 的 latent 是在 forward_speech_features 里现算的(self.model.acoustic_tokenizer.encode(...) 然后 frames.sample(...)),而 semantic 的 latent 不是——它是从 forward 的参数 speech_semantic_tensors 传进来的,函数一进来就直接送进 semantic_connector。第二,SpeechConnector.forward 里没有对 None 做判断,而下游又写了 if semantic_speech_all_connect_features is not None 这样的分支。两处放在一起,语义上是有点错位的,读的时候心里有数就行,这里不替作者推测原因。
还有一个维度上的坑。VibeVoiceSemanticTokenizerConfig 里 vae_dim 的默认值是 64,但 VibeVoiceConfig.__init__ 里取 semantic_vae_dim 时写的是 getattr(self.semantic_tokenizer_config, 'vae_dim', 128),兜底值 128;VibeVoice-1.5B 模型卡随附的 config 里,semantic_vae_dim 与 semantic 侧的 vae_dim 都是 128,acoustic 侧则是 64。所以别拿配置类的默认值去推算实际权重的维度,以随权重发布的 config 为准。
第三块:diffusion head 拿什么当条件
vibevoice/modular/modular_vibevoice_diffusion_head.py 里的 VibeVoiceDiffusionHead 是个很小的模块,forward 只有几行:
x = self.noisy_images_proj(noisy_images)
t = self.t_embedder(timesteps)
condition = self.cond_proj(condition)
c = condition + t
for layer in self.layers:
x = layer(x, c)
x = self.final_layer(x, c)
三个入参分别是加噪后的 latent、timestep、条件向量。条件与 timestep 嵌入相加成 c,再经由 HeadLayer 与 FinalLayer 里的 adaLN_modulation 走 shift/scale 调制(文件里的 modulate 函数就是干这个的)。initialize_weights 里把 adaLN 调制层和输出层的权重全部置零,这是仓库代码里写死的初始化方式。
条件向量从哪来?回到 modeling_vibevoice.py 的训练路径:condition_features = hidden_states[acoustic_loss_mask]——就是 LLM 最后一层在语音位置上的隐状态。然后 noise_scheduler.add_noise 给目标 latent 加噪,prediction_head 预测,按 prediction_type 决定回归目标是噪声还是 velocity,最后对 F.mse_loss 求和。ddpm_batch_mul 的用法在代码里也一目了然:把 speech_features 和 condition_features 各自 repeat_interleave 若干份,同一条隐状态配多个随机 timestep 一起算。
这就是”next-token diffusion”这个说法的落地方式:LLM 仍然一步一步往前推,但它在语音位置上输出的不再是词表 logits,而是一个条件向量,交给扩散头去还原一帧连续 latent。
VibeVoiceDiffusionHeadConfig 里几个直接决定结构的字段:
| 字段 | 作用 |
|---|---|
head_layers | HeadLayer 堆几层 |
head_ffn_ratio | FFN 中间维度相对 hidden_size 的倍数 |
latent_size | 扩散头进出的 latent 维度 |
prediction_type | 回归目标,代码里对 epsilon 与 v_prediction 之外的取值直接抛 NotImplementedError |
ddpm_num_inference_steps | 推理时的去噪步数 |
这些字段的默认值都写在配置类里,也随权重的 config 一起发布,随版本可能变动,用之前请翻当前代码。
推理那一半在哪
一个容易踩空的地方:modeling_vibevoice.py 里没有 generate,整个文件的 forward 走的是训练路径(算 diffusion loss)。完整的采样循环能在 vibevoice/modular/modeling_vibevoice_streaming_inference.py 里读到,VibeVoiceStreamingForConditionalGenerationInference.sample_speech_tokens 的写法是:self.model.noise_scheduler.set_timesteps(self.ddpm_inference_steps),随机初始化一条 torch.randn(condition.shape[0], self.config.acoustic_vae_dim),然后沿 noise_scheduler.timesteps 逐步调用 prediction_head 并做 classifier-free guidance(把 condition 与 neg_condition 拼在一起走一遍,再按 cfg_scale 组合),最后 noise_scheduler.step(...).prev_sample。scheduler 的实现是 vibevoice/schedule/dpm_solver.py 里的 DPMSolverMultistepScheduler。
要注意的是,这条链路属于 Streaming/Realtime 那条线(对应 docs/vibevoice-realtime-0.5b.md 里的 VibeVoice-Realtime-0.5B),和 TTS-1.5B 是不同的模型,别把两边的文件混着读。它之所以值得看,是因为扩散头的接口是同一套:条件向量进、latent 出,采样循环在外面。
以上代码片段均为仓库文件原文,未经实测,以仓库最新代码为准。本文只涉及读源码,不涉及安装与运行,因此没有 Windows 与 Linux/macOS 的差异需要分开说明。
想自己翻的话
按这个顺序看最省事:docs/vibevoice-tts.md 的 Model Architecture 一节拿到全局分工,modular_vibevoice_tokenizer.py 看音频怎么下采样成连续 latent,modeling_vibevoice.py 看 SpeechConnector 与 forward 里那几行 mask 赋值,modular_vibevoice_diffusion_head.py 看条件是怎么进去的,最后用 configuration_vibevoice.py 把字段名对回模型卡的 config。四个文件加一个配置文件,这条路线的骨架就齐了。
本文依据 github.com/microsoft/VibeVoice 仓库与 Hugging Face 模型卡于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有下载权重、没有跑过推理、也没有做过训练,
因此不涉及显存占用、推理速度、识别准确率与音质的任何描述,也不与其它模型做比较或排名。
该项目持续更新,文中涉及的模块路径、配置字段与接口写法随版本变动,请以仓库最新内容为准。
需要说明的是:仓库 README 记载,2025-09-05 微软因发现有与既定意图不符的使用方式,
基于负责任 AI 原则从该仓库移除了 VibeVoice-TTS 代码;
当前 vibevoice/modular/modeling_vibevoice.py 首行注释标明其来自社区 fork,
且该模块未被 vibevoice/modular/__init__.py 的 __all__ 导出。
本文只讲代码与架构,不构成 TTS 推理的可用性保证。
仓库 README 的风险与限制一节写明:该模型仅供研究与开发用途, 未经进一步测试与开发不建议用于商业或真实场景,并特别提示了合成语音被用于伪造与虚假信息的风险。 使用合成语音时应遵守所在司法辖区的法律法规,并在分享 AI 生成内容时主动披露。