转写结果里说话人标记不对:VibeVoice-ASR 的热词与上下文定制怎么传
先说现象
用 VibeVoice-ASR 转一段多人对话,拿到的结构化结果大致是三种不对劲的样子:
一是每一段的说话人显示成 N/A。demo/vibevoice_asr_gradio_demo.py 里渲染分段时写的是 seg.get('speaker_id', 'N/A'),demo/vibevoice_asr_inference_from_file.py 和 finetuning-asr/inference_lora.py 打印分段时也是同一个写法——所以你看到的 N/A 不是模型输出的内容,而是结构化结果里压根没有 speaker_id 这个键时的兜底占位。
二是段落条数比你听到的少,中间某几段直接消失了。
三是人名、产品名、行业术语被听成了同音的其它词,而你明明知道这段音频里会出现哪些专有名词。
这三种现象的排查路径是同一条:先确认热词/上下文有没有真的进到 prompt,再确认模型吐出来的 JSON 键名有没有被后处理认出来。下面按仓库里的代码一处处对。
第一步:确认你走的入口带不带 context_info
这是最容易踩的一脚,而且它不报错。
VibeVoice 仓库里,ASR 的上下文定制统一叫 context_info,入口在 vibevoice/processor/vibevoice_asr_processor.py 的 VibeVoiceASRProcessor.__call__。它的签名里有这个可选参数,docstring 写明是「Optional context information (e.g., hotwords, metadata) to help transcription」,__call__ 会把它原样透传给 _process_single_audio。
问题在于,仓库自带的几个入口并不都把这个参数暴露出来。可执行的判定动作是打开你实际在用的那个脚本,看两处:
demo/vibevoice_asr_gradio_demo.py:ASRModel.transcribe的签名里有context_info,调用 processor 时写的是context_info=context_info;界面上对应「Context Info (Optional)」那个gr.Textbox,占位提示里让你填 hotwords、speaker names、topics。这条路是通的。demo/vibevoice_asr_inference_from_file.py:它的argparse里没有--context_info这一项,调用 processor 时也只传了audio、sampling_rate、return_tensors、padding、add_generation_prompt。
也就是说,如果你是照着 docs/vibevoice-asr.md 里 Usage 2 那条命令用批量脚本起步的,热词根本没有传入口——无论你在哪儿写了词,它都进不了 prompt。这一条排查完,一多半的「热词没生效」就解释清楚了。
另一个判定动作:先看原始输出,再看结构化结果。Gradio demo 的 ASRModel.transcribe 返回的字典里,raw_text 是模型生成的原文,segments 是把同一段文本交给 post_process_transcription 之后的产物;界面上的 Raw Output 标签页显示的是前者。原始输出里如果连专有名词的影子都没有,问题在输入端;原始输出是对的但结构化列表里丢了字段,问题在后处理的键名匹配。
context_info 拼进 prompt 的确切形状
搞清楚它长什么样,很多疑问会自己散掉。_process_single_audio 里这一段是原文:
show_keys = ['Start time', 'End time', 'Speaker ID', 'Content']
if context_info and context_info.strip():
user_suffix = f"This is a {audio_duration:.2f} seconds audio, with extra info: {context_info.strip()}\n\nPlease transcribe it with these keys: " + ", ".join(show_keys)
else:
user_suffix = f"This is a {audio_duration:.2f} seconds audio, please transcribe it with these keys: " + ", ".join(show_keys)
三点要注意:
第一,context_info 是一段自由文本,直接嵌在 with extra info: 后面。它不是结构化的词表,代码里没有对它做切分、去重或加权。所以你写逗号分隔还是换行分隔,取决于你想让模型看到什么样的一段文字——finetuning-asr/README.md 里的推理示例给的是逗号形式 "Tea Brew, Aiden Host",Gradio 界面的说明写的是「One item per line or comma-separated」,两种写法在代码里走的是同一条路。
第二,strip() 之后为空的字符串等价于没传。Gradio demo 的调用处写得很明确:context_info=context_info if context_info and context_info.strip() else None。所以在输入框里敲了几个空格,和什么都没填是一回事。
第三,show_keys 这四个键是写死在代码里的,它们决定了模型被要求输出哪几个字段,Speaker ID 就在其中。加了 context_info 之后,这四个键仍然照原样跟在后面——热词不改变输出结构,只是在同一段 user 文本里多插一句前置信息。
按接口语义组合一个直调 processor 的例子:
inputs = processor(
audio=audio_path,
sampling_rate=None,
return_tensors="pt",
add_generation_prompt=True,
context_info="Tea Brew, Aiden Host",
)
以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
走 vLLM HTTP 时,热词是你自己拼的
如果你用的是 vllm_plugin 那条路线,链路完全不同:请求是标准的 chat completions,prompt 由客户端拼好发过去,VibeVoiceASRProcessor 不在你这一侧。
docs/vibevoice-vllm-asr.md 给的测试命令原样是:
# With hotwords for better recognition of specific terms
docker exec -it vibevoice-vllm python3 vllm_plugin/tests/test_api.py /app/audio.wav --hotwords "Microsoft,VibeVoice"
以上原样抄自仓库文档。vllm_plugin/tests/test_api.py 里对应的拼法与 processor 一致,同样是 with extra info: 加上同样那四个 show_keys,文件头的说明也写明「Hotwords are embedded in the prompt as “with extra info: {hotwords}”」。
但仓库里还有第三处拼法,值得单独指出来:vllm_plugin/scripts/gradio_asr_demo_api_video.py 的 transcribe_streaming 用的是 show_keys = ["Start", "End", "Speaker", "Content"],上下文则以 \n\nContext information (hotwords, speaker names, etc.):\n 追加在 prompt 末尾。两处的键名与措辞都不一样。把这两段代码摆在一起能看到的事实只有这些,仓库里我们没有找到解释这个差异的说明。
输出键名对不上,段落就会被丢掉
这是「说话人一列是 N/A」和「段落变少」的直接来源。post_process_transcription 先从文本里抠出 JSON(支持 ```json 代码块,也支持直接找第一个 [ 或 { 再做括号配对),解析成 list 之后按一张映射表改键:
| 模型输出的键 | 映射到的字段 |
|---|---|
Start time | start_time |
Start | start_time |
End time | end_time |
End | end_time |
Speaker ID | speaker_id |
Speaker | speaker_id |
Content | text |
这张表一共七条,落到四个字段上——同时兼容了前面提到的两套 show_keys。关键在于它后面那句 if cleaned_item: cleaned_result.append(cleaned_item):一个条目里如果没有任何一个键命中这张表,整条就被丢弃;如果只有 Content 命中而说话人那个键没命中,这条会保留下来,但 speaker_id 不存在,于是渲染时落到 'N/A'。
判定动作因此很直接:拿原始输出里的第一个条目,逐字比对它的键名是不是上表左列里的某一个。大小写、有没有空格、是不是被换成了中文键名,都会导致不命中——这张表是逐字匹配,代码里没有做归一化。
训练侧的 customized_context
如果你走 LoRA 微调这条路,热词在数据里是另一个名字。finetuning-asr/README.md 的数据格式一节写明,每个样本的 JSON 里可以带一个可选的 customized_context 数组,注释是「optional, domain-specific terms or context sentences」。finetuning-asr/lora_finetune.py 把它接到同一个参数上:
context_info = None
if self.use_customized_context and "customized_context" in data:
customized_context = data["customized_context"]
if customized_context:
context_info = "\n".join(customized_context)
数组元素以换行拼成一段文本,再交给 processor._process_single_audio(..., context_info=context_info)——和推理时走的是同一个函数。README 的参数表里 --use_customized_context 的默认值是 True,这是仓库当前文档里的默认值,随版本可能变动。
推理侧对应的开关是 finetuning-asr/inference_lora.py 的 --context_info,README 的示例原样是:
python inference_lora.py \
--base_model microsoft/VibeVoice-ASR \
--lora_path ./output \
--audio_file ./toy_dataset/0.mp3 \
--context_info "Tea Brew, Aiden Host"
以上原样抄自仓库文档。顺带一提,README 明确写了 toy_dataset/ 里的音频是为演示准备的合成音频,不是一份完整的微调数据集。
处置后怎么验证
三步,都不需要评价模型好不好:
- 打印 processor 拼好的文本,或者直接看 Gradio 的 Raw Output 上方的输入,确认
with extra info:这段字面量出现了,且后面跟的是你填的词。没出现就是入口没传进去,退回第一步。 - 对着原始输出数一下条目数,再看
post_process_transcription返回的列表长度。两者不等,说明有条目在键名匹配那一步被丢了。 - 挑一条有说话人的段落,确认结构化结果里
speaker_id这个键真的存在,而不是渲染成了N/A。
Windows 侧还有一处环境差异要单独确认,它同样不报错,只发警告。vibevoice/processor/vibevoice_asr_processor.py 顶部把 audio_utils 的导入包在 try/except ImportError 里,失败时置 HAS_FFMPEG_UTILS = False 并发一条警告「audio_utils not available, will fall back to soundfile for audio loading」;即使模块导入成功,load_audio_use_ffmpeg 读单个文件抛异常时也会再 warn 一次「ffmpeg loading failed, falling back to soundfile」并改用 soundfile。两条 soundfile 路径下多声道都用 mean(axis=1) 转单声道;重采样那一步则在 if/else 之外,只要 file_sr 不等于 target_sample_rate 就用 librosa.resample,两条路径都会走。
要注意的是 audio_utils.py 里 load_audio_use_ffmpeg 是用 subprocess.run 直接调 ffprobe 与 ffmpeg 命令的,所以这里依赖的是可执行文件在 PATH 里,而不只是某个 Python 包装好了。文档 Usage 1 那条 apt update && apt install ffmpeg -y 是 Debian/Ubuntu 系的装法(注释就一句 # for demo);Windows 上没有对应说明,你得自己确认这两个命令能在命令行里调起来。日志里出现上面任意一条警告,就说明当前走的是 soundfile 路径而不是 ffmpeg 路径,格式支持范围也随之变了——audio_utils.py 里那份 COMMON_AUDIO_EXTS 扩展名清单是给 ffmpeg 路径准备的。这一层跟热词无关,但很容易被算到热词头上。
什么情况说明不是这个原因
以下几种情况,改热词没有用:
- 日志里出现
Failed to parse JSON from transcription。这是post_process_transcription捕获json.JSONDecodeError后打的 warning,此时函数返回空列表。这是生成文本没能构成合法 JSON 的问题,跟context_info没有关系。 speaker_id有值,但归属错了——第二个人的话被标成了另一个编号。热词是一段拼进 user prompt 的自由文本,代码里没有任何环节把它绑定到具体的说话人编号上。我们在仓库里也没有找到「指定说话人数量」或「传入说话人名单」这类参数:docs/vibevoice-asr.md只写明模型联合完成 ASR、diarization 与时间戳,输出结构化的 who / when / what,没有提供额外的 diarization 控制项。往context_info里塞人名,最多是让模型在上下文里见过这些字符串,不等于建立了编号映射。- 两个入口的键名不一样。前面说过
vllm_plugin/scripts/gradio_asr_demo_api_video.py用的是Start/End/Speaker/Content这套短键名,而映射表两套都收。所以只是键名不同的话,不会导致段落丢失。 - 你自己改过
SYSTEM_PROMPT或自行拼了 prompt。vibevoice/processor/vibevoice_asr_processor.py里的SYSTEM_PROMPT是模块级常量,_process_single_audio通过apply_chat_template把它拼在最前面;一旦这部分被替换,输出格式的约定就不再由这套代码保证,后面键名匹配那一步的行为也无从谈起。
排查顺序就按上面的次序来:入口有没有传 → prompt 里字面量在不在 → 键名对不对 → 才轮到内容本身。前三步都是能在代码里逐字对上的事实,不用猜。
本文依据 github.com/microsoft/VibeVoice 仓库与 Hugging Face 模型卡于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有下载权重、没有跑过推理、也没有做过训练,
因此不涉及显存占用、推理速度、识别准确率与音质的任何描述,也不与其它模型做比较或排名。
该项目持续更新,文中涉及的模块路径、配置字段与接口写法随版本变动,请以仓库最新内容为准。
仓库 README 的风险与限制一节写明:该模型仅供研究与开发用途, 未经进一步测试与开发不建议用于商业或真实场景,并特别提示了合成语音被用于伪造与虚假信息的风险。 使用合成语音时应遵守所在司法辖区的法律法规,并在分享 AI 生成内容时主动披露。