长音频怎么被切:VibeVoice ASR 的 processor 分块逻辑排查
排查这类问题的起手式,通常是有人扔来一句「长音频转出来后半段不对劲」。可能是说话人 ID 从中间开始串,可能是时间戳跳了,也可能是干脆只转了前面一小段。第一反应往往是:是不是音频被切块了,切完各块之间互相不认识?
这个怀疑方向本身没错,但 VibeVoice ASR 里「切」这个动作发生的位置,和大多数人想的不是同一层。下面按排查顺序走一遍,事实全部来自 github.com/microsoft/VibeVoice 仓库里的文档与源码。先说清楚对象:本文只讲 ASR 这一支(对应文档 docs/vibevoice-asr.md),VibeVoice 家族里的 TTS、Realtime 是另外的模型和另外的文档,别把结论搬过去。TTS 那一支还有一层状态要先说明:README 记载 2025-09-05 微软因发现有与既定意图不符的使用方式,基于负责任 AI 原则从本仓库移除了 VibeVoice-TTS 代码,现存的 vibevoice/modular/modeling_vibevoice.py 首行注释标明其复制自社区 fork,且没有出现在 vibevoice/modular/__init__.py 的 __all__ 里。下面讲的分块链路只覆盖 ASR。
一、先确认 processor 到底切没切
vibevoice/processor/vibevoice_asr_processor.py 里的 VibeVoiceASRProcessor 是音频进入模型前的第一站。翻完 _process_single_audio 会发现一件事:它根本没有切音频。
它做的是把整段波形读进来(str 路径走 load_audio_use_ffmpeg,失败回退 soundfile;采样率不等于 target_sample_rate 时用 librosa.resample 重采样),归一化,然后算一个数:
vae_tok_len = math.ceil(len(audio_array) / self.speech_tok_compress_ratio)
speech_tok_compress_ratio 的默认值是 3200,构造函数的 docstring 写明它是编码器各级压缩比 [8,5,5,4,2,2] 的乘积;target_sample_rate 默认 24000。这两个都是仓库当前代码里的默认值,随版本可能变动。
算出来的 vae_tok_len 直接决定文本侧要放多少个占位符:
user_input_string = ''.join(
[sp_start_token] + [sp_pad_token] * vae_tok_len + [sp_end_token]
) + '\n' + user_suffix
也就是说,整段音频在 prompt 里被表示成 <|speech_start|> 与 <|speech_end|> 之间连续的一串 <|speech_pad|>,acoustic_input_mask 逐 token 标出这些位置是不是占位符。这就是判定动作一:把 processor 的返回值取出来,看 acoustic_input_mask 里 True 的个数是不是等于 ceil(样本数 / 3200)。相等,说明 processor 这一层老老实实把整段音频当成一个连续片段,没有分块。
顺带记一条排查时容易吃亏的细节:_batch_encode 对 input_ids 做的是左侧 padding(注释写明是为自回归生成准备),speech_tensors 则按最长的那条右侧补零,再用 speech_masks[i, :vae_len] = True 标出每条真正有效的 token 长度。所以批处理里短音频后面那一截零值不是 bug,掩码已经把它排除了。
还有一处值得先记下:__call__ 的 docstring 写返回值里包含 vae_tok_seqlens,但 _batch_encode 在 return_tensors == "pt" 分支里有一行注释明说 vae_tok_seqlens 和 speech_type 不是模型输入、不放进去。文档串与代码不一致的时候以代码为准,别照着 docstring 去取一个不存在的键。
二、use_streaming 这个参数当前是空转的
__call__ 和 _process_single_audio 都带 use_streaming: bool = True,docstring 写的是「True by default, auto False if <60s」,代码里也确实有这一段:
# Auto-disable streaming for short audio (<60s)
if use_streaming and audio_duration < 60.0:
use_streaming = False
但接着往下看,唯一会用到这个标志的地方被注释掉了:
# if use_streaming:
# vae_tok_len = len(audio_array) // self.speech_tok_compress_ratio
# else:
vae_tok_len = math.ceil(len(audio_array) / self.speech_tok_compress_ratio)
上面那段注释同时留下了作者写明的语义:非流式用 ceil(编码器会为 stride 对齐补 extra_padding),流式用 floor(分段独立处理,没有全局对齐)。把这两处放在一起看,结论只有一个:当前代码里,无论你给 processor 传 use_streaming=True 还是 False,输出都一样,而且 _process_single_audio 返回的字典里也没有这个标志。排查时如果以为改了这个参数就改变了分块行为,方向会一直偏着。
三、真正的切在 encode_speech
分段发生在模型侧。vibevoice/modular/modeling_vibevoice_asr.py 里的 encode_speech 签名带一个参数 streaming_segment_duration: float = 60.0,判定条件是:
segment_samples = int(streaming_segment_duration * sample_rate)
use_streaming = total_samples > segment_samples
sample_rate 在这里是写死的 24000(代码注释:fix 24kHz sample rate)。超过这个长度就走分段分支,用内部的 _iter_segments(total_length, segment_length) 按固定长度切出 (start, end),逐段 speech_tensors[:, start:end] 送进 tokenizer。
这里有一个文档与代码打架的地方值得标出来:encode_speech 的 docstring 写的是「For long audio (>600s by default), uses streaming processing to avoid conv overflow (>2^32)」,而参数默认值和判定条件用的是 60.0 秒。两处对不上,以代码里的默认值为准,并且注意它随版本可能变动。
四、切开之后靠什么把上下文接回来
这是这条链路里最该看明白的一段,也是「切了会不会丢上下文」的直接答案。分段循环里的关键写法是:
acoustic_encoder_output = self.model.acoustic_tokenizer.encode(
chunk.unsqueeze(1),
cache=acoustic_encoder_cache,
sample_indices=sample_indices,
use_cache=True,
is_final_chunk=is_final,
)
三个机制叠在一起:
- 卷积状态缓存。
acoustic_encoder_cache与semantic_encoder_cache都是VibeVoiceTokenizerStreamingCache实例,类 docstring 自述其作用类似注意力里的 KV cache,按(layer_id, sample_idx)存卷积状态。段与段之间的卷积上下文是接续的,不是各切各的。 - 末段补齐。
is_final_chunk只在最后一段为True,vibevoice/modular/modular_vibevoice_tokenizer.py里对应的分支会调get_extra_padding_for_conv1d补上extra_padding。这正好对上 processor 里注释提到的 stride 对齐,也说明当前一律走ceil与末段补齐这两处是对得上的。 - 拼完再采样一次。各段先只取
.mean存进列表,循环结束后torch.cat(acoustic_mean_segments, dim=1)拼成完整序列,再整体sample()一次;semantic 侧同样是先cat再过semantic_connector。
最后 combined_features = acoustic_features[speech_masks] + semantic_features[speech_masks],声学与语义两路特征相加,回到 forward 里按 inputs_embeds[acoustic_input_mask] = speech_features 填进文本序列的占位符位置。
这一行就是判定动作二:布尔掩码赋值要求左边选中的位置数与右边的行数严格相等。如果绕开 processor 自己拼 input_ids,占位符数量与 speech_masks 算出来的 token 数一旦不一致,报错就出在这里,而不是什么玄学的「上下文丢了」。
五、能改的和改不动的
docs/vibevoice-asr.md 的特性一节写明:模型按「一次读完一整段连续音频」的方式工作,文档自述这与「把音频切成短块因而丢失全局上下文」的常规做法不同,并在同一处给出了单遍可处理的音频时长与 token 长度上限(具体数值以仓库文档最新内容为准)。这是文档给出的能力口径,不是代码里的强制校验——processor 的 __call__ 里 truncation 默认 False、max_length 默认 None,它不会替你截断,也不会在超长时报错。
再看能不能调分段粒度。HF 这条路线上,forward 调 encode_speech 时没有把 streaming_segment_duration 透出来,所以走 generate() 的常规用法改不到它,只能按默认值走。vLLM 那条路线不一样:vllm_plugin/model.py 里 enable_streaming 与 streaming_segment_duration 都从配置读(get_cfg(config, "enable_streaming", True)、get_cfg(config, "streaming_segment_duration", 60.0)),并且支持从 kwargs 里逐次调用覆盖。两条路线的可配置面不一样,排查前先确认自己在哪条路线上,vLLM 侧的细节以 docs/vibevoice-vllm-asr.md 为准。
六、处置后怎么验证
验证不需要看转写文本,只看形状和计数就够:
from vibevoice.processor.vibevoice_asr_processor import VibeVoiceASRProcessor
processor = VibeVoiceASRProcessor.from_pretrained("<模型路径或仓库 ID>")
inputs = processor(audio="<你的音频文件>", return_tensors="pt")
print(inputs["input_ids"].shape)
print(inputs["acoustic_input_mask"].sum().item())
print(inputs["speech_tensors"].shape, inputs["speech_masks"].sum().item())
以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
要对的几个等式:acoustic_input_mask 的 True 数、speech_masks 的 True 数、以及按 ceil(有效样本数 / 3200) 算出来的值,三者应当一致;音频有效样本数除以 24000 应当等于你实际的音频秒数,不等就是重采样那一步没走对。demo/vibevoice_asr_inference_from_file.py 里打印了 input_ids、speech_tensors、attention_mask 三个形状,可以直接拿它当对照基准。
Windows 侧多一步要确认:vibevoice/processor/audio_utils.py 里的 load_audio_use_ffmpeg 是用 subprocess 直接调 ffprobe 与 ffmpeg 两个可执行文件的,装不上或不在 PATH 里,代码会 warnings.warn 之后回退到 soundfile。文档给的安装指引是 Linux 容器里的 apt update && apt install ffmpeg -y,Windows 下自行安装并把可执行文件加入 PATH 属于通用做法、不是该项目官方内容。回退路径本身是能走通的,但两条路径的读取实现不是同一套(processor 调 load_audio_use_ffmpeg 时传的是 resample=False,函数会先用 ffprobe 探出原采样率,再让 ffmpeg 按原采样率输出 s16le 单声道,重采样留给后面那步 librosa;soundfile 分支则是读完再 mean(axis=1) 转单声道),排查采样率与声道相关的异常时,先确认自己走的是哪一条。
七、什么情况说明不是分块的问题
最后这一步别省,下面几种现象跟分块没关系:
- 拿到的转写结果是空列表。
post_process_transcription在json.JSONDecodeError时会logger.warning后返回[]。这是模型输出的文本没解析成 JSON,和音频怎么切无关。它优先在输出里找带 json 标记的 markdown 代码块,找不到再按括号配对截取。 - 只转出了前面一小段就停。先看生成长度。
demo/vibevoice_asr_inference_from_file.py里max_new_tokens的默认值是512(这是该 demo 脚本当前代码里的默认值,随版本可能变动),这是输出侧的上限,与输入侧的分段是两回事。 - 字段名对不上。
post_process_transcription的key_mapping只认Start time/Start、End time/End、Speaker ID/Speaker、Content这几种写法,分别映射到start_time、end_time、speaker_id、text。prompt 里要求模型输出哪些键由show_keys决定,那是代码里写死的一组。 - 专有名词一直识别错。processor 的
context_info参数会把你给的内容拼进 user 侧提示(文档把这个能力称作 customized hotwords),这是提示层面的事,改分段没用。 - 同一段音频两次结果不同。
vllm_plugin/model.py里use_sample由环境变量VIBEVOICE_USE_MEAN控制,注释写明设为1走确定性输出、默认走sample()以与训练行为一致。这属于采样,不属于分块。
收口一句:processor 只负责算占位符个数并把整段音频原样带过去,切的动作在 encode_speech 里、按 streaming_segment_duration 走,段间靠 VibeVoiceTokenizerStreamingCache 与「拼接后统一采样」保持连续。按这个顺序看,能省掉大半猜测。该项目持续更新,上面涉及的路径、参数名与默认值请以仓库最新内容为准。
本文依据 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 生成内容时主动披露。