cvpfus Codex commited on
Commit
1ca8e19
·
1 Parent(s): d05b639

Prepare Space-ready demo foundation

Browse files

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

Files changed (5) hide show
  1. FIELD_NOTES.md +43 -0
  2. README.md +29 -0
  3. app.py +25 -5
  4. scripts/verify.py +84 -0
  5. static/app.js +41 -25
FIELD_NOTES.md ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Field Notes
2
+
3
+ These notes are the seed for the hackathon build report.
4
+
5
+ ## Submission Thesis
6
+
7
+ Tiny Narrator is a small-model accessibility article reader. It is not a generic chatbot wrapped in a page. The app uses semantic article structure, a keyboard-first reader mode, and model-specific jobs so each small model has a clear reason to exist.
8
+
9
+ ## Bonus Targets
10
+
11
+ | Target | Evidence we are building |
12
+ | --- | --- |
13
+ | Tiny Titan | Every planned model is at or below 4B parameters. |
14
+ | Llama Champion | The reader-brain layer calls a GGUF model through a llama.cpp OpenAI-compatible endpoint. |
15
+ | Off-Brand | The visible app is custom HTML, CSS, and JavaScript served by `gr.Server`. |
16
+ | Field Notes | This file documents model choices, architecture, fallbacks, and accessibility behavior. |
17
+
18
+ ## Model Budget
19
+
20
+ | Role | Model | Size | Why it belongs |
21
+ | --- | --- | ---: | --- |
22
+ | Reader brain | `nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF` | 3.97B | Local narration planning through llama.cpp. |
23
+ | Vision | `openbmb/MiniCPM-V-2` | 3B | Describes generated article images and handles OCR-like image text. |
24
+ | Speech | `hexgrad/Kokoro-82M` | 82M | Fast screen-reader voice with a tiny footprint. |
25
+ | Image generation | `black-forest-labs/FLUX.2-klein-4B` | 4B | Generates the article illustrations while staying Tiny Titan-safe. |
26
+
27
+ ## Reader Mode Behavior
28
+
29
+ The frontend creates a reading queue from semantic nodes: headings, paragraphs, quotes, figures, captions, and controls. It speaks one item at a time, keeps keyboard focus synchronized with the active node, and exposes the current narration through an `aria-live` region.
30
+
31
+ Keyboard map:
32
+
33
+ - `Space`: play or pause.
34
+ - `N`: next item.
35
+ - `P`: previous item.
36
+ - `H`: next heading.
37
+ - `I`: next image.
38
+ - `R`: repeat current item.
39
+ - `Esc`: stop audio.
40
+
41
+ ## Fallback Strategy
42
+
43
+ Live accessibility demos need dependable behavior. If llama.cpp is not available, the app uses deterministic local narration prefixes. If Kokoro is not installed or cannot load a voice, the app returns a short silent WAV while keeping the transcript visible. If the VLM integration is unavailable, image ids map to cached alt text.
README.md CHANGED
@@ -1,3 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  # Tiny Narrator
2
 
3
  Tiny Narrator is a Build Small Hackathon prototype: a custom Gradio Server article app that can switch into a guided screen-reader mode.
@@ -40,6 +52,23 @@ python app.py
40
 
41
  Open the local URL printed by Gradio. The custom frontend calls `/api/reader-brain`, `/api/describe-image`, `/api/speak`, and `/api/generate-image`.
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  ## Screen Reader Mode
44
 
45
  The frontend builds a reading queue from semantic article nodes. When screen-reader mode is on:
 
1
+ ---
2
+ title: Tiny Narrator
3
+ emoji: 🔊
4
+ colorFrom: teal
5
+ colorTo: yellow
6
+ sdk: gradio
7
+ sdk_version: 6.16.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: apache-2.0
11
+ ---
12
+
13
  # Tiny Narrator
14
 
15
  Tiny Narrator is a Build Small Hackathon prototype: a custom Gradio Server article app that can switch into a guided screen-reader mode.
 
52
 
53
  Open the local URL printed by Gradio. The custom frontend calls `/api/reader-brain`, `/api/describe-image`, `/api/speak`, and `/api/generate-image`.
54
 
55
+ Useful environment variables:
56
+
57
+ | Variable | Default | Purpose |
58
+ | --- | --- | --- |
59
+ | `LLAMA_CPP_BASE_URL` | `http://localhost:8080/v1` | OpenAI-compatible llama.cpp server URL |
60
+ | `LLAMA_CPP_MODEL` | `narrator-brain` | Alias passed to llama.cpp |
61
+ | `GRADIO_SERVER_NAME` | `0.0.0.0` | Bind address for local or Space runtime |
62
+ | `GRADIO_SERVER_PORT` / `PORT` | `7860` | App port |
63
+
64
+ ## Verification
65
+
66
+ ```powershell
67
+ python scripts/verify.py
68
+ ```
69
+
70
+ The verifier checks syntax, static assets, deterministic fallback model paths, and generated speech file behavior.
71
+
72
  ## Screen Reader Mode
73
 
74
  The frontend builds a reading queue from semantic article nodes. When screen-reader mode is on:
app.py CHANGED
@@ -23,6 +23,8 @@ OUTPUT_DIR.mkdir(exist_ok=True)
23
 
24
  LLAMA_CPP_BASE_URL = os.getenv("LLAMA_CPP_BASE_URL", "http://localhost:8080/v1")
25
  LLAMA_CPP_MODEL = os.getenv("LLAMA_CPP_MODEL", "narrator-brain")
 
 
26
 
27
  app = Server(title="Tiny Narrator")
28
  app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@@ -204,11 +206,29 @@ async def health() -> JSONResponse:
204
  {
205
  "ok": True,
206
  "app": "Tiny Narrator",
 
 
207
  "models": {
208
- "reader_brain": "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF via llama.cpp",
209
- "vision": "openbmb/MiniCPM-V-2",
210
- "speech": "hexgrad/Kokoro-82M",
211
- "image_generation": "black-forest-labs/FLUX.2-klein-4B",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  },
213
  }
214
  )
@@ -260,4 +280,4 @@ async def handle_exception(_: Request, exc: Exception) -> JSONResponse:
260
 
261
 
262
  if __name__ == "__main__":
263
- app.launch()
 
23
 
24
  LLAMA_CPP_BASE_URL = os.getenv("LLAMA_CPP_BASE_URL", "http://localhost:8080/v1")
25
  LLAMA_CPP_MODEL = os.getenv("LLAMA_CPP_MODEL", "narrator-brain")
26
+ GRADIO_SERVER_NAME = os.getenv("GRADIO_SERVER_NAME", "0.0.0.0")
27
+ GRADIO_SERVER_PORT = int(os.getenv("PORT", os.getenv("GRADIO_SERVER_PORT", "7860")))
28
 
29
  app = Server(title="Tiny Narrator")
30
  app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
 
206
  {
207
  "ok": True,
208
  "app": "Tiny Narrator",
209
+ "frontend": "custom Gradio Server HTML/CSS/JS",
210
+ "llama_cpp_base_url": LLAMA_CPP_BASE_URL,
211
  "models": {
212
+ "reader_brain": {
213
+ "id": "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF",
214
+ "params": "3.97B",
215
+ "runtime": "llama.cpp",
216
+ },
217
+ "vision": {
218
+ "id": "openbmb/MiniCPM-V-2",
219
+ "params": "3B",
220
+ "runtime": "Python integration planned",
221
+ },
222
+ "speech": {
223
+ "id": "hexgrad/Kokoro-82M",
224
+ "params": "82M",
225
+ "runtime": "Python",
226
+ },
227
+ "image_generation": {
228
+ "id": "black-forest-labs/FLUX.2-klein-4B",
229
+ "params": "4B",
230
+ "runtime": "Python integration planned",
231
+ },
232
  },
233
  }
234
  )
 
280
 
281
 
282
  if __name__ == "__main__":
283
+ app.launch(server_name=GRADIO_SERVER_NAME, server_port=GRADIO_SERVER_PORT)
scripts/verify.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import py_compile
4
+ import sys
5
+ import wave
6
+ from pathlib import Path
7
+
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ sys.path.insert(0, str(ROOT))
11
+
12
+ import app
13
+ from fastapi.testclient import TestClient
14
+
15
+
16
+ def assert_true(condition: bool, message: str) -> None:
17
+ if not condition:
18
+ raise AssertionError(message)
19
+
20
+
21
+ def verify_static_assets() -> None:
22
+ required = [
23
+ ROOT / "static" / "index.html",
24
+ ROOT / "static" / "app.css",
25
+ ROOT / "static" / "app.js",
26
+ ROOT / "static" / "generated" / "desk-reader.svg",
27
+ ROOT / "static" / "generated" / "model-map.svg",
28
+ ROOT / "static" / "generated" / "field-notes.svg",
29
+ ]
30
+ for path in required:
31
+ assert_true(path.exists(), f"Missing required asset: {path}")
32
+
33
+
34
+ def verify_core_fallbacks() -> None:
35
+ narration = app.reader_brain_core(
36
+ node_type="heading",
37
+ text="Why tiny models matter",
38
+ position="item 1 of 1",
39
+ mode="narrate",
40
+ )
41
+ assert_true(narration["ok"], "Reader-brain fallback did not return ok")
42
+ assert_true("Heading." in narration["narration"], "Reader-brain fallback lost heading prefix")
43
+
44
+ description = app.describe_image_core("model-map", caption=None, prompt=None)
45
+ assert_true(description["ok"], "Image description did not return ok")
46
+ assert_true("small AI models" in description["alt_text"], "Image description fallback changed unexpectedly")
47
+
48
+ speech = app.speak_core("Tiny Narrator verification.", voice="af_heart", speed=1.0)
49
+ assert_true(speech["ok"], "Speech path did not return ok")
50
+ audio_path = ROOT / speech["audio_url"].lstrip("/")
51
+ assert_true(audio_path.exists(), f"Speech output missing: {audio_path}")
52
+ with wave.open(str(audio_path), "rb") as wav:
53
+ assert_true(wav.getframerate() == 24000, "Speech output should be 24 kHz")
54
+ assert_true(wav.getnchannels() == 1, "Speech output should be mono")
55
+
56
+
57
+ def verify_routes() -> None:
58
+ client = TestClient(app.app)
59
+
60
+ home = client.get("/")
61
+ assert_true(home.status_code == 200, "Home route should return 200")
62
+ assert_true("Tiny Narrator" in home.text, "Home route should include app title")
63
+ assert_true("readerToggle" in home.text, "Home route should include reader toggle")
64
+
65
+ health = client.get("/api/health")
66
+ assert_true(health.status_code == 200, "Health route should return 200")
67
+ payload = health.json()
68
+ assert_true(payload["ok"], "Health payload should be ok")
69
+ assert_true(
70
+ payload["models"]["reader_brain"]["runtime"] == "llama.cpp",
71
+ "Health route should document llama.cpp reader-brain runtime",
72
+ )
73
+
74
+
75
+ def main() -> None:
76
+ py_compile.compile(str(ROOT / "app.py"), doraise=True)
77
+ verify_static_assets()
78
+ verify_core_fallbacks()
79
+ verify_routes()
80
+ print("Tiny Narrator verification passed.")
81
+
82
+
83
+ if __name__ == "__main__":
84
+ main()
static/app.js CHANGED
@@ -28,6 +28,7 @@ const nodes = [...document.querySelectorAll(".speakable")].map((element, index)
28
  let enabled = false;
29
  let currentIndex = -1;
30
  let playing = false;
 
31
 
32
  function setEnabled(nextValue) {
33
  enabled = nextValue;
@@ -80,39 +81,54 @@ async function postJson(url, payload) {
80
 
81
  async function narrate(index) {
82
  if (!enabled || !nodes[index]) return;
 
83
  setActive(index);
84
  const node = nodes[index];
85
  runtimeStatus.textContent = "Thinking";
86
-
87
- let sourceText = node.text;
88
- if (node.type === "image") {
89
- const description = await postJson("/api/describe-image", {
90
- image_id: node.imageId,
91
- caption: node.text,
 
 
 
 
 
 
 
 
 
 
 
92
  });
93
- sourceText = description.alt_text;
94
- }
95
 
96
- const result = await postJson("/api/reader-brain", {
97
- node_type: node.type,
98
- text: sourceText,
99
- position: `item ${index + 1} of ${nodes.length}`,
100
- mode: "narrate",
101
- });
102
 
103
- runtimeStatus.textContent = result.runtime;
104
- liveNarration.textContent = result.narration;
105
 
106
- const speech = await postJson("/api/speak", {
107
- text: result.narration,
108
- speed: Number(speedControl.value),
109
- });
110
 
111
- if (speech.audio_url) {
112
- audio.src = speech.audio_url;
113
- await audio.play().catch(() => {});
114
- playing = true;
115
- controls.play.textContent = "Pause";
 
 
 
 
 
 
 
 
 
 
116
  }
117
  }
118
 
 
28
  let enabled = false;
29
  let currentIndex = -1;
30
  let playing = false;
31
+ let requestSerial = 0;
32
 
33
  function setEnabled(nextValue) {
34
  enabled = nextValue;
 
81
 
82
  async function narrate(index) {
83
  if (!enabled || !nodes[index]) return;
84
+ const serial = ++requestSerial;
85
  setActive(index);
86
  const node = nodes[index];
87
  runtimeStatus.textContent = "Thinking";
88
+ controls.play.disabled = true;
89
+
90
+ try {
91
+ let sourceText = node.text;
92
+ if (node.type === "image") {
93
+ const description = await postJson("/api/describe-image", {
94
+ image_id: node.imageId,
95
+ caption: node.text,
96
+ });
97
+ sourceText = description.alt_text;
98
+ }
99
+
100
+ const result = await postJson("/api/reader-brain", {
101
+ node_type: node.type,
102
+ text: sourceText,
103
+ position: `item ${index + 1} of ${nodes.length}`,
104
+ mode: "narrate",
105
  });
 
 
106
 
107
+ if (serial !== requestSerial) return;
 
 
 
 
 
108
 
109
+ runtimeStatus.textContent = result.runtime;
110
+ liveNarration.textContent = result.narration;
111
 
112
+ const speech = await postJson("/api/speak", {
113
+ text: result.narration,
114
+ speed: Number(speedControl.value),
115
+ });
116
 
117
+ if (serial !== requestSerial) return;
118
+
119
+ if (speech.audio_url) {
120
+ audio.src = speech.audio_url;
121
+ await audio.play().catch(() => {
122
+ liveNarration.textContent = `${result.narration} Audio is ready. Press Play to hear it.`;
123
+ });
124
+ playing = !audio.paused;
125
+ controls.play.textContent = playing ? "Pause" : "Play";
126
+ }
127
+ } catch (error) {
128
+ runtimeStatus.textContent = "Error";
129
+ liveNarration.textContent = `Narration failed: ${error.message}`;
130
+ } finally {
131
+ controls.play.disabled = false;
132
  }
133
  }
134