pitwall-live / app.py
rogerdemello's picture
PIT WALL model backend on ZeroGPU
321bc2e verified
Raw
History Blame Contribute Delete
5.88 kB
"""PIT WALL — model backend, on ZeroGPU.
Why this exists. The main app is a Next.js frontend against a FastAPI backend,
and both run together in the Docker image. Hugging Face now charges for Docker
and Gradio Spaces on personal accounts, so the public deployment is a *static*
Space — which serves the precomputed corpus perfectly, but has no process to run
a model in. Live Analysis was therefore disabled in public, which is the one
thing about this submission that looked like a missing backend.
Free personal accounts in good standing may host two ZeroGPU Gradio Spaces. So
the model backend moves here, and the static frontend calls it cross-origin.
Gradio's CORS middleware accepts any origin when the host is not a localhost
alias, which is exactly the case on *.hf.space, so no proxy is involved.
What this file is NOT. It is not a second implementation. Every model call below
goes through `pipeline/`, the same package `backend/main.py` imports, copied in
verbatim at build time by `backend/data/build_space_live.py`. If the two ever
disagree, that is a bug in the build script, not a fork to reconcile.
The response is byte-compatible with `POST /api/analyze` in backend/main.py, so
the frontend has one result renderer rather than two.
"""
from __future__ import annotations
import os
import time
import gradio as gr
import librosa
import numpy as np
import spaces # noqa: F401 — must be imported before torch on ZeroGPU
from pipeline import asr, device, fusion, prosody, sentiment
from pipeline.calibration import Calibrator
HERE = os.path.dirname(os.path.abspath(__file__))
CALIBRATION = os.path.join(HERE, "_pooled.calibration.json")
#: The corpus was built with whisper-small.en. The Space runs the same model on
#: purpose: a live result computed by a different model than the one behind the
#: Race Replay would be a quiet inconsistency, and the response says which was
#: used either way. Override to compare.
ASR_MODEL = asr.MODEL_ID
CORPUS_ASR_MODEL = asr.CORPUS_MODEL_ID
MAX_SECONDS = 120.0
# ZeroGPU requires models to be resident before the first decorated call, so
# these load at import rather than lazily on first request.
_CAL: Calibrator | None = None
try:
_CAL = Calibrator.from_json(CALIBRATION)
print(f"[startup] calibration loaded from {CALIBRATION}")
except (OSError, ValueError) as e:
print(f"[startup] no calibration ({e}); percentiles will fall back to raw scores")
print(f"[startup] device: {device.describe()}")
asr._load()
prosody._load()
sentiment._load()
print("[startup] models resident")
@spaces.GPU(duration=45)
def _run(audio: np.ndarray) -> dict:
"""The whole pipeline, on the GPU slice. Identical to backend/main.py."""
tr = asr.transcribe(audio)
af = prosody.analyse(audio)
se = sentiment.analyse(tr.text)
st = fusion.fuse(af, se, calibrator=_CAL, transcript=tr.text)
return {
"transcript": tr.text,
"duration_s": tr.duration_s,
"elapsed_s": tr.elapsed_s,
"rtf": tr.rtf,
"text_sentiment": {"label": se.label, "polarity": se.polarity},
"state": st.to_dict(),
"calibrated_against": "pooled" if _CAL else None,
"model_id": ASR_MODEL,
"matches_corpus_model": ASR_MODEL == CORPUS_ASR_MODEL,
}
def analyze(path: str | None) -> dict:
"""Entry point. Returns the error in the payload rather than raising.
A Gradio exception surfaces to an API caller as an opaque 500, and this is
called cross-origin by the frontend, so failures are described instead.
"""
if not path:
return {"error": "no audio supplied"}
started = time.time()
try:
audio, _ = librosa.load(path, sr=asr.SAMPLE_RATE, mono=True)
except Exception as e:
return {"error": f"could not decode audio: {e}"}
seconds = len(audio) / asr.SAMPLE_RATE
if seconds < 0.2:
return {"error": f"clip is only {seconds:.2f}s; nothing to analyse"}
if seconds > MAX_SECONDS:
return {"error": f"clip is {seconds:.0f}s; limit is {MAX_SECONDS:.0f}s"}
try:
out = _run(audio)
except Exception as e: # ZeroGPU quota exhausted, OOM, model failure
return {"error": f"{type(e).__name__}: {e}"}
out["total_s"] = round(time.time() - started, 2)
return out
DESCRIPTION = """
# PIT WALL — the model backend
Upload or record a few seconds of speech. This runs the same four-stage pipeline
the [PIT WALL app](https://huggingface.co/spaces/rogerdemello/pitwall) uses:
Whisper for the words, a wav2vec2 dimensional-affect model for the voice, a
RoBERTa sentiment model for the text, and a calibration layer that places the
result against 2,042 real F1 team-radio messages.
**This Space is the backend.** It exists so the app's Live Analysis works for
anyone, without running anything locally. The app itself is the place to look —
this page is the raw endpoint, and is also callable as an API at
`/gradio_api/call/analyze`.
Two things it will tell you honestly: the valence axis of the affect model
scores at chance against gold labels, so a state's calm/stressed *direction* is
much less reliable than its high/low activation; and the index is calibrated
against F1 radio, so a clip of ordinary speech will be scored as unusually calm.
"""
demo = gr.Interface(
fn=analyze,
inputs=gr.Audio(type="filepath", sources=["upload", "microphone"],
label="Radio clip"),
outputs=gr.JSON(label="Analysis"),
title="PIT WALL — model backend",
description=DESCRIPTION,
article=(
"Source: [github/pitwall](https://huggingface.co/spaces/rogerdemello/pitwall) · "
"Dataset: [pitwall-f1-radio-analysis]"
"(https://huggingface.co/datasets/rogerdemello/pitwall-f1-radio-analysis)"
),
api_name="analyze",
flagging_mode="never",
)
if __name__ == "__main__":
demo.launch()