arXiv'26 | VibeVoice-ASR-Streaming:LLM 如何实时转写谁说了什么

arXiv’26 | VibeVoice-ASR-Streaming:LLM 如何实时转写谁说了什么

原文:VibeVoice-ASR-Streaming Technical Report
代码:microsoft/VibeVoice(本文结合仓库 1541f59 阅读)


1. 这篇论文到底要解决什么:让“文字”和“谁在说”同时实时成立

会议助手、实时字幕、语音 agent 都有一个和离线转写不同的要求:音频还没结束时,系统就要把结果交出去,而且交出去的结果马上会被下游使用。

对单人语音来说,任务大致是“听到什么写什么”。多人对话则多了一层约束:

Speaker 0: 我们先看延迟。
Speaker 1: 好,我把结果发出来。

后面 Speaker 0 再次开口,系统必须继续使用 Speaker 0。如果等整段录音结束后才把标签改对,最终稿也许更漂亮,但实时 agent 早就可能根据错误的说话人做了错误决策。

论文把这个任务称为 streaming speaker-attributed ASR。ASR(Automatic Speech Recognition)负责把语音变成文字;speaker attribution 负责把文字归给会话中已经出现过的说话人。这里的“身份”不是张三、李四这样的实名身份,而是按首次出现顺序分配的 Speaker 0Speaker 1,并在后续保持一致。

这件事重要,是因为实时系统同时面对两种不同的信息需求:

  • 当前这句话可能还没有说完,需要一点未来音频才能判断边界;
  • 当前声音可能属于几分钟之前出现过的人,需要保留足够的历史来匹配编号。

未来信息决定“现在这句话该怎么收尾”,历史信息决定“现在这句话是谁说的”。 这两个问题不能用同一种缓存方式解决,也是全文理解 VibeVoice 设计的入口。

2. 为什么“每段独立识别”不够:两个缺口会在实时场景里同时出现

最直观的 streaming 实现是把音频切成 2 秒一段,各自调用一次 ASR。这样每段请求彼此独立,吞吐和接口都简单,却丢掉了两类必要信息。

第一类是语言上下文。0-2 秒说“那个方案”,2-4 秒才说“我觉得不太行”,第二段模型看不到前一句,很难处理跨块的指代、断句和半个词。第二类是说话人历史:第 0 段第一次出现的声音被临时叫作 Speaker 0,第 15 段同一个人回来,独立请求没有任何理由继续使用这个编号。

传统系统会在 ASR 外面补一个 diarization 模块。Diarization 是“什么时候换了说话人、这一段属于谁”的分段与聚类过程;工程上常见做法是抽取 speaker embedding,放进 speaker cache,再把新片段和缓存里的向量匹配。这个方案可以工作,但它带来一个事实:转写和说话人归属是两套状态,标签可能在未来被重新聚类、回写,实时输出就不再是最终意义上的提交。

因此问题不是“能不能把 ASR 切成块”,而是:怎样在每次只读一小段新音频的同时,让模型继续拥有会话历史,并且在当前边界附近多看一点证据?

3. 论文给出的总答案:一条交错序列,同时承载新音频和历史

VibeVoice-ASR-Streaming 的核心设计可以先用一句话概括:把新音频块和模型已经生成的带说话人文本交错写进同一个 LLM 上下文。

第 $k$ 个音频块记为 $X_k$,对应输出记为 $Y_k$,模型看到的是:

提示词, X0, Y0, X1, Y1, X2, Y2, ...

生成当前 $Y_k$ 时,条件写成:

\[p(Y_k \mid X_{<k}, \widetilde{X}_k, Y_{<k})\]

其中:

  • $X_{<k}$ 是此前已经听过的音频;
  • $Y_{<k}$ 是此前已经写出的文字和 Speaker 编号;
  • $\widetilde{X}_k$ 是当前负责的音频块,再加上一小段 look-ahead。

这条式子表达的是一个很具体的分工:历史用于延续会话状态,look-ahead 用于降低当前边界的不确定性。 生成的 $Y_k$ 也不是旁路结果,而是直接成为下一轮的上下文,所以 Speaker 0 的编号和此前的语言内容都还在。

下面的架构图中,蓝色连续 latent 和文本 token 交替进入同一个自回归模型;不是“一个声学模型先出结果,再让语言模型另行润色”。

交错的语音与文本上下文

按图从下往上读会比较清楚。最底部是原始波形,第一段括号标出本轮新增的 2.9 秒,右侧斜线区域是额外读取的 0.5 秒前瞻。波形先分别经过 Acoustic 和 Semantic 两个 encoder,两个小圆圈表示同一时刻的两路特征拼接成一个 latent frame。中间的 BOS、连续蓝色 latent、EOC 组成当前音频输入,EOC(end of chunk)告诉 LLM:这一段声音已经结束,可以开始生成文字。最上方的文本 token 是模型自回归生成的 Speaker 0: ...Speaker 1: ...;生成完一个块后,下一块音频接在同一条序列后面。图左侧的 optional context 是名字、术语、缩写等提示词,它在开头加入,之后和音频、文本历史一起保留。

图中最重要的不是两个 tokenizer,而是同一条横向序列的顺序:先放音频,再放它对应的文字;文字生成后不被丢弃,而是继续留在模型上下文里。因此下一轮既能看到新的声音,也能看到“之前谁说过什么”。

这个方案成立的前提,是说话人归属确实可以从上下文中预测,而不是必须依赖一个独立的声纹聚类器。后面的标签实验和模型规模实验会检验这个前提;先把窗口如何工作讲清楚。

4. 为什么要 look-ahead:当前块的答案,常常要靠边界右侧来决定

假设当前输出覆盖 0.0-2.93 秒。若模型只看到这段音频,2.93 秒处的“我觉得这个方案……”可能有三种后续:下一句是“可以”、是“不行”,或者说话人已经换了。没有边界右侧的证据,系统必须在最不确定的位置提前做决定。

VibeVoice 的做法是:在生成当前块的文字前,再读取未来 $L=4$ 个 latent frame。24 kHz 音频每帧约 133.3 ms,所以前瞻约为:

\[T_{\text{lookahead}}=4\times\frac{3200}{24000}\approx0.5\text{ s}\]

这半秒只用于决定上一块如何结束、以及边界附近的说话人归属,不会把未来半秒的文字提前输出。下一轮窗口会再次包含这段音频,那时它才成为下一块的正式内容。

发布了两种新增音频长度:15 帧约 2.0 秒,22 帧约 2.9 秒;两者都固定使用 4 帧前瞻。因此一次送进模型的窗口实际是“新增 chunk + 0.5 秒重叠”,而时间轴每轮只前进新增 chunk。

一次流式处理的状态流

这张图可以按四个框依次走一遍:

  1. 缓冲:浏览器持续送入 PCM,服务端先等到“新增 chunk + look-ahead”长度。22-frame 配置就是约 3.4 秒;推理后只从 buffer 删除前 2.9 秒,末尾 0.5 秒继续留着。
  2. 编码:窗口波形经过两路 tokenizer,每 133.3 ms 变成一帧 latent,再投影到 LLM 的输入空间。前瞻帧和当前 chunk 一起进入这次 forward。
  3. 生成:LLM 读取历史序列和当前窗口,逐 token 生成 Speaker k: ...,直到遇到 <|text_chunk_end|>。这个 token 才是本块结束信号,文本长度并不是预先写死的。
  4. 记账:当前窗口和生成文本都追加到会话状态,下一轮只把新窗口作为真正的数据发送。历史音频可以由 KV Cache 复用,历史文本仍作为条件保留。

所以图中的“窗口”与“时间推进”不是一回事:窗口有重叠,时间边界不重叠。这样模型能看见边界右侧,但不会把同一句话交付两次。

论文定义的稳态期望延迟是:

\[\ell_{\text{expected}}=\frac{C}{2}+T_{\text{lookahead}}\]

$C/2$ 表示一个词平均落在 chunk 中间,所以要等半个 chunk;前瞻再增加约 0.53 秒。22-frame 配置的报告值是 2.00 秒,15-frame 是 1.53 秒。

这里必须把首包和稳态分开。第一段没有任何已完成的 chunk 可以先输出,必须先等满一个 chunk 加前瞻:22-frame 约 3.47 秒,论文按帧取整报告为 3.5 秒;15-frame 约 2.53 秒,报告为 2.5 秒。2.00 秒是持续输入后的期望算法延迟,不是“用户一开口两秒后必然看到第一句话”。

前瞻不是越大越好。固定 22-frame、7B 模型测试 $L=0,2,4$ 时,每增加 2 帧要多付出 0.267 秒期望延迟;五套评测的平均 WER/CER 从 27.18 降到 25.91、再到 24.66,cpWER/cpCER 从 35.88 降到 34.09、再到 31.55。WER/CER 是不考虑说话人的词/字错误率,越低越好;cpWER/cpCER 则在此基础上还考察“话有没有挂到正确的说话人名下”,所以更能反映本文目标。

前瞻长度的消融结果

读这张表时先看表头:横向三组分别是 $L=0$、$L=2$、$L=4$,括号里的 1.47 s、1.73 s、2.00 s 是对应的期望延迟;每组里面左列是普通 WER,右列是带说话人归属的 cpWER。纵向前四行是四个会议集,MLC-Challenge 是九种语言的宏平均,最下面的 Average 是五项结果的平均。可以看到从左到右每个数据集的两个指标都下降,而不是只靠某一个 benchmark 拉低平均值;底部的 Δ vs. previous column 则直接告诉你每增加两帧带来多少改善。

最后两帧对 cp 指标的改善达到 2.54 个点,而普通识别只改善 1.24 个点。这个结果支持一个具体判断:边界右侧的信息对“谁在说”尤其重要。 它不等于证明 0.5 秒适用于所有数据集,只说明在这套训练和评测设定下,4 帧仍有收益。

5. 音频怎样进入 LLM:两种 tokenizer 提供互补证据

look-ahead 解决了“看多远”,但模型还得先把波形变成 LLM 能处理的表示。VibeVoice 冻结两个预训练 tokenizer 的 encoder:

  • Acoustic tokenizer 保留频谱、音色、韵律等声学细节,帮助区分声音和感知重叠;
  • Semantic tokenizer 提供与语音内容更对齐的确定性特征,帮助预测文字。

两路特征在同一时间网格上对齐、拼接,再投影到 Qwen2.5 的 embedding 空间。embedding 可以理解成 LLM 内部表示一个输入位置的向量。由于波形累计下采样 3200 倍,24 kHz 输入每 133.3 ms 产生一帧 latent(压缩后的连续语音表示),即每秒 7.5 帧。

所以“15 帧”“22 帧”“4 帧”不是抽象的模型 token 数,而是明确的时间预算:

配置 每轮新增音频 前瞻 输入窗口
15-frame 2.0 s 4 帧,约 0.53 s 约 2.53 s
22-frame 约 2.93 s 4 帧,约 0.53 s 约 3.47 s

仓库的 ChunkGeometry.from_pretrained() 会从 checkpoint 的 preprocessor_config.json 读取这些数字。这样做不是配置洁癖:训练分布已经绑定了窗口长度和边界语义,拿 15-frame 模型按 22-frame 的节奏运行,即使 tensor shape 不报错,也已经改变了模型学到的输入输出契约。

6. 说话人归属为什么能放进同一个生成任务

模型的输出不是一段没有标签的文字,而是每个 chunk 内的 speaker-labeled utterance,例如:

Speaker 0: Okay, let's start with the deployment plan.
Speaker 1: Sure. I think we should first verify the latency.

编号按首次出现顺序分配,之后重复使用。关键在于 Speaker 0: 也属于历史文本:当第 12 个音频块到来时,模型仍能读到第 0 块的声音、文字和编号。它可以综合声学线索、措辞、话题连续性和对话轮次,而不是只对当前 2-3 秒做一次孤立的声纹匹配。

这并不是说 LLM 能识别现实世界中的姓名,也不是说它永远不会把相似声音混淆。更准确的说法是:它把“会话内编号保持一致”建模成了同一条自回归序列里的下一个 token 预测,因而不再强制要求一个独立的 diarization 阶段。

论文还比较了 speaker label 放在片段文字前面(head)和末尾(tail)。如果标签完全依赖声纹,等整段说完再输出似乎应该更有利;但五套数据平均 cpWER/cpCER 几乎一样,31.55 对 31.56,tail 的普通 WER/CER 反而高 0.98 个点。

说话人标签位置的对比

表头的 Head 表示先输出 Speaker k: 再输出这段话,Tail 表示先输出文字、等片段结束后再补标签;最右侧的 $\Delta$ 是 Tail 减 Head,所以正数代表尾置更差。逐行看,AISHELL-4、AliMeeting、AMI-IHM 的 cpWER 尾置分别高 0.26、0.98、0.74,AMI-SDM 反而低 2.49,MLC-Challenge 高 0.57;这些方向并不完全一致,最终五集平均几乎抵消为 31.55 对 31.56。这个表支持的是“没有稳定的尾置收益”,不是“Head 在所有数据上都更好”。

这组实验只能支持一个有限结论:在本文的窗口和模型里,LLM 已经能在输出片段开头时利用当前声学证据加历史上下文完成归属,等待整段结束没有显示出额外收益。它不推翻传统 speaker embedding 方法,也不代表长时间多人重叠已经解决;论文明确指出,重叠语音最终要被串行化到一条文本流中,长重叠时性能会下降。

7. 模型凭什么知道一块文字该在哪里结束:训练里有明确的交接协议

如果只在推理时切音频,模型并不会自动学会“每个块输出到这里就停”。因此作者不只是改变推理脚本,而是改变训练样本的组织方式。

训练分三阶段:

  1. 离线 speaker-attributed 训练:先让模型学会完整录音上的“谁说了什么”。
  2. streaming pre-training:从同尺度离线 checkpoint 初始化,把样本组织成 X0,Y0,X1,Y1,...,每个音频块带固定前瞻;使用约 42 万小时中英文语音,让这种上下文格式成为默认工作模式。
  3. streaming fine-tuning:用约 1.3 万小时精选、合成和公开数据,收敛转写规范、Speaker 编号一致性和热词遵循等用户可见行为。

训练目标中的关键 token 是 <|text_chunk_end|>。每个 Yk 最后都监督它,包括静音、目标文本为空的 chunk。生成到这个 token,模型就向音频流发出“本块完成”的信号,系统才追加下一块音频。输出长度没有预先给定,何时停止本身就是模型要学习的预测。

训练样本也不是按字符长度硬切。作者先用 Qwen3-ForcedAligner-0.6B 得到词级时间,再按词结束时间把参考文本分配到 chunk,边界允许 0.1 秒容差。合成数据加入专业术语、自然口语读法与规范书写的差异、多人 overlap 和 room impulse response(模拟房间混响与麦克风响应),确保“块边界应该交付什么”有真实的监督来源。

8. 源码里的一个 turn:先追加音频,再生成文字,再把结束符写回状态

论文描述的是算法契约,源码展示了这个契约怎样执行。Hugging Face 参考路径中的 streaming_generate 维护 past_key_values。它是 Transformer 对已经处理过的 token 的 key/value 中间结果缓存;有了它,下一块可以从历史状态继续,而不是从提示词重算。

# vibevoice/modular/modeling_vibevoice_asr.py
# class VibeVoiceASRForConditionalGeneration
# def streaming_generate(...),约第 529 行
for chunk_idx, feat_chunk in enumerate(feature_chunks):
    audio_embeds = torch.cat([sp_start_embed, feat_chunk, sp_end_embed], dim=1)
    outputs = self(inputs_embeds=audio_embeds,
                   past_key_values=past_key_values, use_cache=True,
                   return_dict=True)
    past_key_values = outputs.past_key_values
    next_logits = outputs.logits[:, -1:, :]

    chunk_tokens = []
    for _ in range(max_new_tokens_per_chunk):
        next_token_id = torch.argmax(next_logits[:, -1, :], dim=-1).item()
        if next_token_id == text_chunk_end_id or next_token_id == eos_id:
            break
        chunk_tokens.append(next_token_id)
        next_embed = embed_tokens(torch.tensor([[next_token_id]], device=device))
        outputs = self(inputs_embeds=next_embed,
                       past_key_values=past_key_values, use_cache=True,
                       return_dict=True)
        past_key_values = outputs.past_key_values
        next_logits = outputs.logits

    tce_embed = embed_tokens(torch.tensor([[text_chunk_end_id]], device=device))
    outputs = self(inputs_embeds=tce_embed,
                   past_key_values=past_key_values, use_cache=True,
                   return_dict=True)
    past_key_values = outputs.past_key_values
    yield chunk_idx, total_chunks, tokenizer.decode(chunk_tokens, skip_special_tokens=True)

输入 feat_chunk 是一个“新增 chunk + look-ahead”的 audio latent,past_key_values 里已有提示词和此前所有轮次。输出是 generator 每轮 yield 的一段 chunk 文本,不是整篇最终稿。最后那次 forward 看起来像多余的 bookkeeping,实际上很关键:<|text_chunk_end|> 必须进入下一轮上下文,否则序列会和训练格式错位。

9. vLLM 服务怎样避免“保留历史”变成重复计算

参考实现手里有显式 KV Cache;vLLM 的服务接口则通常是一轮一个 request,没有直接暴露“把我的 KV Cache 拿回来继续 append”的 API。仓库因此采用 automatic prefix caching:每轮重建到目前为止的 token prompt,依靠完全相同的前缀命中缓存。

# vllm_plugin/asr_streaming_server.py
# class StreamingSession
# async def push(self, window),约第 275 行
self._windows.append(window)
tokens = self._tokens + [self.audio_token_id]
k = len(self._windows) - 1

# 已处理窗口不重复传波形;uuid 必须保持不变。
uuids = [f"{self.session_id}-{i}" for i in range(k + 1)]
audio = [None] * k + [window]
out = await self.engine.generate(tokens, audio, uuids,
                                 self.sampling_params, req_id)

gen = list(out.outputs[0].token_ids)
while gen and gen[-1] == self.text_chunk_end_id:
    gen.pop()

# stop token 不回显,但要手动补到下一轮 prompt。
self._tokens = tokens + gen + [self.text_chunk_end_id]
return _strip_specials(out.outputs[0].text)

这个函数的输入 window 是当前新到的一段 PCM 音频,返回值是当前 chunk 的可显示文本;self._tokensself._windows 则留给下一轮。实现里有三个容易被忽略的约束:

  1. audio = [None] * k + [window] 让旧窗口不必随每轮请求重复上传;UUID 仍要字节级稳定,vLLM 才能把它们识别成相同的多模态前缀。
  2. 文本历史仍然完整保留。省掉的是重新计算,不是语义信息;Speaker 编号依然依赖这份历史。
  3. 如果多模态处理缓存被驱逐,代码会降级为重发所有窗口。这比静默丢掉历史可靠,但意味着部署时要给 mm_processor_cache_gb 留足空间。

WebSocket 入口和这个状态模型是一致的:缓冲区达到 window_samples 后送出窗口,然后执行 buf = buf[chunk_samples:],只消费新增 chunk,保留 look-ahead 重叠。尾部不足一个完整窗口时补零,避免最后一次编码出现训练时没见过的“短窗口但没有前瞻”。

10. 实验结果说明了什么:收益存在,但流式化确实有代价

论文在英语上用 WER、中文/日文/韩文上用 CER,都是“把预测文本改成参考文本需要多少编辑”的错误率,越低越好。7B、22-frame 配置在四个会议集和 MLC-Challenge 的五集平均普通识别误差为 24.66;论文对比的 Gemini 3.5 Transcribe Live 为 25.23。speaker-attributed 评测中,VibeVoice 在 13 个可比较设置里有 12 个达到最好或并列最好。

下面这张是完整的说话人归属结果表。Google STT 的 D0 使用首次输出时的标签,Dfin 使用整段录音完成后的修订标签;两列文字相同,区别只在说话人归属。这个对照很能说明本文的任务边界:论文评估的是标签在实时交付时能否成立,而不是事后是否还能继续改进。

完整的实时说话人结果

这张表需要先看三组系统列,再看指标含义。Azure CT 只有一组 WER/CER 和 cpWER/cpCER;Google STT 的文本指标在中间,右侧额外给出 D0(首次发出时的 speaker label)和 Dfin(整段处理结束后的最终 label)对应的 cp 指标;VibeVoice 在最右侧同时报告普通识别和说话人归属。第一行的 Avg. latency 不是某个数据集分数,而是各系统标签稳定所需的平均延迟。再往下,MLC-Challenge 的 AVERAGE 是九种语言的平均,下面四行是会议数据集。比如在 AISHELL-4 上,VibeVoice 的普通 CER 是 22.76,cpCER 是 28.70;两者相差 5.94 个点,正好体现了“字听错”和“字听对但归错人”是两种不同错误。

这也解释了为什么不能只盯着最右侧粗体:Google 的 D0Dfin 文本相同,标签却可能在整段录音期间被重排。VibeVoice 的优势是直接给出一个较早的固定标签,代价则是它接受了流式约束,后面不会依靠完整录音再回头重算。

延迟数字需要按口径解读。VibeVoice 的 2.00 秒是由 chunk 和 look-ahead 推出的期望算法延迟;Azure CT 的 8.21 秒、Google STT 的 9.12 秒是作者在云服务上测出的 wall-clock 平均值,不能直接当成完全同条件的端到端对决。论文还观察到 Google 的文字约 1.5 秒稳定,但 speaker label 后面仍会修改 16.5-31.3 秒,这说明“文字先稳定”和“说话人标签稳定”本来就是两件事。

更值得看的,是 streaming 和非 streaming 的同源 checkpoint 对比:流式化后普通 WER/CER 增加 0.75-3.53 个点,cpWER/cpCER 增加 5.13-6.67 个点。后者下降更多,和前面“有限未来上下文对说话人归属更敏感”的推断一致。

流式化带来的准确率代价

这张表是同一个 7B 模型的配对比较:左边是初始化来源的 non-streaming checkpoint,中间是加入 22-frame streaming 约束后的结果,最右侧 $\Delta$ 是“流式减离线”。因此所有正数都是流式化带来的误差增加。逐行看,普通 WER/CER 增加 0.75-3.53 个点,而 cpWER/cpCER 增加 5.13-6.67 个点;后者在每个数据集都更大,说明额外损失主要不只是词识别变差,还来自说话人归属在有限上下文下更难。

模型规模和 chunk 长度也主要影响说话人归属:22-frame 比 15-frame 在 7B 五集平均改善 1.46 个 WER/CER 点、4.06 个 cpWER/cpCER 点;1.5B 升到 7B 时,22-frame 的 cp 指标改善 12.76 点,而普通指标改善 4.69 点。这个现象更像是更长窗口和更大模型提供了更丰富的 speaker evidence,而不是单纯提升了词汇建模能力。

服务成本方面,论文只报告了一个明确配置:7B、15-frame、batch size 1、A100 80GB PCIe、bfloat16 和 vLLM。在 30-480 秒音频上,每个 2 秒 chunk 的 decode 为 146-208 ms,RTF(real-time factor,处理耗时除以音频时长,低于 1 才能跟上实时输入)为 0.073-0.104。这里测的是模型 decode,浏览器采集、网络、排队和音频解码不在这个数字里。

11. 适用范围和我的判断:它解决了状态一致性,但没有消灭所有困难

这篇工作最清楚的贡献,不是宣称 streaming ASR 已经没有问题,而是把一个难以拆开的状态问题拆成了两个可验证的机制:

  • look-ahead 给当前边界有限的未来证据;
  • 交错的历史音频和文本给说话人编号与语义连续性。

再用 <|text_chunk_end|> 把生成和音频接收连接起来,用 KV Cache 或 prefix cache 控制重复计算,算法契约才真正落到了服务实现上。

相应的限制也很明确:训练语言受 Qwen3-ForcedAligner-0.6B 的词级对齐覆盖限制,目前是十种语言;长时间多人重叠会因为单一文本流的串行化而下降;发布 checkpoint 支持的录音长度约为 8 分钟,主要受未压缩上下文的计算成本限制;首包延迟仍是完整 chunk 加前瞻,而不是报告里的稳态数字。

因此比较稳妥的结论是:VibeVoice-ASR-Streaming 给出了一条把“实时转写”和“会话内说话人一致性”放进同一个 LLM 状态里的可运行路径,实验也显示它在本文设置下有竞争力;但它不是把 diarization 的所有困难凭空消除了,而是把这些困难转成了上下文长度、前瞻预算、训练对齐和服务缓存的工程取舍。


顺带一提,ASR streaming 和《动手学 AutoML:从 NAS 到大语言模型优化实战》并不是同一个方向;但两者都说明,模型效果不能脱离输入格式、上下文长度和部署资源单独讨论。书里把 LLM 架构自动化、压缩和后训练剪枝整理成了可复现的实现路径,感兴趣可以看下。

动手学AutoML书籍封面

Flag Counter