音频输入格式不对:VibeVoice 的 audio_utils.py 对采样率和声道要求什么

2026-08-18

有个现象很容易让人怀疑人生:同一段 48kHz 立体声的会议录音,用文件路径喂给 VibeVoice 的 ASR processor 一切正常;同事把它先用别的库读成 numpy 数组再传进同一个接口,结果时间戳全乱、或者干脆抛出一个看不懂的 shape 错误。

这不是玄学。VibeVoice 仓库里处理音频输入的入口不止一条,不同分支对采样率和声道的处理强度差得很远。本文只顺着 ASR 这条链路走:vibevoice/processor/audio_utils.pyvibevoice/processor/vibevoice_tokenizer_processor.pyvibevoice/processor/vibevoice_asr_processor.py,以及 vllm_plugin/inputs.py

现象长什么样

按仓库代码语义,能对上号的表现有这么几类:

  • 传 numpy 数组进去不报错,但结果里的时长明显不对;
  • 传一个 6 声道的数组,抛 Unexpected audio shape: ...
  • .npz 文件,抛出的错误信息里却把 .npz 列为「支持的格式」;
  • 日志里出现一句 Please resample your audio. 的 warning,但程序照跑不误。

最后这条最坑:它只是 warning,不是异常。

怎么确认是这个问题

第一步:看你走的是哪条入口

vibevoice/processor/audio_utils.py 里的 load_audio_use_ffmpeg(file, resample=False, target_sr=24000) 是文件路径这条路的底座。它拼出来的解码命令是写死的:

cmd = [
    "ffmpeg",
    "-loglevel", "error",
    "-nostdin",
    "-threads", "0",
    "-i", file,
    "-f", "s16le",
    "-ac", "1",
    "-acodec", "pcm_s16le",
    "-ar", str(sr_to_use),
    "-",
]

-ac 1 是硬编码的。也就是说,只要走到这个函数,声道问题就在 ffmpeg 层被消化掉了,不管源文件是几声道。函数最后把 int16 字节流转成 float32 再除以 32768.0

resample 这个开关决定 sr_to_use 是谁。resample=False 时,代码先跑一次 ffprobe 拿原始采样率:

cmd_probe = [
    "ffprobe",
    "-v", "quiet",
    "-show_entries", "stream=sample_rate",
    "-of", "default=noprint_wrappers=1:nokey=1",
    file
]

然后按这个原始采样率解码,返回值第二项就是它。resample=Trueoriginal_sr 直接置 None,按 target_sr 解码。函数签名里 target_sr 的默认值是 24000——这是仓库当前代码里的默认值,随版本可能变动。

同一文件里的 load_audio_bytes_use_ffmpeg 走 stdin 管道,它更直接:resample 不为真就抛

raise ValueError("load_audio_bytes_use_ffmpeg requires resample=True")

代码注释自述的理由是,对 stdin 字节流没有便宜且稳妥的方式探测原始采样率。

第二步:确认你传的是路径还是数组

VibeVoiceASRProcessor._process_single_audio 的三个分支要分开看。

传字符串路径时:先试 load_audio_use_ffmpeg(audio, resample=False),失败则 warnings.warn 后回退 soundfile.read,回退分支自己用 audio_array.mean(axis=1) 转单声道。拿到 file_sr 后有一句关键判断——file_sr != self.target_sample_rate 就调 librosa.resample 转过去。这条路采样率和声道都替你兜住了。

torch.Tensor 或 numpy 数组时:只做 squeeze()astype(np.float32)没有任何重采样动作。更值得注意的是,__call___process_single_audio 的签名里都有 sampling_rate 参数,docstring 也写着 “Sampling rate of input audio”,但在 _process_single_audio 的方法体里,除签名与 docstring 外这个参数没有再被使用。你告诉它音频是 16kHz,它不会因此做任何事。

后面紧跟着的两行会因此一起歪掉:

audio_duration = len(audio_array) / self.target_sample_rate
vae_tok_len = math.ceil(len(audio_array) / self.speech_tok_compress_ratio)

audio_duration 会被写进拼给模型的那句 user 提示(代码里是 This is a {audio_duration:.2f} seconds audio, ...),vae_tok_len 决定占位 speech token 铺多少个。speech_tok_compress_ratio 的默认值是 3200,docstring 自述它是 encoder 各级 ratio [8,5,5,4,2,2] 的乘积(这几个数相乘确实是 3200)。

第三步:看声道形状能不能过 _ensure_mono

VibeVoiceTokenizerProcessor._ensure_mono 的判据很硬:1D 原样返回;2D 时 shape[0] == 2axis=0 取均值、shape[1] == 2axis=1 取均值;哪一维是 1 就 squeeze 掉;都不满足就抛 ValueError(f"Unexpected audio shape: {audio.shape}");维度超过 2 抛 Audio should be 1D or 2D

所以 5.1 声道的 (N, 6) 数组在这里是直接失败,不是被下混。而 (2, N)(N, 2) 都被当成立体声——这个判据只认「2」这个数字,不认哪一维是时间轴。

同一个类的 __call__ 里那句 warning 也在这一层:

logger.warning(
    f"Input sampling rate ({sampling_rate}) differs from expected "
    f"sampling rate ({self.sampling_rate}). Please resample your audio."
)

它只是提醒,不会替你转

第四步:看错的采样率会往下游传到哪

传数组进来时那个没被使用的 sampling_rate,影响的不止一句提示文案。_process_single_audio 算出 audio_duration 之后紧接着还有一句:

if use_streaming and audio_duration < 60.0:
    use_streaming = False

这里的阈值是仓库当前代码里的取值,随版本可能变动。长度算错,会连带把「要不要走 streaming」这个开关拨错。

再往下一层,模型侧也把这个前提写死了。vibevoice/modular/modeling_vibevoice_asr.py 里有一行 sample_rate = 24000,后面跟着注释 # fix 24kHz sample rate,随后按 segment_samples = int(streaming_segment_duration * sample_rate) 算分段长度,再用 total_samples > segment_samples 判断一次是否分段。

于是链路上至少三处都按 24kHz 这个前提去解释你手里的样本数:processor 算时长、processor 决定要不要 streaming、模型侧再按段长切一次。把一段别的采样率的波形当 24kHz 交上去,这三处会一起偏,而且没有任何一处会拦下来。这也解释了为什么这类问题的表现通常是「结果对不上」而不是「程序崩了」——排查时别指望有异常给你指路。

仓库代码语义给出的处置

按上面这些分支,处置方向其实只有三条:

能给路径就给路径。 文件路径分支同时管了重采样和单声道,是唯一两件事都兜住的入口。这条路要求 ffmpeg 与 ffprobe 可执行,docs/vibevoice-asr.md 里 Gradio demo 的步骤就写了 apt update && apt install ffmpeg -y。Windows 上没有这一行对应的官方说明,需要自己把 ffmpeg 与 ffprobe 所在目录加进 PATH,让 subprocess.run(["ffmpeg", ...]) 能找到 ffmpeg.exe;这属于通用做法,不是该项目官方文档里的内容。另外要注意,vibevoice_asr_processor.py 顶部那个 try/except ImportError 只覆盖模块导入失败,ffmpeg 二进制缺失是在调用时才暴露的,会走 except Exception 那条回退到 soundfile 的分支。

必须传数组时,自己把两件事做完。 在传进去之前就转成 24kHz、单声道、float32 的一维数组。

走 vLLM 那条路的话,注意它也分叉。 vllm_plugin/inputs.py 里的 load_audio 用的是 load_audio_use_ffmpeg(audio_path, resample=True, target_sr=target_sr)AudioNormalizer();bytes 分支用 load_audio_bytes_use_ffmpeg(data, resample=True, target_sr=24000) 后同样归一化;而 isinstance(data, np.ndarray) 分支是 audio_waveform = data ——既不重采样也不归一化。紧接着那句 duration_sec = len(audio_waveform) / 24000 里的 24000 也是写死的。

顺带一提 AudioNormalizer:默认 target_dB_FS=-25eps=1e-6(仓库当前代码里的默认值,随版本可能变动),tailor_dB_FS10 ** (self.target_dB_FS / 20) / (rms + self.eps) 求缩放系数,再由 avoid_clipping 按最大绝对值缩回。rms 极小时分母由 eps 主导,这两步的先后关系值得在排查静音片段时留意。

处置后怎么验证

判定动作按接口语义组合如下:

from vibevoice.processor.audio_utils import load_audio_use_ffmpeg

audio, sr = load_audio_use_ffmpeg("<你的音频文件>", resample=False)
print(sr, audio.shape, audio.dtype)

resample=False 时返回的 sr 就是 ffprobe 读到的原始采样率,audio.shape 应当是一维(因为 -ac 1),dtype 应当是 float32。把这三项和 VibeVoiceASRProcessortarget_sample_rate 对一下即可。若你走的是数组入口,就把数组按 _ensure_mono 的判据自查一遍:len(shape) 是不是 1、不是的话哪一维等于 2。

以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。

什么情况说明不是这个原因

  • 扩展名被挡在门外的,不是采样率问题。 demo/vibevoice_asr_gradio_demo.pyCOMMON_AUDIO_EXTS 做扩展名门禁,代码注释自述这是为了防止任意文件探测。被这层挡住只说明后缀不在名单里。
  • .npz 报错是另一回事。 VibeVoiceTokenizerProcessor._load_audio_from_path 的分支链只处理 .wav/.mp3/.flac/.m4a/.ogg(走 librosa.load(sr=self.sampling_rate, mono=True))、.pt.npy.npz 会落进 else,而那句异常文案里偏偏把 .npz 写进了「Supported formats」。这两处对不上,属于源码内部的不一致,不是你的音频有问题。
  • ffmpeg 完全没装的,先解决二进制。 表现会是解码阶段直接失败或走回退,docs/vibevoice-vllm-asr.md 排查小节里「Audio decoding failed」那一条给的第一步就是确认 ffmpeg -version 能跑。
  • 并发下解码失败的,看信号量那条线。 _get_ffmpeg_max_concurrency 在模块导入时读一次 VIBEVOICE_FFMPEG_MAX_CONCURRENCY,取不到或不是正数就把 _FFMPEG_SEM 置为 None(不加限制)。这里有个两处对不上的地方值得留意:docs/vibevoice-vllm-asr.md 的环境变量表给这个变量标了一个默认值,而模块代码在变量缺省时的行为是不加限制——文档表格里的那个值出现在文档给出的容器启动命令里,不是模块代码在变量缺省时会取到的值。另外按代码语义,这个值在模块导入时就固定下来了,进程运行中再改环境变量不会重新读取。
  • .pt / .npy 读进来后结果不对的,问题在你存文件的时候。 这两个分支只做 squeeze().pt 分支)与 astype(np.float32),采样率信息压根不在文件里,代码也无从校验。

一句话收口:采样率和声道在 VibeVoice 里不是由某一个统一的校验器把关的,而是「你走哪条入口,它就替你做多少」。 排查时第一件事永远是确认入口分支,而不是先怀疑音频文件。


本文依据 github.com/microsoft/VibeVoice 仓库与 Hugging Face 模型卡于 2026-08-18 的公开内容整理, 事实来自仓库内的文档与源码。我们没有下载权重、没有跑过推理、也没有做过训练, 因此不涉及显存占用、推理速度、识别准确率与音质的任何描述,也不与其它模型做比较或排名。 该项目持续更新,文中涉及的模块路径、配置字段与接口写法随版本变动,请以仓库最新内容为准。

仓库 README 的风险与限制一节写明:该模型仅供研究与开发用途, 未经进一步测试与开发不建议用于商业或真实场景,并特别提示了合成语音被用于伪造与虚假信息的风险。 使用合成语音时应遵守所在司法辖区的法律法规,并在分享 AI 生成内容时主动披露。

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