Signed-off-by: Sy03 <1370724210@qq.com> Signed-off-by: lishunyang12 <lishunyang03@gmail.com> Signed-off-by: Yueqian Lin <linyueqian@outlook.com> Co-authored-by: lishunyang12 <lishunyang03@gmail.com> Co-authored-by: Yueqian Lin <linyueqian@outlook.com> Co-authored-by: StormMelody <22371184@zju.edu.cn> Co-authored-by: Gao Han <hgaoaf@connect.ust.hk> Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
187 lines
6.4 KiB
Python
187 lines
6.4 KiB
Python
"""Static host and same-origin Realtime proxy for the full-duplex demo."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import base64
|
|
import contextlib
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from urllib.parse import urlencode
|
|
|
|
import uvicorn
|
|
import websockets
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import HTMLResponse, Response
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
logger = logging.getLogger(__name__)
|
|
APP_DIR = Path(__file__).parent / "app"
|
|
STATIC_DIR = APP_DIR / "static"
|
|
|
|
|
|
def _join_ws_url(base: str, path: str, query: str) -> str:
|
|
return base.rstrip("/") + path + (("?" + query) if query else "")
|
|
|
|
|
|
async def _pump_client_to_backend(client: WebSocket, backend) -> None:
|
|
try:
|
|
while True:
|
|
message = await client.receive()
|
|
if message["type"] == "websocket.disconnect":
|
|
await backend.close()
|
|
return
|
|
if message.get("text") is not None:
|
|
await backend.send(message["text"])
|
|
elif message.get("bytes") is not None:
|
|
await backend.send(message["bytes"])
|
|
except (WebSocketDisconnect, websockets.ConnectionClosed):
|
|
with contextlib.suppress(Exception):
|
|
await backend.close()
|
|
|
|
|
|
async def _pump_backend_to_client(client: WebSocket, backend) -> None:
|
|
try:
|
|
async for message in backend:
|
|
if isinstance(message, bytes):
|
|
await client.send_bytes(message)
|
|
else:
|
|
await client.send_text(message)
|
|
except (WebSocketDisconnect, websockets.ConnectionClosed):
|
|
return
|
|
except RuntimeError as exc:
|
|
if "websocket.send" in str(exc) and "websocket.close" in str(exc):
|
|
return
|
|
raise
|
|
|
|
|
|
def _expected_proxy_close(exc: BaseException) -> bool:
|
|
if isinstance(exc, (WebSocketDisconnect, websockets.ConnectionClosed, asyncio.CancelledError)):
|
|
return True
|
|
return isinstance(exc, RuntimeError) and "websocket.send" in str(exc) and "websocket.close" in str(exc)
|
|
|
|
|
|
def build_app(
|
|
*,
|
|
ws_backend: str = "ws://127.0.0.1:8099",
|
|
model: str = "openbmb/MiniCPM-o-4_5",
|
|
public_realtime_url: str | None = None,
|
|
ref_audio: str | None = None,
|
|
) -> FastAPI:
|
|
app = FastAPI(title="Experimental Full-Duplex Web Demo")
|
|
index_path = APP_DIR / "index.html"
|
|
app_version_hash = hashlib.sha256()
|
|
for asset_path in (
|
|
STATIC_DIR / "app.js",
|
|
STATIC_DIR / "pcm_worklet.js",
|
|
STATIC_DIR / "playback_worklet.js",
|
|
):
|
|
app_version_hash.update(asset_path.read_bytes())
|
|
app_version = app_version_hash.hexdigest()[:12]
|
|
|
|
ref_audio_uri: str | None = None
|
|
if ref_audio:
|
|
ref_path = Path(ref_audio)
|
|
if not ref_path.is_file():
|
|
raise SystemExit(f"--ref-audio not found: {ref_audio}")
|
|
encoded = base64.b64encode(ref_path.read_bytes()).decode("ascii")
|
|
ref_audio_uri = f"data:audio/wav;base64,{encoded}"
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index() -> HTMLResponse:
|
|
config = json.dumps(
|
|
{
|
|
"model": model,
|
|
"realtimePath": public_realtime_url or "v1/realtime",
|
|
"refAudio": ref_audio_uri,
|
|
"appVersion": app_version,
|
|
},
|
|
ensure_ascii=True,
|
|
)
|
|
html = (
|
|
index_path.read_text(encoding="utf-8")
|
|
.replace("__FULL_DUPLEX_CONFIG__", config)
|
|
.replace("__FULL_DUPLEX_APP_VERSION__", app_version)
|
|
)
|
|
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
|
|
|
|
@app.get("/healthz")
|
|
def healthz() -> Response:
|
|
return Response(content="ok", media_type="text/plain")
|
|
|
|
@app.websocket("/v1/realtime")
|
|
async def realtime_proxy(websocket: WebSocket) -> None:
|
|
await websocket.accept()
|
|
query = urlencode(websocket.query_params.multi_items())
|
|
backend_url = _join_ws_url(ws_backend, "/v1/realtime", query)
|
|
logger.info("Proxying Realtime WebSocket to %s", backend_url)
|
|
try:
|
|
async with websockets.connect(
|
|
backend_url,
|
|
max_size=64 * 1024 * 1024,
|
|
) as backend:
|
|
tasks = {
|
|
asyncio.create_task(_pump_client_to_backend(websocket, backend)),
|
|
asyncio.create_task(_pump_backend_to_client(websocket, backend)),
|
|
}
|
|
done, pending = await asyncio.wait(
|
|
tasks,
|
|
return_when=asyncio.FIRST_COMPLETED,
|
|
)
|
|
for task in pending:
|
|
task.cancel()
|
|
await asyncio.gather(*pending, return_exceptions=True)
|
|
for result in await asyncio.gather(*done, return_exceptions=True):
|
|
if isinstance(result, BaseException) and not _expected_proxy_close(result):
|
|
raise result
|
|
except (WebSocketDisconnect, websockets.ConnectionClosed):
|
|
return
|
|
except Exception:
|
|
logger.exception("Realtime WebSocket proxy failed")
|
|
with contextlib.suppress(Exception):
|
|
await websocket.close(code=1011)
|
|
|
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
return app
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--host", default="0.0.0.0")
|
|
parser.add_argument("--port", type=int, default=7862)
|
|
parser.add_argument("--ws-backend", default="ws://127.0.0.1:8099")
|
|
parser.add_argument(
|
|
"--public-realtime-url",
|
|
help="Browser-visible ws:// or wss:// Realtime URL; defaults to the same-origin proxy.",
|
|
)
|
|
parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5")
|
|
parser.add_argument(
|
|
"--ref-audio",
|
|
required=True,
|
|
help=(
|
|
"Reference voice wav for TTS voice cloning, e.g. the "
|
|
"official MiniCPM-o-Demo assets/ref_audio/ref_minicpm_signature.wav"
|
|
),
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
uvicorn.run(
|
|
build_app(
|
|
ws_backend=args.ws_backend,
|
|
model=args.model,
|
|
public_realtime_url=args.public_realtime_url,
|
|
ref_audio=args.ref_audio,
|
|
),
|
|
host=args.host,
|
|
port=args.port,
|
|
log_level="info",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|