长音频怎么被切:VibeVoice ASR 的 processor 分块逻辑排查

2026-08-18

排查这类问题的起手式,通常是有人扔来一句「长音频转出来后半段不对劲」。可能是说话人 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_maskTrue 的个数是不是等于 ceil(样本数 / 3200)。相等,说明 processor 这一层老老实实把整段音频当成一个连续片段,没有分块。

顺带记一条排查时容易吃亏的细节:_batch_encodeinput_ids 做的是左侧 padding(注释写明是为自回归生成准备),speech_tensors 则按最长的那条右侧补零,再用 speech_masks[i, :vae_len] = True 标出每条真正有效的 token 长度。所以批处理里短音频后面那一截零值不是 bug,掩码已经把它排除了。

还有一处值得先记下:__call__ 的 docstring 写返回值里包含 vae_tok_seqlens,但 _batch_encodereturn_tensors == "pt" 分支里有一行注释明说 vae_tok_seqlensspeech_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_cachesemantic_encoder_cache 都是 VibeVoiceTokenizerStreamingCache 实例,类 docstring 自述其作用类似注意力里的 KV cache,按 (layer_id, sample_idx) 存卷积状态。段与段之间的卷积上下文是接续的,不是各切各的。
  • 末段补齐is_final_chunk 只在最后一段为 Truevibevoice/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 默认 Falsemax_length 默认 None它不会替你截断,也不会在超长时报错

再看能不能调分段粒度。HF 这条路线上,forwardencode_speech 时没有把 streaming_segment_duration 透出来,所以走 generate() 的常规用法改不到它,只能按默认值走。vLLM 那条路线不一样:vllm_plugin/model.pyenable_streamingstreaming_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_maskTrue 数、speech_masksTrue 数、以及按 ceil(有效样本数 / 3200) 算出来的值,三者应当一致;音频有效样本数除以 24000 应当等于你实际的音频秒数,不等就是重采样那一步没走对。demo/vibevoice_asr_inference_from_file.py 里打印了 input_idsspeech_tensorsattention_mask 三个形状,可以直接拿它当对照基准。

Windows 侧多一步要确认:vibevoice/processor/audio_utils.py 里的 load_audio_use_ffmpeg 是用 subprocess 直接调 ffprobeffmpeg 两个可执行文件的,装不上或不在 PATH 里,代码会 warnings.warn 之后回退到 soundfile。文档给的安装指引是 Linux 容器里的 apt update && apt install ffmpeg -y,Windows 下自行安装并把可执行文件加入 PATH 属于通用做法、不是该项目官方内容。回退路径本身是能走通的,但两条路径的读取实现不是同一套(processor 调 load_audio_use_ffmpeg 时传的是 resample=False,函数会先用 ffprobe 探出原采样率,再让 ffmpeg 按原采样率输出 s16le 单声道,重采样留给后面那步 librosasoundfile 分支则是读完再 mean(axis=1) 转单声道),排查采样率与声道相关的异常时,先确认自己走的是哪一条。

七、什么情况说明不是分块的问题

最后这一步别省,下面几种现象跟分块没关系:

  • 拿到的转写结果是空列表post_process_transcriptionjson.JSONDecodeError 时会 logger.warning 后返回 []。这是模型输出的文本没解析成 JSON,和音频怎么切无关。它优先在输出里找带 json 标记的 markdown 代码块,找不到再按括号配对截取。
  • 只转出了前面一小段就停。先看生成长度。demo/vibevoice_asr_inference_from_file.pymax_new_tokens 的默认值是 512(这是该 demo 脚本当前代码里的默认值,随版本可能变动),这是输出侧的上限,与输入侧的分段是两回事。
  • 字段名对不上post_process_transcriptionkey_mapping 只认 Start time/StartEnd time/EndSpeaker ID/SpeakerContent 这几种写法,分别映射到 start_timeend_timespeaker_idtext。prompt 里要求模型输出哪些键由 show_keys 决定,那是代码里写死的一组。
  • 专有名词一直识别错。processor 的 context_info 参数会把你给的内容拼进 user 侧提示(文档把这个能力称作 customized hotwords),这是提示层面的事,改分段没用。
  • 同一段音频两次结果不同vllm_plugin/model.pyuse_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 生成内容时主动披露。

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