VibeVoice 流式 demo 拆解:vibevoice_realtime_demo.py 的调用链

2026-08-18

你手上有个会不断吐字的模型,想让它一边吐一边出声,而不是等整段文字生成完再合成。这类需求真正麻烦的地方不在模型,在链路:谁负责把文本喂进去、生成出来的音频块从哪个口子出来、传到浏览器之后靠什么把它接住播出去。VibeVoice 仓库里的 demo/vibevoice_realtime_demo.py 就是一条这样的完整链路,从 WebSocket 一路接到浏览器的音频回调。

不过打开这个文件你大概会愣一下——它只有十几行。真正的东西在别处,这篇就把它拆开逐段读。

先把状态交代清楚,免得读到一半走错路。仓库 README 记载,2025-09-05 微软因发现有与既定意图不符的使用方式,基于负责任 AI 原则从该仓库移除了 VibeVoice-TTS 代码;现在仓库里的 vibevoice/modular/modeling_vibevoice.py 首行注释写明它是从社区 fork github.com/vibevoice-community/VibeVoice 复制回来的,并且没有出现在 vibevoice/modular/__init__.py__all__ 里。而本文这条链路用到的 VibeVoiceStreamingForConditionalGenerationInference 恰好在 __all__ 导出的六个符号之内——Streaming 这一支和被移除的那一支不是同一套代码,读的时候别串。

前置条件

pyproject.tomlrequires-python 写的是 >=3.10。文档 docs/vibevoice-realtime-0.5b.md 给的安装方式是克隆仓库后执行 pip install -e .[streamingtts]。这个 extra 值得留意:[project.optional-dependencies] 下的 streamingtts 只有一项,它把基础依赖里 transformers 的版本区间钉成了一个确定版本。也就是说,装 .[streamingtts] 和只装基础依赖,拿到的 transformers 未必是同一个——具体钉在哪一版以仓库 pyproject.toml 为准。

设备侧,vibevoice_realtime_demo.py--device 参数 choices 只接受四个取值:cpucudampxmps。其中 mpxdemo/web/app.py 里会被当成 mps 处理并打印一行提示;mps 若不可用则回落到 cpu。文档还提到可以用 bash demo/download_experimental_voices.sh 下载额外的多语种音色,但同一份文档明确把这些多语种能力称作探索性质、未经充分测试。Windows 上没有现成的 bash,要跑这个脚本得走 Git Bash 或 WSL——这是通用做法,不是该项目官方说明。

音色文件本身是前置条件的一部分:_load_voice_presets 会去 demo/voices/streaming_model 目录下 rglob("*.pt"),目录不存在或一个 .pt 都没扫到,直接抛 RuntimeError

第一段:初始化

入口脚本干的事非常有限——argparse 解析参数,把 --model_path--device 分别写进环境变量 MODEL_PATHMODEL_DEVICE,然后 uvicorn.run("web.app:app", host="0.0.0.0", port=args.port, reload=args.reload)。注意 host 写死成 0.0.0.0,也就是监听所有网卡。

真正的初始化落在 demo/web/app.py 的启动钩子里:读环境变量,构造 StreamingTTSService,调 service.load()load() 内部按顺序做这么几件事:

  1. VibeVoiceStreamingProcessor.from_pretrained(self.model_path) 拿到 processor
  2. 按设备分支决定 load_dtypedevice_mapattn_impl_primary 三个值,cuda 分支走 flash_attention_2mpscpu 分支走 sdpa
  3. VibeVoiceStreamingForConditionalGenerationInference.from_pretrained(...) 加载模型,随后 self.model.eval()
  4. noise_scheduler.from_config(..., algorithm_type="sde-dpmsolver++", beta_schedule="squaredcos_cap_v2") 替换掉调度器,再调 set_ddpm_inference_steps(num_steps=self.inference_steps)
  5. 扫音色目录,按 VOICE_PRESET 环境变量决定默认音色,_determine_voice_key 里写死的兜底 key 是 "en-Carter_man",它不在就取排序后的第一个。

第 3 步有个容易被略过的分支:from_pretrained 抛异常时,如果原本用的是 flash_attention_2,代码会 catch 住改用 sdpa 重试,并打印一句提示,说明只有 flash_attention_2 经过完整测试。这是代码里原样写着的警告,遇到这条日志说明你已经掉进了回退路径。

第 5 步的音色加载也不是读音频。_ensure_voice_cachedtorch.load(preset_path, map_location=..., weights_only=True),外面套着 torch.serialization.safe_globals([BaseModelOutputWithPast, DynamicCache])。配合 vibevoice/processor/vibevoice_streaming_processor.pyprocess_input_with_cached_prompt 的参数说明——cached_prompt 那一项写的是它装着 voice prompt 的 kv cache——可以确认这些 .pt 存的是预先算好的缓存,不是波形。

顺带一提 StreamingTTSService.__init__ 里那行注释:把 model_path 保持成字符串而不是 Path,理由写在注释里——Path() 在 Windows 上会把 / 转成 \,而 Hugging Face 的仓库 ID 是用 / 分隔的。Windows 用户如果自己改这段代码,这个坑就在那儿摆着。

第二段:文本怎么送进去

服务端只暴露了一个 WebSocket 端点 /stream,参数全部走 query string:textcfgstepsvoicecfg 解析失败或小于等于 0 时回落到 1.5steps 解析失败则置 None、由服务侧的默认值接管(构造函数里 inference_steps 写的是 5)。这两个是仓库当前代码里的默认值,随版本可能变动。

query string 解析完,处理函数做的第一件正事是抢 app.state.websocket_lock 这把 asyncio.Lock。锁已被占用时,服务端发一条 eventbackend_busy 的 JSON 日志,然后 await ws.close(code=1013, reason="Service busy")。这条决定了这份 demo 的并发模型:同一时刻只服务一个连接。

拿到锁后进 service.stream()。文本先被 text.replace("’", "'") 做了一次引号规整,接着 _prepare_inputs 调:

processed = self.processor.process_input_with_cached_prompt(
    text=text.strip(),
    cached_prompt=prefilled_outputs,
    padding=True,
    return_tensors="pt",
    return_attention_mask=True,
)

以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。返回的每个值带 to 方法的都会被搬到目标设备上。

然后建 AudioStreamer(batch_size=1, stop_signal=None, timeout=None),另起一个 daemon=Truethreading.Thread 去跑 self.model.generate(...),传进去的关键参数包括 max_new_tokens=Nonecfg_scaletokenizergeneration_configaudio_streamerstop_check_fn=stop_event.is_setrefresh_negative,以及 all_prefilled_outputs=copy.deepcopy(prefilled_outputs)。留意最后这个 deepcopy_voice_cache 里的 prefilled prompt 是跨请求复用的同一个对象,而传进 generate 的是它的深拷贝,这两处放在一起看就是代码写明的事实,仓库里没有对这么写的理由再作说明。stop_check_fn 则是从外面掐断生成线程的入口:客户端断线、出异常、正常收尾,finally 里都会 stop_signal.set()audio_streamer.end(),然后 thread.join()

第三段:音频怎么出来

AudioStreamer 定义在 vibevoice/modular/streamer.py,继承 transformers.generation.BaseStreamer。它的结构比想象中朴素:batch 里每个样本一个 Queueput(audio_chunks, sample_indices) 把 chunk detach().cpu() 之后按索引塞进对应队列,end() 往队列里塞 stop_signal 并翻 finished_flags。消费侧用 get_stream(0) 拿到 AudioSampleIterator,它的 __next__ 从队列 get,取到的值等于 stop_signalraise StopIteration()

这是个阻塞队列,所以 WebSocket 处理函数里这样取:chunk = await asyncio.to_thread(next, iterator, sentinel),用哨兵对象区分「取到数据」和「迭代结束」,同时不把事件循环卡住。同一个文件里还有 AsyncAudioStreamer,把队列换成 asyncio.Queue、用 loop.call_soon_threadsafe 投递,get_stream 是个异步生成器;这份 demo 没有用它,走的是同步版加 to_thread 的路子。

chunk 出队后在 stream() 里过一遍整形:张量转 float32 的 numpy、多维 reshape(-1)、峰值超过 1.0 就整体除以峰值,然后通过 log_callback 发一条 model_progress,带 generated_secchunk_sec 两个字段(都是采样数除以采样率算出来的)。发到网络之前再经 chunk_to_pcm16np.clip[-1.0, 1.0],乘 32767.0int16tobytes()ws.send_bytes

浏览器侧在 demo/web/index.htmlsocket.binaryType = 'arraybuffer'onmessage 里字符串走日志分支、ArrayBuffer 走音频分支,用 view.getInt16(i * 2, true) / 32768 逐样本还原成 Float32Array——注意第二个参数 true,是小端序,和服务端 tobytes() 的字节序对应上了。还原出来的数据 appendAudio 拼进一个不断增长的 Float32Array

播放靠的是 audioCtx.createScriptProcessor(BUFFER_SIZE, 0, 1)onaudioprocess 回调:每次回调 pullAudio(output.length) 从缓冲里取够一帧写进 outputBuffer。开播前有个预缓冲门槛,攒够 PREBUFFER_SEC 对应的样本数(或者 socket 已关)才翻 hasStartedPlayback,在那之前回调里 output.fill(0) 直接返回。收尾条件是 socket 已关、缓冲空、且当前帧全零,连续满足若干帧就调 stop()

这段播放逻辑的节奏由页面顶部几个常量定死:SAMPLE_RATE 决定 AudioContext 的采样率,BUFFER_SIZEcreateScriptProcessor 的帧长,PREBUFFER_SEC 乘上采样率算出 minBufferSamples 这个起播门槛。它们是仓库当前代码里的取值,随版本可能变动。要注意的是 SAMPLE_RATEdemo/web/index.htmldemo/web/app.py 顶部各写了一份,两处是同一个数——你要改采样率,得两边一起改。

边界

  • 「流式文本输入」当前指的不是逐 token 喂docs/vibevoice-realtime-0.5b.md 的 TODO 列表里,「实现流式文本输入功能,在音频仍在生成时喂入新 token」这一项是未勾选状态;而这份 demo 的 WebSocket 确实是把整段 text 一次性放在 query string 里带过来的。这两处放在一起看,结论就摆在那里,本文不再往外推。
  • 单说话人、面向英文。同一份文档写明这个实时变体只支持单说话人,多说话人请用其它 VibeVoice 模型;文本语言方面文档写的是面向英文,其它语言可能产生不可预期的输出。多语种音色被明确标为探索性质、未经充分测试。
  • processor 不接受常规调用VibeVoiceStreamingProcessor.__call__ 直接抛异常,提示改用 process_input_with_cached_prompt,而后者的 docstring 写明当前只支持单个样本。
  • 单并发。前面那把 asyncio.Lock 决定了第二个连接会被 1013 关掉,不排队。
  • 音色定制不在仓库里。文档说明为缓解 deepfake 风险、并让首个语音块保持低延迟,voice prompt 以嵌入形式提供,需要定制的用户须联系其团队。
  • 监听地址。入口脚本把 host 写死为 0.0.0.0,暴露面自己评估。
  • 更细的取值范围与不支持项,仓库里没有超出上述文档与代码的进一步说明。

怎么确认配通了

不必先听到声音。最省事的一步是打 GET /config,它返回 {"voices": [...], "default_voice": ...},能确认音色目录被扫到、默认 key 落在哪个上。

其次看启动日志。load() 一路上打了这么几条:[startup] Loading processor from ...Using device: ..., torch_dtype: ..., attn_implementation: ...[startup] Found N voice presets[startup] Loading voice preset ... from ...,最后是 [startup] Model ready.。第二条尤其要看一眼——它把实际生效的设备、精度、attention 实现三个值一起打出来了,和你以为的不一致就在这儿露馅。

跑起来之后,服务端会依次发出 backend_request_receivedbackend_first_chunk_sentbackend_stream_complete 这几个日志事件,中间穿插 model_progress;前端页面上则有「Received first audio chunk」与「Browser started to play audio」两条。这几条日志分别打在链路的不同位置,对照它们出现到哪一条,就能把问题圈到哪一段代码里,不用一上来就改模型参数。

该项目持续更新,上面提到的模块路径、参数名与默认值随版本变动,以仓库最新内容为准。


本文依据 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?报名体系课或加入会员,照着学、照着用。