DevEmmy commited on
Commit
9795cbf
·
1 Parent(s): b3e0e3c

Serve NCAIR1/Hausa-ASR + opus-mt-ha-en over REST

Browse files

Gradio SDK, but the REST routes are the interface: the Blocks UI is mounted
onto our own FastAPI so /asr and /translate stay at the root, where the
ScriptFlow backend already expects them.

/asr returns word-level timings in the shape Chirp 3 produced, so the
backend's existing cue splitter consumes the output unchanged.

Files changed (5) hide show
  1. Dockerfile +26 -0
  2. README.md +58 -5
  3. app.py +252 -0
  4. packages.txt +1 -0
  5. requirements.txt +8 -0
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Space (Docker SDK), free CPU tier: 2 vCPU / 16GB RAM.
2
+ FROM python:3.11-slim
3
+
4
+ # ffmpeg decodes whatever audio the backend posts; transformers' ffmpeg_read
5
+ # shells out to it rather than depending on a Python codec stack.
6
+ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ # Spaces run as uid 1000; HF caches must be writable or model download fails.
10
+ RUN useradd -m -u 1000 user
11
+ USER user
12
+ ENV HOME=/home/user \
13
+ PATH=/home/user/.local/bin:$PATH \
14
+ HF_HOME=/home/user/.cache/huggingface \
15
+ PYTHONUNBUFFERED=1
16
+
17
+ WORKDIR $HOME/app
18
+
19
+ COPY --chown=user requirements.txt .
20
+ RUN pip install --no-cache-dir --user -r requirements.txt
21
+
22
+ COPY --chown=user app.py .
23
+
24
+ # Spaces route external traffic to 7860.
25
+ EXPOSE 7860
26
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
- title: Scriptflow Hausa
3
- emoji: 🌍
4
- colorFrom: blue
5
- colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.24.0
8
  python_version: '3.12'
@@ -12,4 +12,57 @@ license: other
12
  short_description: Hausa ASR + MT for ScriptFlow
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: ScriptFlow Hausa Inference
3
+ emoji: 🎬
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.24.0
8
  python_version: '3.12'
 
12
  short_description: Hausa ASR + MT for ScriptFlow
13
  ---
14
 
15
+ # ScriptFlow inference service
16
+
17
+ Hausa ASR + Hausa→English MT, served over HTTP so the Render backend does not
18
+ have to hold ~2GB of model weights in a 512MB process.
19
+
20
+ - **ASR** — [`NCAIR1/Hausa-ASR`](https://huggingface.co/NCAIR1/Hausa-ASR) (Whisper-small fine-tune)
21
+ - **MT** — [`Helsinki-NLP/opus-mt-ha-en`](https://huggingface.co/Helsinki-NLP/opus-mt-ha-en)
22
+
23
+ ## Deploying
24
+
25
+ 1. Create a Space: **New Space → Gradio → Blank**, hardware **CPU basic (free)**.
26
+ Gradio rather than Docker because Docker Spaces need PRO. It costs nothing
27
+ here: Gradio is FastAPI underneath, so `/asr` and `/translate` sit at the
28
+ root exactly as they would have, with a status UI at `/ui`. `Dockerfile` is
29
+ kept for anyone who does have PRO — switch `sdk:` back to `docker` to use it.
30
+ 2. Push the contents of this directory to it. `packages.txt` installs ffmpeg.
31
+ 3. On the model page for `NCAIR1/Hausa-ASR`, **accept the licence** — the model
32
+ is gated (`gated: auto`), and without acceptance the download 403s.
33
+ 4. In **Space → Settings → Secrets**, set:
34
+ - `HF_TOKEN` — a read token from the account that accepted the licence
35
+ - `SERVICE_TOKEN` — any random string; the backend must send the same value
36
+
37
+ The first request downloads weights and can take several minutes. `GET /`
38
+ answers immediately throughout and reports load state, so you can watch it come
39
+ up without holding a request open.
40
+
41
+ ## API
42
+
43
+ `POST /asr` — raw audio bytes as the body, `X-Service-Token` header.
44
+
45
+ ```json
46
+ {
47
+ "text": "...",
48
+ "duration": 41.2,
49
+ "wordLevel": true,
50
+ "words": [{"word": "sannu", "start": 0.4, "end": 0.9, "speaker": null}]
51
+ }
52
+ ```
53
+
54
+ Times are relative to the audio posted; the backend adds each chunk's offset.
55
+ `end` may be `null` — that is meaningful, and the backend infers a real end from
56
+ the following word rather than inventing a duration here.
57
+
58
+ `POST /translate` — `{"texts": [...]}`, returns `{"translations": [...]}` with
59
+ one entry per input, same order.
60
+
61
+ ## Notes
62
+
63
+ - Free Spaces sleep after inactivity; the first call after a sleep pays the
64
+ cold start again.
65
+ - CPU inference on Whisper-small runs roughly 1–3× realtime, so a 20-minute
66
+ chunk is minutes of compute, not seconds.
67
+ - `NCAIR1/Hausa-ASR` is licensed with a 1000 active end-user cap for
68
+ non-commercial use. Check that against how ScriptFlow ships.
app.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ScriptFlow inference service — Hausa ASR + Hausa->English MT on a free HF Space.
3
+
4
+ Exists so the Render backend stays a thin 512MB web process: Whisper-small plus
5
+ an MT model needs ~2GB resident, which the free Render plan cannot hold. A free
6
+ Space (2 vCPU / 16GB) can, and both are reached over plain HTTP.
7
+
8
+ GET / -> {"status", "asr", "mt"} (never blocks on model load)
9
+ POST /asr -> {"text", "duration", "words": [{word,start,end,speaker}]}
10
+ POST /translate -> {"translations": [...]}
11
+
12
+ /asr returns word-level timings in exactly the shape Chirp 3 produced, so the
13
+ backend's cue splitter consumes it unchanged.
14
+ """
15
+
16
+ import os
17
+ import time
18
+ import threading
19
+
20
+ import numpy as np
21
+ from fastapi import FastAPI, Request, HTTPException, Header
22
+ from pydantic import BaseModel
23
+
24
+ ASR_MODEL = os.environ.get("ASR_MODEL", "NCAIR1/Hausa-ASR")
25
+ MT_MODEL = os.environ.get("MT_MODEL", "Helsinki-NLP/opus-mt-ha-en")
26
+ # NCAIR1/Hausa-ASR is gated: the token's account must have accepted the licence
27
+ # on the model page, or from_pretrained 403s.
28
+ HF_TOKEN = os.environ.get("HF_TOKEN") or None
29
+ # Free Spaces are world-reachable. A shared secret keeps strangers off the CPU
30
+ # budget; unset means open, which is fine for local testing only.
31
+ SERVICE_TOKEN = os.environ.get("SERVICE_TOKEN") or None
32
+
33
+ SAMPLE_RATE = 16000
34
+ # Whisper's context is 30s. The pipeline slides this window with overlap so a
35
+ # word straddling a boundary is still decoded once, correctly.
36
+ CHUNK_LENGTH_S = float(os.environ.get("CHUNK_LENGTH_S", "30"))
37
+ STRIDE_LENGTH_S = float(os.environ.get("STRIDE_LENGTH_S", "5"))
38
+ MT_BATCH = int(os.environ.get("MT_BATCH", "16"))
39
+
40
+ # Gradio owns the process on a Gradio-SDK Space, but it is a FastAPI app
41
+ # underneath — so the REST routes below are the real interface and the little UI
42
+ # mounted at /ui is only there to give the Space a face (and to make it obvious
43
+ # at a glance whether the models have finished loading).
44
+ app = FastAPI()
45
+
46
+ # --------------------------------------------------------------- MODEL LOAD ---
47
+ # Loading takes minutes on a cold Space (weights download + CPU init). Doing it
48
+ # at import time would make the Space fail its health check and restart-loop, so
49
+ # models load lazily behind a lock and / reports progress instead.
50
+ _lock = threading.Lock()
51
+ _models: dict = {"asr": None, "mt": None}
52
+ _errors: dict = {"asr": None, "mt": None}
53
+
54
+
55
+ def _load(kind: str):
56
+ if _models[kind] is not None:
57
+ return _models[kind]
58
+ with _lock:
59
+ if _models[kind] is not None:
60
+ return _models[kind]
61
+ from transformers import pipeline
62
+ t0 = time.time()
63
+ try:
64
+ if kind == "asr":
65
+ obj = pipeline(
66
+ "automatic-speech-recognition",
67
+ model=ASR_MODEL,
68
+ token=HF_TOKEN,
69
+ chunk_length_s=CHUNK_LENGTH_S,
70
+ stride_length_s=STRIDE_LENGTH_S,
71
+ device=-1,
72
+ )
73
+ else:
74
+ obj = pipeline("translation", model=MT_MODEL, token=HF_TOKEN, device=-1)
75
+ except Exception as e:
76
+ _errors[kind] = f"{type(e).__name__}: {e}"
77
+ raise
78
+ print(f"loaded {kind} in {time.time() - t0:.1f}s", flush=True)
79
+ _models[kind] = obj
80
+ return obj
81
+
82
+
83
+ def _auth(token):
84
+ if SERVICE_TOKEN and token != SERVICE_TOKEN:
85
+ raise HTTPException(status_code=401, detail="bad service token")
86
+
87
+
88
+ @app.get("/")
89
+ def health():
90
+ return {
91
+ "status": "ok",
92
+ "asr": {"model": ASR_MODEL, "loaded": _models["asr"] is not None,
93
+ "error": _errors["asr"]},
94
+ "mt": {"model": MT_MODEL, "loaded": _models["mt"] is not None,
95
+ "error": _errors["mt"]},
96
+ }
97
+
98
+
99
+ # ----------------------------------------------------------------------- ASR ---
100
+ def _decode(raw: bytes) -> np.ndarray:
101
+ """
102
+ Any container -> float32 mono @16k. The backend already sends 16k mono WAV,
103
+ but decoding defensively costs nothing and keeps the service reusable.
104
+ """
105
+ from transformers.pipelines.audio_utils import ffmpeg_read
106
+ return ffmpeg_read(raw, SAMPLE_RATE)
107
+
108
+
109
+ def _words_from_chunks(chunks, audio_secs: float):
110
+ """
111
+ Whisper chunks -> the backend's word contract.
112
+
113
+ With return_timestamps="word" each chunk is already one word. With the
114
+ phrase-level fallback a chunk is several words, so its span is divided
115
+ across them by character length — an approximation, but one that keeps cue
116
+ boundaries near the speech instead of collapsing them onto a single instant.
117
+ """
118
+ words = []
119
+ for ch in chunks or []:
120
+ text = (ch.get("text") or "").strip()
121
+ if not text:
122
+ continue
123
+ ts = ch.get("timestamp") or (None, None)
124
+ start = ts[0]
125
+ end = ts[1] if len(ts) > 1 else None
126
+ if start is None:
127
+ continue
128
+ start = float(start)
129
+ if audio_secs:
130
+ start = min(max(start, 0.0), audio_secs)
131
+ if end is not None:
132
+ end = float(end)
133
+ end = min(max(end, start), audio_secs) if audio_secs else max(end, start)
134
+
135
+ parts = text.split()
136
+ if len(parts) <= 1:
137
+ # A None end is meaningful downstream: the backend infers a real end
138
+ # from the next word rather than inventing a duration here.
139
+ words.append({"word": text, "start": start, "end": end, "speaker": None})
140
+ continue
141
+
142
+ span = (end - start) if end is not None else None
143
+ total = sum(len(p) for p in parts) or 1
144
+ cursor = start
145
+ for p in parts:
146
+ if span is None:
147
+ words.append({"word": p, "start": cursor, "end": None, "speaker": None})
148
+ cursor += 0.3
149
+ else:
150
+ width = span * (len(p) / total)
151
+ words.append({"word": p, "start": cursor, "end": cursor + width,
152
+ "speaker": None})
153
+ cursor += width
154
+ return words
155
+
156
+
157
+ @app.post("/asr")
158
+ async def asr(request: Request, x_service_token: str = Header(default=None)):
159
+ _auth(x_service_token)
160
+ raw = await request.body()
161
+ if not raw:
162
+ raise HTTPException(status_code=400, detail="empty audio body")
163
+
164
+ audio = _decode(raw)
165
+ audio_secs = len(audio) / float(SAMPLE_RATE)
166
+ pipe = _load("asr")
167
+
168
+ # language/task are forced because a fine-tune sometimes ships a generation
169
+ # config still defaulting to English transcription, which silently produces
170
+ # garbage on Hausa audio.
171
+ gen = {"language": "ha", "task": "transcribe"}
172
+
173
+ t0 = time.time()
174
+ word_level = True
175
+ try:
176
+ out = pipe(audio.copy(), return_timestamps="word", generate_kwargs=gen)
177
+ except Exception as e:
178
+ # Word timings need alignment_heads in the generation config. Fine-tunes
179
+ # frequently drop them; phrase-level timings still make usable cues.
180
+ print(f"word timestamps unavailable ({type(e).__name__}: {e}) — phrase level",
181
+ flush=True)
182
+ word_level = False
183
+ out = pipe(audio.copy(), return_timestamps=True, generate_kwargs=gen)
184
+
185
+ words = _words_from_chunks(out.get("chunks"), audio_secs)
186
+ print(f"asr {audio_secs:.1f}s audio -> {len(words)} words in {time.time() - t0:.1f}s "
187
+ f"({'word' if word_level else 'phrase'} timings)", flush=True)
188
+
189
+ return {
190
+ "text": (out.get("text") or "").strip(),
191
+ "duration": audio_secs,
192
+ "wordLevel": word_level,
193
+ "words": words,
194
+ }
195
+
196
+
197
+ # --------------------------------------------------------------- TRANSLATION ---
198
+ class TranslateIn(BaseModel):
199
+ texts: list[str]
200
+
201
+
202
+ @app.post("/translate")
203
+ def translate(body: TranslateIn, x_service_token: str = Header(default=None)):
204
+ _auth(x_service_token)
205
+ texts = body.texts or []
206
+ if not texts:
207
+ return {"translations": []}
208
+
209
+ pipe = _load("mt")
210
+ # Blank inputs are held out and reinserted: MarianMT will happily emit a
211
+ # hallucinated sentence for an empty string.
212
+ idx = [i for i, t in enumerate(texts) if (t or "").strip()]
213
+ out = [""] * len(texts)
214
+ if idx:
215
+ res = pipe([texts[i] for i in idx], batch_size=MT_BATCH, truncation=True)
216
+ for i, r in zip(idx, res):
217
+ out[i] = (r.get("translation_text") or "").strip()
218
+ return {"translations": out}
219
+
220
+
221
+ # ------------------------------------------------------------------- LAUNCH ---
222
+ # A Gradio-SDK Space runs `python app.py` and expects something listening on
223
+ # 7860. Mounting the Blocks onto our own FastAPI (rather than calling
224
+ # demo.launch()) keeps /asr and /translate at the root, where the backend
225
+ # already expects them, and puts the UI at /ui.
226
+ def _ui_check(_):
227
+ import json as _json
228
+ return _json.dumps(health(), indent=2)
229
+
230
+
231
+ def _build_ui():
232
+ import gradio as gr
233
+ with gr.Blocks(title="ScriptFlow Hausa inference") as demo:
234
+ gr.Markdown(
235
+ "## ScriptFlow inference service\n"
236
+ "Hausa ASR + Hausa→English MT. The REST API is the real interface:\n"
237
+ "`POST /asr` (raw audio body) and `POST /translate` "
238
+ "(`{\"texts\": [...]}`), both with an `X-Service-Token` header.\n\n"
239
+ "Press the button to see whether the models have loaded — the first "
240
+ "call after a cold start downloads ~1GB and takes several minutes."
241
+ )
242
+ out = gr.Code(label="GET /", language="json")
243
+ gr.Button("Check status").click(_ui_check, inputs=[gr.State(None)], outputs=out)
244
+ return demo
245
+
246
+
247
+ if __name__ == "__main__":
248
+ import uvicorn
249
+ import gradio as gr
250
+
251
+ application = gr.mount_gradio_app(app, _build_ui(), path="/ui")
252
+ uvicorn.run(application, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cpu
2
+
3
+ gradio
4
+ torch
5
+ transformers>=4.44,<5
6
+ sentencepiece
7
+ sacremoses
8
+ numpy