LoRA 微调数据准备踩坑:VibeVoice ASR 的 README 对格式的要求逐条看
准备 LoRA 微调数据这件事,最麻烦的不是格式复杂,而是格式不对的时候通常不会报错。VibeVoice 仓库 finetuning-asr/lora_finetune.py 里的数据集加载逻辑对不合格样本一律走 logger.warning 加 continue,你看到的只是一行 Loaded N samples from ...,那个 N 比你以为的少了多少,得自己数。
这篇按脚本读取数据的实际顺序走一遍:字段在哪一行被读、哪些是硬要求、哪些有默认值兜底,以及几类常见的不匹配分别有什么可执行的判定动作。事实全部来自 finetuning-asr/README.md、finetuning-asr/lora_finetune.py 与 vibevoice/processor/vibevoice_asr_processor.py。该项目持续更新,下面涉及的文件路径、参数名与默认值随版本变动,请以仓库最新内容为准。
数据长什么样:README 与代码各说了一半
finetuning-asr/README.md 的 Data Format 一节给的是目录形态:音频与同名 JSON 放在同一个目录里,0.mp3 配 0.json,1.mp3 配 1.json。JSON 里写明的字段是 audio_duration、audio_path、segments(每段含 speaker、text、start、end),外加一个标注为 optional 的 customized_context,README 对它的说明是 domain-specific terms or context sentences。
但代码里真正起作用的不是「同名」这件事。VibeVoiceASRDataset._load_samples() 做的是 sorted(self.data_dir.glob("*.json")),然后从 JSON 内部取 data.get("audio_path"),再用 self.data_dir / audio_filename 拼出音频路径。也就是说:扫描的入口是 JSON 文件,音频靠 JSON 里的 audio_path 字段指过去。外面那层 sorted() 也别忽略:它把样本的读入顺序固定成了文件名的排序结果,所以你自己攒数据时文件怎么命名,直接决定了样本以什么顺序进入训练。同名只是仓库自带 toy_dataset/ 的组织习惯,不是代码的约束——反过来说,你的 7.json 里 audio_path 写成 interview-a.mp3,按这段代码同样能定位到音频,只要文件在同一个目录里。
顺带说明 toy_dataset/ 的性质:README 明确标注它是 synthetic audio generated by VibeVoice TTS,仅用于演示,不是完整的微调数据集,并建议用自己的数据时准备真实录音与准确转写,按数据规模和领域调整超参。关于 TTS 代码在这个仓库里的当前状态,文末另有一段说明。
症状一:日志里的样本数比文件数少
现象:训练启动时打印 Loaded 12 samples from ./my_data,但目录里明明有 20 个 JSON。全程没有 Traceback。
怎么确认:把日志级别开着看 warning。_load_samples() 里只有三种情况会跳过样本,每种都对应一句固定文案:
No audio_path specified in <json_path>——JSON 里没有audio_path或值为空。Audio file not found: <audio_path>——拼出来的路径不存在。Error loading <json_path>: <e>——json.load或整个读取块抛了任何异常,被except Exception兜住了。这一条会吃掉 JSON 语法错误、编码问题等所有情况。
另外还有一条走 logger.info 而不是 warning 的:Skipping <stem>: duration ...s > max ...s,来自 --max_audio_length 过滤。判定动作很直接:数一下 warning 行数,加上 info 里的 Skipping 行数,与缺口对不对得上。
处置:前两条按文案改 JSON 或补文件即可。第三条要注意 except Exception 的覆盖面——它把异常内容拼进了 warning 文案,所以真正的原因就在那行日志的冒号后面,不要只看前半句。
验证:改完重跑,看 Loaded N samples 这一行的 N 是否等于你的 JSON 数量。脚本在 train() 里对 len(train_dataset) == 0 单独判了一次,会打 No training samples found! 然后直接 return,不会进入训练。
什么情况说明不是这个原因:如果 N 与文件数一致,问题就不在加载阶段。_load_samples() 只校验 audio_path 与文件存在性,它完全没有碰 segments——segments 相关的问题要到下一节才会暴露。
症状二:加载数正常,但训练开始后抛 KeyError
现象:样本数对得上,Starting training... 也打出来了,然后在取第一批数据时抛 KeyError。
怎么确认:看 KeyError 的键名,对照 __getitem__ 与 _format_transcription 里的取值方式。这两个函数对字段的态度并不一致,值得逐个对:
| 字段 | 代码里的取法 | 缺了会怎样 |
|---|---|---|
segments | data["segments"] | 直接 KeyError |
start / end | round(seg['start'], 2) / round(seg['end'], 2) | 直接 KeyError |
speaker | if "speaker" in seg 判断后才取 | 跳过,输出里不带 Speaker 键 |
text | seg.get("text", "") | 当空字符串处理 |
audio_duration | data.get("audio_duration", len(speech) / 24000) | 用音频采样点数兜底 |
customized_context | if ... "customized_context" in data | 不带上下文 |
结论是:真正的必填只有 audio_path、segments,以及每个 segment 里的 start 与 end。speaker 和 text 缺了不会崩,但会安静地改变训练目标——text 缺失时那一段的 Content 就是空串,样本还在,标签变了。
处置:在自己的转写导出脚本里,把 start/end 当作硬约束校验,把 text 缺失当作要拒绝的样本而不是可容忍的默认值。
验证:这些字段的最终去向是 _format_transcription(),它把每段拼成 {"Start": ..., "End": ..., "Speaker": ..., "Content": ...},再用 json.dumps(..., ensure_ascii=False, separators=(',', ':')) 序列化成紧凑字符串当训练目标。注意输入 JSON 用小写键,训练目标用首字母大写键,两套命名不是一回事,别在自己的数据里提前写成大写。
什么情况说明不是这个原因:如果异常不是 KeyError 而出在 processor 调用上(_process_single_audio),那是音频侧的问题,见下一节。
症状三:设了 --max_audio_length 之后样本全被跳过
现象:加上时长过滤后,日志里刷满 Skipping,最后落到 No training samples found!。
怎么确认:看 Skipping 那行打出来的 duration 值。_load_samples() 的写法是 duration = data.get("audio_duration", float("inf")),也就是说JSON 里没有 audio_duration 时默认取无穷大,无穷大必然大于任何阈值,样本必被跳过。而在 __getitem__ 里同一个字段的兜底是 len(speech) / 24000——同一个字段,两处默认值不同,一处保守到会丢样本,一处会算出真实时长。
处置:要用 --max_audio_length 就必须保证每个 JSON 都写了 audio_duration;不写这个字段的话,就别开这个过滤参数。--max_audio_length 在仓库当前文档里的默认值是 None(不过滤),--use_customized_context 的默认值是 True,这两个是仓库当前代码与文档里的默认值,随版本可能变动。
验证:先不带 --max_audio_length 跑一次加载,确认样本数正常;再加上参数,看被跳过的是不是确实是超长的那几条。
什么情况说明不是这个原因:如果你压根没传 --max_audio_length,日志里不会出现 Skipping 行,那样本流失就是症状一里的三种情况之一。
症状四:customized_context 看起来没生效
现象:JSON 里写了 customized_context,但不确定它有没有真的进到 prompt 里。
怎么确认:跟着这条链路走。__getitem__ 在 use_customized_context 为真且键存在、值非空时,用 "\n".join(customized_context) 拼成 context_info,传给 processor._process_single_audio(...)。在 processor 里,只有 context_info and context_info.strip() 为真时才会走带上下文的分支,把它拼进 user 段落的 with extra info: 后面。所以空列表、空字符串都会被过滤掉,且没有任何日志提示。
这里有个容易混的地方:训练侧把列表用换行连接,而 README 的推理示例 inference_lora.py 传的是 --context_info "Tea Brew, Aiden Host",是逗号分隔的一整串。两处只是文本形态不同,最后都变成同一个 context_info 字符串塞进同一个模板,仓库里没有对这两种写法的差异做进一步说明。
处置:想让某条样本不带上下文,删掉这个键或把值留空即可,不必改命令行;想全局关掉,就把 use_customized_context 这个参数设成假值——它在 DataArguments 里是一个默认为 True 的布尔字段,具体该在命令行上写成什么形式由 HfArgumentParser 对布尔字段的处理决定,finetuning-asr/README.md 只在参数表里列了这个参数与它的默认值,没有给出关闭时的写法示例。
验证:这条链路可以脱离训练单独看——processor 拼 prompt 的分支就在 _process_single_audio 里,判断条件和拼接文案都是明文。
什么情况说明不是这个原因:customized_context 不影响样本是否被加载,也不影响标签内容,它只进 prompt。样本数不对、标签不对都跟它无关。
音频侧:格式与依赖
音频的读取不在训练脚本里,而在 VibeVoiceASRProcessor._process_single_audio()。传进去的是路径字符串时,它优先用 vibevoice/processor/audio_utils.py 的 load_audio_use_ffmpeg();这个函数是 subprocess 调 ffprobe 取原始采样率、再调外部程序解码,依赖机器上装了 ffmpeg 工具链。调用失败时会被 try/except 接住,warnings.warn 一句 ffmpeg loading failed 之后回退到 soundfile,多声道在这个分支里按 mean(axis=1) 合成单声道。采样率与 processor 的 target_sample_rate 不一致时用 librosa.resample 重采样,target_sample_rate 在构造函数里的默认值是 24000,from_pretrained 会从配置里读、读不到再退回这个值。
Windows 侧要多留一步:ffprobe/ffmpeg 需要自己装并加进 PATH,否则每条样本都会先失败一次再回退。另外仓库 pyproject.toml 的依赖清单里列了 librosa,没有单列 soundfile——回退分支能不能用取决于你的环境里是否另外装了它,仓库没有对此作说明。finetuning-asr/README.md 的 Requirements 只写了两条:
# Install vibevoice first
pip install -e .
pip install peft
以上原样抄自 finetuning-asr/README.md。至于 CUDA 环境,docs/vibevoice-asr.md 的 Installation 一节给的是 NVIDIA PyTorch 容器的路子,并注明容器里若不含 flash attention 需自行安装;训练与推理两个脚本里 attn_implementation="flash_attention_2" 是写死的,没有留开关。Linux/macOS 与 Windows 在这一层的差别,仓库里没有找到专门说明。
最后一件事:长音频的分支
_process_single_audio 里有一行值得知道:use_streaming 传进来为真时,若音频时长小于 60 秒会被自动关掉(if use_streaming and audio_duration < 60.0)。训练侧的 __getitem__ 是固定传 use_streaming=True 的,所以短样本走的实际是非 streaming 分支。这个 60 秒是代码里的字面阈值,随版本可能变动。同一处的 token 长度计算目前统一用 math.ceil(len(audio_array) / self.speech_tok_compress_ratio),streaming 用 floor 的那一版在源码里是注释掉的状态——注释还在,逻辑已经不走了,看代码时别被上面的说明文字带偏。
还有一处 padding 方向的差异,代码注释里写得很清楚:VibeVoiceASRDataCollator 训练时用右 padding,而 processor 在推理/生成路径上用的是左 padding。collator 里 labels 的填充值是 -100,input_ids 的填充值取自 processor.pad_id。准备数据时不需要自己 pad,但如果你打算改 collator,这个方向差别得记住。
本文依据 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 生成内容时主动披露。