VibeVoice 自带的 Web demo:demo/web/app.py 怎么组织

2026-08-18

想给一个流式语音模型做个能在浏览器里听到声音的最小界面,多数人第一反应是自己搭:后端起个服务,前端拿 Web Audio 播。真动手才发现麻烦的不是模型,是中间那段——音频要边生成边发,发的是什么格式,前端怎么攒够再播,生成线程和事件循环怎么互不阻塞。

VibeVoice 仓库里就有一份现成的答案。demo/web/ 目录下只有两个文件:app.pyindex.html,没有 __init__.py,也没有前端构建产物。整套东西加起来就是一个 FastAPI 应用加一段原生 JavaScript。与其从零拼,不如先把这两个文件读明白。

下面提到的路径、字段名和默认值都来自 github.com/microsoft/VibeVoice 仓库;该项目持续更新,以仓库最新内容为准。

一、启动前需要准备什么

这一段最容易被跳过,但它决定了你启动时会不会直接吃到一个 RuntimeError

Python 与依赖pyproject.toml 里写的是 requires-python = ">=3.10"dependencies 中已经包含 fastapiuvicorn[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_PATHMODEL_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)--deviceargparse 里限定了 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.5steps 解析失败或非正数就置 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_receivedbackend_first_chunk_sentmodel_progressgeneration_errorbackend_errorclient_disconnectedbackend_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=Truethreading.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 再试一次;mpscpu 分支本来就直接用 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,应当返回带 voicesdefault_voice 两个键的 JSON。页面上的 Speaker 下拉框显示「Load failed」,基本就是这条路由没通。

页面 Runtime Logs 的顺序。点 Start 之后,日志面板里应当先出现前端自己打的那条 Start 记录,随后是后端的 Received requestSent first audio chunk,最后 Backend finished。收到第一个二进制块时前端会补一条 Received first audio chunk。全流程结束后 recordingComplete 置 true,Save 按钮才从 disabled 变为可用——createWavBlob() 会自己拼一个 44 字节的 RIFF 头,导出单声道 16 位 WAV。

Windows 侧要多留意三件事。第一,StreamingTTSService.__init__ 里有一行注释解释了为什么 model_path 保持字符串不转 PathPath() 在 Windows 上会把 / 变成 \,从而破坏 Hugging Face 的 repo id 形式。你自己改这份 demo 时别顺手把它包成 Path。第二,--devicemps 分支是 macOS 的路径,代码里还专门处理了把 mpx 当作 mps 的情况,Windows 上只有 cpucuda 两条分支有意义。第三,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 生成内容时主动披露。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。