VibeVoice 的 modeling_vibevoice_asr.py:ASR 和 TTS 差在哪

2026-08-18

翻这个仓库时最容易卡住的一个问题是:一段音频交给 ASR 模型之后,它到底在哪一步变成了语言模型能吃的东西?名字里带 VAE、带 tokenizer、带 connector 的模块一堆,但 vibevoice/modular/modeling_vibevoice_asr.py 全文并不长,把这条路径从头到尾读完是可行的。这篇就沿着这个文件走一遍,顺带把它和 TTS 侧的 vibevoice/modular/modeling_vibevoice.py 摆在一起看,两份文件长得很像,但差的地方恰好是理解这个模型家族的关键。

三个类,各管一层

文件里定义了三个类,__all__ 也就导出这三个:

  • VibeVoiceASRPreTrainedModel,继承 transformersPreTrainedModel,只负责声明 config_class = VibeVoiceASRConfigbase_model_prefix = "model" 这类元信息,以及一串 _supports_* 开关和 _init_weights
  • VibeVoiceASRModel,是主干,forward 里其实只做一件事:把参数原样转给 self.language_model,返回 BaseModelOutputWithPast
  • VibeVoiceASRForConditionalGeneration,同时继承上面的基类和 GenerationMixin,装上 lm_head,并额外提供 encode_speechprepare_inputs_for_generation

值得留意的是第三个类的多继承里带了 GenerationMixin。而 TTS 侧的 VibeVoiceForConditionalGeneration 只继承 VibeVoicePreTrainedModel,没有混入 GenerationMixin,文件里也没有 prepare_inputs_for_generation。这是两份文件写法上白纸黑字的差别,demo/vibevoice_asr_inference_from_file.py 里直接调 self.model.generate(**inputs, **generation_config),走的正是前者这条链。

VibeVoiceASRModel.__init__ 里装了五个子模块:由 AutoModel.from_config(lm_config) 建出的 language_model,两个由 config 建出的 acoustic_tokenizersemantic_tokenizer,以及两个 SpeechConnector——分别是 SpeechConnector(config.acoustic_vae_dim, lm_config.hidden_size)SpeechConnector(config.semantic_vae_dim, lm_config.hidden_size)SpeechConnector 这个类本身并不定义在 ASR 文件里,它是从 modeling_vibevoice.py import 进来的,实现是 fc1LlamaRMSNormfc2 三层,把 VAE 维度映射到语言模型的 hidden_size。同一行 import 还带回来一个 VibeVoiceCausalLMOutputWithPast,ASR 的 forward 最终就返回这个 dataclass。

config 怎么拼出来

VibeVoiceASRConfig 定义在 vibevoice/modular/configuration_vibevoice.pyis_composition = Truesub_configs 里挂了三个子配置:acoustic_tokenizer_configsemantic_tokenizer_configdecoder_config,最后一个的类型是 Qwen2Config。构造函数里对 decoder 有一条硬判断:传字典进来时,只有 model_type"qwen2" 才会构造,否则直接 raise ValueError(f"Unsupported decoder model type: ...")。也就是说这份 config 在代码层面没给别的 decoder 留口子。

模型里用到的 acoustic_vae_dimsemantic_vae_dim 不是独立字段,而是从子 config 里取出来的:getattr(self.acoustic_tokenizer_config, 'vae_dim', 64)getattr(self.semantic_tokenizer_config, 'vae_dim', 128)。这两个兜底数值是仓库当前代码里的默认值,随版本可能变动,实际加载权重时以 config 文件里的 vae_dim 为准。

还有一处容易踩的地方:VibeVoiceASRConfigmodel_type 字面值是 "vibevoice",而同一个文件里 VibeVoiceConfigmodel_type 也是 "vibevoice";两份 modeling 文件末尾又都调用了 AutoModel.register(...)AutoModelForCausalLM.register(...)。所以别指望靠 model_type 自动分派挑中 ASR 那一支,demo 里的写法是按完整模块路径导入具体类:from vibevoice.modular.modeling_vibevoice_asr import VibeVoiceASRForConditionalGeneration。这也解释了另一件事——vibevoice/modular/__init__.py__all__ 只导出 Streaming 系列六个符号,ASR 和 TTS 的类都不在里面,所以只能写全路径。

encode_speech:音频进来后的两条分支

真正把波形变成特征的是 VibeVoiceASRForConditionalGeneration.encode_speech,签名是 (speech_tensors, speech_masks=None, speech_semantic_tensors=None, streaming_segment_duration=60.0)

它先按 config 的 torch_dtype 转 dtype,一维输入自动 unsqueeze(0) 补成 (batch, samples)。接着是一行硬编码:sample_rate = 24000 # fix 24kHz sample rate。分段长度由 segment_samples = int(streaming_segment_duration * sample_rate) 算出,然后 use_streaming = total_samples > segment_samples 决定走哪条分支。注意这几步都在 torch.no_grad() 之外,真正包进 with torch.no_grad(): 的是后面的编码与特征相加。

短音频分支比较直白:acoustic_tokenizer.encode(speech_tensors.unsqueeze(1)),拿到的 encoder 输出调 .sample(dist_type=self.model.acoustic_tokenizer.std_dist_type)[0] 采样出 audio_tokens,再过 acoustic_connector。语义一侧有个开关——如果调用方传了 speech_semantic_tensors,就直接过 semantic_connector,否则才自己跑 semantic_tokenizer.encode(...).mean

长音频分支要绕一圈。它给两个 tokenizer 各建一个 VibeVoiceTokenizerStreamingCache,用内部的 _iter_segments(total_length, segment_length) 生成器切片,逐段调 encode(chunk.unsqueeze(1), cache=..., sample_indices=..., use_cache=True, is_final_chunk=is_final),只收集每段的 .mean。声学那一侧所有段跑完之后再 torch.cat 拼起来,用 VibeVoiceTokenizerEncoderOutput(mean=acoustic_mean_full, std=self.model.acoustic_tokenizer.fix_std) 重新包一次,这时才做一次采样;语义那一侧则只是把各段的 .mean 拼起来直接过 semantic_connector,代码里没有对它做采样。仓库注释自述这么做的原因是长音频会让卷积溢出(>2^32),分段处理再拼接可以规避。

这里有个源码内部对不上的地方值得记一笔:encode_speech 的 docstring 写的是 “For long audio (>600s by default), uses streaming processing”,而签名上的默认值是 streaming_segment_duration: float = 60.0。两处数值不一致,是文档串还是默认值改过,仓库里没有找到进一步说明——真要依赖这个阈值,就显式传参,别信 docstring。

最后无论走哪条分支,收尾都是同一句:声学特征与语义特征相加。有 speech_masks 就先按 mask 取再相加,没有就整块相加。

特征怎么塞进 inputs_embeds

forward 的语音处理只有短短几行:先 inputs_embeds = self.get_input_embeddings()(input_ids),然后当 speech_tensorsacoustic_input_mask 同时不为 None 时,调 encode_speech 拿到 speech_featuresinputs_embeds = inputs_embeds.clone()(注释写明是为了避免训练时对叶子变量做 in-place),最后一句是 inputs_embeds[acoustic_input_mask] = speech_features。之后调 self.model(input_ids=None, inputs_embeds=inputs_embeds, ...)input_ids 被显式置空。

这个 mask 从哪来?vibevoice/processor/vibevoice_asr_processor.py 里,单条音频编码时先算 vae_tok_len = math.ceil(len(audio_array) / self.speech_tok_compress_ratio),再把提示词拼成 <|speech_start|> + <|speech_pad|> 重复 vae_tok_len 次 + <|speech_end|>,然后 acoustic_input_mask = [1 if token == self.speech_pad_id else 0 for token in full_tokens]。占位符占了多少个 token,特征就有多少帧,两边靠这个长度对齐。speech_tok_compress_ratio 的默认值是 3200,仓库注释说明它是 encoder 各级压缩比 [8,5,5,4,2,2] 的乘积(这是仓库当前代码里的默认值,随版本可能变动)。

labels 传进来时,forward 会做标准的错位交叉熵:logits[..., :-1, :]labels[..., 1:],用 nn.CrossEntropyLoss(ignore_index=-100)

生成时语音只传一次

prepare_inputs_for_generation 里有一段注释明确写着这是照 Qwen2-VL 的做法:只在 cache_position[0] == 0,也就是第一次前向时,把 speech_tensorsspeech_masksspeech_semantic_tensorsacoustic_input_mask 放进 model_inputs;后续每一步生成都把这四项显式置为 None。注释只写了「照 Qwen2-VL 的做法」,没有再解释更多;代码层面的效果是明确的——forward 里那段 encode_speech 的调用条件是 speech_tensors is not None and acoustic_input_mask is not None,两项都被置空之后就不会再进去。

如果你在自己的推理循环里绕过 generate() 手写多步解码,这一层就得自己复刻,否则每步都会重跑 encode_speech

和 TTS 那份文件的结构对照

modeling_vibevoice.py 摆在旁边,同名的那部分几乎一模一样:VibeVoiceModel.__init__ 同样建 language_model、两个 tokenizer、两个 SpeechConnectorforward 同样是转给语言模型。差别集中在三处。

第一,组件多寡。TTS 侧的 VibeVoiceModel 额外注册了 speech_scaling_factorspeech_bias_factor 两个 buffer,还建了 prediction_head(由 diffusion_head_config 构造)和一个 DPMSolverMultistepScheduler 作为 noise_scheduler。ASR 侧这四样一个都没有:那份文件里不出现 prediction_head、不出现噪声调度器,输出侧只有 lm_head

第二,loss 的落点相反。ASR 的 forwardlabels 走标准交叉熵;TTS 的 forward 里交叉熵那一段直接 pass,注释写明”带 mask 的自定义 CE loss 在训练脚本里算”,而文件里实打实计算的是 diffusion_lossnoise_scheduler.add_noise 加噪,prediction_head 预测,按 prediction_typeepsilonv_prediction 作为回归目标,最后 F.mse_loss

第三,语音特征的入口函数不同。ASR 用 encode_speech,只做编码与相加;TTS 用 forward_speech_features,里面还有 speech_type 参数("audio""vae" 两种取值,其它值抛 NotImplementedError),以及那对 scaling / bias 因子在首次遇到 NaN 时的统计初始化逻辑。

还有几处更细的写法差异,改代码时容易被绊到。VibeVoiceASRForConditionalGeneration.__init__ 里建 lm_head 时跟了一句 .to(dtype),dtype 由 config 的 torch_dtype 决定(字符串就 getattr(torch, ...) 取,没有就退回 torch.float32);TTS 侧那个 lm_head 的构造没有这个 dtype 转换。tie_weights 两边都有,都以 config.decoder_config.tie_word_embeddings 为开关,但 TTS 那份还多一段对 output_embeddings.biasnn.functional.pad 的处理,并且会 print 出是否绑定成功,ASR 那份只做权重赋值、不打印。

另外两边都保留了同一个 get_input_embeddings 实现:先看 language_model 有没有 embed_tokens 属性,没有就遍历 self.language_model.fullmap,找 orig_name == 'embed_tokens.weight' 的那一项。注释写明这是为了兼容 nnscaler 做并行后属性名被改掉的情况,走不到就直接 assert Falseset_speech_tokenizers(acoustic_tokenizer, semantic_tokenizer) 也是两边共有的方法,允许从外部替换两个 tokenizer,替换后会各自调一次 .eval()

顺带一个细节:两个 PreTrainedModel 子类声明的能力开关也不完全一样。ASR 的那份同时写了 _supports_flash_attn_supports_flash_attn_2,TTS 的那份只有 _supports_flash_attn_2。这只是类属性的差异,具体到某个 transformers 版本会怎么解释这些开关,得看你装的那一版。

读到这一步,那个”ASR 和 TTS 差在哪”的问题其实有了很具体的答案:共享的是 Qwen2 decoder 加双 tokenizer 加 connector 这套前端,分岔点在语言模型之后——一边接 lm_head 出 token,一边接 diffusion head 出声学 latent。


本文依据 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 推理的可用性保证。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。