airwaves / app.py
AndresCarreon's picture
AIRWAVES v0 — air-DJ (MediaPipe + Web Audio) + VoxCPM2 hype-man
860eb59 verified
Raw
History Blame Contribute Delete
5.86 kB
"""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()
@app.get("/api/health")
async def health() -> JSONResponse:
return JSONResponse(
{
"ok": True,
"backend": os.environ.get("AIRWAVES_BACKEND", "mock"),
"bank_ready": app.state.bank is not None,
}
)
@app.get("/api/hype")
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)
@app.get("/", include_in_schema=False)
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())