OpenCode commited on
Commit
dd6bf3b
·
1 Parent(s): 839fa96

feat(asr): add optional Nemotron voice-note input

Browse files
Files changed (5) hide show
  1. AGENTS.md +2 -0
  2. README.md +7 -4
  3. app.py +165 -2
  4. modal_app.py +33 -17
  5. 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
- All three models sit comfortably under the **32B cap** — Fabella uses **10B of parameters total** for inference, with the largest single model at 4B. That makes Fabella a candidate for the **Tiny Titan** special award (≤4B).
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 all three inference services: the Gemma drafter, the Nemotron judge, and the VoxCPM2 TTS service.
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
- - **Modal** — 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):
 
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">&rarr;</span></button>
1006
  </div>
1007
  </form>
1008
- <div class="composer-hint">Fabella checks every draft against a six-criterion rubric. Read-aloud uses VoxCPM2 on demand.</div>
 
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 ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[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">&rarr;</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 ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[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
- 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,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. **Smaller judge** is not the lever here — the bottleneck is
42
- vLLM's import + compile, not 4B vs 7B.
43
- 4. **Space-side warmup ping** on Space startup keeps the first parent
44
- request warm (see ``app.py`` ``/health`` pattern). The cold
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
- @app.function(image=download_image, volumes={MODEL_PATH: model_volume}, timeout=60 * 60)
128
- def download_drafter(force: bool = False):
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 download_judge(force: bool = False):
152
- """Pull Nemotron-Nano-9B-v2 weights to the Volume (run once)."""
 
 
 
 
 
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
- download_drafter,
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
- download_judge,
281
  volumes={MODEL_PATH: model_volume},
282
  force_build=False,
283
  )
@@ -428,9 +439,8 @@ async def synthesize(payload: dict):
428
  '''
429
 
430
 
431
- @app.function(image=download_image, volumes={MODEL_PATH: model_volume}, timeout=60 * 60)
432
- def download_tts(force: bool = False):
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
- download_tts,
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")