"""Gradio demo for VoxCPM2 TTS with gapless streaming audio playback. Uses a custom AudioWorklet-based player for gap-free streaming (adapted from the Qwen3-TTS demo). Audio is streamed from the vLLM server through a same-origin proxy and played via the Web Audio API's AudioWorklet, which maintains a FIFO buffer queue and plays samples at the audio clock rate. Usage: # Start the vLLM server first: vllm serve openbmb/VoxCPM2 --omni --host 0.0.0.0 --port 8000 # Then launch the demo: python gradio_demo.py --api-base http://localhost:8000 """ from __future__ import annotations import argparse import base64 import io import json import logging import gradio as gr import httpx import numpy as np import soundfile as sf from fastapi import FastAPI, Request from fastapi.responses import Response, StreamingResponse logger = logging.getLogger(__name__) SAMPLE_RATE = 48000 # ── AudioWorklet processor (loaded in browser via Blob URL) ────────── WORKLET_JS = r""" class TTSPlaybackProcessor extends AudioWorkletProcessor { constructor() { super(); this.queue = []; this.buf = null; this.pos = 0; this.playing = false; this.played = 0; this.port.onmessage = (e) => { if (e.data && e.data.type === 'clear') { this.queue = []; this.buf = null; this.pos = 0; this.played = 0; if (this.playing) { this.playing = false; this.port.postMessage({type:'stopped'}); } return; } this.queue.push(e.data); }; } process(inputs, outputs) { const out = outputs[0][0]; for (let i = 0; i < out.length; i++) { if (!this.buf || this.pos >= this.buf.length) { if (this.queue.length > 0) { this.buf = this.queue.shift(); this.pos = 0; } else { for (let j = i; j < out.length; j++) out[j] = 0; if (this.playing) { this.playing = false; this.port.postMessage({type:'stopped', played:this.played}); } return true; } } out[i] = this.buf[this.pos++] / 32768; this.played++; } if (!this.playing) { this.playing = true; this.port.postMessage({type:'started'}); } return true; } } registerProcessor('tts-playback-processor', TTSPlaybackProcessor); """ PLAYER_HTML = """
Ready
""" def _build_player_js() -> str: return f""" """ def _encode_audio(audio_data: tuple) -> str: sr, audio_np = audio_data if audio_np.dtype in (np.float32, np.float64): audio_np = np.clip(audio_np, -1.0, 1.0) audio_np = (audio_np * 32767).astype(np.int16) elif audio_np.dtype != np.int16: audio_np = audio_np.astype(np.int16) buf = io.BytesIO() sf.write(buf, audio_np, sr, format="WAV") return f"data:audio/wav;base64,{base64.b64encode(buf.getvalue()).decode()}" def create_app(api_base: str): app = FastAPI() _pending: dict[str, dict] = {} @app.post("/proxy/v1/audio/speech") async def proxy_speech(request: Request): body = await request.json() req_id = body.get("_req_id") if req_id and req_id in _pending: body = _pending.pop(req_id) logger.info("Proxy: %s", {k: (f"<{len(str(v))} chars>" if k == "ref_audio" else v) for k, v in body.items()}) try: client = httpx.AsyncClient(timeout=300) resp = await client.send( client.build_request( "POST", f"{api_base}/v1/audio/speech", json=body, headers={"Authorization": "Bearer EMPTY", "Content-Type": "application/json"}, ), stream=True, ) except Exception as exc: logger.exception("Proxy connection error") await client.aclose() return Response(content=str(exc), status_code=502) if resp.status_code != 200: content = await resp.aread() await resp.aclose() await client.aclose() return Response(content=content, status_code=resp.status_code) async def relay(): try: async for chunk in resp.aiter_bytes(): yield chunk finally: await resp.aclose() await client.aclose() return StreamingResponse(relay(), media_type="application/octet-stream") css = """ #generate-btn button { width: 100%; } #streaming-player { border: 1px solid var(--border-color-primary) !important; border-radius: var(--block-radius) !important; padding: var(--block-padding) !important; } """ theme = gr.themes.Default( primary_hue=gr.themes.Color( c50="#f0f5ff", c100="#dce6f9", c200="#b8cef3", c300="#8eb2eb", c400="#6496e0", c500="#4A90D9", c600="#3a7bc8", c700="#2d66b0", c800="#1f4f8f", c900="#163a6e", c950="#0e2650", ), ) with gr.Blocks(title="VoxCPM2 TTS Demo") as demo: gr.HTML(f"""
vLLM-Omni

VoxCPM2 Streaming Demo

Served by vLLM-Omni · {api_base} · 48 kHz
""") gr.Markdown( "**Three modes:** " "**Voice Design** (control instruction only) · " "**Controllable Cloning** (ref audio + optional style control) · " "**Ultimate Cloning** (ref audio + transcript for audio continuation)" ) with gr.Row(): with gr.Column(scale=3): text_input = gr.Textbox( label="Target Text", placeholder="Enter text to synthesize...", lines=4, ) control_instruction = gr.Textbox( label="Control Instruction (optional)", placeholder="e.g. A warm young woman / Excited and fast-paced", lines=2, info="Describe voice style, emotion, pace. Works for both Voice Design and Controllable Cloning.", ) with gr.Accordion("Voice Cloning", open=False): ref_audio = gr.Audio( label="Reference Audio (upload for cloning)", type="numpy", sources=["upload", "microphone"], ) ref_audio_url = gr.Textbox( label="or Reference Audio URL", placeholder="https://example.com/reference.wav", ) ultimate_clone = gr.Checkbox( label="Ultimate Cloning Mode", value=False, info="Provide transcript of ref audio for audio continuation (disables control instruction)", ) prompt_text = gr.Textbox( label="Reference Audio Transcript", placeholder="Transcript of your reference audio (for ultimate cloning)", lines=2, visible=False, ) with gr.Row(): stream_checkbox = gr.Checkbox( label="Stream (gapless)", value=True, info="AudioWorklet streaming", ) with gr.Row(): generate_btn = gr.Button( "Generate Speech", variant="primary", size="lg", elem_id="generate-btn", scale=3, ) reset_btn = gr.Button("Reset", variant="secondary", size="lg", scale=1) with gr.Column(scale=2): player_html = gr.HTML( value=PLAYER_HTML, visible=True, label="streaming player", elem_id="streaming-player", ) audio_output = gr.Audio( label="generated audio", interactive=False, autoplay=True, visible=False, ) gr.Examples( examples=[ ["Hello, this is a VoxCPM2 demo running on vLLM-Omni.", ""], [ "I have a dream that my four little children will one day live in a nation " "where they will not be judged by the color of their skin but by the content " "of their character.", "", ], [ "I never asked you to stay. It's not like I care or anything. " "But why does it still hurt so much now that you're gone?", "A young girl with a soft, sweet voice. Speaks slowly with a melancholic tone.", ], ], inputs=[text_input, control_instruction], label="examples", ) gr.HTML("""
vLLM-Omni
""") hidden_payload = gr.Textbox(visible=False, elem_id="tts-payload") def on_ultimate_toggle(checked): return ( gr.update(visible=checked), # prompt_text gr.update(interactive=not checked), # control_instruction ) ultimate_clone.change( fn=on_ultimate_toggle, inputs=[ultimate_clone], outputs=[prompt_text, control_instruction], ) def on_stream_change(stream: bool): if stream: return gr.update(visible=True), gr.update(visible=False) return gr.update(visible=False), gr.update(visible=True) stream_checkbox.change( fn=on_stream_change, inputs=[stream_checkbox], outputs=[player_html, audio_output], ) def on_reset(): return "", "", None, "", False, "", PLAYER_HTML reset_btn.click( fn=on_reset, outputs=[ text_input, control_instruction, audio_output, hidden_payload, ultimate_clone, prompt_text, player_html, ], js="() => { if (window.ttsStop) window.ttsStop(); }", ) def on_generate(stream_enabled, text, ctrl_instr, ref_a, ref_url, ult_clone, p_text): import time as _time if not text or not text.strip(): raise gr.Error("Please enter text to synthesize.") # VoxCPM2 uses "(instruction)text" format for control ctrl = ctrl_instr.strip() if ctrl_instr and not ult_clone else "" final_text = f"({ctrl}){text.strip()}" if ctrl else text.strip() payload: dict = { "input": final_text, "voice": "default", "response_format": "pcm" if stream_enabled else "wav", "stream": stream_enabled, } # Reference audio for cloning ref_url_s = ref_url.strip() if ref_url else "" if ref_url_s: payload["ref_audio"] = ref_url_s elif ref_a is not None: payload["ref_audio"] = _encode_audio(ref_a) # Ultimate cloning: prompt_audio + prompt_text for continuation if ult_clone and p_text and p_text.strip(): if ref_url_s: payload["prompt_audio"] = ref_url_s elif ref_a is not None: payload["prompt_audio"] = payload.get("ref_audio", "") payload["prompt_text"] = p_text.strip() if stream_enabled: if ref_a is not None and not ref_url_s: req_id = f"req-{int(_time.time() * 1000)}" _pending[req_id] = payload browser_payload = {"_req_id": req_id, "_nonce": int(_time.time() * 1000)} return json.dumps(browser_payload), gr.update() payload["_nonce"] = int(_time.time() * 1000) return json.dumps(payload), gr.update() else: try: with httpx.Client(timeout=300.0) as client: resp = client.post( f"{api_base}/v1/audio/speech", json=payload, headers={"Content-Type": "application/json", "Authorization": "Bearer EMPTY"}, ) except httpx.ConnectError: raise gr.Error(f"Cannot connect to server at {api_base}.") if resp.status_code != 200: raise gr.Error(f"Server error ({resp.status_code}): {resp.text[:200]}") audio_np, sr = sf.read(io.BytesIO(resp.content)) if audio_np.ndim > 1: audio_np = audio_np[:, 0] return "", (sr, audio_np.astype(np.float32)) generate_btn.click( fn=on_generate, inputs=[ stream_checkbox, text_input, control_instruction, ref_audio, ref_audio_url, ultimate_clone, prompt_text, ], outputs=[hidden_payload, audio_output], ).then( fn=lambda p: p, inputs=[hidden_payload], outputs=[hidden_payload], js="(p) => { if (p && p.trim()) { const d = JSON.parse(p); delete d._nonce; window.ttsGenerate(d); } return p; }", ) demo.queue() return gr.mount_gradio_app(app, demo, path="/", css=css, theme=theme, head=_build_player_js()) def main(): parser = argparse.ArgumentParser(description="VoxCPM2 streaming Gradio demo") parser.add_argument("--api-base", default="http://localhost:8000", help="vLLM API server URL") parser.add_argument("--host", default="0.0.0.0", help="Gradio server host") parser.add_argument("--port", type=int, default=7860, help="Gradio server port") args = parser.parse_args() logging.basicConfig(level=logging.INFO) print(f"Connecting to vLLM server at: {args.api_base}") import uvicorn uvicorn.run(create_app(args.api_base), host=args.host, port=args.port) if __name__ == "__main__": main()