Spaces:
Sleeping
Sleeping
| """AIRWAVES — Space entrypoint. Conduct a live set with your bare hands. | |
| The static SPA (web/) is served at / and does ALL the work that matters in the | |
| browser: MediaPipe hand tracking + the Web Audio bend engine, zero GPU, camera | |
| never leaves the device. The ONLY server/GPU touch is GET /api/hype, which | |
| synthesizes the VoxCPM2 hype-man voice bank ONCE inside a single @spaces.GPU | |
| window, caches it, and serves base64 WAVs to every visitor instantly. | |
| The "her" pattern: deterministic browser code owns every audio fact (beat, | |
| filter Hz, gains, loop points); the tiny model owns only the hype-man's voice. | |
| Run: python app.py | |
| Backends: AIRWAVES_BACKEND = mock | zerogpu (default mock) | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import base64 | |
| import logging | |
| import os | |
| import threading | |
| # ZeroGPU: `spaces` must be imported before torch anywhere in the process, and | |
| # TORCHDYNAMO_DISABLE must precede any torch import. No-op off the GPU backend. | |
| if os.environ.get("AIRWAVES_BACKEND", "").strip().lower() in ("zerogpu", "zero-gpu", "vox", "voxcpm"): | |
| os.environ.setdefault("TORCHDYNAMO_DISABLE", "1") | |
| try: | |
| import spaces # noqa: F401 | |
| except ImportError: | |
| pass | |
| from pathlib import Path | |
| from fastapi import FastAPI | |
| from fastapi.responses import JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from mind import backends as mind_backends | |
| log = logging.getLogger("airwaves") | |
| ROOT = Path(__file__).resolve().parent | |
| WEB_DIR = ROOT / "web" | |
| # The hype-man lines — KEYS MUST MATCH web/hype.js HYPE_LINES. | |
| HYPE_LINES = { | |
| "hello": "Manos arriba — let's go!", | |
| "build": "Build it up… here it comes…", | |
| "drop": "Dale! Drop it!", | |
| "sweep": "Bring it up!", | |
| "mix": "Mix it — vamos!", | |
| } | |
| VOICE_ID = "club_mc" | |
| def create_app() -> FastAPI: | |
| """gr.Server (the proven 'her' idiom): a FastAPI app the sdk:gradio runtime | |
| serves natively. API routes are registered BEFORE launch; the web/ static | |
| mount is deferred until AFTER launch (see _mount_static).""" | |
| try: | |
| import gradio as gr | |
| app: FastAPI = gr.Server(title="AIRWAVES") | |
| except Exception: # pragma: no cover — gradio always present in prod | |
| app = FastAPI(title="AIRWAVES", docs_url=None, redoc_url=None) | |
| app.state.bank = None | |
| app.state.bank_lock = threading.Lock() | |
| async def health() -> JSONResponse: | |
| return JSONResponse( | |
| { | |
| "ok": True, | |
| "backend": os.environ.get("AIRWAVES_BACKEND", "mock"), | |
| "bank_ready": app.state.bank is not None, | |
| } | |
| ) | |
| async def hype() -> JSONResponse: | |
| """The VoxCPM2 hype-man bank: generated once (one GPU window), cached | |
| forever, served instantly thereafter. On any failure return an empty | |
| bank — the frontend falls back to browser speech, the show goes on.""" | |
| if app.state.bank is None: | |
| try: | |
| app.state.bank = await asyncio.to_thread(_build_bank, app) | |
| except Exception: | |
| log.exception("hype bank synthesis failed; client falls back to speech") | |
| return JSONResponse({"bank": [], "error": "voice unavailable"}) | |
| return JSONResponse({"bank": app.state.bank, "voice": "openbmb/VoxCPM2"}) | |
| def _mount_static() -> None: | |
| """Root static mount, deferred until AFTER launch: a '/' catch-all | |
| registered before gr.Server.launch() shadows the /gradio_api routes and | |
| the launch self-check 404s. Evict only gradio's own '/' SPA route.""" | |
| if WEB_DIR.is_dir(): | |
| app.router.routes[:] = [ | |
| r for r in app.router.routes if getattr(r, "path", None) != "/" | |
| ] | |
| app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web") | |
| else: # pragma: no cover | |
| log.warning("web/ not found at %s; serving API only", WEB_DIR) | |
| async def root_placeholder() -> JSONResponse: | |
| return JSONResponse({"ok": True, "hint": "frontend not built"}) | |
| app.state.mount_static = _mount_static | |
| if not hasattr(app, "launch"): # plain-FastAPI dev path mounts immediately | |
| _mount_static() | |
| return app | |
| def _build_bank(app: FastAPI) -> list: | |
| """Synthesize all hype lines once (thread-safe); return [{key, wav_b64}].""" | |
| with app.state.bank_lock: | |
| if app.state.bank is not None: | |
| return app.state.bank | |
| wavs = mind_backends.synth_bank(HYPE_LINES, VOICE_ID) | |
| return [ | |
| {"key": k, "wav_b64": base64.b64encode(v).decode("ascii")} | |
| for k, v in wavs.items() | |
| ] | |
| def _serve(application: FastAPI) -> None: | |
| port = int(os.environ.get("PORT", os.environ.get("GRADIO_SERVER_PORT", 7860))) | |
| log.info("AIRWAVES listening on http://0.0.0.0:%d", port) | |
| if hasattr(application, "launch"): | |
| application.launch( | |
| server_name="0.0.0.0", | |
| server_port=port, | |
| show_error=False, | |
| prevent_thread_lock=True, | |
| ) | |
| mount_static = getattr(application.state, "mount_static", None) | |
| if mount_static is not None: | |
| mount_static() | |
| # Warm the voice model off the serving thread so the first /api/hype is fast. | |
| threading.Thread(target=mind_backends.warm_voice, daemon=True).start() | |
| import time as _time | |
| while True: | |
| _time.sleep(3600) | |
| else: # pragma: no cover — gradio-less dev fallback | |
| import uvicorn | |
| uvicorn.run(application, host="0.0.0.0", port=port) | |
| if __name__ == "__main__" or os.environ.get("SPACE_ID"): | |
| logging.basicConfig( | |
| level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s" | |
| ) | |
| _serve(create_app()) | |