Spaces:
Running on Zero
Running on Zero
| """ | |
| 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() | |
| 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") | |
| 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) | |
| 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) | |
| 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() | |