Spaces:
Runtime error
Runtime error
OpenCode commited on
Commit ·
dd6bf3b
1
Parent(s): 839fa96
feat(asr): add optional Nemotron voice-note input
Browse files- AGENTS.md +2 -0
- README.md +7 -4
- app.py +165 -2
- modal_app.py +33 -17
- modal_asr_app.py +294 -0
AGENTS.md
CHANGED
|
@@ -225,6 +225,8 @@ hf spaces variables add build-small-hackathon/Fabella \
|
|
| 225 |
--env MODAL_JUDGE_URL=https://khoitruong071510--fabella-serve-judge.modal.run
|
| 226 |
hf spaces variables add build-small-hackathon/Fabella \
|
| 227 |
--env MODAL_TTS_URL=https://khoitruong071510--fabella-serve-tts.modal.run
|
|
|
|
|
|
|
| 228 |
|
| 229 |
# HF Space: upload code
|
| 230 |
hf upload build-small-hackathon/Fabella app.py --type space
|
|
|
|
| 225 |
--env MODAL_JUDGE_URL=https://khoitruong071510--fabella-serve-judge.modal.run
|
| 226 |
hf spaces variables add build-small-hackathon/Fabella \
|
| 227 |
--env MODAL_TTS_URL=https://khoitruong071510--fabella-serve-tts.modal.run
|
| 228 |
+
hf spaces variables add build-small-hackathon/Fabella \
|
| 229 |
+
--env MODAL_ASR_URL=https://khoitruong071510--fabella-asr-experiment-serve-asr.modal.run
|
| 230 |
|
| 231 |
# HF Space: upload code
|
| 232 |
hf upload build-small-hackathon/Fabella app.py --type space
|
README.md
CHANGED
|
@@ -73,18 +73,19 @@ The parent sees the validated draft, not a raw model output. If the judge reject
|
|
| 73 |
| **Drafter** | `google/gemma-4-E4B-it` | 4B | Modal A10G · vLLM | Apache 2.0, fast on short empathetic text, native tool calling | **LangGraph ReAct** — needs the state machine (draft → validate → revise → end) with tool calls and middleware-driven early exit |
|
| 74 |
| **Judge** | `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` | 4B | Modal A10G · vLLM | Follows structured-output instructions reliably | **Pydantic v2** + one LLM call + one repair retry — task is bounded, no agent loop needed |
|
| 75 |
| **Read aloud** | `openbmb/VoxCPM2` | ~2B | Modal L4 · FastAPI | Apache 2.0, 48 kHz, voice-description control | Separate FastAPI server; only called when the user clicks **Read aloud** |
|
|
|
|
| 76 |
|
| 77 |
The split is deliberate. The drafter needs agentic machinery (state machine, tool calls, conditional edges, jump-to-end). The judge doesn't — its job is "receive rubric + draft, return a structured verdict." Pydantic gives disciplined output, type safety, and a one-shot repair retry. Two layers, two files, two execution models: `agent.py` for the loop, `judge.py` for the verdict.
|
| 78 |
|
| 79 |
-
|
| 80 |
|
| 81 |
---
|
| 82 |
|
| 83 |
## Sponsor prize notes
|
| 84 |
|
| 85 |
- **OpenAI / Codex** — Codex was used as a coding assistant for early boilerplate and scaffolding. This sponsor-track note is about development assistance, not runtime inference: Fabella's model pipeline uses Gemma, Nemotron, and VoxCPM2.
|
| 86 |
-
- **NVIDIA** — `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` is the second model in the pipeline and acts as the structured-output judge in `judge.py`.
|
| 87 |
-
- **Modal** — Modal runs
|
| 88 |
- **OpenBMB** — `openbmb/VoxCPM2` powers the optional **Read aloud** feature.
|
| 89 |
|
| 90 |
---
|
|
@@ -94,7 +95,8 @@ All three models sit comfortably under the **32B cap** — Fabella uses **10B of
|
|
| 94 |
- **HF Space (CPU)** — custom HTML + CSS + JS frontend served by `gradio.Server` (FastAPI subclass). Chat-style, parent-friendly UI: welcome screen with example situations, alternating parent / Fabella turns, per-turn Read-aloud button, no default Gradio chrome.
|
| 95 |
- **HF OAuth** — enabled for personalization; unsigned users fall back to browser-local anonymous sessions.
|
| 96 |
- **HF Bucket per-user JSON** — minimal chat history and parent preferences persist at `/data/fabella-data/user-<owner_key>.json` (signed-in users keyed by HF username, anonymous users keyed by a `localStorage` session ID).
|
| 97 |
-
- **
|
|
|
|
| 98 |
- **Drafter** (A10G) — vLLM with `--language-model-only --enable-auto-tool-choice --tool-call-parser gemma4 --enforce-eager --safetensors-load-strategy eager --max-model-len 8192`
|
| 99 |
- **Judge** (A10G) — vLLM with `--enforce-eager --safetensors-load-strategy eager --max-model-len 4096` (no tool-calling flags; Nemotron's tool-call dialect isn't a vLLM built-in)
|
| 100 |
- **TTS** (L4) — VoxCPM2 wrapped in a tiny FastAPI app on the smallest GPU that fits
|
|
@@ -102,6 +104,7 @@ All three models sit comfortably under the **32B cap** — Fabella uses **10B of
|
|
| 102 |
- **Aggressive summarization in `agent.py`**: `_build_user_prompt` keeps the last 2 conversation turns verbatim and compresses everything older into a single short line capped at 320 chars. This is what lets us run the drafter at `--max-model-len 8192` instead of the model's nominal 32k, and it directly reduces per-request drafter token cost on long follow-up conversations.
|
| 103 |
- **Cold-start tunings**: `--enforce-eager` skips CUDA-graph capture (saves 20–40s of cold start at a small per-token throughput cost). `VLLM_DEEP_GEMM_WARMUP=skip` skips the dense-model MoE kernel warmup. `VLLM_USE_AOT_COMPILE=1` + `VLLM_CACHE_ROOT=/root/.cache/vllm` lets torch.compile artifacts persist across cold starts via the cache volume.
|
| 104 |
- **No warmup ping on Space import.** The previous deployment fired a `/health` request to each endpoint on Space startup so the first parent click would land on a warm container. We removed it: every Space restart (code push, env-var change, periodic rebalance) paid for an A10G cold start whether or not a parent ever arrived. With the ping gone, the first request after a quiet period still pays a 30-60s cold start (image-baked weights, eager mode, AOT compile cache, deep-gemm warmup skip) and the 2-minute `scaledown_window` keeps a parent who reads the welcome screen and clicks a chip on a warm container for free.
|
|
|
|
| 105 |
- **LangChain 1.x** ReAct loop with a custom middleware (`FabellaAgentMiddleware`) that jumps to `end` after a successful validation or after a hard cap of two tool calls. The `@hook_config(can_jump_to=["end"])` is required — without it the early-exit silently does nothing.
|
| 106 |
- **Pydantic v2** for the judge's structured output. `JudgeVerdict` has five fields (`ok`, `issues`, `score`, `verdict`, `reasoning`); cross-field consistency (`ok` ⇔ `verdict`) is enforced in code, not in the prompt.
|
| 107 |
|
|
|
|
| 73 |
| **Drafter** | `google/gemma-4-E4B-it` | 4B | Modal A10G · vLLM | Apache 2.0, fast on short empathetic text, native tool calling | **LangGraph ReAct** — needs the state machine (draft → validate → revise → end) with tool calls and middleware-driven early exit |
|
| 74 |
| **Judge** | `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` | 4B | Modal A10G · vLLM | Follows structured-output instructions reliably | **Pydantic v2** + one LLM call + one repair retry — task is bounded, no agent loop needed |
|
| 75 |
| **Read aloud** | `openbmb/VoxCPM2` | ~2B | Modal L4 · FastAPI | Apache 2.0, 48 kHz, voice-description control | Separate FastAPI server; only called when the user clicks **Read aloud** |
|
| 76 |
+
| **Voice note** | `nvidia/nemotron-3.5-asr-streaming-0.6b` | 0.6B | Modal T4 · NeMo | Small multilingual streaming ASR with language prompts | Optional **Record** button: transcribes a short parent voice note into the textbox for review before drafting |
|
| 77 |
|
| 78 |
The split is deliberate. The drafter needs agentic machinery (state machine, tool calls, conditional edges, jump-to-end). The judge doesn't — its job is "receive rubric + draft, return a structured verdict." Pydantic gives disciplined output, type safety, and a one-shot repair retry. Two layers, two files, two execution models: `agent.py` for the loop, `judge.py` for the verdict.
|
| 79 |
|
| 80 |
+
The core drafter/judge/read-aloud path uses **10B of parameters total**, with the largest single model at 4B. The optional voice-note input adds a separate 0.6B ASR model. The largest single model remains 4B, so Fabella is still a candidate for the **Tiny Titan** special award (≤4B).
|
| 81 |
|
| 82 |
---
|
| 83 |
|
| 84 |
## Sponsor prize notes
|
| 85 |
|
| 86 |
- **OpenAI / Codex** — Codex was used as a coding assistant for early boilerplate and scaffolding. This sponsor-track note is about development assistance, not runtime inference: Fabella's model pipeline uses Gemma, Nemotron, and VoxCPM2.
|
| 87 |
+
- **NVIDIA** — `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` is the second model in the pipeline and acts as the structured-output judge in `judge.py`. `nvidia/nemotron-3.5-asr-streaming-0.6b` powers the optional **Record** voice-note input.
|
| 88 |
+
- **Modal** — Modal runs the inference services: the Gemma drafter, the Nemotron judge, the VoxCPM2 TTS service, and the isolated T4 ASR experiment endpoint.
|
| 89 |
- **OpenBMB** — `openbmb/VoxCPM2` powers the optional **Read aloud** feature.
|
| 90 |
|
| 91 |
---
|
|
|
|
| 95 |
- **HF Space (CPU)** — custom HTML + CSS + JS frontend served by `gradio.Server` (FastAPI subclass). Chat-style, parent-friendly UI: welcome screen with example situations, alternating parent / Fabella turns, per-turn Read-aloud button, no default Gradio chrome.
|
| 96 |
- **HF OAuth** — enabled for personalization; unsigned users fall back to browser-local anonymous sessions.
|
| 97 |
- **HF Bucket per-user JSON** — minimal chat history and parent preferences persist at `/data/fabella-data/user-<owner_key>.json` (signed-in users keyed by HF username, anonymous users keyed by a `localStorage` session ID).
|
| 98 |
+
- **Voice note ASR** — the optional **Record** button uses the browser `MediaRecorder`, sends a short base64 audio note to the Space's `transcribe_audio` API, and the Space proxies it to an isolated Modal T4 endpoint (`modal_asr_app.py`). The ASR endpoint follows NVIDIA NeMo's documented cache-aware streaming path (`set_inference_prompt`, `CacheAwareStreamingAudioBuffer`, `conformer_stream_step`) rather than plain `transcribe()`.
|
| 99 |
+
- **Modal core app** — one app, three web servers, all `min_containers=0` with a 2-minute `scaledown_window` so they cold-start on demand (3-day demo budget):
|
| 100 |
- **Drafter** (A10G) — vLLM with `--language-model-only --enable-auto-tool-choice --tool-call-parser gemma4 --enforce-eager --safetensors-load-strategy eager --max-model-len 8192`
|
| 101 |
- **Judge** (A10G) — vLLM with `--enforce-eager --safetensors-load-strategy eager --max-model-len 4096` (no tool-calling flags; Nemotron's tool-call dialect isn't a vLLM built-in)
|
| 102 |
- **TTS** (L4) — VoxCPM2 wrapped in a tiny FastAPI app on the smallest GPU that fits
|
|
|
|
| 104 |
- **Aggressive summarization in `agent.py`**: `_build_user_prompt` keeps the last 2 conversation turns verbatim and compresses everything older into a single short line capped at 320 chars. This is what lets us run the drafter at `--max-model-len 8192` instead of the model's nominal 32k, and it directly reduces per-request drafter token cost on long follow-up conversations.
|
| 105 |
- **Cold-start tunings**: `--enforce-eager` skips CUDA-graph capture (saves 20–40s of cold start at a small per-token throughput cost). `VLLM_DEEP_GEMM_WARMUP=skip` skips the dense-model MoE kernel warmup. `VLLM_USE_AOT_COMPILE=1` + `VLLM_CACHE_ROOT=/root/.cache/vllm` lets torch.compile artifacts persist across cold starts via the cache volume.
|
| 106 |
- **No warmup ping on Space import.** The previous deployment fired a `/health` request to each endpoint on Space startup so the first parent click would land on a warm container. We removed it: every Space restart (code push, env-var change, periodic rebalance) paid for an A10G cold start whether or not a parent ever arrived. With the ping gone, the first request after a quiet period still pays a 30-60s cold start (image-baked weights, eager mode, AOT compile cache, deep-gemm warmup skip) and the 2-minute `scaledown_window` keeps a parent who reads the welcome screen and clicks a chip on a warm container for free.
|
| 107 |
+
- **Modal ASR experiment app** — separate `fabella-asr-experiment` deployment on T4 with `min_containers=0`; only wakes when a parent clicks **Record**.
|
| 108 |
- **LangChain 1.x** ReAct loop with a custom middleware (`FabellaAgentMiddleware`) that jumps to `end` after a successful validation or after a hard cap of two tool calls. The `@hook_config(can_jump_to=["end"])` is required — without it the early-exit silently does nothing.
|
| 109 |
- **Pydantic v2** for the judge's structured output. `JudgeVerdict` has five fields (`ok`, `issues`, `score`, `verdict`, `reasoning`); cross-field consistency (`ok` ⇔ `verdict`) is enforced in code, not in the prompt.
|
| 110 |
|
app.py
CHANGED
|
@@ -78,6 +78,10 @@ MODAL_TTS_URL = os.environ.get(
|
|
| 78 |
"MODAL_TTS_URL",
|
| 79 |
"https://khoitruong071510--fabella-serve-tts.modal.run",
|
| 80 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
# HF Spaces bucket mount. The current Space has a writable bucket mounted at
|
| 83 |
# /models. We avoid a separate database cloud API by storing one minimal JSON
|
|
@@ -464,6 +468,61 @@ def make_audio(text: str, tone: str, opener: str = "", body: str = "", closer: s
|
|
| 464 |
return _make_audio_sync(text, tone, opener, body, closer, followup)
|
| 465 |
|
| 466 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
|
| 468 |
@app.get("/api/me")
|
| 469 |
async def api_me(request: Request):
|
|
@@ -929,6 +988,19 @@ a { color: var(--accent-strong); }
|
|
| 929 |
}
|
| 930 |
.composer textarea::placeholder { color: var(--text-muted); }
|
| 931 |
.composer-controls { display: flex; align-items: center; gap: 8px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 932 |
.btn-send {
|
| 933 |
border: 0;
|
| 934 |
background: var(--accent);
|
|
@@ -948,7 +1020,14 @@ a { color: var(--accent-strong); }
|
|
| 948 |
text-align: center;
|
| 949 |
margin-top: 8px;
|
| 950 |
}
|
| 951 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 952 |
.typing {
|
| 953 |
display: inline-flex; align-items: center; gap: 6px;
|
| 954 |
font: 500 13px var(--font-sans); color: var(--text-muted);
|
|
@@ -1002,10 +1081,12 @@ a { color: var(--accent-strong); }
|
|
| 1002 |
<form id="composer" class="composer-inner" novalidate>
|
| 1003 |
<textarea id="input" rows="1" placeholder="Describe the hard situation. A sentence or two is enough." maxlength="800" required></textarea>
|
| 1004 |
<div class="composer-controls">
|
|
|
|
| 1005 |
<button type="submit" class="btn-send" id="send-btn"><span id="send-label">Draft</span><span aria-hidden="true">→</span></button>
|
| 1006 |
</div>
|
| 1007 |
</form>
|
| 1008 |
-
<div class="
|
|
|
|
| 1009 |
</section>
|
| 1010 |
|
| 1011 |
<dialog id="settings" style="border:1px solid var(--line); border-radius: var(--radius); padding: 0; max-width: 480px; width: calc(100% - 32px); background: var(--surface); color: var(--text);">
|
|
@@ -1067,6 +1148,8 @@ a { color: var(--accent-strong); }
|
|
| 1067 |
|
| 1068 |
var thread = document.getElementById("thread");
|
| 1069 |
var input = document.getElementById("input");
|
|
|
|
|
|
|
| 1070 |
var sendBtn = document.getElementById("send-btn");
|
| 1071 |
var sendLabel = document.getElementById("send-label");
|
| 1072 |
var ageChip = document.getElementById("age-chip");
|
|
@@ -1086,6 +1169,9 @@ a { color: var(--accent-strong); }
|
|
| 1086 |
var currentAge = 7;
|
| 1087 |
var currentTone = "gentle";
|
| 1088 |
var childNameValue = "";
|
|
|
|
|
|
|
|
|
|
| 1089 |
|
| 1090 |
function escapeHTML(s) {
|
| 1091 |
return String(s).replace(/[&<>"']/g, function (c) { return ({"&":"&","<":"<",">":">",'"':""","'":"'"})[c]; });
|
|
@@ -1270,9 +1356,86 @@ a { color: var(--accent-strong); }
|
|
| 1270 |
function setBusy(busy) {
|
| 1271 |
sendBtn.disabled = busy;
|
| 1272 |
input.disabled = busy;
|
|
|
|
| 1273 |
sendLabel.textContent = busy ? "Drafting..." : "Draft";
|
| 1274 |
}
|
| 1275 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1276 |
function _extractSseText(obj) {
|
| 1277 |
if (obj == null) return null;
|
| 1278 |
if (Array.isArray(obj)) return obj.find(function (v) { return typeof v === "string"; }) || null;
|
|
|
|
| 78 |
"MODAL_TTS_URL",
|
| 79 |
"https://khoitruong071510--fabella-serve-tts.modal.run",
|
| 80 |
)
|
| 81 |
+
MODAL_ASR_URL = os.environ.get(
|
| 82 |
+
"MODAL_ASR_URL",
|
| 83 |
+
"https://khoitruong071510--fabella-asr-experiment-serve-asr.modal.run",
|
| 84 |
+
)
|
| 85 |
|
| 86 |
# HF Spaces bucket mount. The current Space has a writable bucket mounted at
|
| 87 |
# /models. We avoid a separate database cloud API by storing one minimal JSON
|
|
|
|
| 468 |
return _make_audio_sync(text, tone, opener, body, closer, followup)
|
| 469 |
|
| 470 |
|
| 471 |
+
def _validate_audio_data_url(data_url: str) -> None:
|
| 472 |
+
if not data_url.startswith("data:audio/") or "," not in data_url:
|
| 473 |
+
raise ValueError("Expected a browser audio recording data URL.")
|
| 474 |
+
header, payload = data_url.split(",", 1)
|
| 475 |
+
if ";base64" not in header:
|
| 476 |
+
raise ValueError("Audio recording must be base64 encoded.")
|
| 477 |
+
# Validate and cap size before proxying. Browser MediaRecorder output is
|
| 478 |
+
# compact; 8 MB is plenty for a short parent voice note.
|
| 479 |
+
audio = base64.b64decode(payload, validate=True)
|
| 480 |
+
if len(audio) > 8 * 1024 * 1024:
|
| 481 |
+
raise ValueError("Voice note is too large. Please keep it under about 30 seconds.")
|
| 482 |
+
if len(audio) < 256:
|
| 483 |
+
raise ValueError("Voice note was empty.")
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def _transcribe_audio_sync(data_url: str, target_lang: str = "en-US") -> str:
|
| 487 |
+
clean_lang = (target_lang or "en-US").strip()
|
| 488 |
+
if clean_lang != "auto" and not re.match(r"^[a-z]{2}-[A-Z]{2}$", clean_lang):
|
| 489 |
+
clean_lang = "en-US"
|
| 490 |
+
try:
|
| 491 |
+
_validate_audio_data_url(data_url)
|
| 492 |
+
except Exception as e:
|
| 493 |
+
return f"ERROR: {e}"
|
| 494 |
+
payload = json.dumps({"audio_data_url": data_url, "target_lang": clean_lang}).encode("utf-8")
|
| 495 |
+
req = urllib.request.Request(
|
| 496 |
+
MODAL_ASR_URL.rstrip("/") + "/transcribe",
|
| 497 |
+
data=payload,
|
| 498 |
+
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
| 499 |
+
method="POST",
|
| 500 |
+
)
|
| 501 |
+
try:
|
| 502 |
+
with urllib.request.urlopen(req, timeout=600) as res:
|
| 503 |
+
body = json.loads(res.read().decode("utf-8"))
|
| 504 |
+
except urllib.error.HTTPError as e:
|
| 505 |
+
detail = e.read().decode("utf-8", errors="replace")[:300]
|
| 506 |
+
print(f"[asr] modal HTTP error: {e.code}: {detail}", flush=True)
|
| 507 |
+
return f"ERROR: Voice transcription failed: HTTP {e.code}"
|
| 508 |
+
except Exception as e:
|
| 509 |
+
print(f"[asr] modal error: {type(e).__name__}: {e}", flush=True)
|
| 510 |
+
return f"ERROR: Voice transcription failed: {type(e).__name__}: {e}"
|
| 511 |
+
if not body.get("ok"):
|
| 512 |
+
return f"ERROR: Voice transcription failed: {body.get('error') or 'unknown error'}"
|
| 513 |
+
text = str(body.get("text") or "").strip()
|
| 514 |
+
return text or "ERROR: Nemotron ASR returned an empty transcript."
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
@app.api(name="transcribe_audio")
|
| 518 |
+
def transcribe_audio(data_url: str, target_lang: str = "en-US") -> str:
|
| 519 |
+
"""Transcribe a short browser-recorded voice note via Modal T4 ASR.
|
| 520 |
+
|
| 521 |
+
The ASR runtime is deliberately isolated in ``modal_asr_app.py`` so NeMo,
|
| 522 |
+
ffmpeg, and CUDA dependencies cannot break the HF Space build.
|
| 523 |
+
"""
|
| 524 |
+
return _transcribe_audio_sync(data_url, target_lang)
|
| 525 |
+
|
| 526 |
|
| 527 |
@app.get("/api/me")
|
| 528 |
async def api_me(request: Request):
|
|
|
|
| 988 |
}
|
| 989 |
.composer textarea::placeholder { color: var(--text-muted); }
|
| 990 |
.composer-controls { display: flex; align-items: center; gap: 8px; }
|
| 991 |
+
.btn-record {
|
| 992 |
+
border: 1px solid var(--line);
|
| 993 |
+
background: var(--bg);
|
| 994 |
+
color: var(--text-soft);
|
| 995 |
+
border-radius: 999px;
|
| 996 |
+
padding: 10px 12px;
|
| 997 |
+
font: 600 13px var(--font-sans);
|
| 998 |
+
cursor: pointer;
|
| 999 |
+
white-space: nowrap;
|
| 1000 |
+
}
|
| 1001 |
+
.btn-record:hover:not([disabled]) { border-color: var(--accent); color: var(--accent-strong); }
|
| 1002 |
+
.btn-record.is-recording { border-color: var(--danger); color: var(--danger); background: color-mix(in srgb, var(--danger) 8%, var(--surface)); }
|
| 1003 |
+
.btn-record[disabled] { opacity: 0.55; cursor: not-allowed; }
|
| 1004 |
.btn-send {
|
| 1005 |
border: 0;
|
| 1006 |
background: var(--accent);
|
|
|
|
| 1020 |
text-align: center;
|
| 1021 |
margin-top: 8px;
|
| 1022 |
}
|
| 1023 |
+
.asr-status {
|
| 1024 |
+
max-width: 760px;
|
| 1025 |
+
margin: 6px auto 0;
|
| 1026 |
+
font: 500 11px var(--font-mono);
|
| 1027 |
+
color: var(--text-muted);
|
| 1028 |
+
letter-spacing: 0.04em;
|
| 1029 |
+
text-align: left;
|
| 1030 |
+
}
|
| 1031 |
.typing {
|
| 1032 |
display: inline-flex; align-items: center; gap: 6px;
|
| 1033 |
font: 500 13px var(--font-sans); color: var(--text-muted);
|
|
|
|
| 1081 |
<form id="composer" class="composer-inner" novalidate>
|
| 1082 |
<textarea id="input" rows="1" placeholder="Describe the hard situation. A sentence or two is enough." maxlength="800" required></textarea>
|
| 1083 |
<div class="composer-controls">
|
| 1084 |
+
<button type="button" class="btn-record" id="record-btn" title="Record a short voice note">Record</button>
|
| 1085 |
<button type="submit" class="btn-send" id="send-btn"><span id="send-label">Draft</span><span aria-hidden="true">→</span></button>
|
| 1086 |
</div>
|
| 1087 |
</form>
|
| 1088 |
+
<div class="asr-status" id="asr-status"></div>
|
| 1089 |
+
<div class="composer-hint">Fabella checks every draft against a six-criterion rubric. Record uses Nemotron 3.5 ASR on demand.</div>
|
| 1090 |
</section>
|
| 1091 |
|
| 1092 |
<dialog id="settings" style="border:1px solid var(--line); border-radius: var(--radius); padding: 0; max-width: 480px; width: calc(100% - 32px); background: var(--surface); color: var(--text);">
|
|
|
|
| 1148 |
|
| 1149 |
var thread = document.getElementById("thread");
|
| 1150 |
var input = document.getElementById("input");
|
| 1151 |
+
var recordBtn = document.getElementById("record-btn");
|
| 1152 |
+
var asrStatus = document.getElementById("asr-status");
|
| 1153 |
var sendBtn = document.getElementById("send-btn");
|
| 1154 |
var sendLabel = document.getElementById("send-label");
|
| 1155 |
var ageChip = document.getElementById("age-chip");
|
|
|
|
| 1169 |
var currentAge = 7;
|
| 1170 |
var currentTone = "gentle";
|
| 1171 |
var childNameValue = "";
|
| 1172 |
+
var mediaRecorder = null;
|
| 1173 |
+
var recordTimer = null;
|
| 1174 |
+
var recordChunks = [];
|
| 1175 |
|
| 1176 |
function escapeHTML(s) {
|
| 1177 |
return String(s).replace(/[&<>"']/g, function (c) { return ({"&":"&","<":"<",">":">",'"':""","'":"'"})[c]; });
|
|
|
|
| 1356 |
function setBusy(busy) {
|
| 1357 |
sendBtn.disabled = busy;
|
| 1358 |
input.disabled = busy;
|
| 1359 |
+
if (recordBtn && !recordBtn.classList.contains("is-recording")) recordBtn.disabled = busy;
|
| 1360 |
sendLabel.textContent = busy ? "Drafting..." : "Draft";
|
| 1361 |
}
|
| 1362 |
|
| 1363 |
+
function setAsrStatus(text) {
|
| 1364 |
+
if (asrStatus) asrStatus.textContent = text || "";
|
| 1365 |
+
}
|
| 1366 |
+
|
| 1367 |
+
function blobToDataUrl(blob) {
|
| 1368 |
+
return new Promise(function (resolve, reject) {
|
| 1369 |
+
var reader = new FileReader();
|
| 1370 |
+
reader.onload = function () { resolve(String(reader.result || "")); };
|
| 1371 |
+
reader.onerror = function () { reject(reader.error || new Error("Could not read recording")); };
|
| 1372 |
+
reader.readAsDataURL(blob);
|
| 1373 |
+
});
|
| 1374 |
+
}
|
| 1375 |
+
|
| 1376 |
+
async function transcribeBlob(blob) {
|
| 1377 |
+
setAsrStatus("Transcribing with Nemotron 3.5 ASR. First run can take a minute...");
|
| 1378 |
+
var dataUrl = await blobToDataUrl(blob);
|
| 1379 |
+
var text = await readGradioString("transcribe_audio", [dataUrl, "en-US"]);
|
| 1380 |
+
if (text.startsWith("ERROR:")) throw new Error(text.slice(6).trim());
|
| 1381 |
+
input.value = text;
|
| 1382 |
+
autosize();
|
| 1383 |
+
input.focus();
|
| 1384 |
+
setAsrStatus("Transcript inserted. Edit it before drafting if needed.");
|
| 1385 |
+
}
|
| 1386 |
+
|
| 1387 |
+
async function startRecording() {
|
| 1388 |
+
if (!navigator.mediaDevices || !window.MediaRecorder) {
|
| 1389 |
+
setAsrStatus("This browser does not support voice recording.");
|
| 1390 |
+
return;
|
| 1391 |
+
}
|
| 1392 |
+
try {
|
| 1393 |
+
var stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 1394 |
+
recordChunks = [];
|
| 1395 |
+
var recorderOptions = MediaRecorder.isTypeSupported("audio/webm") ? { mimeType: "audio/webm" } : undefined;
|
| 1396 |
+
mediaRecorder = new MediaRecorder(stream, recorderOptions);
|
| 1397 |
+
mediaRecorder.ondataavailable = function (event) {
|
| 1398 |
+
if (event.data && event.data.size > 0) recordChunks.push(event.data);
|
| 1399 |
+
};
|
| 1400 |
+
mediaRecorder.onstop = async function () {
|
| 1401 |
+
stream.getTracks().forEach(function (track) { track.stop(); });
|
| 1402 |
+
recordBtn.classList.remove("is-recording");
|
| 1403 |
+
recordBtn.textContent = "Record";
|
| 1404 |
+
recordBtn.disabled = true;
|
| 1405 |
+
clearTimeout(recordTimer);
|
| 1406 |
+
try {
|
| 1407 |
+
var blob = new Blob(recordChunks, { type: mediaRecorder.mimeType || "audio/webm" });
|
| 1408 |
+
await transcribeBlob(blob);
|
| 1409 |
+
} catch (err) {
|
| 1410 |
+
setAsrStatus(String(err.message || err));
|
| 1411 |
+
} finally {
|
| 1412 |
+
recordBtn.disabled = false;
|
| 1413 |
+
mediaRecorder = null;
|
| 1414 |
+
}
|
| 1415 |
+
};
|
| 1416 |
+
mediaRecorder.start();
|
| 1417 |
+
recordBtn.classList.add("is-recording");
|
| 1418 |
+
recordBtn.textContent = "Stop";
|
| 1419 |
+
setAsrStatus("Recording. Keep it short; auto-stops at 30 seconds.");
|
| 1420 |
+
recordTimer = setTimeout(function () {
|
| 1421 |
+
if (mediaRecorder && mediaRecorder.state === "recording") mediaRecorder.stop();
|
| 1422 |
+
}, 30000);
|
| 1423 |
+
} catch (err) {
|
| 1424 |
+
setAsrStatus("Microphone unavailable: " + String(err.message || err));
|
| 1425 |
+
}
|
| 1426 |
+
}
|
| 1427 |
+
|
| 1428 |
+
if (recordBtn) {
|
| 1429 |
+
recordBtn.addEventListener("click", function () {
|
| 1430 |
+
if (mediaRecorder && mediaRecorder.state === "recording") {
|
| 1431 |
+
setAsrStatus("Preparing transcript...");
|
| 1432 |
+
mediaRecorder.stop();
|
| 1433 |
+
return;
|
| 1434 |
+
}
|
| 1435 |
+
startRecording();
|
| 1436 |
+
});
|
| 1437 |
+
}
|
| 1438 |
+
|
| 1439 |
function _extractSseText(obj) {
|
| 1440 |
if (obj == null) return null;
|
| 1441 |
if (Array.isArray(obj)) return obj.find(function (v) { return typeof v === "string"; }) || null;
|
modal_app.py
CHANGED
|
@@ -5,7 +5,7 @@ judge) or L4 (TTS):
|
|
| 5 |
|
| 6 |
serve_drafter (port 8000) — Gemma 4 E4B-IT (4B). Generates explanations.
|
| 7 |
serve_judge (port 8001) — Nemotron-3 Nano 4B. Scores the draft against
|
| 8 |
-
|
| 9 |
serve_tts (port 8002) — VoxCPM2. Synthesizes read-aloud WAV audio.
|
| 10 |
|
| 11 |
The judge runs after the drafter; if the verdict is "revise", the
|
|
@@ -38,11 +38,10 @@ The most effective mitigations, in order:
|
|
| 38 |
2. **Skip CUDA-graph capture** with ``--enforce-eager`` for the demo.
|
| 39 |
Drops cold start by ~20–40s. Trades a small amount of throughput
|
| 40 |
for much faster first-token.
|
| 41 |
-
3. **
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
request still happens — just not in front of a parent.
|
| 46 |
|
| 47 |
Volume layout
|
| 48 |
-------------
|
|
@@ -121,12 +120,13 @@ tts_image = (
|
|
| 121 |
)
|
| 122 |
|
| 123 |
|
|
|
|
|
|
|
| 124 |
# --- Model download (one entry per model) --------------------------------
|
| 125 |
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
"""Pull Gemma 4 E4B-IT weights to the Volume (run once)."""
|
| 130 |
from huggingface_hub import snapshot_download
|
| 131 |
target = Path(MODEL_PATH) / DRAFTER_DIR
|
| 132 |
if target.exists() and any(target.iterdir()) and not force:
|
|
@@ -148,8 +148,13 @@ def download_drafter(force: bool = False):
|
|
| 148 |
|
| 149 |
|
| 150 |
@app.function(image=download_image, volumes={MODEL_PATH: model_volume}, timeout=60 * 60)
|
| 151 |
-
def
|
| 152 |
-
"""Pull
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
from huggingface_hub import snapshot_download
|
| 154 |
target = Path(MODEL_PATH) / JUDGE_DIR
|
| 155 |
if target.exists() and any(target.iterdir()) and not force:
|
|
@@ -170,6 +175,12 @@ def download_judge(force: bool = False):
|
|
| 170 |
print("Judge download complete")
|
| 171 |
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
# --- vLLM servers --------------------------------------------------------
|
| 174 |
|
| 175 |
MINUTES = 60
|
|
@@ -265,7 +276,7 @@ vllm_drafter_image = (
|
|
| 265 |
vllm_image
|
| 266 |
.env(VLLM_RUNTIME_ENV)
|
| 267 |
.run_function(
|
| 268 |
-
|
| 269 |
volumes={MODEL_PATH: model_volume},
|
| 270 |
force_build=False,
|
| 271 |
)
|
|
@@ -277,7 +288,7 @@ vllm_judge_image = (
|
|
| 277 |
vllm_image
|
| 278 |
.env(VLLM_RUNTIME_ENV)
|
| 279 |
.run_function(
|
| 280 |
-
|
| 281 |
volumes={MODEL_PATH: model_volume},
|
| 282 |
force_build=False,
|
| 283 |
)
|
|
@@ -428,9 +439,8 @@ async def synthesize(payload: dict):
|
|
| 428 |
'''
|
| 429 |
|
| 430 |
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
"""Pull VoxCPM2 weights to the Volume (run once)."""
|
| 434 |
from huggingface_hub import snapshot_download
|
| 435 |
target = Path(MODEL_PATH) / TTS_DIR
|
| 436 |
if target.exists() and any(target.iterdir()) and not force:
|
|
@@ -454,11 +464,17 @@ def download_tts(force: bool = False):
|
|
| 454 |
print("TTS download complete")
|
| 455 |
|
| 456 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
# Bake VoxCPM2 weights into the TTS image so cold start only has to
|
| 458 |
# load them to VRAM (~5-10s), not download from the Volume (~10-20s).
|
| 459 |
# Defined after ``download_tts`` so the forward reference resolves.
|
| 460 |
tts_image_baked = tts_image.run_function(
|
| 461 |
-
|
| 462 |
volumes={MODEL_PATH: model_volume},
|
| 463 |
force_build=False,
|
| 464 |
)
|
|
|
|
| 5 |
|
| 6 |
serve_drafter (port 8000) — Gemma 4 E4B-IT (4B). Generates explanations.
|
| 7 |
serve_judge (port 8001) — Nemotron-3 Nano 4B. Scores the draft against
|
| 8 |
+
the request and returns a structured verdict.
|
| 9 |
serve_tts (port 8002) — VoxCPM2. Synthesizes read-aloud WAV audio.
|
| 10 |
|
| 11 |
The judge runs after the drafter; if the verdict is "revise", the
|
|
|
|
| 38 |
2. **Skip CUDA-graph capture** with ``--enforce-eager`` for the demo.
|
| 39 |
Drops cold start by ~20–40s. Trades a small amount of throughput
|
| 40 |
for much faster first-token.
|
| 41 |
+
3. **No Space-side warmup ping**. A warmup ping makes the first click
|
| 42 |
+
feel better, but every Space restart would pay for an A10G cold
|
| 43 |
+
start whether or not a parent ever arrives. For budget safety, only
|
| 44 |
+
a real parent action wakes Modal.
|
|
|
|
| 45 |
|
| 46 |
Volume layout
|
| 47 |
-------------
|
|
|
|
| 120 |
)
|
| 121 |
|
| 122 |
|
| 123 |
+
|
| 124 |
+
|
| 125 |
# --- Model download (one entry per model) --------------------------------
|
| 126 |
|
| 127 |
|
| 128 |
+
def _download_drafter(force: bool = False):
|
| 129 |
+
"""Pull Gemma 4 E4B-IT weights to the Volume/image layer."""
|
|
|
|
| 130 |
from huggingface_hub import snapshot_download
|
| 131 |
target = Path(MODEL_PATH) / DRAFTER_DIR
|
| 132 |
if target.exists() and any(target.iterdir()) and not force:
|
|
|
|
| 148 |
|
| 149 |
|
| 150 |
@app.function(image=download_image, volumes={MODEL_PATH: model_volume}, timeout=60 * 60)
|
| 151 |
+
def download_drafter(force: bool = False):
|
| 152 |
+
"""Pull Gemma 4 E4B-IT weights to the Volume (run once)."""
|
| 153 |
+
return _download_drafter(force=force)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _download_judge(force: bool = False):
|
| 157 |
+
"""Pull Nemotron-Nano-4B weights to the Volume/image layer."""
|
| 158 |
from huggingface_hub import snapshot_download
|
| 159 |
target = Path(MODEL_PATH) / JUDGE_DIR
|
| 160 |
if target.exists() and any(target.iterdir()) and not force:
|
|
|
|
| 175 |
print("Judge download complete")
|
| 176 |
|
| 177 |
|
| 178 |
+
@app.function(image=download_image, volumes={MODEL_PATH: model_volume}, timeout=60 * 60)
|
| 179 |
+
def download_judge(force: bool = False):
|
| 180 |
+
"""Pull Nemotron-Nano-4B weights to the Volume (run once)."""
|
| 181 |
+
return _download_judge(force=force)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
# --- vLLM servers --------------------------------------------------------
|
| 185 |
|
| 186 |
MINUTES = 60
|
|
|
|
| 276 |
vllm_image
|
| 277 |
.env(VLLM_RUNTIME_ENV)
|
| 278 |
.run_function(
|
| 279 |
+
_download_drafter,
|
| 280 |
volumes={MODEL_PATH: model_volume},
|
| 281 |
force_build=False,
|
| 282 |
)
|
|
|
|
| 288 |
vllm_image
|
| 289 |
.env(VLLM_RUNTIME_ENV)
|
| 290 |
.run_function(
|
| 291 |
+
_download_judge,
|
| 292 |
volumes={MODEL_PATH: model_volume},
|
| 293 |
force_build=False,
|
| 294 |
)
|
|
|
|
| 439 |
'''
|
| 440 |
|
| 441 |
|
| 442 |
+
def _download_tts(force: bool = False):
|
| 443 |
+
"""Pull VoxCPM2 weights to the Volume/image layer."""
|
|
|
|
| 444 |
from huggingface_hub import snapshot_download
|
| 445 |
target = Path(MODEL_PATH) / TTS_DIR
|
| 446 |
if target.exists() and any(target.iterdir()) and not force:
|
|
|
|
| 464 |
print("TTS download complete")
|
| 465 |
|
| 466 |
|
| 467 |
+
@app.function(image=download_image, volumes={MODEL_PATH: model_volume}, timeout=60 * 60)
|
| 468 |
+
def download_tts(force: bool = False):
|
| 469 |
+
"""Pull VoxCPM2 weights to the Volume (run once)."""
|
| 470 |
+
return _download_tts(force=force)
|
| 471 |
+
|
| 472 |
+
|
| 473 |
# Bake VoxCPM2 weights into the TTS image so cold start only has to
|
| 474 |
# load them to VRAM (~5-10s), not download from the Volume (~10-20s).
|
| 475 |
# Defined after ``download_tts`` so the forward reference resolves.
|
| 476 |
tts_image_baked = tts_image.run_function(
|
| 477 |
+
_download_tts,
|
| 478 |
volumes={MODEL_PATH: model_volume},
|
| 479 |
force_build=False,
|
| 480 |
)
|
modal_asr_app.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Isolated Nemotron 3.5 ASR experiment on Modal T4.
|
| 2 |
+
|
| 3 |
+
This is deliberately separate from ``modal_app.py`` so ASR can be tested
|
| 4 |
+
without adding a fourth endpoint to the production Fabella app. Deploy/run this
|
| 5 |
+
first, call ``/transcribe`` manually, then wire the HF Space only if it works.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import base64
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
import subprocess
|
| 13 |
+
import tempfile
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import modal
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
app = modal.App("fabella-asr-experiment")
|
| 20 |
+
|
| 21 |
+
MODEL_PATH = "/models"
|
| 22 |
+
ASR_REPO = "nvidia/nemotron-3.5-asr-streaming-0.6b"
|
| 23 |
+
ASR_DIR = "nemotron-3.5-asr-streaming-0.6b"
|
| 24 |
+
ASR_NEMO = "nemotron-3.5-asr-streaming-0.6b.nemo"
|
| 25 |
+
ASR_PORT = 8003
|
| 26 |
+
MINUTES = 60
|
| 27 |
+
|
| 28 |
+
model_volume = modal.Volume.from_name("fabella-asr-models", create_if_missing=True)
|
| 29 |
+
cache_volume = modal.Volume.from_name("fabella-asr-cache", create_if_missing=True)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
download_image = (
|
| 33 |
+
modal.Image.debian_slim(python_version="3.11")
|
| 34 |
+
.pip_install("huggingface_hub[hf_xet]>=0.24")
|
| 35 |
+
.env({"HF_HUB_CACHE": MODEL_PATH})
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _download_asr(force: bool = False):
|
| 40 |
+
from huggingface_hub import snapshot_download
|
| 41 |
+
|
| 42 |
+
target = Path(MODEL_PATH) / ASR_DIR
|
| 43 |
+
if target.exists() and (target / ASR_NEMO).exists() and not force:
|
| 44 |
+
print(f"ASR checkpoint already at {target}; skipping", flush=True)
|
| 45 |
+
return
|
| 46 |
+
print(f"Downloading {ASR_REPO} to {target}...", flush=True)
|
| 47 |
+
snapshot_download(
|
| 48 |
+
repo_id=ASR_REPO,
|
| 49 |
+
local_dir=str(target),
|
| 50 |
+
allow_patterns=[ASR_NEMO],
|
| 51 |
+
)
|
| 52 |
+
model_volume.commit()
|
| 53 |
+
print("ASR download complete", flush=True)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@app.function(image=download_image, volumes={MODEL_PATH: model_volume}, timeout=60 * MINUTES)
|
| 57 |
+
def download_asr(force: bool = False):
|
| 58 |
+
"""Pull the .nemo checkpoint once before deploying the web endpoint."""
|
| 59 |
+
return _download_asr(force=force)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
asr_image = (
|
| 63 |
+
modal.Image.from_registry("nvidia/cuda:12.9.0-devel-ubuntu22.04", add_python="3.11")
|
| 64 |
+
.entrypoint([])
|
| 65 |
+
.apt_install("ffmpeg", "git", "libsndfile1")
|
| 66 |
+
.pip_install(
|
| 67 |
+
"Cython",
|
| 68 |
+
"packaging",
|
| 69 |
+
"torch>=2.5.0",
|
| 70 |
+
"torchaudio>=2.5.0",
|
| 71 |
+
"soundfile",
|
| 72 |
+
"fastapi>=0.110",
|
| 73 |
+
"uvicorn[standard]>=0.27",
|
| 74 |
+
"huggingface_hub[hf_xet]>=0.24",
|
| 75 |
+
"git+https://github.com/NVIDIA/NeMo.git@main#egg=nemo_toolkit[asr]",
|
| 76 |
+
)
|
| 77 |
+
.env(
|
| 78 |
+
{
|
| 79 |
+
"HF_HUB_CACHE": MODEL_PATH,
|
| 80 |
+
"TORCH_HOME": "/cache/torch",
|
| 81 |
+
"NUMBA_CACHE_DIR": "/cache/numba",
|
| 82 |
+
}
|
| 83 |
+
)
|
| 84 |
+
.run_function(_download_asr, volumes={MODEL_PATH: model_volume}, force_build=False)
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
ASR_SERVER_PY = r'''
|
| 89 |
+
import base64
|
| 90 |
+
import json
|
| 91 |
+
import os
|
| 92 |
+
import re
|
| 93 |
+
import subprocess
|
| 94 |
+
import tempfile
|
| 95 |
+
import traceback
|
| 96 |
+
from pathlib import Path
|
| 97 |
+
from threading import Lock
|
| 98 |
+
|
| 99 |
+
from fastapi import FastAPI, HTTPException
|
| 100 |
+
|
| 101 |
+
os.environ.setdefault("HF_HUB_CACHE", "/models")
|
| 102 |
+
os.environ.setdefault("TORCH_HOME", "/cache/torch")
|
| 103 |
+
os.environ.setdefault("NUMBA_CACHE_DIR", "/cache/numba")
|
| 104 |
+
|
| 105 |
+
MODEL_PATH = "/models/nemotron-3.5-asr-streaming-0.6b/nemotron-3.5-asr-streaming-0.6b.nemo"
|
| 106 |
+
|
| 107 |
+
print("[asr] importing torch/nemo", flush=True)
|
| 108 |
+
import torch
|
| 109 |
+
import nemo.collections.asr as nemo_asr
|
| 110 |
+
from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis
|
| 111 |
+
from nemo.collections.asr.parts.utils.streaming_utils import CacheAwareStreamingAudioBuffer
|
| 112 |
+
|
| 113 |
+
print(f"[asr] restoring {MODEL_PATH}", flush=True)
|
| 114 |
+
_model = nemo_asr.models.ASRModel.restore_from(MODEL_PATH, map_location="cuda" if torch.cuda.is_available() else "cpu")
|
| 115 |
+
_model.eval()
|
| 116 |
+
if torch.cuda.is_available():
|
| 117 |
+
_model = _model.cuda()
|
| 118 |
+
print(f"[asr] loaded on {'cuda' if torch.cuda.is_available() else 'cpu'}", flush=True)
|
| 119 |
+
_model_lock = Lock()
|
| 120 |
+
|
| 121 |
+
app = FastAPI()
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _suffix(header: str) -> str:
|
| 125 |
+
header = header.lower()
|
| 126 |
+
if "wav" in header:
|
| 127 |
+
return ".wav"
|
| 128 |
+
if "mp4" in header or "m4a" in header:
|
| 129 |
+
return ".m4a"
|
| 130 |
+
if "ogg" in header:
|
| 131 |
+
return ".ogg"
|
| 132 |
+
return ".webm"
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _decode_data_url(data_url: str) -> tuple[bytes, str]:
|
| 136 |
+
if not data_url.startswith("data:audio/") or "," not in data_url:
|
| 137 |
+
raise HTTPException(status_code=400, detail="expected audio data URL")
|
| 138 |
+
header, payload = data_url.split(",", 1)
|
| 139 |
+
if ";base64" not in header:
|
| 140 |
+
raise HTTPException(status_code=400, detail="expected base64 audio data URL")
|
| 141 |
+
audio = base64.b64decode(payload, validate=True)
|
| 142 |
+
if len(audio) > 8 * 1024 * 1024:
|
| 143 |
+
raise HTTPException(status_code=413, detail="audio too large")
|
| 144 |
+
if len(audio) < 256:
|
| 145 |
+
raise HTTPException(status_code=400, detail="audio was empty")
|
| 146 |
+
return audio, _suffix(header)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _normalize_result(result) -> str:
|
| 150 |
+
if isinstance(result, tuple):
|
| 151 |
+
result = result[0]
|
| 152 |
+
if isinstance(result, list):
|
| 153 |
+
result = result[0] if result else ""
|
| 154 |
+
text = getattr(result, "text", result)
|
| 155 |
+
if isinstance(text, list):
|
| 156 |
+
text = text[0] if text else ""
|
| 157 |
+
return re.sub(r"\s+<[^>]+>\s*$", "", str(text or "")).strip()
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _extract_transcriptions(hyps):
|
| 161 |
+
if not hyps:
|
| 162 |
+
return []
|
| 163 |
+
if isinstance(hyps[0], Hypothesis):
|
| 164 |
+
return [hyp.text for hyp in hyps]
|
| 165 |
+
return hyps
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _transcribe_wav(wav_path: str, target_lang: str) -> str:
|
| 169 |
+
# Follow NeMo's documented cache-aware streaming path for
|
| 170 |
+
# prompt-conditioned models. Plain ASRModel.transcribe() does not populate
|
| 171 |
+
# the per-stream prompt correctly for this checkpoint.
|
| 172 |
+
with _model_lock:
|
| 173 |
+
if hasattr(_model, "set_inference_prompt"):
|
| 174 |
+
_model.set_inference_prompt(target_lang)
|
| 175 |
+
if hasattr(_model, "decoding") and hasattr(_model.decoding, "set_strip_lang_tags"):
|
| 176 |
+
_model.decoding.set_strip_lang_tags(True)
|
| 177 |
+
if hasattr(_model.encoder, "set_default_att_context_size"):
|
| 178 |
+
_model.encoder.set_default_att_context_size(att_context_size=[56, 13])
|
| 179 |
+
|
| 180 |
+
device = next(_model.parameters()).device
|
| 181 |
+
compute_dtype = torch.float32
|
| 182 |
+
streaming_buffer = CacheAwareStreamingAudioBuffer(
|
| 183 |
+
model=_model,
|
| 184 |
+
online_normalization=False,
|
| 185 |
+
pad_and_drop_preencoded=False,
|
| 186 |
+
)
|
| 187 |
+
streaming_buffer.append_audio_file(wav_path, stream_id=-1)
|
| 188 |
+
batch_size = len(streaming_buffer.streams_length)
|
| 189 |
+
cache_last_channel, cache_last_time, cache_last_channel_len = _model.encoder.get_initial_cache_state(
|
| 190 |
+
batch_size=batch_size
|
| 191 |
+
)
|
| 192 |
+
previous_hypotheses = None
|
| 193 |
+
pred_out_stream = None
|
| 194 |
+
transcribed_texts = []
|
| 195 |
+
for step_num, (chunk_audio, chunk_lengths) in enumerate(iter(streaming_buffer)):
|
| 196 |
+
chunk_audio = chunk_audio.to(device=device, dtype=compute_dtype)
|
| 197 |
+
chunk_lengths = chunk_lengths.to(device=device)
|
| 198 |
+
(
|
| 199 |
+
pred_out_stream,
|
| 200 |
+
transcribed_texts,
|
| 201 |
+
cache_last_channel,
|
| 202 |
+
cache_last_time,
|
| 203 |
+
cache_last_channel_len,
|
| 204 |
+
previous_hypotheses,
|
| 205 |
+
) = _model.conformer_stream_step(
|
| 206 |
+
processed_signal=chunk_audio,
|
| 207 |
+
processed_signal_length=chunk_lengths,
|
| 208 |
+
cache_last_channel=cache_last_channel,
|
| 209 |
+
cache_last_time=cache_last_time,
|
| 210 |
+
cache_last_channel_len=cache_last_channel_len,
|
| 211 |
+
keep_all_outputs=streaming_buffer.is_buffer_empty(),
|
| 212 |
+
previous_hypotheses=previous_hypotheses,
|
| 213 |
+
previous_pred_out=pred_out_stream,
|
| 214 |
+
drop_extra_pre_encoded=0 if step_num == 0 else _model.encoder.streaming_cfg.drop_extra_pre_encoded,
|
| 215 |
+
return_transcription=True,
|
| 216 |
+
)
|
| 217 |
+
texts = _extract_transcriptions(transcribed_texts)
|
| 218 |
+
return _normalize_result(texts[0] if texts else "")
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
@app.get("/health")
|
| 222 |
+
async def health():
|
| 223 |
+
return {"ok": True, "model": "nvidia/nemotron-3.5-asr-streaming-0.6b", "cuda": bool(torch.cuda.is_available())}
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@app.post("/transcribe")
|
| 227 |
+
async def transcribe(payload: dict):
|
| 228 |
+
data_url = str(payload.get("audio_data_url") or "")
|
| 229 |
+
target_lang = str(payload.get("target_lang") or "auto").strip()
|
| 230 |
+
if target_lang != "auto" and not re.match(r"^[a-z]{2}-[A-Z]{2}$", target_lang):
|
| 231 |
+
target_lang = "auto"
|
| 232 |
+
audio, suffix = _decode_data_url(data_url)
|
| 233 |
+
try:
|
| 234 |
+
with tempfile.TemporaryDirectory(prefix="fabella-asr-") as tmp:
|
| 235 |
+
src = Path(tmp) / f"input{suffix}"
|
| 236 |
+
wav = Path(tmp) / "converted.wav"
|
| 237 |
+
src.write_bytes(audio)
|
| 238 |
+
subprocess.run(
|
| 239 |
+
["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(src), "-ac", "1", "-ar", "16000", str(wav)],
|
| 240 |
+
check=True,
|
| 241 |
+
stdout=subprocess.DEVNULL,
|
| 242 |
+
stderr=subprocess.PIPE,
|
| 243 |
+
)
|
| 244 |
+
text = _transcribe_wav(str(wav), target_lang)
|
| 245 |
+
except subprocess.CalledProcessError as e:
|
| 246 |
+
detail = (e.stderr or b"").decode("utf-8", errors="replace")[:300]
|
| 247 |
+
raise HTTPException(status_code=400, detail=f"ffmpeg failed: {detail}")
|
| 248 |
+
except Exception as e:
|
| 249 |
+
print(f"[asr] transcribe failed: {type(e).__name__}: {e}", flush=True)
|
| 250 |
+
traceback.print_exc()
|
| 251 |
+
raise HTTPException(status_code=500, detail=f"transcribe failed: {e}")
|
| 252 |
+
return {"ok": True, "text": text, "target_lang": target_lang}
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@app.post("/warmup")
|
| 256 |
+
async def warmup():
|
| 257 |
+
return {"ok": True, "message": "model loaded"}
|
| 258 |
+
'''
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
@app.function(
|
| 262 |
+
image=asr_image,
|
| 263 |
+
gpu="T4",
|
| 264 |
+
min_containers=0,
|
| 265 |
+
scaledown_window=2 * MINUTES,
|
| 266 |
+
timeout=10 * MINUTES,
|
| 267 |
+
volumes={MODEL_PATH: model_volume, "/cache": cache_volume},
|
| 268 |
+
)
|
| 269 |
+
@modal.concurrent(max_inputs=2)
|
| 270 |
+
@modal.web_server(port=ASR_PORT, startup_timeout=15 * MINUTES)
|
| 271 |
+
def serve_asr():
|
| 272 |
+
server_path = "/root/asr_server.py"
|
| 273 |
+
with open(server_path, "w") as f:
|
| 274 |
+
f.write(ASR_SERVER_PY)
|
| 275 |
+
cmd = [
|
| 276 |
+
"uvicorn",
|
| 277 |
+
"asr_server:app",
|
| 278 |
+
"--app-dir",
|
| 279 |
+
"/root",
|
| 280 |
+
"--host",
|
| 281 |
+
"0.0.0.0",
|
| 282 |
+
"--port",
|
| 283 |
+
str(ASR_PORT),
|
| 284 |
+
"--log-level",
|
| 285 |
+
"info",
|
| 286 |
+
]
|
| 287 |
+
print(f"Starting ASR experiment: {' '.join(cmd)}", flush=True)
|
| 288 |
+
subprocess.Popen(cmd)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
@app.local_entrypoint()
|
| 292 |
+
def main():
|
| 293 |
+
print("Deploy with: .venv/bin/modal deploy modal_asr_app.py")
|
| 294 |
+
print("Download only: .venv/bin/modal run modal_asr_app.py::download_asr")
|