VibeVoice 自带的 Web demo:demo/web/app.py 怎么组织
想给一个流式语音模型做个能在浏览器里听到声音的最小界面,多数人第一反应是自己搭:后端起个服务,前端拿 Web Audio 播。真动手才发现麻烦的不是模型,是中间那段——音频要边生成边发,发的是什么格式,前端怎么攒够再播,生成线程和事件循环怎么互不阻塞。
VibeVoice 仓库里就有一份现成的答案。demo/web/ 目录下只有两个文件:app.py 和 index.html,没有 __init__.py,也没有前端构建产物。整套东西加起来就是一个 FastAPI 应用加一段原生 JavaScript。与其从零拼,不如先把这两个文件读明白。
下面提到的路径、字段名和默认值都来自 github.com/microsoft/VibeVoice 仓库;该项目持续更新,以仓库最新内容为准。
一、启动前需要准备什么
这一段最容易被跳过,但它决定了你启动时会不会直接吃到一个 RuntimeError。
Python 与依赖。pyproject.toml 里写的是 requires-python = ">=3.10",dependencies 中已经包含 fastapi 和 uvicorn[standard],所以跑这个 demo 不需要额外装 Web 框架。可选依赖组只有一个:
[project.optional-dependencies]
streamingtts = [
"transformers==4.51.3",
]
注意它是钉死的等号版本,而主依赖里写的是 transformers>=4.51.3,<5.0.0。装 streamingtts 这一组会把 transformers 固定到那个具体版本上。这是仓库当前代码里的写法,随版本可能变动。
docs/vibevoice-realtime-0.5b.md 给出的安装方式是:
git clone https://github.com/microsoft/VibeVoice.git
cd VibeVoice/
pip install -e .[streamingtts]
三个环境变量。app.py 的 startup 钩子里读的是 MODEL_PATH、MODEL_DEVICE 和(在服务初始化时读的)VOICE_PRESET。其中 MODEL_PATH 没设就直接抛错:
model_path = os.environ.get("MODEL_PATH")
if not model_path:
raise RuntimeError("MODEL_PATH not set in environment")
MODEL_DEVICE 取不到时用 "cuda"。这些是仓库当前代码里的默认值,随版本可能变动。
不过多数人不会自己去设这两个变量——demo/vibevoice_realtime_demo.py 会替你设。那个脚本用 argparse 收 --port、--model_path、--device、--reload 四个参数,把前两个塞进 os.environ,然后调 uvicorn.run("web.app:app", host="0.0.0.0", port=args.port, reload=args.reload)。--device 在 argparse 里限定了 choices=["cpu", "cuda", "mpx", "mps"],--port 的默认值是 3000,--model_path 的默认值是 microsoft/VibeVoice-Realtime-0.5B——这两个是仓库当前代码里的默认值,随版本可能变动。
这里有两个细节值得记下来。一是 uvicorn 拿到的是 import 字符串 "web.app:app" 而不是 app 对象本身,--reload 要工作也依赖这种写法;而 demo/web/ 里并没有 __init__.py,所以这个 import 能否成立取决于 demo/ 目录在不在模块搜索路径里。仓库文档给的调用方式是在仓库根目录执行 python demo/vibevoice_realtime_demo.py --model_path microsoft/VibeVoice-Realtime-0.5B。二是 host="0.0.0.0" 意味着服务会监听所有网卡,不只是本机回环。
音色文件。StreamingTTSService._load_voice_presets() 去的是 BASE.parent / "voices" / "streaming_model",也就是 demo/voices/streaming_model/,用 rglob("*.pt") 把所有 .pt 收进字典,键是文件名主干(pt_path.stem)。目录不存在、或者一个 .pt 都没有,都会抛 RuntimeError。默认音色的挑选逻辑写在 _determine_voice_key() 里:环境变量 VOICE_PRESET 命中就用它,否则找 "en-Carter_man",再不行就取排序后的第一个。文档另外提到可以用 bash demo/download_experimental_voices.sh 下载更多实验性多语种音色。
二、三条路由,各管一件事
整个 app.py 对外只暴露三个入口,装饰器原文如下:
@app.get("/")
def index():
return FileResponse(BASE / "index.html")
@app.get("/config")
def get_config():
service: StreamingTTSService = app.state.tts_service
voices = sorted(service.voice_presets.keys())
return {
"voices": voices,
"default_voice": service.default_voice_key,
}
第三个是 @app.websocket("/stream")。
/ 直接返回 BASE / "index.html",BASE = Path(__file__).parent。顺带提一句:文件顶部 from fastapi.staticfiles import StaticFiles 这一行导入了 StaticFiles,但全文没有任何 app.mount 调用——读代码时别以为有静态目录挂载,页面就是这一个文件直发。
/config 是前端下拉框的数据源。index.html 里的 loadVoices() 用 fetch('/config') 拿到 voices 数组和 default_voice,逐个塞成 <option>,命中默认值就选中它。这条路由依赖 app.state.tts_service,而它是在 startup 里 service.load() 之后才挂上去的。
/stream 是主通道。它不接收请求体,参数全从 query string 取:
text = ws.query_params.get("text", "")
cfg_param = ws.query_params.get("cfg")
steps_param = ws.query_params.get("steps")
voice_param = ws.query_params.get("voice")
对应的前端拼法在 start() 里,用 URLSearchParams 组好 text/cfg/steps/voice,然后 location.origin.replace(/^http/, 'ws') 换协议头拼出 wss:// 或 ws:// 的 /stream 地址。cfg 解析失败或小于等于 0 会回落到 1.5,steps 解析失败或非正数就置 None(表示沿用服务里已有的步数)。
服务同时只处理一个请求。startup 里挂了 app.state.websocket_lock = asyncio.Lock(),/stream 一进来先判断 lock.locked(),锁被占就发一条 backend_busy 的日志消息,然后 await ws.close(code=1013, reason="Service busy") 直接断开。这个行为在做压测或者多人共用一台机器时会很显眼,提前知道能省不少排查时间。
三、前后端到底在传什么
这是这份 demo 最值得抄的部分:同一条 WebSocket 上跑两种帧。
二进制帧是音频。后端把生成的浮点块转成 16 位 PCM 再发:
def chunk_to_pcm16(self, chunk: np.ndarray) -> bytes:
chunk = np.clip(chunk, -1.0, 1.0)
pcm = (chunk * 32767.0).astype(np.int16)
return pcm.tobytes()
前端 socket.binaryType = 'arraybuffer',在 onmessage 里按小端逐样本还原回 Float32:
const floatChunk = new Float32Array(view.byteLength / 2);
for (let i = 0; i < floatChunk.length; i += 1) {
floatChunk[i] = view.getInt16(i * 2, true) / 32768;
}
一端除以 32768、另一端乘以 32767,这是两边代码里各自的写法,原样记下来即可。
文本帧是日志。后端所有状态都包成同一个信封发出去:{"type": "log", "event": ..., "data": ..., "timestamp": ...}。事件名在代码里是固定的一组:backend_request_received、backend_first_chunk_sent、model_progress、generation_error、backend_error、client_disconnected、backend_stream_complete。前端 handleLogMessage() 用一个 switch 分发,其中 model_progress 不打印文字,只把 data.generated_sec 更新到页面上那个「Model Generated Audio」的数字上,switch 还留了 default 分支兜底打印未知事件名。前面提到的 backend_busy 不走这条队列——它是在拿不到锁时直接拼好 JSON send_text 的,信封格式相同。
生成跑在后台线程。StreamingTTSService.stream() 里创建 AudioStreamer(batch_size=1, stop_signal=None, timeout=None),把真正的 self.model.generate(...) 丢进一个 daemon=True 的 threading.Thread,主体则从 audio_streamer.get_stream(0) 迭代拿块。generate 收到的 stop_check_fn=stop_event.is_set,配合 finally 里的 stop_signal.set() 和 audio_streamer.end(),构成停止路径。
在 WebSocket 那一侧,同步迭代器是这样接进事件循环的:
chunk = await asyncio.to_thread(next, iterator, sentinel)
if chunk is sentinel:
break
sentinel = object(),用 next() 的第二个参数当哨兵,比捕获 StopIteration 干净。日志则走一个 queue.Queue:生成线程里的 log_callback 只管 put,协程侧的 flush_logs() 用 get_nowait() 排空后 send_text,避免跨线程直接碰 WebSocket。
前端的播放链在 createAudioChain() 里:new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE }),createScriptProcessor(BUFFER_SIZE, 0, 1),在 onaudioprocess 回调里从本地缓冲 pullAudio()。开播前有个预缓冲门槛 PREBUFFER_SEC,攒够 audioCtx.sampleRate * PREBUFFER_SEC 个样本、或者 socket 已关闭,才置 hasStartedPlayback。采样率两边各写了一份常量 SAMPLE_RATE = 24_000,改一处忘另一处就会跑偏。
以上片段均为仓库代码原文摘录,我们没有跑过,以仓库最新代码为准。
四、边界:哪些是这份 demo 明确不做的
- 不是真正的流式文本输入。页面上有个「Streaming Input Text」预览区,但
index.html的说明文字写明这个 demo 需要一次性提供完整文本;预览区的逐词效果是前端本地按STREAMING_WPM = 180定时setTimeout打出来的动画。docs/vibevoice-realtime-0.5b.md的 TODO 清单里,「Implement streaming text input function to feed new tokens while audio is still being generated」这一条仍是未勾选状态。 - 单条请求、单说话人。前面说的
asyncio.Lock决定了并发上限;文档也写明这个实时变体只支持单说话人,多说话人要用其它变体。 - 处理器只支持单条样本。
VibeVoiceStreamingProcessor.process_input_with_cached_prompt()的 docstring 原文写着 “The function currently only supports single examples.”。 - 注意力实现有一条回退分支。
load()里 CUDA 分支首选flash_attention_2,加载抛异常才用sdpa再试一次;mps与cpu分支本来就直接用sdpa。那段异常分支打印的提示里写明只有flash_attention_2经过完整测试——这是仓库代码里的原话,我们没有验证过。 - 多语种是探索性的。文档另外列了一批英语之外的语言供用户探索,并写明「这些多语种行为未经充分测试,请谨慎使用」;风险与限制一节还写明:该模型当前面向英语,非英语文本可能产生意外输出,模型不处理背景音、音乐与音效,不支持朗读代码、数学公式与不常见符号,输入极短(文档原文为三个词及以下)的情况文档也单列了一条提醒。以上均为文档自述,不是我们的结论。
- 输入清洗只有一处。
stream()里空文本直接return,另外只有一行text = text.replace("’", "'")处理了弯引号,其它符号得你自己在前面做归一化。
五、怎么确认配对了
按仓库文档启动后,从三个层面往下核:
后端启动日志。load() 会依次打印 [startup] Loading processor from ...、设备与 dtype 那行、找到的音色预设条数、[startup] Loading voice preset ...,最后是 startup 钩子里的 print("[startup] Model ready.")。看不到最后这句,说明模型或音色还没就绪。
/config 能不能直出。浏览器直接访问 /config,应当返回带 voices 与 default_voice 两个键的 JSON。页面上的 Speaker 下拉框显示「Load failed」,基本就是这条路由没通。
页面 Runtime Logs 的顺序。点 Start 之后,日志面板里应当先出现前端自己打的那条 Start 记录,随后是后端的 Received request、Sent first audio chunk,最后 Backend finished。收到第一个二进制块时前端会补一条 Received first audio chunk。全流程结束后 recordingComplete 置 true,Save 按钮才从 disabled 变为可用——createWavBlob() 会自己拼一个 44 字节的 RIFF 头,导出单声道 16 位 WAV。
Windows 侧要多留意三件事。第一,StreamingTTSService.__init__ 里有一行注释解释了为什么 model_path 保持字符串不转 Path:Path() 在 Windows 上会把 / 变成 \,从而破坏 Hugging Face 的 repo id 形式。你自己改这份 demo 时别顺手把它包成 Path。第二,--device 的 mps 分支是 macOS 的路径,代码里还专门处理了把 mpx 当作 mps 的情况,Windows 上只有 cpu 与 cuda 两条分支有意义。第三,download_experimental_voices.sh 是 shell 脚本,Windows 下需要在能执行 sh 的环境里跑(这是通用做法,不是该项目官方说明)。另外文档的安装一节给的是 NVIDIA 深度学习容器的路径,如果你在 Windows 上直接用本机 Python 环境,flash attention 一类的依赖要自己解决,仓库里没有针对 Windows 的单独安装说明。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。特别提醒:默认的 host="0.0.0.0" 会让服务在局域网内可达,而这份 demo 里没有任何鉴权逻辑。
本文依据 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 生成内容时主动披露。