JacobLinCool Codex commited on
Commit
7d1e08d
·
verified ·
1 Parent(s): 4e335c6

feat: add nemotron voice input

Browse files

Co-authored-by: Codex <noreply@openai.com>

DESIGN.md CHANGED
@@ -1,6 +1,6 @@
1
  # Build Small Hackathon Advisor — Design & Implementation Notes
2
 
3
- > A **small-model agent** (text-first; voice is a later bonus) that investigates what other people have already built
4
  > for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon) and brainstorms an original new design
5
  > *with you*. Output = streaming text + live visuals (no TTS). All models small, open-weight, run locally.
6
  >
@@ -21,13 +21,13 @@ authoritative decision log; the rest of the doc is written to be consistent with
21
  archivist divines a fate-page; ink **bleeds and cites the real Spaces** you overlap (page 47, page 112…), or
22
  **blooms gold + sprouts a leaf** when it's unwritten. Engine unchanged underneath (crawl → whitespace/originality →
23
  score). The dry "advisor" stays under the hood. Full spec + de-risking grafts in §2.
24
- 2. **Text-first.** Ship a fully demoable text-input (type / button) vertical slice first. Voice (push-to-talk → batch
25
- ASR) is a later BONUS layer. Real-time streaming + in-browser turn detection are **deferred** (see §13 appendix).
 
26
  3. **Add a 🎯 Well-Tuned fine-tune** — a small LoRA (MiniCPM5 advisor persona / tool-calling), trained on Modal,
27
  published to the Hub → 6/6 badges → strong shot at 🎖️ Bonus Quest Champion ($2,000).
28
- 4. **ASR = Nemotron primary (batch) + Parakeet fallback.** `nvidia/nemotron-speech-streaming-en-0.6b` used in BATCH
29
- per-turn mode (`transcribe([wav])` — simple API). Day-1 spike confirms it builds + runs in one `@spaces.GPU` call;
30
- if the ZeroGPU *environment* fights it, drop in `nvidia/parakeet-tdt-0.6b-v3` (native transformers, same batch API).
31
 
32
  **Verified corrections:**
33
  - **Drop SGLang.** It needs a persistent GPU process → incompatible with ZeroGPU (same root cause as vLLM). Run
@@ -58,7 +58,7 @@ authoritative decision log; the rest of the doc is written to be consistent with
58
  - Trivial `@spaces.GPU` hello-cuda build GREEN on torch 2.8+, deps pinned, heavy deps added one at a time.
59
  - `gr.Server` minimal: static `index.html` + one same-origin `/api/agent-turn` NDJSON stream, plus the retained
60
  `@app.api()` generator for external clients, on the real ZeroGPU Space.
61
- - Nemotron `nemo_toolkit[asr]` install + one batch `transcribe()` inside `@spaces.GPU` (decision #4; else Parakeet).
62
 
63
  ---
64
 
@@ -110,7 +110,7 @@ UNWRITTEN." People compile draws into a "chapter" and dare friends to get a page
110
 
111
  **Defaults (revisit if time):** single-page artifact first (chapter compiler later); page-numbers visible, real titles
112
  on hover (keep the burn aimed at the idea, not a named builder); seal animation = safe typewriter + static-stamp floor
113
- first, bespoke ink-reveal last (graceful degradation); voice input stays text-first, a post-MVP flourish.
114
 
115
  Input is **text-first**; the experience is fully delightful with typed input alone.
116
 
@@ -124,18 +124,16 @@ investigate → ideate → score loop — the experience collapses without the m
124
 
125
  | Role | Model | Params | Runtime | License | Prize hook |
126
  |---|---|---|---|---|---|
127
- | STT (batch, voice-later) | **`nvidia/nemotron-speech-streaming-en-0.6b`** (used in batch) | 0.6B | NeMo, GPU+CUDA | NVIDIA Open Model (commercial OK) | 🟩 NVIDIA Nemotron Quest |
128
- | ↳ fallback | `nvidia/parakeet-tdt-0.6b-v3` | 0.6B | **transformers** (no NeMo) | CC-BY-4.0 | 🟩 (Quest brand — verify, §5.1) |
129
  | LLM brain | **`openbmb/MiniCPM5-1B`** ("OpenCPM5") | 1.08B | **transformers** (self-parse XML) / llama.cpp | **Apache-2.0** | 🏮 OpenBMB |
130
- | Turn detection (voice-later) | **`pipecat-ai/smart-turn-v3`** | ~8M | ONNX Runtime (browser) | BSD-2 | (natural voice UX) |
131
  | Embedder | **`ggml-org/embeddinggemma-300M-qat-q4_0-GGUF`** | ~300M | llama.cpp / llama-cpp-python | Gemma | 🔌 Off the Grid · 🦙 Llama Champion · 🟢 Modal |
132
  | Fine-tune | LoRA on MiniCPM5 → published to Hub | — | PEFT / HF Jobs | — | 🎯 Well-Tuned |
133
 
134
- **Total ≈ 1.9B params → ≤4B → 🐜 Tiny Titan eligible.** All open-weight, all runnable locally → 🔌 Off the Grid.
135
 
136
  > Naming: "OpenCPM5 1B" = `openbmb/MiniCPM5-1B` (MiniCPM 5.0, ~May 2026). "EmbeddingGemma 270M" =
137
  > `google/embeddinggemma-300m` (308M total; 270M = non-embedding transformer params). **SGLang dropped** (ZeroGPU
138
- > incompatible). STT used in **batch per-turn** mode, not streaming.
139
 
140
  ---
141
 
@@ -148,21 +146,21 @@ With **text-first + batch ASR**, the old "streaming ASR vs ZeroGPU" Config A/B t
148
  - **Text-first runtime loop:** user types → custom `/api/agent-turn` NDJSON endpoint → one `@spaces.GPU` call runs
149
  MiniCPM5 (tool loop, in `transformers`) → streamed text tokens + live visual updates. The `@app.api()` endpoint
150
  remains as the Gradio-client contract for external checks.
151
- - **Voice (later bonus):** push-to-talk records an utterance → POST blob → the same `@spaces.GPU` call also runs
152
- Nemotron/Parakeet ASR (batch) before the brain. No persistent stream, no WebRTC, **no TURN server**.
 
153
  - **Modal (build-time only):** crawl the org + build the llama.cpp EmbeddingGemma vector index offline; the Space ships
154
  with checked-in project vectors. Runtime never calls Modal → 🔌 Off the Grid holds (see §10).
155
 
156
  > Off the Grid = no proprietary cloud inference APIs. Open weights on an HF GPU Space / local box / Modal all qualify.
157
 
158
- **Deferred (voice-later appendix, §13):** real-time streaming ASR (`conformer_stream_step`), in-browser Smart Turn +
159
- Silero VAD turn detection, FastRTC. Documented but not on the text-first critical path.
160
 
161
  ---
162
 
163
  ## 5. Per-model implementation notes
164
 
165
- ### 5.1 ASR — `nvidia/nemotron-speech-streaming-en-0.6b` (batch) · fallback `parakeet-tdt-0.6b-v3`
166
 
167
  - **Primary, batch usage (simple):**
168
  ```python
@@ -170,20 +168,10 @@ Silero VAD turn detection, FastRTC. Documented but not on the text-first critica
170
  asr = nemo_asr.models.ASRModel.from_pretrained("nvidia/nemotron-speech-streaming-en-0.6b")
171
  text = asr.transcribe(["utterance.wav"]) # 16 kHz mono WAV in; punctuated EN text out
172
  ```
173
- Install (heavy; CUDA): `apt-get install -y libsndfile1 ffmpeg` + `pip install Cython packaging` +
174
- `pip install "git+https://github.com/NVIDIA/NeMo.git@main#egg=nemo_toolkit[asr]"` (NeMo ≥ 25.11).
175
- **Day-1 spike** must confirm this installs + runs in one `@spaces.GPU` call (load with `map_location="cpu"` at module
176
- level, `.to("cuda")` inside the decorator; watch NeMo CUDA-at-import + the RNNT CUDA-graphs path).
177
- - **Fallback (if the ZeroGPU env fights NeMo):** `nvidia/parakeet-tdt-0.6b-v3` has **native transformers** support —
178
- ```python
179
- from transformers import pipeline
180
- asr = pipeline("automatic-speech-recognition", model="nvidia/parakeet-tdt-0.6b-v3")
181
- text = asr("utterance.wav")
182
- ```
183
- Same 0.6B, CC-BY-4.0, 25 languages, one clean `@spaces.GPU` batch call, no NeMo/git build.
184
- - **Quest caveat:** the award says "standout **Nemotron** builds." Parakeet is the encoder *inside* the Nemotron-Speech
185
- family but is branded "Parakeet" → if we ship Parakeet, **confirm Quest eligibility with organizers**, or keep
186
- Nemotron documented as the Quest narrative. Hosted NVIDIA NIM API would break Off the Grid — never in the badge demo.
187
 
188
  ### 5.2 MiniCPM5-1B brain — `openbmb/MiniCPM5-1B` (transformers, self-parsed XML)
189
 
@@ -203,24 +191,6 @@ Silero VAD turn detection, FastRTC. Documented but not on the text-first critica
203
  tool-calling is a pending PR — verify before relying on it for the badge runtime.)
204
  - **1B discipline:** small tool schemas, few params each, clear descriptions, low temp, single-hop tool calls.
205
 
206
- ### 5.3 Smart Turn v3 — `pipecat-ai/smart-turn-v3` (voice-later)
207
-
208
- - Whisper-tiny encoder + linear head, ~8M params, ONNX (`smart-turn-v3.1.onnx`), 8 MB int8 / 32 MB fp32, BSD-2.
209
- - Input: 16 kHz mono PCM float32, ≤8 s (front-padded). Output: sigmoid prob; **>0.5 = "user finished."** ~12 ms CPU.
210
- ```python
211
- import numpy as np, onnxruntime as ort
212
- from transformers import WhisperFeatureExtractor
213
- fe = WhisperFeatureExtractor(chunk_length=8); sess = ort.InferenceSession("smart-turn-v3.1.onnx")
214
- def turn_complete(audio):
215
- x = fe(audio, sampling_rate=16000, return_tensors="np", padding="max_length",
216
- max_length=8*16000, truncation=True, do_normalize=True).input_features.astype("float32")
217
- return sess.run(None, {"input_features": x})[0][0].item() > 0.5
218
- ```
219
- - **Browser path (voice-later):** runs via ONNX Runtime Web / Transformers.js. The `.onnx` needs Whisper **log-mel
220
- input_features** (128 mel bins, 8 s, NOT whisper-tiny's 80-mel) — no upstream JS example exists, so a small POC is
221
- required before relying on it; fallbacks: port pipecat's numpy-only mel extractor to JS, or do feature-extraction +
222
- onnx **server-side** per posted blob. Pair with `@ricky0123/vad-web` (Silero) for the speech start/stop gate.
223
-
224
  ### 5.4 EmbeddingGemma GGUF — `ggml-org/embeddinggemma-300M-qat-q4_0-GGUF`
225
 
226
  - Active retrieval model: `embeddinggemma-300M-qat-Q4_0.gguf`, 768-dimensional normalized embeddings.
@@ -239,8 +209,7 @@ llama.cpp on Modal, and runtime query embeddings use the same llama.cpp path.
239
  |---|---|---|---|
240
  | `openbmb/MiniCPM5-1B` | ✅ planned only | llama.cpp / Ollama | Not used for deployed tool-calling; Transformers + LoRA is the deployed brain. |
241
  | `ggml-org/embeddinggemma-300M-qat-q4_0-GGUF` | ✅ active | llama.cpp / llama-cpp-python | Builds project vectors on Modal and embeds runtime queries in the Space. |
242
- | ASR (Nemotron / Parakeet) | ❌ | NeMo / transformers | FastConformer-RNNT |
243
- | `pipecat-ai/smart-turn-v3` | ❌ | ONNX Runtime | Whisper encoder + classifier head |
244
 
245
  If retrieval quality becomes the bottleneck, compare Q4_0 against Q8_0, but do not keep two runtime retrieval paths.
246
 
@@ -393,12 +362,12 @@ open grimoire as the hero component.
393
  | Target | How it's earned |
394
  |---|---|
395
  | 🍄 Thousand Token Wood | **The Unwritten Almanac** (§2) — the bleed-citation wow IS the engine rendered; AI load-bearing; original |
396
- | 🐜 Tiny Titan (special, $1.5k) | total ~1.9B, every model ≤4B; largest single = MiniCPM5 1.08B |
397
  | 🔌 Off the Grid (badge) | all open weights run locally; offline index; no cloud inference at runtime |
398
  | 🎯 Well-Tuned (badge) | published LoRA fine-tune of MiniCPM5 on the Hub (§10) → **6/6 badges** |
399
  | 🎨 Off-Brand (badge + $1.5k) | `gr.Server` custom UI is the agent's output surface |
400
  | 🏮 OpenBMB ($10k) | brain = MiniCPM5-1B ("OpenBMB pick") |
401
- | 🟩 NVIDIA Quest (2× RTX 5080) | ASR = Nemotron (verify if Parakeet qualifies, §5.1) |
402
  | 🦙 Llama Champion (badge) | EmbeddingGemma GGUF retrieval index and runtime query embeddings run through llama.cpp (§5.5) |
403
  | 📡 Sharing is Caring (badge) | publish the agent's tool-call trace to the Hub |
404
  | 📓 Field Notes (badge) | this DESIGN.md → a build blog post |
@@ -417,22 +386,20 @@ lever is §11 custom-UI polish.
417
 
418
  ## 13. Risks / open items
419
 
420
- 1. **Day-1 spikes are go/no-go** (§1): ZeroGPU hello-cuda build; gr.Server same-origin NDJSON browser streaming;
421
- Nemotron batch in `@spaces.GPU` (else Parakeet). Do these before feature work.
422
  2. **EmbeddingGemma is gated** — accept Gemma terms + `HF_TOKEN` before any crawl/build.
423
  3. **MiniCPM5 tool-call reliability at 1B** — covered by the degradation ladder (§8); validate name+args in code.
424
- 4. **NVIDIA Quest brand** — Parakeet is not "Nemotron"-branded; confirm eligibility with organizers or keep Nemotron
425
- primary (§5.1).
426
- 5. **Concept skin** — **chosen: The Unwritten Almanac** (§2). Make-or-break is the bleed/bloom hero animation; build the
427
  safe typewriter + static-stamp floor first (graceful degradation), upgrade ink last. Watch the thin-org echo
428
  threshold + the dry-but-benevolent tone (real cited Spaces, never punch at a named builder).
429
- 6. **Param-budget claim** — document the 1.9B total in the README/Space card for Tiny Titan judging.
430
 
431
  ---
432
 
433
  ## 14. Build order
434
 
435
- **Text-first vertical slice first; voice as a bonus layer.** Always keep a demoable artifact.
436
 
437
  0. **Day-1 spikes** (§1) — get the three go/no-go builds green.
438
  1. **`crawler.py` + Modal index** — crawl the org, embed with EmbeddingGemma, build the local index. *You immediately
@@ -442,27 +409,24 @@ lever is §11 custom-UI polish.
442
  4. **`app.py`** — `gr.Server` custom frontend (idea board, project/whitespace wall, streaming text), called via
443
  first-party `/api/...` endpoints; concept skin applied.
444
  5. **Well-Tuned LoRA** — small fine-tune on Modal → publish to Hub (→ 6/6 badges).
445
- 6. **Polish + submission** — demo video + social post (Best Demo / Community Choice), publish agent trace (📡),
 
446
  write up Field Notes (📓).
447
 
448
- **Voice bonus (only if time):** push-to-talk record → batch ASR (Nemotron/Parakeet) in the existing `@spaces.GPU` call.
449
- **Deferred:** real-time streaming ASR, in-browser Smart Turn + Silero VAD, FastRTC + TURN. (Original streaming design is
450
- preserved in git history if revisited.)
451
 
452
  ---
453
 
454
  ## 15. Sources
455
 
456
  **Models:** [nemotron-speech-streaming-en-0.6b](https://huggingface.co/nvidia/nemotron-speech-streaming-en-0.6b) ·
457
- [parakeet-tdt-0.6b-v3](https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3) ·
458
- [parakeet transformers doc](https://huggingface.co/docs/transformers/en/model_doc/parakeet) ·
459
  [MiniCPM5-1B](https://huggingface.co/openbmb/MiniCPM5-1B) · [MiniCPM5-1B-GGUF](https://huggingface.co/openbmb/MiniCPM5-1B-GGUF) ·
460
- [smart-turn-v3](https://huggingface.co/pipecat-ai/smart-turn-v3) · [embeddinggemma-300m](https://huggingface.co/google/embeddinggemma-300m)
461
 
462
  **Platforms:** [ZeroGPU docs](https://huggingface.co/docs/hub/spaces-zerogpu) ·
463
  [Introducing gradio.Server](https://huggingface.co/blog/introducing-gradio-server) · [Gradio Server Mode guide](https://www.gradio.app/guides/server-mode) ·
464
  [Modal GPU](https://modal.com/docs/guide/gpu) · [Modal model weights](https://modal.com/docs/guide/model-weights) · [Modal pricing](https://modal.com/pricing) ·
465
  [Build Small Hackathon](https://huggingface.co/build-small-hackathon)
466
 
467
- *Verify-before-ship: Nemotron-in-ZeroGPU (Day-1 spike); MiniCPM5 license on the live card; NVIDIA Quest eligibility for
468
- Parakeet; Smart Turn in-browser feature extraction (voice-later); llama.cpp MiniCPM5 tool-calling (pending PR).*
 
1
  # Build Small Hackathon Advisor — Design & Implementation Notes
2
 
3
+ > A **small-model agent** with text and voice input that investigates what other people have already built
4
  > for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon) and brainstorms an original new design
5
  > *with you*. Output = streaming text + live visuals (no TTS). All models small, open-weight, run locally.
6
  >
 
21
  archivist divines a fate-page; ink **bleeds and cites the real Spaces** you overlap (page 47, page 112…), or
22
  **blooms gold + sprouts a leaf** when it's unwritten. Engine unchanged underneath (crawl → whitespace/originality →
23
  score). The dry "advisor" stays under the hood. Full spec + de-risking grafts in §2.
24
+ 2. **Text-first with voice input.** The core workflow remains typed/editable text. Voice records or uploads a note,
25
+ transcribes it with batch ASR, and places the transcript in the same idea box. Real-time streaming + in-browser turn
26
+ detection are **deferred**.
27
  3. **Add a 🎯 Well-Tuned fine-tune** — a small LoRA (MiniCPM5 advisor persona / tool-calling), trained on Modal,
28
  published to the Hub → 6/6 badges → strong shot at 🎖️ Bonus Quest Champion ($2,000).
29
+ 4. **ASR = Nemotron batch.** `nvidia/nemotron-speech-streaming-en-0.6b` runs through NVIDIA NeMo in a ZeroGPU function.
30
+ Audio is normalized to mono WAV before calling `transcribe([wav])`.
 
31
 
32
  **Verified corrections:**
33
  - **Drop SGLang.** It needs a persistent GPU process → incompatible with ZeroGPU (same root cause as vLLM). Run
 
58
  - Trivial `@spaces.GPU` hello-cuda build GREEN on torch 2.8+, deps pinned, heavy deps added one at a time.
59
  - `gr.Server` minimal: static `index.html` + one same-origin `/api/agent-turn` NDJSON stream, plus the retained
60
  `@app.api()` generator for external clients, on the real ZeroGPU Space.
61
+ - Nemotron `nemo_toolkit[asr]` install + one batch `transcribe()` inside `@spaces.GPU` (decision #4).
62
 
63
  ---
64
 
 
110
 
111
  **Defaults (revisit if time):** single-page artifact first (chapter compiler later); page-numbers visible, real titles
112
  on hover (keep the burn aimed at the idea, not a named builder); seal animation = safe typewriter + static-stamp floor
113
+ first, bespoke ink-reveal last. Voice input is batch ASR that fills the same idea box before the user presses Ink.
114
 
115
  Input is **text-first**; the experience is fully delightful with typed input alone.
116
 
 
124
 
125
  | Role | Model | Params | Runtime | License | Prize hook |
126
  |---|---|---|---|---|---|
127
+ | STT (batch voice input) | **`nvidia/nemotron-speech-streaming-en-0.6b`** | 0.6B | NeMo, GPU+CUDA | NVIDIA Open Model (commercial OK) | 🟩 NVIDIA Nemotron Quest |
 
128
  | LLM brain | **`openbmb/MiniCPM5-1B`** ("OpenCPM5") | 1.08B | **transformers** (self-parse XML) / llama.cpp | **Apache-2.0** | 🏮 OpenBMB |
 
129
  | Embedder | **`ggml-org/embeddinggemma-300M-qat-q4_0-GGUF`** | ~300M | llama.cpp / llama-cpp-python | Gemma | 🔌 Off the Grid · 🦙 Llama Champion · 🟢 Modal |
130
  | Fine-tune | LoRA on MiniCPM5 → published to Hub | — | PEFT / HF Jobs | — | 🎯 Well-Tuned |
131
 
132
+ **Total ≈ 1.98B params → ≤4B → 🐜 Tiny Titan eligible.** All open-weight, all runnable locally → 🔌 Off the Grid.
133
 
134
  > Naming: "OpenCPM5 1B" = `openbmb/MiniCPM5-1B` (MiniCPM 5.0, ~May 2026). "EmbeddingGemma 270M" =
135
  > `google/embeddinggemma-300m` (308M total; 270M = non-embedding transformer params). **SGLang dropped** (ZeroGPU
136
+ > incompatible). STT is used in **batch voice-note** mode, not a persistent stream.
137
 
138
  ---
139
 
 
146
  - **Text-first runtime loop:** user types → custom `/api/agent-turn` NDJSON endpoint → one `@spaces.GPU` call runs
147
  MiniCPM5 (tool loop, in `transformers`) → streamed text tokens + live visual updates. The `@app.api()` endpoint
148
  remains as the Gradio-client contract for external checks.
149
+ - **Voice input:** push-to-talk records an utterance or uploads a voice note → `/api/transcribe` normalizes audio with
150
+ ffmpeg → one `@spaces.GPU` call runs Nemotron ASR through NeMo → transcript fills the idea box. No persistent stream,
151
+ no WebRTC, **no TURN server**.
152
  - **Modal (build-time only):** crawl the org + build the llama.cpp EmbeddingGemma vector index offline; the Space ships
153
  with checked-in project vectors. Runtime never calls Modal → 🔌 Off the Grid holds (see §10).
154
 
155
  > Off the Grid = no proprietary cloud inference APIs. Open weights on an HF GPU Space / local box / Modal all qualify.
156
 
157
+ **Deferred:** real-time streaming ASR and turn detection are not part of the shipped app.
 
158
 
159
  ---
160
 
161
  ## 5. Per-model implementation notes
162
 
163
+ ### 5.1 ASR — `nvidia/nemotron-speech-streaming-en-0.6b` (batch)
164
 
165
  - **Primary, batch usage (simple):**
166
  ```python
 
168
  asr = nemo_asr.models.ASRModel.from_pretrained("nvidia/nemotron-speech-streaming-en-0.6b")
169
  text = asr.transcribe(["utterance.wav"]) # 16 kHz mono WAV in; punctuated EN text out
170
  ```
171
+ Runtime install: `packages.txt` provides `ffmpeg` and `libsndfile1`; `requirements.txt` pins
172
+ `nemo_toolkit[asr]==2.7.3` plus Cython and packaging. The app records or uploads audio, normalizes it to mono
173
+ 16 kHz WAV, runs NeMo in a ZeroGPU function, then returns the transcript to the idea box. Hosted NVIDIA NIM API would
174
+ break Off the Grid, so it is not used.
 
 
 
 
 
 
 
 
 
 
175
 
176
  ### 5.2 MiniCPM5-1B brain — `openbmb/MiniCPM5-1B` (transformers, self-parsed XML)
177
 
 
191
  tool-calling is a pending PR — verify before relying on it for the badge runtime.)
192
  - **1B discipline:** small tool schemas, few params each, clear descriptions, low temp, single-hop tool calls.
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  ### 5.4 EmbeddingGemma GGUF — `ggml-org/embeddinggemma-300M-qat-q4_0-GGUF`
195
 
196
  - Active retrieval model: `embeddinggemma-300M-qat-Q4_0.gguf`, 768-dimensional normalized embeddings.
 
209
  |---|---|---|---|
210
  | `openbmb/MiniCPM5-1B` | ✅ planned only | llama.cpp / Ollama | Not used for deployed tool-calling; Transformers + LoRA is the deployed brain. |
211
  | `ggml-org/embeddinggemma-300M-qat-q4_0-GGUF` | ✅ active | llama.cpp / llama-cpp-python | Builds project vectors on Modal and embeds runtime queries in the Space. |
212
+ | ASR (Nemotron) | ❌ | NeMo | FastConformer-RNNT |
 
213
 
214
  If retrieval quality becomes the bottleneck, compare Q4_0 against Q8_0, but do not keep two runtime retrieval paths.
215
 
 
362
  | Target | How it's earned |
363
  |---|---|
364
  | 🍄 Thousand Token Wood | **The Unwritten Almanac** (§2) — the bleed-citation wow IS the engine rendered; AI load-bearing; original |
365
+ | 🐜 Tiny Titan (special, $1.5k) | total ~1.98B, every model ≤4B; largest single = MiniCPM5 1.08B |
366
  | 🔌 Off the Grid (badge) | all open weights run locally; offline index; no cloud inference at runtime |
367
  | 🎯 Well-Tuned (badge) | published LoRA fine-tune of MiniCPM5 on the Hub (§10) → **6/6 badges** |
368
  | 🎨 Off-Brand (badge + $1.5k) | `gr.Server` custom UI is the agent's output surface |
369
  | 🏮 OpenBMB ($10k) | brain = MiniCPM5-1B ("OpenBMB pick") |
370
+ | 🟩 NVIDIA Quest (2× RTX 5080) | ASR = Nemotron (§5.1) |
371
  | 🦙 Llama Champion (badge) | EmbeddingGemma GGUF retrieval index and runtime query embeddings run through llama.cpp (§5.5) |
372
  | 📡 Sharing is Caring (badge) | publish the agent's tool-call trace to the Hub |
373
  | 📓 Field Notes (badge) | this DESIGN.md → a build blog post |
 
386
 
387
  ## 13. Risks / open items
388
 
389
+ 1. **Deployment smoke tests are mandatory:** ZeroGPU Space build, same-origin NDJSON browser streaming, and Nemotron
390
+ batch ASR in `@spaces.GPU` must be verified after every runtime dependency change.
391
  2. **EmbeddingGemma is gated** — accept Gemma terms + `HF_TOKEN` before any crawl/build.
392
  3. **MiniCPM5 tool-call reliability at 1B** — covered by the degradation ladder (§8); validate name+args in code.
393
+ 4. **Concept skin** — **chosen: The Unwritten Almanac** (§2). Make-or-break is the bleed/bloom hero animation; build the
 
 
394
  safe typewriter + static-stamp floor first (graceful degradation), upgrade ink last. Watch the thin-org echo
395
  threshold + the dry-but-benevolent tone (real cited Spaces, never punch at a named builder).
396
+ 5. **Param-budget claim** — document the 1.98B total in the README/Space card for Tiny Titan judging.
397
 
398
  ---
399
 
400
  ## 14. Build order
401
 
402
+ **Text-first vertical slice first; voice input is now part of the app.** Always keep a demoable artifact.
403
 
404
  0. **Day-1 spikes** (§1) — get the three go/no-go builds green.
405
  1. **`crawler.py` + Modal index** — crawl the org, embed with EmbeddingGemma, build the local index. *You immediately
 
409
  4. **`app.py`** — `gr.Server` custom frontend (idea board, project/whitespace wall, streaming text), called via
410
  first-party `/api/...` endpoints; concept skin applied.
411
  5. **Well-Tuned LoRA** — small fine-tune on Modal → publish to Hub (→ 6/6 badges).
412
+ 6. **Voice input** — push-to-talk record and voice-note upload through Nemotron batch ASR in `/api/transcribe`.
413
+ 7. **Polish + submission** — demo video + social post (Best Demo / Community Choice), publish agent trace (📡),
414
  write up Field Notes (📓).
415
 
416
+ **Deferred:** real-time streaming ASR and turn detection. The shipped path stays batch audio → transcript → editable idea.
 
 
417
 
418
  ---
419
 
420
  ## 15. Sources
421
 
422
  **Models:** [nemotron-speech-streaming-en-0.6b](https://huggingface.co/nvidia/nemotron-speech-streaming-en-0.6b) ·
 
 
423
  [MiniCPM5-1B](https://huggingface.co/openbmb/MiniCPM5-1B) · [MiniCPM5-1B-GGUF](https://huggingface.co/openbmb/MiniCPM5-1B-GGUF) ·
424
+ [embeddinggemma-300m](https://huggingface.co/google/embeddinggemma-300m)
425
 
426
  **Platforms:** [ZeroGPU docs](https://huggingface.co/docs/hub/spaces-zerogpu) ·
427
  [Introducing gradio.Server](https://huggingface.co/blog/introducing-gradio-server) · [Gradio Server Mode guide](https://www.gradio.app/guides/server-mode) ·
428
  [Modal GPU](https://modal.com/docs/guide/gpu) · [Modal model weights](https://modal.com/docs/guide/model-weights) · [Modal pricing](https://modal.com/pricing) ·
429
  [Build Small Hackathon](https://huggingface.co/build-small-hackathon)
430
 
431
+ *Verify-before-ship: Nemotron-in-ZeroGPU after dependency changes; MiniCPM5 license on the live card; llama.cpp MiniCPM5
432
+ tool-calling remains planned only and is not used by the deployed brain.*
README.md CHANGED
@@ -29,6 +29,7 @@ The current milestone is a deployed ZeroGPU + MiniCPM5 LoRA advisor:
29
 
30
  - Local snapshot of public `build-small-hackathon` Spaces.
31
  - Modal-built EmbeddingGemma GGUF retrieval index, with runtime query embeddings computed through llama.cpp.
 
32
  - Jargon correction for hackathon/model terms.
33
  - MiniCPM5 tool-call planning with a published PEFT LoRA adapter, plus deterministic local rules for tests and CPU-only
34
  development.
@@ -88,6 +89,13 @@ Users can also click any Idea Board page to make it current before pressing `Pla
88
  If the board is empty, `Plan` and `Compare` do not create placeholder pages; they prompt the user to write an idea or
89
  press `Gap` first.
90
 
 
 
 
 
 
 
 
91
  ## Gap Exploration
92
 
93
  The `Gap` command walks through unused whitespace candidates instead of repeating the same first suggestion. Each chosen
@@ -180,12 +188,13 @@ ADVISOR_ADAPTER_ID=build-small-hackathon/hackathon-advisor-minicpm5-lora
180
  ADVISOR_ADAPTER_REVISION=25de69bcde397e1bcdd852923b56a42f10222650
181
  ADVISOR_EMBEDDING_MODEL_REPO=ggml-org/embeddinggemma-300M-qat-q4_0-GGUF
182
  ADVISOR_EMBEDDING_MODEL_FILE=embeddinggemma-300M-qat-Q4_0.gguf
 
183
  ```
184
 
185
  `agent_turn` wraps the engine call with `spaces.GPU` when `ADVISOR_ZERO_GPU=1`, so model loading and generation run on
186
  the ZeroGPU allocation. The retrieval query embedder downloads the GGUF model through `huggingface_hub` unless
187
- `ADVISOR_EMBEDDING_MODEL_PATH` points to a local file. Local tests and CPU-only development still default to
188
- `ADVISOR_MODEL_BACKEND=rules`.
189
 
190
  ## Test
191
 
 
29
 
30
  - Local snapshot of public `build-small-hackathon` Spaces.
31
  - Modal-built EmbeddingGemma GGUF retrieval index, with runtime query embeddings computed through llama.cpp.
32
+ - Nemotron Speech Streaming voice input through NVIDIA NeMo ASR on ZeroGPU.
33
  - Jargon correction for hackathon/model terms.
34
  - MiniCPM5 tool-call planning with a published PEFT LoRA adapter, plus deterministic local rules for tests and CPU-only
35
  development.
 
89
  If the board is empty, `Plan` and `Compare` do not create placeholder pages; they prompt the user to write an idea or
90
  press `Gap` first.
91
 
92
+ ## Voice Input
93
+
94
+ The `Speak` and `Voice note` controls send audio to `/api/transcribe`. The backend normalizes the uploaded audio with
95
+ ffmpeg, then transcribes it with `nvidia/nemotron-speech-streaming-en-0.6b` through NVIDIA NeMo inside the same ZeroGPU
96
+ runtime used by the advisor. The transcript is placed back in the idea box so the user can edit it before pressing
97
+ `Ink`.
98
+
99
  ## Gap Exploration
100
 
101
  The `Gap` command walks through unused whitespace candidates instead of repeating the same first suggestion. Each chosen
 
188
  ADVISOR_ADAPTER_REVISION=25de69bcde397e1bcdd852923b56a42f10222650
189
  ADVISOR_EMBEDDING_MODEL_REPO=ggml-org/embeddinggemma-300M-qat-q4_0-GGUF
190
  ADVISOR_EMBEDDING_MODEL_FILE=embeddinggemma-300M-qat-Q4_0.gguf
191
+ ADVISOR_ASR_MODEL_ID=nvidia/nemotron-speech-streaming-en-0.6b
192
  ```
193
 
194
  `agent_turn` wraps the engine call with `spaces.GPU` when `ADVISOR_ZERO_GPU=1`, so model loading and generation run on
195
  the ZeroGPU allocation. The retrieval query embedder downloads the GGUF model through `huggingface_hub` unless
196
+ `ADVISOR_EMBEDDING_MODEL_PATH` points to a local file. `/api/transcribe` uses the same ZeroGPU wrapper for Nemotron ASR.
197
+ Local tests and CPU-only development still default to `ADVISOR_MODEL_BACKEND=rules`.
198
 
199
  ## Test
200
 
app.py CHANGED
@@ -3,14 +3,16 @@ from __future__ import annotations
3
  import json
4
  import os
5
  from pathlib import Path
 
6
  from typing import Any, Iterator
7
 
8
- from fastapi import Body
9
  from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response, StreamingResponse
10
  from gradio import Server
11
 
12
  from hackathon_advisor.agent import AdvisorEngine
13
  from hackathon_advisor.artifact_bundle import BUNDLE_FILENAME, build_demo_bundle_zip
 
14
  from hackathon_advisor.chapter import build_chapter_markdown
15
  from hackathon_advisor.data import ProjectIndex
16
  from hackathon_advisor.demo_rehearsal import build_demo_rehearsal
@@ -31,9 +33,11 @@ STATIC_DIR = ROOT / "static"
31
  DATA_PATH = ROOT / "data" / "projects.json"
32
  INDEX_PATH = ROOT / "data" / "project_index.json"
33
  PROFILE_FIELDS = ["skills", "time", "preferences", "constraints"]
 
34
 
35
  index = ProjectIndex.from_files(DATA_PATH, INDEX_PATH)
36
  engine = AdvisorEngine(index)
 
37
  app = Server()
38
 
39
 
@@ -46,6 +50,11 @@ def _engine_turn(message: str, session: dict[str, Any]):
46
  return engine.turn(message, session)
47
 
48
 
 
 
 
 
 
49
  def _session_from_json(session_json: str = "{}") -> dict[str, Any]:
50
  try:
51
  session = json.loads(session_json or "{}")
@@ -107,6 +116,7 @@ def health() -> dict:
107
  "ok": True,
108
  "projects": len(index.projects),
109
  "runtime": engine.runtime_status(),
 
110
  **trace_metadata(index),
111
  }
112
 
@@ -117,6 +127,7 @@ def bootstrap() -> dict:
117
  return {
118
  "project_count": len(index.projects),
119
  "runtime": runtime_status,
 
120
  **trace_metadata(index),
121
  "top_projects": [project.to_public_dict() for project in index.top_projects(limit=8)],
122
  "whitespace": [item.to_dict() for item in index.find_whitespace(limit=5)],
@@ -134,7 +145,7 @@ def runtime() -> dict:
134
 
135
  @app.get("/api/prize-ledger")
136
  def prize_ledger_endpoint() -> dict:
137
- return prize_ledger(engine.runtime_status(), trace_metadata(index))
138
 
139
 
140
  @app.get("/api/tool-contracts")
@@ -153,7 +164,7 @@ def demo_session() -> dict:
153
  @app.get("/api/demo-bundle.zip")
154
  def demo_bundle() -> Response:
155
  runtime_status = engine.runtime_status()
156
- ledger = prize_ledger(runtime_status, trace_metadata(index))
157
  metadata = {
158
  **trace_metadata(index),
159
  "project_count": len(index.projects),
@@ -190,6 +201,34 @@ def agent_turn_stream(payload: dict[str, Any] | None = Body(default=None)) -> St
190
  return StreamingResponse(stream(), media_type="application/x-ndjson")
191
 
192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  @app.post("/api/field-notes")
194
  def field_notes_api(payload: dict[str, Any] | None = Body(default=None)) -> Response:
195
  session = _session_from_payload(payload)
@@ -219,7 +258,7 @@ def chapter_api(payload: dict[str, Any] | None = Body(default=None)) -> Response
219
  @app.get("/api/lora-training-kit.zip")
220
  def lora_training_kit() -> Response:
221
  runtime_status = engine.runtime_status()
222
- ledger = prize_ledger(runtime_status, trace_metadata(index))
223
  metadata = {
224
  **trace_metadata(index),
225
  "project_count": len(index.projects),
@@ -291,7 +330,7 @@ def submission_packet_artifact(session_json: str = "{}") -> str:
291
  **trace_metadata(index),
292
  "project_count": len(index.projects),
293
  },
294
- prize_ledger(runtime_status, trace_metadata(index)),
295
  )
296
 
297
 
 
3
  import json
4
  import os
5
  from pathlib import Path
6
+ import tempfile
7
  from typing import Any, Iterator
8
 
9
+ from fastapi import Body, File, HTTPException, UploadFile
10
  from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response, StreamingResponse
11
  from gradio import Server
12
 
13
  from hackathon_advisor.agent import AdvisorEngine
14
  from hackathon_advisor.artifact_bundle import BUNDLE_FILENAME, build_demo_bundle_zip
15
+ from hackathon_advisor.asr_runtime import create_asr_transcriber
16
  from hackathon_advisor.chapter import build_chapter_markdown
17
  from hackathon_advisor.data import ProjectIndex
18
  from hackathon_advisor.demo_rehearsal import build_demo_rehearsal
 
33
  DATA_PATH = ROOT / "data" / "projects.json"
34
  INDEX_PATH = ROOT / "data" / "project_index.json"
35
  PROFILE_FIELDS = ["skills", "time", "preferences", "constraints"]
36
+ MAX_AUDIO_UPLOAD_BYTES = 25 * 1024 * 1024
37
 
38
  index = ProjectIndex.from_files(DATA_PATH, INDEX_PATH)
39
  engine = AdvisorEngine(index)
40
+ voice_transcriber = create_asr_transcriber()
41
  app = Server()
42
 
43
 
 
50
  return engine.turn(message, session)
51
 
52
 
53
+ @gpu_task
54
+ def _transcribe_voice(audio_path: str) -> dict[str, Any]:
55
+ return voice_transcriber.transcribe(Path(audio_path)).to_dict()
56
+
57
+
58
  def _session_from_json(session_json: str = "{}") -> dict[str, Any]:
59
  try:
60
  session = json.loads(session_json or "{}")
 
116
  "ok": True,
117
  "projects": len(index.projects),
118
  "runtime": engine.runtime_status(),
119
+ "voice": voice_transcriber.status().to_dict(),
120
  **trace_metadata(index),
121
  }
122
 
 
127
  return {
128
  "project_count": len(index.projects),
129
  "runtime": runtime_status,
130
+ "voice": voice_transcriber.status().to_dict(),
131
  **trace_metadata(index),
132
  "top_projects": [project.to_public_dict() for project in index.top_projects(limit=8)],
133
  "whitespace": [item.to_dict() for item in index.find_whitespace(limit=5)],
 
145
 
146
  @app.get("/api/prize-ledger")
147
  def prize_ledger_endpoint() -> dict:
148
+ return prize_ledger(engine.runtime_status(), trace_metadata(index), voice_transcriber.status().to_dict())
149
 
150
 
151
  @app.get("/api/tool-contracts")
 
164
  @app.get("/api/demo-bundle.zip")
165
  def demo_bundle() -> Response:
166
  runtime_status = engine.runtime_status()
167
+ ledger = prize_ledger(runtime_status, trace_metadata(index), voice_transcriber.status().to_dict())
168
  metadata = {
169
  **trace_metadata(index),
170
  "project_count": len(index.projects),
 
201
  return StreamingResponse(stream(), media_type="application/x-ndjson")
202
 
203
 
204
+ @app.post("/api/transcribe")
205
+ async def transcribe_audio(audio: UploadFile = File(...)) -> dict[str, Any]:
206
+ content_type = str(audio.content_type or "")
207
+ if content_type and not content_type.startswith("audio/"):
208
+ raise HTTPException(status_code=415, detail="Voice input must be an audio file.")
209
+ with tempfile.TemporaryDirectory(prefix="advisor-upload-") as directory:
210
+ filename = Path(str(audio.filename or "voice-note")).name
211
+ suffix = Path(filename).suffix or ".audio"
212
+ source = Path(directory) / f"voice{suffix}"
213
+ await _save_audio_upload(audio, source)
214
+ return _transcribe_voice(str(source))
215
+
216
+
217
+ async def _save_audio_upload(upload: UploadFile, target: Path) -> None:
218
+ total = 0
219
+ with target.open("wb") as handle:
220
+ while True:
221
+ chunk = await upload.read(1024 * 1024)
222
+ if not chunk:
223
+ break
224
+ total += len(chunk)
225
+ if total > MAX_AUDIO_UPLOAD_BYTES:
226
+ raise HTTPException(status_code=413, detail="Voice note is too large.")
227
+ handle.write(chunk)
228
+ if total == 0:
229
+ raise HTTPException(status_code=400, detail="Voice note is empty.")
230
+
231
+
232
  @app.post("/api/field-notes")
233
  def field_notes_api(payload: dict[str, Any] | None = Body(default=None)) -> Response:
234
  session = _session_from_payload(payload)
 
258
  @app.get("/api/lora-training-kit.zip")
259
  def lora_training_kit() -> Response:
260
  runtime_status = engine.runtime_status()
261
+ ledger = prize_ledger(runtime_status, trace_metadata(index), voice_transcriber.status().to_dict())
262
  metadata = {
263
  **trace_metadata(index),
264
  "project_count": len(index.projects),
 
330
  **trace_metadata(index),
331
  "project_count": len(index.projects),
332
  },
333
+ prize_ledger(runtime_status, trace_metadata(index), voice_transcriber.status().to_dict()),
334
  )
335
 
336
 
hackathon_advisor/asr_runtime.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import os
5
+ from pathlib import Path
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ from typing import Any
10
+
11
+
12
+ DEFAULT_ASR_MODEL_ID = "nvidia/nemotron-speech-streaming-en-0.6b"
13
+ DEFAULT_ASR_BACKEND = "nemo-asr"
14
+ DEFAULT_ASR_SAMPLE_RATE = 16_000
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class AsrTranscript:
19
+ transcript: str
20
+ model_id: str
21
+ backend: str
22
+ sample_rate: int
23
+
24
+ def to_dict(self) -> dict[str, Any]:
25
+ return {
26
+ "transcript": self.transcript,
27
+ "model_id": self.model_id,
28
+ "backend": self.backend,
29
+ "sample_rate": self.sample_rate,
30
+ }
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class AsrStatus:
35
+ backend: str
36
+ model_id: str
37
+ loaded: bool
38
+ sample_rate: int
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ return {
42
+ "backend": self.backend,
43
+ "model_id": self.model_id,
44
+ "loaded": self.loaded,
45
+ "sample_rate": self.sample_rate,
46
+ }
47
+
48
+
49
+ class NemotronAsrTranscriber:
50
+ backend = DEFAULT_ASR_BACKEND
51
+
52
+ def __init__(
53
+ self,
54
+ model_id: str = DEFAULT_ASR_MODEL_ID,
55
+ sample_rate: int = DEFAULT_ASR_SAMPLE_RATE,
56
+ ) -> None:
57
+ self.model_id = model_id.strip() or DEFAULT_ASR_MODEL_ID
58
+ self.sample_rate = sample_rate
59
+ self._model = None
60
+
61
+ def status(self) -> AsrStatus:
62
+ return AsrStatus(
63
+ backend=self.backend,
64
+ model_id=self.model_id,
65
+ loaded=self._model is not None,
66
+ sample_rate=self.sample_rate,
67
+ )
68
+
69
+ def transcribe(self, audio_path: Path) -> AsrTranscript:
70
+ source = Path(audio_path)
71
+ if not source.is_file():
72
+ raise RuntimeError("Voice note was not saved before transcription.")
73
+ self._ensure_loaded()
74
+ with tempfile.TemporaryDirectory(prefix="advisor-asr-") as directory:
75
+ wav_path = Path(directory) / "voice.wav"
76
+ normalize_audio_for_asr(source, wav_path, self.sample_rate)
77
+ outputs = self._model.transcribe([str(wav_path)], batch_size=1)
78
+ transcript = extract_transcript(outputs).strip()
79
+ if not transcript:
80
+ raise RuntimeError("Nemotron ASR returned an empty transcript.")
81
+ return AsrTranscript(
82
+ transcript=transcript,
83
+ model_id=self.model_id,
84
+ backend=self.backend,
85
+ sample_rate=self.sample_rate,
86
+ )
87
+
88
+ def _ensure_loaded(self) -> None:
89
+ if self._model is not None:
90
+ return
91
+ try:
92
+ import torch
93
+ import nemo.collections.asr as nemo_asr
94
+ except ImportError as error:
95
+ raise RuntimeError(
96
+ "Nemotron voice input requires NVIDIA NeMo ASR. Install `nemo_toolkit[asr]` "
97
+ "before enabling voice transcription."
98
+ ) from error
99
+ model = nemo_asr.models.ASRModel.from_pretrained(model_name=self.model_id)
100
+ device = os.environ.get("ADVISOR_ASR_DEVICE", "").strip()
101
+ if not device:
102
+ device = "cuda" if torch.cuda.is_available() else "cpu"
103
+ model.to(device)
104
+ model.eval()
105
+ self._model = model
106
+
107
+
108
+ def create_asr_transcriber() -> NemotronAsrTranscriber:
109
+ sample_rate = int(os.environ.get("ADVISOR_ASR_SAMPLE_RATE", str(DEFAULT_ASR_SAMPLE_RATE)))
110
+ if sample_rate <= 0:
111
+ raise RuntimeError("ADVISOR_ASR_SAMPLE_RATE must be a positive integer.")
112
+ return NemotronAsrTranscriber(
113
+ model_id=os.environ.get("ADVISOR_ASR_MODEL_ID", DEFAULT_ASR_MODEL_ID),
114
+ sample_rate=sample_rate,
115
+ )
116
+
117
+
118
+ def normalize_audio_for_asr(source: Path, target: Path, sample_rate: int = DEFAULT_ASR_SAMPLE_RATE) -> None:
119
+ ffmpeg = shutil.which("ffmpeg")
120
+ if not ffmpeg:
121
+ raise RuntimeError("Voice transcription requires ffmpeg to normalize audio.")
122
+ command = [
123
+ ffmpeg,
124
+ "-hide_banner",
125
+ "-loglevel",
126
+ "error",
127
+ "-y",
128
+ "-i",
129
+ str(source),
130
+ "-ac",
131
+ "1",
132
+ "-ar",
133
+ str(sample_rate),
134
+ "-sample_fmt",
135
+ "s16",
136
+ str(target),
137
+ ]
138
+ completed = subprocess.run(command, check=False, capture_output=True, text=True)
139
+ if completed.returncode != 0:
140
+ message = completed.stderr.strip() or "ffmpeg could not read this audio file."
141
+ raise RuntimeError(message)
142
+
143
+
144
+ def extract_transcript(outputs: Any) -> str:
145
+ if isinstance(outputs, str):
146
+ return outputs
147
+ if isinstance(outputs, dict):
148
+ return str(outputs.get("text") or outputs.get("transcript") or "")
149
+ if isinstance(outputs, (list, tuple)):
150
+ if not outputs:
151
+ return ""
152
+ return extract_transcript(outputs[0])
153
+ text = getattr(outputs, "text", None)
154
+ if text is not None:
155
+ return str(text)
156
+ transcript = getattr(outputs, "transcript", None)
157
+ if transcript is not None:
158
+ return str(transcript)
159
+ return str(outputs)
hackathon_advisor/prize_ledger.py CHANGED
@@ -20,11 +20,11 @@ MODEL_STACK = [
20
  "runtime": "Modal-built llama.cpp GGUF index + runtime llama.cpp query embeddings",
21
  },
22
  {
23
- "role": "Voice bonus",
24
  "model": "nvidia/nemotron-speech-streaming-en-0.6b",
25
  "params_b": 0.60,
26
- "status": "deferred bonus",
27
- "runtime": "batch ASR",
28
  },
29
  ]
30
 
@@ -87,12 +87,17 @@ TRAINING_ARTIFACTS = [
87
  ]
88
 
89
 
90
- def prize_ledger(runtime: dict[str, Any], index_metadata: dict[str, Any] | None = None) -> dict[str, Any]:
 
 
 
 
91
  total_params = round(sum(float(item["params_b"]) for item in MODEL_STACK), 2)
92
  largest = max(MODEL_STACK, key=lambda item: float(item["params_b"]))
93
  return {
94
  "runtime": runtime,
95
  "retrieval_index": index_metadata or {},
 
96
  "model_stack": MODEL_STACK,
97
  "total_params_b": total_params,
98
  "largest_model": {
 
20
  "runtime": "Modal-built llama.cpp GGUF index + runtime llama.cpp query embeddings",
21
  },
22
  {
23
+ "role": "Voice input",
24
  "model": "nvidia/nemotron-speech-streaming-en-0.6b",
25
  "params_b": 0.60,
26
+ "status": "deployed",
27
+ "runtime": "ZeroGPU + NVIDIA NeMo ASR",
28
  },
29
  ]
30
 
 
87
  ]
88
 
89
 
90
+ def prize_ledger(
91
+ runtime: dict[str, Any],
92
+ index_metadata: dict[str, Any] | None = None,
93
+ voice_metadata: dict[str, Any] | None = None,
94
+ ) -> dict[str, Any]:
95
  total_params = round(sum(float(item["params_b"]) for item in MODEL_STACK), 2)
96
  largest = max(MODEL_STACK, key=lambda item: float(item["params_b"]))
97
  return {
98
  "runtime": runtime,
99
  "retrieval_index": index_metadata or {},
100
+ "voice": voice_metadata or {},
101
  "model_stack": MODEL_STACK,
102
  "total_params_b": total_params,
103
  "largest_model": {
packages.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ ffmpeg
2
+ libsndfile1
pyproject.toml CHANGED
@@ -8,9 +8,12 @@ license = { text = "MIT" }
8
  authors = [{ name = "Jacob LinCool" }]
9
  dependencies = [
10
  "accelerate>=1.0,<2",
 
11
  "gradio>=6.16.0,<7",
12
  "huggingface-hub>=0.36,<1",
13
  "llama-cpp-python>=0.3.26,<1",
 
 
14
  "peft>=0.13,<1",
15
  "pillow>=10,<13",
16
  "spaces>=0.50,<1",
@@ -24,7 +27,10 @@ dev = [
24
  ]
25
  model = [
26
  "accelerate>=1.0,<2",
 
27
  "llama-cpp-python>=0.3.26,<1",
 
 
28
  "peft>=0.13,<1",
29
  "spaces>=0.50,<1",
30
  "torch>=2.8,<3",
 
8
  authors = [{ name = "Jacob LinCool" }]
9
  dependencies = [
10
  "accelerate>=1.0,<2",
11
+ "Cython>=3,<4",
12
  "gradio>=6.16.0,<7",
13
  "huggingface-hub>=0.36,<1",
14
  "llama-cpp-python>=0.3.26,<1",
15
+ "nemo_toolkit[asr]==2.7.3",
16
+ "packaging>=24,<26",
17
  "peft>=0.13,<1",
18
  "pillow>=10,<13",
19
  "spaces>=0.50,<1",
 
27
  ]
28
  model = [
29
  "accelerate>=1.0,<2",
30
+ "Cython>=3,<4",
31
  "llama-cpp-python>=0.3.26,<1",
32
+ "nemo_toolkit[asr]==2.7.3",
33
+ "packaging>=24,<26",
34
  "peft>=0.13,<1",
35
  "spaces>=0.50,<1",
36
  "torch>=2.8,<3",
requirements.txt CHANGED
@@ -1,9 +1,12 @@
1
  --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
2
 
3
  accelerate>=1.0,<2
 
4
  gradio>=6.16.0,<7
5
  huggingface-hub>=0.36,<1
6
  llama-cpp-python==0.3.26
 
 
7
  peft>=0.13,<1
8
  pillow>=10,<13
9
  spaces>=0.50,<1
 
1
  --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
2
 
3
  accelerate>=1.0,<2
4
+ Cython>=3,<4
5
  gradio>=6.16.0,<7
6
  huggingface-hub>=0.36,<1
7
  llama-cpp-python==0.3.26
8
+ nemo_toolkit[asr]==2.7.3
9
+ packaging>=24,<26
10
  peft>=0.13,<1
11
  pillow>=10,<13
12
  spaces>=0.50,<1
static/app.js CHANGED
@@ -26,6 +26,9 @@ const exportButton = document.querySelector("#export-artifact");
26
  const exportNotesButton = document.querySelector("#export-notes");
27
  const exportChapterButton = document.querySelector("#export-chapter");
28
  const resetButton = document.querySelector("#reset-session");
 
 
 
29
 
30
  const SESSION_STORAGE_KEY = "hackathon-advisor-session-v2";
31
  const FIELD_NOTES_FILENAME = "hackathon-advisor-field-notes.md";
@@ -43,6 +46,10 @@ let sawTurnToken = false;
43
  let bootstrapData = null;
44
  let sessionRevision = 0;
45
  let sessionControlsLocked = false;
 
 
 
 
46
 
47
  bootstrap().catch(handleBootstrapError);
48
 
@@ -87,6 +94,22 @@ resetButton.addEventListener("click", () => {
87
  resetSession();
88
  });
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  goalsEl.addEventListener("change", (event) => {
91
  const target = event.target;
92
  if (!(target instanceof HTMLInputElement) || !target.dataset.goal) return;
@@ -210,6 +233,121 @@ async function runCommand(command) {
210
  await runTurn(command);
211
  }
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  async function bootstrap() {
214
  const response = await fetch("/api/bootstrap");
215
  if (!response.ok) throw new Error(`project index failed with ${response.status}`);
@@ -344,6 +482,13 @@ function setSessionControlsDisabled(disabled) {
344
  whitespaceEl.querySelectorAll("button[data-gap-prompt]").forEach((gap) => {
345
  gap.disabled = disabled;
346
  });
 
 
 
 
 
 
 
347
  }
348
 
349
  function resetSession() {
 
26
  const exportNotesButton = document.querySelector("#export-notes");
27
  const exportChapterButton = document.querySelector("#export-chapter");
28
  const resetButton = document.querySelector("#reset-session");
29
+ const recordVoiceButton = document.querySelector("#record-voice");
30
+ const uploadVoiceButton = document.querySelector("#upload-voice");
31
+ const voiceFileInput = document.querySelector("#voice-file");
32
 
33
  const SESSION_STORAGE_KEY = "hackathon-advisor-session-v2";
34
  const FIELD_NOTES_FILENAME = "hackathon-advisor-field-notes.md";
 
46
  let bootstrapData = null;
47
  let sessionRevision = 0;
48
  let sessionControlsLocked = false;
49
+ let voiceBusy = false;
50
+ let voiceRecorder = null;
51
+ let voiceStream = null;
52
+ let voiceChunks = [];
53
 
54
  bootstrap().catch(handleBootstrapError);
55
 
 
94
  resetSession();
95
  });
96
 
97
+ recordVoiceButton.addEventListener("click", async () => {
98
+ await toggleVoiceRecording();
99
+ });
100
+
101
+ uploadVoiceButton.addEventListener("click", () => {
102
+ if (uploadVoiceButton.disabled || voiceBusy || sessionControlsLocked) return;
103
+ voiceFileInput.click();
104
+ });
105
+
106
+ voiceFileInput.addEventListener("change", async () => {
107
+ const file = voiceFileInput.files?.[0] || null;
108
+ voiceFileInput.value = "";
109
+ if (!file) return;
110
+ await transcribeVoiceBlob(file, file.name || "voice-note.audio");
111
+ });
112
+
113
  goalsEl.addEventListener("change", (event) => {
114
  const target = event.target;
115
  if (!(target instanceof HTMLInputElement) || !target.dataset.goal) return;
 
233
  await runTurn(command);
234
  }
235
 
236
+ async function toggleVoiceRecording() {
237
+ if (voiceRecorder?.state === "recording") {
238
+ voiceRecorder.stop();
239
+ return;
240
+ }
241
+ await startVoiceRecording();
242
+ }
243
+
244
+ async function startVoiceRecording() {
245
+ if (sessionControlsLocked || voiceBusy) return;
246
+ if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
247
+ setSessionStatus("Voice recording is not available in this browser. Upload a voice note instead.");
248
+ return;
249
+ }
250
+ try {
251
+ voiceStream = await navigator.mediaDevices.getUserMedia({ audio: true });
252
+ voiceChunks = [];
253
+ const mimeType = recordingMimeType();
254
+ voiceRecorder = new MediaRecorder(voiceStream, mimeType ? { mimeType } : undefined);
255
+ voiceRecorder.addEventListener("dataavailable", (event) => {
256
+ if (event.data?.size) voiceChunks.push(event.data);
257
+ });
258
+ voiceRecorder.addEventListener("stop", () => {
259
+ const recorderMimeType = voiceRecorder?.mimeType || mimeType || "audio/webm";
260
+ stopVoiceStream();
261
+ const extension = recorderMimeType.includes("mp4")
262
+ ? "m4a"
263
+ : recorderMimeType.includes("ogg")
264
+ ? "ogg"
265
+ : "webm";
266
+ const blob = new Blob(voiceChunks, { type: recorderMimeType });
267
+ voiceRecorder = null;
268
+ voiceChunks = [];
269
+ setActionButtonLabel(recordVoiceButton, "Speak");
270
+ setVoiceControlsDisabled(false);
271
+ transcribeVoiceBlob(blob, `recorded-idea.${extension}`);
272
+ });
273
+ voiceRecorder.start();
274
+ setActionButtonLabel(recordVoiceButton, "Stop");
275
+ setVoiceControlsDisabled(false);
276
+ setSessionStatus("Listening. Press Stop when your idea is ready.");
277
+ } catch (error) {
278
+ stopVoiceStream();
279
+ voiceRecorder = null;
280
+ setActionButtonLabel(recordVoiceButton, "Speak");
281
+ setVoiceControlsDisabled(false);
282
+ setSessionStatus(`Voice recording could not start: ${error.message}`);
283
+ }
284
+ }
285
+
286
+ function recordingMimeType() {
287
+ const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus", "audio/mp4"];
288
+ return candidates.find((type) => MediaRecorder.isTypeSupported(type)) || "";
289
+ }
290
+
291
+ function stopVoiceStream() {
292
+ if (!voiceStream) return;
293
+ voiceStream.getTracks().forEach((track) => track.stop());
294
+ voiceStream = null;
295
+ }
296
+
297
+ async function transcribeVoiceBlob(blob, filename) {
298
+ if (sessionControlsLocked || voiceBusy) return false;
299
+ if (!blob?.size) {
300
+ setSessionStatus("Voice note is empty.");
301
+ return false;
302
+ }
303
+ const revision = bumpSessionRevision();
304
+ voiceBusy = true;
305
+ submit.disabled = true;
306
+ input.disabled = true;
307
+ setCommandDisabled(true);
308
+ setSessionControlsDisabled(true);
309
+ setVoiceControlsDisabled(true);
310
+ setActionButtonLabel(recordVoiceButton, "Hearing...");
311
+ setSessionStatus("Transcribing voice note.");
312
+ try {
313
+ const formData = new FormData();
314
+ formData.append("audio", blob, filename || "voice-note.audio");
315
+ const response = await fetch("/api/transcribe", {
316
+ method: "POST",
317
+ body: formData,
318
+ });
319
+ if (!response.ok) throw new Error(`voice note failed with ${response.status}`);
320
+ const data = await response.json();
321
+ const transcript = String(data.transcript || "").trim();
322
+ if (!transcript) throw new Error("empty transcript");
323
+ if (!isCurrentSessionRevision(revision)) return false;
324
+ input.value = mergeDraftWithTranscript(input.value, transcript);
325
+ session.ui_status = "Voice note transcribed. Edit the draft or press Ink.";
326
+ corrections.textContent = session.ui_status;
327
+ saveSession();
328
+ return true;
329
+ } catch (error) {
330
+ if (isCurrentSessionRevision(revision)) setSessionStatus(`Voice note could not be transcribed: ${error.message}`);
331
+ return false;
332
+ } finally {
333
+ voiceBusy = false;
334
+ setActionButtonLabel(recordVoiceButton, "Speak");
335
+ if (isCurrentSessionRevision(revision)) {
336
+ submit.disabled = false;
337
+ input.disabled = false;
338
+ setSessionControlsDisabled(false);
339
+ setCommandDisabled(false);
340
+ setVoiceControlsDisabled(false);
341
+ input.focus();
342
+ }
343
+ }
344
+ }
345
+
346
+ function mergeDraftWithTranscript(draft, transcript) {
347
+ const current = String(draft || "").trim();
348
+ return current ? `${current}\n${transcript}` : transcript;
349
+ }
350
+
351
  async function bootstrap() {
352
  const response = await fetch("/api/bootstrap");
353
  if (!response.ok) throw new Error(`project index failed with ${response.status}`);
 
482
  whitespaceEl.querySelectorAll("button[data-gap-prompt]").forEach((gap) => {
483
  gap.disabled = disabled;
484
  });
485
+ setVoiceControlsDisabled(disabled);
486
+ }
487
+
488
+ function setVoiceControlsDisabled(disabled) {
489
+ const recording = voiceRecorder?.state === "recording";
490
+ recordVoiceButton.disabled = voiceBusy || (disabled && !recording) || !bootstrapData;
491
+ uploadVoiceButton.disabled = voiceBusy || recording || disabled || !bootstrapData;
492
  }
493
 
494
  function resetSession() {
static/index.html CHANGED
@@ -36,6 +36,13 @@
36
  <symbol id="icon-check" viewBox="0 0 24 24">
37
  <path d="M5 12l4 4 10-11" />
38
  </symbol>
 
 
 
 
 
 
 
39
  </svg>
40
 
41
  <div class="desk-glow"></div>
@@ -112,6 +119,18 @@
112
  ></textarea>
113
  <div class="underline"></div>
114
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  <div class="toolbar command-row" aria-label="Advisor commands">
116
  <button id="submit" class="btn btn-ink" type="submit" title="Score this idea">
117
  <svg class="icon"><use href="#icon-quill"></use></svg>
 
36
  <symbol id="icon-check" viewBox="0 0 24 24">
37
  <path d="M5 12l4 4 10-11" />
38
  </symbol>
39
+ <symbol id="icon-mic" viewBox="0 0 24 24">
40
+ <path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z" />
41
+ <path d="M5 11a7 7 0 0 0 14 0M12 18v3M9 21h6" />
42
+ </symbol>
43
+ <symbol id="icon-upload" viewBox="0 0 24 24">
44
+ <path d="M12 17V4M7 9l5-5 5 5M5 20h14" />
45
+ </symbol>
46
  </svg>
47
 
48
  <div class="desk-glow"></div>
 
119
  ></textarea>
120
  <div class="underline"></div>
121
 
122
+ <div class="toolbar voice-row" aria-label="Voice input">
123
+ <button id="record-voice" class="btn btn-voice" type="button" title="Record an idea">
124
+ <svg class="icon"><use href="#icon-mic"></use></svg>
125
+ Speak
126
+ </button>
127
+ <button id="upload-voice" class="btn btn-ghost" type="button" title="Transcribe a voice note">
128
+ <svg class="icon"><use href="#icon-upload"></use></svg>
129
+ Voice note
130
+ </button>
131
+ <input id="voice-file" type="file" accept="audio/*" hidden />
132
+ </div>
133
+
134
  <div class="toolbar command-row" aria-label="Advisor commands">
135
  <button id="submit" class="btn btn-ink" type="submit" title="Score this idea">
136
  <svg class="icon"><use href="#icon-quill"></use></svg>
static/styles.css CHANGED
@@ -302,6 +302,10 @@ textarea:disabled {
302
  flex: 1 1 20px;
303
  }
304
 
 
 
 
 
305
  .btn {
306
  display: inline-flex;
307
  min-height: 36px;
@@ -349,6 +353,17 @@ textarea:disabled {
349
  border-color: #38260f;
350
  }
351
 
 
 
 
 
 
 
 
 
 
 
 
352
  .btn-ghost {
353
  color: var(--ink-soft);
354
  background: transparent;
 
302
  flex: 1 1 20px;
303
  }
304
 
305
+ .voice-row {
306
+ margin-top: 10px;
307
+ }
308
+
309
  .btn {
310
  display: inline-flex;
311
  min-height: 36px;
 
353
  border-color: #38260f;
354
  }
355
 
356
+ .btn-voice {
357
+ color: #f6ecd2;
358
+ background: var(--leaf);
359
+ border-color: var(--leaf);
360
+ }
361
+
362
+ .btn-voice:hover:not(:disabled) {
363
+ background: #265935;
364
+ border-color: #265935;
365
+ }
366
+
367
  .btn-ghost {
368
  color: var(--ink-soft);
369
  background: transparent;
tests/test_app.py CHANGED
@@ -21,6 +21,7 @@ from app import (
21
  prize_ledger_endpoint,
22
  runtime,
23
  submission_packet_artifact,
 
24
  tool_contract_check,
25
  tool_contracts,
26
  trace_artifact,
@@ -34,6 +35,28 @@ async def _read_streaming_response(response) -> str:
34
  return "".join(chunks)
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  def test_health_exposes_index_metadata() -> None:
38
  payload = health()
39
 
@@ -41,6 +64,7 @@ def test_health_exposes_index_metadata() -> None:
41
  assert payload["projects"] == len(index.projects)
42
  assert payload["index_algorithm"] == "llama-cpp-embedding-v1"
43
  assert payload["runtime"]["backend"] == "rules"
 
44
  assert len(payload["snapshot_digest"]) == 64
45
 
46
 
@@ -51,6 +75,7 @@ def test_bootstrap_exposes_index_metadata() -> None:
51
  assert payload["index_generated_at"]
52
  assert payload["snapshot_digest"]
53
  assert payload["runtime"]["tool_count"] >= 8
 
54
  assert payload["top_projects"]
55
  assert payload["default_goals"] == payload["goal_options"][:3]
56
  assert [goal["id"] for goal in payload["goal_profiles"]] == payload["goal_options"]
@@ -78,6 +103,48 @@ def test_agent_turn_stream_endpoint_exports_ndjson_events() -> None:
78
  assert lines[-1]["state"]["ideas"]
79
 
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  def test_markdown_api_endpoints_return_plain_markdown() -> None:
82
  state = engine.turn("A local-first archive cartographer for family photos", {}).state
83
 
@@ -246,8 +313,10 @@ def test_prize_ledger_endpoint_reports_submission_evidence() -> None:
246
 
247
  assert payload["runtime"]["backend"] == "rules"
248
  assert payload["tiny_titan_eligible"] is True
 
249
  assert any(badge["name"] == "Sharing is Caring" for badge in payload["badges"])
250
  assert {badge["name"]: badge["status"] for badge in payload["badges"]}["Llama Champion"] == "ready"
 
251
  assert payload["retrieval_index"]["index_algorithm"] == "llama-cpp-embedding-v1"
252
  assert payload["retrieval_index"]["embedding_runtime"] == "llama.cpp via llama-cpp-python"
253
  assert payload["training_artifacts"][0]["endpoint"] == "lora_dataset"
 
21
  prize_ledger_endpoint,
22
  runtime,
23
  submission_packet_artifact,
24
+ transcribe_audio,
25
  tool_contract_check,
26
  tool_contracts,
27
  trace_artifact,
 
35
  return "".join(chunks)
36
 
37
 
38
+ class DummyUpload:
39
+ def __init__(
40
+ self,
41
+ content: bytes,
42
+ filename: str = "voice.wav",
43
+ content_type: str = "audio/wav",
44
+ ) -> None:
45
+ self._content = content
46
+ self._offset = 0
47
+ self.filename = filename
48
+ self.content_type = content_type
49
+
50
+ async def read(self, size: int = -1) -> bytes:
51
+ if self._offset >= len(self._content):
52
+ return b""
53
+ if size is None or size < 0:
54
+ size = len(self._content) - self._offset
55
+ start = self._offset
56
+ self._offset = min(len(self._content), self._offset + size)
57
+ return self._content[start : self._offset]
58
+
59
+
60
  def test_health_exposes_index_metadata() -> None:
61
  payload = health()
62
 
 
64
  assert payload["projects"] == len(index.projects)
65
  assert payload["index_algorithm"] == "llama-cpp-embedding-v1"
66
  assert payload["runtime"]["backend"] == "rules"
67
+ assert payload["voice"]["model_id"] == "nvidia/nemotron-speech-streaming-en-0.6b"
68
  assert len(payload["snapshot_digest"]) == 64
69
 
70
 
 
75
  assert payload["index_generated_at"]
76
  assert payload["snapshot_digest"]
77
  assert payload["runtime"]["tool_count"] >= 8
78
+ assert payload["voice"]["backend"] == "nemo-asr"
79
  assert payload["top_projects"]
80
  assert payload["default_goals"] == payload["goal_options"][:3]
81
  assert [goal["id"] for goal in payload["goal_profiles"]] == payload["goal_options"]
 
103
  assert lines[-1]["state"]["ideas"]
104
 
105
 
106
+ def test_transcribe_audio_endpoint_saves_audio(monkeypatch) -> None:
107
+ captured = {}
108
+
109
+ def fake_transcribe(path: str) -> dict:
110
+ captured["path"] = path
111
+ return {
112
+ "transcript": "A local-first memory archive.",
113
+ "model_id": "nvidia/nemotron-speech-streaming-en-0.6b",
114
+ "backend": "nemo-asr",
115
+ "sample_rate": 16000,
116
+ }
117
+
118
+ monkeypatch.setattr("app._transcribe_voice", fake_transcribe)
119
+
120
+ payload = asyncio.run(transcribe_audio(DummyUpload(b"RIFF....WAVE")))
121
+
122
+ assert payload["transcript"] == "A local-first memory archive."
123
+ assert captured["path"].endswith(".wav")
124
+
125
+
126
+ def test_transcribe_audio_endpoint_rejects_non_audio() -> None:
127
+ upload = DummyUpload(b"hello", filename="note.txt", content_type="text/plain")
128
+
129
+ try:
130
+ asyncio.run(transcribe_audio(upload))
131
+ except Exception as error:
132
+ assert getattr(error, "status_code", None) == 415
133
+ else:
134
+ raise AssertionError("non-audio upload should fail")
135
+
136
+
137
+ def test_transcribe_audio_endpoint_rejects_empty_audio() -> None:
138
+ upload = DummyUpload(b"", filename="empty.wav", content_type="audio/wav")
139
+
140
+ try:
141
+ asyncio.run(transcribe_audio(upload))
142
+ except Exception as error:
143
+ assert getattr(error, "status_code", None) == 400
144
+ else:
145
+ raise AssertionError("empty audio upload should fail")
146
+
147
+
148
  def test_markdown_api_endpoints_return_plain_markdown() -> None:
149
  state = engine.turn("A local-first archive cartographer for family photos", {}).state
150
 
 
313
 
314
  assert payload["runtime"]["backend"] == "rules"
315
  assert payload["tiny_titan_eligible"] is True
316
+ assert payload["voice"]["model_id"] == "nvidia/nemotron-speech-streaming-en-0.6b"
317
  assert any(badge["name"] == "Sharing is Caring" for badge in payload["badges"])
318
  assert {badge["name"]: badge["status"] for badge in payload["badges"]}["Llama Champion"] == "ready"
319
+ assert {item["role"]: item["status"] for item in payload["model_stack"]}["Voice input"] == "deployed"
320
  assert payload["retrieval_index"]["index_algorithm"] == "llama-cpp-embedding-v1"
321
  assert payload["retrieval_index"]["embedding_runtime"] == "llama.cpp via llama-cpp-python"
322
  assert payload["training_artifacts"][0]["endpoint"] == "lora_dataset"
tests/test_asr_runtime.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+
3
+ from hackathon_advisor.asr_runtime import (
4
+ DEFAULT_ASR_MODEL_ID,
5
+ NemotronAsrTranscriber,
6
+ extract_transcript,
7
+ )
8
+
9
+
10
+ @dataclass
11
+ class Hypothesis:
12
+ text: str
13
+
14
+
15
+ def test_nemotron_transcriber_status_is_lazy() -> None:
16
+ transcriber = NemotronAsrTranscriber()
17
+
18
+ status = transcriber.status().to_dict()
19
+
20
+ assert status["backend"] == "nemo-asr"
21
+ assert status["model_id"] == DEFAULT_ASR_MODEL_ID
22
+ assert status["loaded"] is False
23
+ assert status["sample_rate"] == 16_000
24
+
25
+
26
+ def test_extract_transcript_accepts_nemo_output_shapes() -> None:
27
+ assert extract_transcript(["A spoken idea."]) == "A spoken idea."
28
+ assert extract_transcript([{"text": "A mapped archive."}]) == "A mapped archive."
29
+ assert extract_transcript([Hypothesis("A private timeline.")]) == "A private timeline."
tests/test_frontend_copy.py CHANGED
@@ -13,6 +13,8 @@ def test_main_interface_copy_is_builder_facing() -> None:
13
  assert "Example idea board loaded with a plan and share page." in app_js
14
  assert "/api/artifact.png" in app_js
15
  assert "/api/agent-turn" in app_js
 
 
16
  assert "readNdjson" in app_js
17
  assert "@gradio/client" not in app_js
18
  assert "renderArtifactCanvas" not in app_js
@@ -20,6 +22,8 @@ def test_main_interface_copy_is_builder_facing() -> None:
20
  assert 'aria-label="Export build notes"' in html
21
  assert 'aria-label="Export idea-board chapter"' in html
22
  assert 'aria-label="Export current page as PNG"' in html
 
 
23
  assert "ideaCardAriaLabel" in app_js
24
  assert "Select idea:" in app_js
25
 
 
13
  assert "Example idea board loaded with a plan and share page." in app_js
14
  assert "/api/artifact.png" in app_js
15
  assert "/api/agent-turn" in app_js
16
+ assert "/api/transcribe" in app_js
17
+ assert "MediaRecorder" in app_js
18
  assert "readNdjson" in app_js
19
  assert "@gradio/client" not in app_js
20
  assert "renderArtifactCanvas" not in app_js
 
22
  assert 'aria-label="Export build notes"' in html
23
  assert 'aria-label="Export idea-board chapter"' in html
24
  assert 'aria-label="Export current page as PNG"' in html
25
+ assert 'aria-label="Voice input"' in html
26
+ assert "Voice note" in html
27
  assert "ideaCardAriaLabel" in app_js
28
  assert "Select idea:" in app_js
29