Spaces:
Running on Zero
Running on Zero
File size: 14,460 Bytes
9795cbf 868e5b5 9795cbf 80a7b2e 9795cbf 7cb4735 9795cbf aaf92dc 9795cbf 85d2c5b 9795cbf 7cb4735 9795cbf 80a7b2e 868e5b5 80a7b2e 9795cbf 868e5b5 9795cbf 80a7b2e aaf92dc 868e5b5 80a7b2e 9795cbf 868e5b5 9795cbf aaf92dc 9795cbf 85d2c5b 74d52f2 85d2c5b 74d52f2 9795cbf 74d52f2 85d2c5b 74d52f2 9795cbf 74d52f2 85d2c5b 74d52f2 9795cbf 85d2c5b 9795cbf 85d2c5b 4bb4d57 85d2c5b 4bb4d57 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 | """
ScriptFlow inference service β Hausa ASR + Hausa->English MT on a free HF Space.
Exists so the Render backend stays a thin 512MB web process: Whisper-small plus
an MT model needs ~2GB resident, which the free Render plan cannot hold. A free
Space (2 vCPU / 16GB) can, and both are reached over plain HTTP.
GET / -> {"status", "asr", "mt"} (never blocks on model load)
POST /asr -> {"text", "duration", "words": [{word,start,end,speaker}]}
POST /translate -> {"translations": [...]}
/asr returns word-level timings in exactly the shape Chirp 3 produced, so the
backend's cue splitter consumes it unchanged.
"""
import os
import time
import threading
import numpy as np
from fastapi import FastAPI, Request, HTTPException, Header
from pydantic import BaseModel
ASR_MODEL = os.environ.get("ASR_MODEL", "NCAIR1/Hausa-ASR")
MT_MODEL = os.environ.get("MT_MODEL", "Helsinki-NLP/opus-mt-ha-en")
# NCAIR1/Hausa-ASR is gated: the token's account must have accepted the licence
# on the model page, or from_pretrained 403s.
HF_TOKEN = os.environ.get("HF_TOKEN") or None
# Free Spaces are world-reachable. A shared secret keeps strangers off the CPU
# budget; unset means open, which is fine for local testing only.
SERVICE_TOKEN = os.environ.get("SERVICE_TOKEN") or None
SAMPLE_RATE = 16000
# Whisper's context is 30s. The pipeline slides this window with overlap so a
# word straddling a boundary is still decoded once, correctly.
CHUNK_LENGTH_S = float(os.environ.get("CHUNK_LENGTH_S", "30"))
STRIDE_LENGTH_S = float(os.environ.get("STRIDE_LENGTH_S", "5"))
MT_BATCH = int(os.environ.get("MT_BATCH", "16"))
# ZeroGPU hands out a GPU only inside @spaces.GPU functions, and refuses to
# start a Space that declares none ("No @spaces.GPU function detected during
# startup"). Importing spaces also tells us we are on that hardware, so the
# device default follows it.
try:
import spaces
_GPU = spaces.GPU(duration=int(os.environ.get("GPU_DURATION", "120")))
_ON_ZEROGPU = True
except Exception: # local runs, or a plain CPU host
_ON_ZEROGPU = False
def _GPU(fn):
return fn
DEVICE = os.environ.get("ASR_DEVICE", "cuda" if _ON_ZEROGPU else "cpu")
# Gradio owns the process on a Gradio-SDK Space, but it is a FastAPI app
# underneath β so the REST routes below are the real interface and the little UI
# mounted at /ui is only there to give the Space a face (and to make it obvious
# at a glance whether the models have finished loading).
app = FastAPI()
@app.exception_handler(Exception)
def _unhandled(request, exc):
"""
Return the traceback in the response body. Reading a Space's logs needs an
HF token, so an opaque 500 is genuinely hard to diagnose from the client
that is calling this. Nothing here handles user data worth hiding.
"""
import traceback
from fastapi.responses import JSONResponse
tb = traceback.format_exc()
print(tb, flush=True)
return JSONResponse(status_code=500,
content={"error": f"{type(exc).__name__}: {exc}",
"traceback": tb.splitlines()[-12:],
"device": DEVICE})
# --------------------------------------------------------------- MODEL LOAD ---
# Loading takes minutes on a cold Space (weights download + CPU init). Doing it
# at import time would make the Space fail its health check and restart-loop, so
# models load lazily behind a lock and / reports progress instead.
_lock = threading.Lock()
_models: dict = {"asr": None, "mt": None}
_errors: dict = {"asr": None, "mt": None}
def _load(kind: str):
if _models[kind] is not None:
return _models[kind]
with _lock:
if _models[kind] is not None:
return _models[kind]
from transformers import pipeline
t0 = time.time()
try:
if kind == "asr":
obj = pipeline(
"automatic-speech-recognition",
model=ASR_MODEL,
token=HF_TOKEN,
chunk_length_s=CHUNK_LENGTH_S,
stride_length_s=STRIDE_LENGTH_S,
device=DEVICE,
)
else:
# transformers 5 dropped the "translation" pipeline task, so the
# seq2seq model is driven directly. Beam search, no sampling β
# the same cue gives the same English on every run, which is the
# whole reason for moving off Gemini.
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
tok = AutoTokenizer.from_pretrained(MT_MODEL, token=HF_TOKEN)
mdl = AutoModelForSeq2SeqLM.from_pretrained(MT_MODEL, token=HF_TOKEN)
obj = (tok, mdl.to(DEVICE).eval())
except Exception as e:
_errors[kind] = f"{type(e).__name__}: {e}"
raise
print(f"loaded {kind} in {time.time() - t0:.1f}s", flush=True)
_models[kind] = obj
return obj
def _auth(token):
if SERVICE_TOKEN and token != SERVICE_TOKEN:
raise HTTPException(status_code=401, detail="bad service token")
@app.get("/health")
def health():
return {
"status": "ok",
"asr": {"model": ASR_MODEL, "loaded": _models["asr"] is not None,
"error": _errors["asr"]},
"mt": {"model": MT_MODEL, "loaded": _models["mt"] is not None,
"error": _errors["mt"]},
}
# ----------------------------------------------------------------------- ASR ---
def _decode(raw: bytes) -> np.ndarray:
"""
Any container -> float32 mono @16k. The backend already sends 16k mono WAV,
but decoding defensively costs nothing and keeps the service reusable.
"""
try:
from transformers.pipelines.audio_utils import ffmpeg_read
except ImportError: # moved in transformers 5
from transformers.audio_utils import ffmpeg_read
return ffmpeg_read(raw, SAMPLE_RATE)
def _words_from_chunks(chunks, audio_secs: float):
"""
Whisper chunks -> the backend's word contract.
With return_timestamps="word" each chunk is already one word. With the
phrase-level fallback a chunk is several words, so its span is divided
across them by character length β an approximation, but one that keeps cue
boundaries near the speech instead of collapsing them onto a single instant.
"""
words = []
for ch in chunks or []:
text = (ch.get("text") or "").strip()
if not text:
continue
ts = ch.get("timestamp") or (None, None)
start = ts[0]
end = ts[1] if len(ts) > 1 else None
if start is None:
continue
start = float(start)
if audio_secs:
start = min(max(start, 0.0), audio_secs)
if end is not None:
end = float(end)
end = min(max(end, start), audio_secs) if audio_secs else max(end, start)
parts = text.split()
if len(parts) <= 1:
# A None end is meaningful downstream: the backend infers a real end
# from the next word rather than inventing a duration here.
words.append({"word": text, "start": start, "end": end, "speaker": None})
continue
span = (end - start) if end is not None else None
total = sum(len(p) for p in parts) or 1
cursor = start
for p in parts:
if span is None:
words.append({"word": p, "start": cursor, "end": None, "speaker": None})
cursor += 0.3
else:
width = span * (len(p) / total)
words.append({"word": p, "start": cursor, "end": cursor + width,
"speaker": None})
cursor += width
return words
def _device_fallback() -> bool:
"""
Drop to CPU and force a reload. ZeroGPU only grants a device inside its
decorated functions, and the grant can fail for reasons that have nothing
to do with the model β quota, a worker thread, a cold pool. Falling back is
slower but keeps the service answering instead of 500ing.
"""
global DEVICE
if DEVICE == "cpu":
return False
print(f"GPU path failed on {DEVICE} β reloading on CPU", flush=True)
DEVICE = "cpu"
_models["asr"] = _models["mt"] = None
_errors["asr"] = _errors["mt"] = None
return True
def _asr_impl(audio, gen):
"""Decoding, on the GPU when ZeroGPU has granted one."""
pipe = _load("asr")
try:
return pipe(audio.copy(), return_timestamps="word", generate_kwargs=gen), True
except Exception as e:
# Word timings need alignment_heads in the generation config. Fine-tunes
# frequently drop them; phrase-level timings still make usable cues.
print(f"word timestamps unavailable ({type(e).__name__}: {e}) β phrase level",
flush=True)
return pipe(audio.copy(), return_timestamps=True, generate_kwargs=gen), False
_asr_gpu = _GPU(_asr_impl)
def _asr_run(audio, gen):
try:
return _asr_gpu(audio, gen)
except Exception as e:
print(f"asr on {DEVICE} failed ({type(e).__name__}: {e})", flush=True)
if not _device_fallback():
raise
return _asr_impl(audio, gen)
@app.post("/asr")
async def asr(request: Request, x_service_token: str = Header(default=None)):
_auth(x_service_token)
raw = await request.body()
if not raw:
raise HTTPException(status_code=400, detail="empty audio body")
audio = _decode(raw)
audio_secs = len(audio) / float(SAMPLE_RATE)
# language/task are forced because a fine-tune sometimes ships a generation
# config still defaulting to English transcription, which silently produces
# garbage on Hausa audio.
gen = {"language": "ha", "task": "transcribe"}
t0 = time.time()
out, word_level = _asr_run(audio, gen)
words = _words_from_chunks(out.get("chunks"), audio_secs)
print(f"asr {audio_secs:.1f}s audio -> {len(words)} words in {time.time() - t0:.1f}s "
f"({'word' if word_level else 'phrase'} timings)", flush=True)
return {
"text": (out.get("text") or "").strip(),
"duration": audio_secs,
"wordLevel": word_level,
"words": words,
}
# --------------------------------------------------------------- TRANSLATION ---
class TranslateIn(BaseModel):
texts: list[str]
def _mt_impl(texts):
import torch
tok, mdl = _load("mt")
out = []
for start in range(0, len(texts), MT_BATCH):
chunk = texts[start:start + MT_BATCH]
enc = tok(chunk, return_tensors="pt", padding=True, truncation=True,
max_length=512).to(DEVICE)
with torch.inference_mode():
gen = mdl.generate(**enc, num_beams=4, max_new_tokens=256)
out.extend(tok.batch_decode(gen, skip_special_tokens=True))
return out
_mt_gpu = _GPU(_mt_impl)
def _mt_run(texts):
try:
return _mt_gpu(texts)
except Exception as e:
print(f"mt on {DEVICE} failed ({type(e).__name__}: {e})", flush=True)
if not _device_fallback():
raise
return _mt_impl(texts)
@app.post("/translate")
def translate(body: TranslateIn, x_service_token: str = Header(default=None)):
_auth(x_service_token)
texts = body.texts or []
if not texts:
return {"translations": []}
# Blank inputs are held out and reinserted: MarianMT will happily emit a
# hallucinated sentence for an empty string.
idx = [i for i, t in enumerate(texts) if (t or "").strip()]
out = [""] * len(texts)
if idx:
res = _mt_run([texts[i] for i in idx])
for i, r in zip(idx, res):
out[i] = (r or "").strip()
return {"translations": out}
# ------------------------------------------------------------------- LAUNCH ---
# A Gradio-SDK Space expects Gradio itself to bind the port. Two earlier shapes
# both failed here: running our own uvicorn raced whatever the runner had
# already put on 7860 ("address already in use"), and defining a bare ASGI app
# with no listener left the container with nothing serving at all.
#
# So Gradio launches, and the REST routes are grafted onto the server it starts.
# They are *prepended*, because Gradio registers catch-all routes for its own
# frontend that would otherwise shadow /asr and /translate.
def _ui_check():
import json as _json
return _json.dumps(health(), indent=2)
_UI_TEXT = (
"## ScriptFlow inference service\n\n"
"Hausa ASR + Hausa->English MT. The REST API is the real interface:\n\n"
"- `POST /asr` β raw audio bytes as the body\n"
"- `POST /translate` β `{\"texts\": [...]}`\n"
"- `GET /health` β model load state\n\n"
"Both POSTs take an `X-Service-Token` header. The first call after a cold "
"start downloads ~1GB of weights and takes several minutes."
)
def _build_ui():
import gradio as gr
with gr.Blocks(title="ScriptFlow Hausa inference") as demo:
gr.Markdown(_UI_TEXT)
out = gr.Code(label="GET /health", language="json")
gr.Button("Check status").click(_ui_check, inputs=None, outputs=out)
return demo
if __name__ == "__main__":
import gradio as gr
demo = _build_ui()
# prevent_thread_lock so this returns and the routes can be attached; the
# process is held open by block_thread() at the end instead.
demo.launch(
server_name="0.0.0.0",
server_port=int(os.environ.get("PORT", 7860)),
prevent_thread_lock=True,
show_error=True,
# SSR is Gradio 6's default and puts a Node proxy on the public port,
# serving Python on 7861 behind it. Routes grafted onto the Python app
# are then unreachable from outside, so the REST API has to have the
# public port to itself.
ssr_mode=False,
)
demo.app.router.routes[0:0] = app.router.routes
print("REST routes attached: " +
", ".join(sorted(r.path for r in app.router.routes if hasattr(r, "methods"))),
flush=True)
# block_thread() returned immediately here and the container exited; an
# explicit wait keeps the process alive for as long as the server runs.
try:
demo.block_thread()
except KeyboardInterrupt:
pass
else:
threading.Event().wait()
|