VibeVoice 连续语音 tokenizer:acoustic 与 semantic 两路怎么分工
刚翻这个仓库的人常有一个疑问:说的是「连续语音 tokenizer」,但代码里既没有码本也没有量化查表,那它到底把音频变成了什么?还有一个更让人犯迷糊的地方——为什么同一个文件里定义了两个 tokenizer 模型类,一个带 decoder,另一个没有。
这两个问题的答案都在 vibevoice/modular/modular_vibevoice_tokenizer.py 这一个文件里。下面就沿着一段波形进入这个文件之后的路径走一遍。
两个模型类:一个能回来,一个回不来
文件末尾的 __all__ 一共导出三个符号:VibeVoiceTokenizerStreamingCache、VibeVoiceAcousticTokenizerModel、VibeVoiceSemanticTokenizerModel。两个模型类都继承 PreTrainedModel,也都在文件最后通过 AutoModel.register 把自己和对应的 config 类绑上。上层确实是这么取实例的:vibevoice/modular/modeling_vibevoice_asr.py 里写的是 self.acoustic_tokenizer = AutoModel.from_config(config.acoustic_tokenizer_config).to(dtype),semantic 那一行只差配置对象。
结构上的差别在构造函数里一眼能看出来。VibeVoiceAcousticTokenizerModel.__init__ 同时建了 self.encoder = TokenizerEncoder(encoder_config) 和 self.decoder = TokenizerDecoder(decoder_config),它的 _no_split_modules 里列了 TokenizerEncoder 和 TokenizerDecoder 两项;而 VibeVoiceSemanticTokenizerModel 只建了 self.encoder,_no_split_modules 里也只有 TokenizerEncoder。类的 docstring 写得很直白,前者是 encoder 加 decoder 的组合,后者是 encoder only。
这个差别一路传到 forward 的返回值上。acoustic 那个类的 forward 是 encode、sampling、decode 一条龙,最后 return reconstructed, sampled_latents;semantic 那个类的 forward 里没有 decode 这一步,返回的是 return None, sampled_latents——第一个位置就是个 None。所以要是你顺手拿 semantic tokenizer 的输出去接后续重建流程,拿到的会是空值,这不是 bug,是这个类本来就没有解码能力。
编码路径:一串下采样卷积加 Block1D
两个类的 encode 方法签名几乎一样,都带 @torch.no_grad() 装饰,参数是 (audio, cache=None, sample_indices=None, use_cache=False, debug=False, is_final_chunk=False),里面都是把音频丢给 self.encoder,再把结果包成 VibeVoiceTokenizerEncoderOutput。
TokenizerEncoder.forward 分两步:先 forward_features,再过一层 self.head。forward_features 里按 len(self.depths) 循环,每一轮先走 downsample_layers[i],再走 stages[i] 里的若干个 Block1D。downsample_layers 的第一项是个 stem,就是一个 SConv1d;之后每个下采样层的构造是 SConv1d(in_ch, out_ch, kernel_size=self.ratios[i] * 2, stride=self.ratios[i], ...),也就是 kernel 取步长的两倍。通道数按 self.n_filters * (2 ** i) 逐层翻倍。最后 self.head 又是一个 SConv1d,把通道压到 self.dimension,而在模型类里 encoder_config.dimension 被赋成了 config.vae_dim。
有个容易看漏的细节:TokenizerEncoder.__init__ 里写的是 self.ratios = list(reversed(config.ratios)),而 TokenizerDecoder.__init__ 里是 self.ratios = config.ratios,不反转;decoder 那边还专门留了一行注释说明 depths 不再反转一次,因为在 VibeVoiceAcousticTokenizerModel 里已经处理过了——模型类里当 config.decoder_depths 为 None 时,走的是 decoder_depths = list(reversed(encoder_depths))。这两处放在一起看才能明白编解码器的层级顺序是怎么对上的。
encoder_depths 在 config 里是字符串形式,模型类用 [int(d) for d in config.encoder_depths.split('-')] 拆成整数列表。这也是为什么配置文件里那一栏看起来像是用连字符连起来的一串数字。hop_length 则是 np.prod(self.ratios) 直接连乘出来的。
Block1D 是每个 stage 的基本单元:一个归一化、一个 mixer(Convlayer 包着 SConv1d)、再一个 FFN,两处都带残差和可选的 gamma 缩放。这里有一处两份源码对不齐的地方值得记一笔:Block1D 里选归一化写成 kwargs.get('layernorm', 'LN') == 'LN',紧接着的 elif 用的却是 kwargs.get('layernorm', 'RMSNorm') == 'RMSNorm',两个分支的兜底默认值并不相同;而 VibeVoiceAcousticTokenizerConfig 里 layernorm 的默认值是 'RMSNorm'。同样地,config 里 mixer_layer 默认是 'depthwise_conv',Block1D 签名上的默认是 'conv'——实际生效的是从 config 传下来的那个。这些是仓库当前代码里的默认值,随版本可能变动。
输出不是离散 id,是一个分布对象
encode 返回的 VibeVoiceTokenizerEncoderOutput 是个 dataclass,只有两个字段:mean 和 std。它带三个方法——sample(dist_type=...)、kl()、mode(),其中 mode() 直接返回 self.mean。到这一步就能回答开头那个问题了:这里没有码本,输出是连续的隐变量,「token」指的是低帧率下一帧一帧的 latent。仓库 README 写明这两个 tokenizer 工作在 7.5 Hz 的帧率上。
两个类在这里分道扬镳。acoustic 的 encode 返回 VibeVoiceTokenizerEncoderOutput(mean=latents.permute(0, 2, 1), std=self.fix_std),fix_std 是构造时用 register_buffer('fix_std', torch.tensor(config.fix_std), persistent=False) 注册的非持久 buffer;semantic 的 encode 只传了 mean,std 留空。
sampling 方法的写法也不同。acoustic 的 sampling 会读 self.std_dist_type,只接受 'fix' 和 'gaussian' 两种取值,其它值直接 raise ValueError;semantic 的 sampling 则是硬编码 return encoder_output.sample(dist_type='none')。而 sample() 内部对 'fix' 和 'gaussian' 各有一条加噪分支,剩下的一切取值都落到 else,原样返回 self.mean, self.std。也就是说 semantic 这一路走的是不加噪、直接取均值的口径。配置默认值也和这个走向一致:VibeVoiceAcousticTokenizerConfig 的 std_dist_type 默认 'gaussian'、fix_std 默认 0.5,VibeVoiceSemanticTokenizerConfig 的 std_dist_type 默认 'none'、fix_std 默认 0(同样是仓库当前默认值,随版本可能变动)。
解码只有 acoustic 有。decode 开头有一段容易被忽略的形状判断:if latents.shape[1] == self.config.vae_dim: pass,否则 latents = latents.permute(0, 2, 1)。因为 encode 出来的 mean 已经被 permute 成了时间维在中间,喂回 decode 时得转回来,这个判断就是替调用方兜住这次转置。之后进 TokenizerDecoder,上采样用的是 SConvTranspose1d,转置卷积多出来的部分靠 unpad1d 按 trim_right_ratio 算出的左右量裁掉。
缓存类:键是 (layer_id, sample_idx)
VibeVoiceTokenizerStreamingCache 是个纯 Python 类,不继承 nn.Module,注释里自己说这是给流式卷积用的、类似注意力里的 KV cache。内部就一个字典 self.cache,键是 (layer_id, sample_idx) 元组。
get(layer_id, sample_indices) 有两个行为要留意。一是全有或全无:遍历 sample_indices 时只要有一个键不在字典里就直接 return None,不会返回部分结果。二是长度对齐时在左侧补零——注释写明是为了对齐最近的样本,F.pad(state, (pad_size, 0), ...),补在时间维前面而不是后面。set 存的是 states[i].detach()。另外还有 set_to_zero(sample_indices) 把指定样本的缓存原地换成 torch.zeros_like,以及 clear(layer_id=None, sample_indices=None),按两个参数给不给分三种清法:都不给清空全部、只给 layer 清该层全部样本、都给则精确清掉那几个键。
那 layer_id 从哪来?SConv1d 和 SConvTranspose1d 各有一个 layer_id 属性,第一次访问时生成,格式分别是 f"sconv1d_{id(self)}" 和 f"sconvtr1d_{id(self)}"。用的是 Python 内置的 id(),取的是对象在运行期的唯一标识。这意味着缓存键和具体的模型对象绑死,换一个模型实例就对不上了。
真正用到缓存的是流式分支。SConv1d.forward 里,use_cache 为假或 cache 为 None 就走 _forward_non_streaming;走流式则先 assert self.causal,注释和断言消息都写明流式模式只支持因果卷积,还会断言 sample_indices 长度等于 batch。_forward_streaming 取出上一块缓存拼在输入前面,缓存缺失时按 self.context_size 造一段零;context_size 的算式是 (kernel_size - 1) * dilation - (stride - 1)。只有 is_final_chunk=True 时才会调 get_extra_padding_for_conv1d 在尾部补齐那点对齐用的 padding,理由在非流式分支里能看到——非流式每次都会补这段,流式只在最后一块补,两边的输出长度才对得上。SConvTranspose1d 那边 context_size = kernel_size - 1,且它的流式分支没有 causal 断言。
这些缓存在哪里被创建
光看 tokenizer 文件还不够,得看调用方。vibevoice/modular/modeling_vibevoice_asr.py 里,短音频直接一次 encode;长音频则分段处理,acoustic_encoder_cache 和 semantic_encoder_cache 各 VibeVoiceTokenizerStreamingCache() 一份,sample_indices 用 torch.arange(batch_size, ...),逐段调 encode(chunk.unsqueeze(1), cache=..., sample_indices=..., use_cache=True, is_final_chunk=is_final)。注意顺序:每段只收 .mean,全部段拼完之后才构造一次 VibeVoiceTokenizerEncoderOutput 并调一次 sample——采样是整段做的,不是每段各采一次。两路的结果最后分别过 acoustic_connector 和 semantic_connector 再相加。
另一处在 vibevoice/modular/modeling_vibevoice_streaming_inference.py,那里只建了一个 acoustic_cache,用在 acoustic_tokenizer.decode(...) 上,也就是解码方向。这和 docs/vibevoice-realtime-0.5b.md 里的描述能对上:该文档写明这个流式模型去掉了 semantic tokenizer、只依赖 acoustic tokenizer;vibevoice/modular/configuration_vibevoice_streaming.py 的 sub_configs 里确实也只有 acoustic_tokenizer_config 一项,而 vibevoice/modular/configuration_vibevoice.py 里的 VibeVoiceConfig 两项都有。两处是一致的。
顺带一提,那段流式解码代码里写着 assert batch_size == 1, "Currently only supports batch size == 1"——这是代码里明写的限制,别按多 batch 去规划。
最后是一段调用示意,参数名与语义都取自上面读到的接口:
cache = VibeVoiceTokenizerStreamingCache()
sample_indices = torch.arange(batch_size, device=chunk.device)
out = acoustic_tokenizer.encode(
chunk.unsqueeze(1),
cache=cache,
sample_indices=sample_indices,
use_cache=True,
is_final_chunk=is_final,
)
latents = out.mean
以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
本文依据 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 生成内容时主动披露。