VibeVoice 流式 demo 拆解:vibevoice_realtime_demo.py 的调用链
你手上有个会不断吐字的模型,想让它一边吐一边出声,而不是等整段文字生成完再合成。这类需求真正麻烦的地方不在模型,在链路:谁负责把文本喂进去、生成出来的音频块从哪个口子出来、传到浏览器之后靠什么把它接住播出去。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.toml 里 requires-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 只接受四个取值:cpu、cuda、mpx、mps。其中 mpx 在 demo/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_PATH 与 MODEL_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() 内部按顺序做这么几件事:
VibeVoiceStreamingProcessor.from_pretrained(self.model_path)拿到processor;- 按设备分支决定
load_dtype、device_map、attn_impl_primary三个值,cuda分支走flash_attention_2,mps与cpu分支走sdpa; VibeVoiceStreamingForConditionalGenerationInference.from_pretrained(...)加载模型,随后self.model.eval();- 用
noise_scheduler.from_config(..., algorithm_type="sde-dpmsolver++", beta_schedule="squaredcos_cap_v2")替换掉调度器,再调set_ddpm_inference_steps(num_steps=self.inference_steps); - 扫音色目录,按
VOICE_PRESET环境变量决定默认音色,_determine_voice_key里写死的兜底 key 是"en-Carter_man",它不在就取排序后的第一个。
第 3 步有个容易被略过的分支:from_pretrained 抛异常时,如果原本用的是 flash_attention_2,代码会 catch 住改用 sdpa 重试,并打印一句提示,说明只有 flash_attention_2 经过完整测试。这是代码里原样写着的警告,遇到这条日志说明你已经掉进了回退路径。
第 5 步的音色加载也不是读音频。_ensure_voice_cached 用 torch.load(preset_path, map_location=..., weights_only=True),外面套着 torch.serialization.safe_globals([BaseModelOutputWithPast, DynamicCache])。配合 vibevoice/processor/vibevoice_streaming_processor.py 里 process_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:text、cfg、steps、voice。cfg 解析失败或小于等于 0 时回落到 1.5,steps 解析失败则置 None、由服务侧的默认值接管(构造函数里 inference_steps 写的是 5)。这两个是仓库当前代码里的默认值,随版本可能变动。
query string 解析完,处理函数做的第一件正事是抢 app.state.websocket_lock 这把 asyncio.Lock。锁已被占用时,服务端发一条 event 为 backend_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=True 的 threading.Thread 去跑 self.model.generate(...),传进去的关键参数包括 max_new_tokens=None、cfg_scale、tokenizer、generation_config、audio_streamer、stop_check_fn=stop_event.is_set、refresh_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 里每个样本一个 Queue,put(audio_chunks, sample_indices) 把 chunk detach().cpu() 之后按索引塞进对应队列,end() 往队列里塞 stop_signal 并翻 finished_flags。消费侧用 get_stream(0) 拿到 AudioSampleIterator,它的 __next__ 从队列 get,取到的值等于 stop_signal 就 raise 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_sec 与 chunk_sec 两个字段(都是采样数除以采样率算出来的)。发到网络之前再经 chunk_to_pcm16:np.clip 到 [-1.0, 1.0],乘 32767.0 转 int16,tobytes() 后 ws.send_bytes。
浏览器侧在 demo/web/index.html。socket.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_SIZE 是 createScriptProcessor 的帧长,PREBUFFER_SEC 乘上采样率算出 minBufferSamples 这个起播门槛。它们是仓库当前代码里的取值,随版本可能变动。要注意的是 SAMPLE_RATE 在 demo/web/index.html 和 demo/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_received、backend_first_chunk_sent、backend_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 生成内容时主动披露。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。