cvpfus Codex commited on
Commit
f72f54e
·
0 Parent(s):

Start Tiny Narrator hackathon app

Browse files

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

.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ outputs/*.wav
6
+ outputs/*.mp3
7
+ outputs/*.flac
8
+ .gradio/
9
+ .env
README.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
4
+
5
+ ## Award Strategy
6
+
7
+ - **Tiny Titan:** every planned model is at or below 4B parameters.
8
+ - **Llama Champion:** the reader-brain layer calls a GGUF model through `llama.cpp`.
9
+ - **Off-Brand:** the visible UI is custom HTML, CSS, and JavaScript served by `gr.Server`.
10
+ - **Field Notes:** the repo documents model sizes, runtime choices, fallbacks, and accessibility decisions.
11
+
12
+ ## Recommended Models
13
+
14
+ | Role | Model | Params | Runtime |
15
+ | --- | ---: | ---: | --- |
16
+ | Reader brain | `nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF` | 3.97B | `llama.cpp` |
17
+ | Image understanding | `openbmb/MiniCPM-V-2` | 3B | Python integration planned |
18
+ | Text to speech | `hexgrad/Kokoro-82M` | 82M | Python |
19
+ | Image generation | `black-forest-labs/FLUX.2-klein-4B` | 4B | Python integration planned |
20
+
21
+ ## Run Locally
22
+
23
+ Install dependencies:
24
+
25
+ ```powershell
26
+ python -m pip install -r requirements.txt
27
+ ```
28
+
29
+ Start the llama.cpp reader-brain server:
30
+
31
+ ```powershell
32
+ llama-server -hf nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF:Q4_K_M --alias narrator-brain --port 8080 --host 0.0.0.0
33
+ ```
34
+
35
+ Start the app:
36
+
37
+ ```powershell
38
+ python app.py
39
+ ```
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:
46
+
47
+ - `Space` plays or pauses.
48
+ - `N` moves to the next item.
49
+ - `P` moves to the previous item.
50
+ - `H` moves to the next heading.
51
+ - `I` moves to the next image.
52
+ - `R` repeats the current item.
53
+ - `Esc` stops the current audio.
54
+
55
+ Each readable node is sent to the reader brain for concise narration, then Kokoro generates speech. If a model is unavailable, the app uses deterministic fallbacks so the demo remains navigable.
app.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import wave
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from uuid import uuid4
9
+
10
+ import requests
11
+ from fastapi import Request
12
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ from gradio import Server
15
+ from pydantic import BaseModel
16
+
17
+
18
+ ROOT = Path(__file__).parent
19
+ STATIC_DIR = ROOT / "static"
20
+ OUTPUT_DIR = ROOT / "outputs"
21
+ OUTPUT_DIR.mkdir(exist_ok=True)
22
+
23
+ LLAMA_CPP_BASE_URL = os.getenv("LLAMA_CPP_BASE_URL", "http://localhost:8080/v1")
24
+ LLAMA_CPP_MODEL = os.getenv("LLAMA_CPP_MODEL", "narrator-brain")
25
+
26
+ app = Server(title="Tiny Narrator")
27
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
28
+ app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs")
29
+
30
+
31
+ class ReaderBrainRequest(BaseModel):
32
+ node_type: str = "paragraph"
33
+ text: str
34
+ position: str | None = None
35
+ mode: str = "narrate"
36
+
37
+
38
+ class ImageDescriptionRequest(BaseModel):
39
+ image_id: str
40
+ caption: str | None = None
41
+ prompt: str | None = None
42
+
43
+
44
+ class SpeechRequest(BaseModel):
45
+ text: str
46
+ voice: str = "af_heart"
47
+ speed: float = 1.0
48
+
49
+
50
+ class ImageGenerationRequest(BaseModel):
51
+ prompt: str
52
+ seed: int | None = None
53
+
54
+
55
+ def _json(data: dict[str, Any], status_code: int = 200) -> JSONResponse:
56
+ return JSONResponse(data, status_code=status_code)
57
+
58
+
59
+ def reader_brain_core(node_type: str, text: str, position: str | None, mode: str) -> dict[str, Any]:
60
+ prompt = (
61
+ "Convert this article node into concise screen-reader narration.\n"
62
+ f"Mode: {mode}\n"
63
+ f"Node type: {node_type}\n"
64
+ f"Position: {position or 'unknown'}\n"
65
+ f"Content: {text}\n\n"
66
+ "Rules: announce the node type only when it helps orientation, keep prose short, "
67
+ "and never mention implementation details."
68
+ )
69
+
70
+ try:
71
+ response = requests.post(
72
+ f"{LLAMA_CPP_BASE_URL}/chat/completions",
73
+ json={
74
+ "model": LLAMA_CPP_MODEL,
75
+ "messages": [
76
+ {
77
+ "role": "system",
78
+ "content": (
79
+ "You are Tiny Narrator's accessibility layer. "
80
+ "Produce clear screen-reader narration for article content."
81
+ ),
82
+ },
83
+ {"role": "user", "content": prompt},
84
+ ],
85
+ "temperature": 0.2,
86
+ "max_tokens": 180,
87
+ },
88
+ timeout=45,
89
+ )
90
+ response.raise_for_status()
91
+ payload = response.json()
92
+ narration = payload["choices"][0]["message"]["content"].strip()
93
+ return {
94
+ "ok": True,
95
+ "runtime": "llama.cpp",
96
+ "model": LLAMA_CPP_MODEL,
97
+ "narration": narration,
98
+ }
99
+ except Exception as exc:
100
+ prefix = {
101
+ "heading": "Heading. ",
102
+ "image": "Image. ",
103
+ "button": "Control. ",
104
+ "quote": "Quote. ",
105
+ }.get(node_type, "")
106
+ return {
107
+ "ok": True,
108
+ "runtime": "fallback",
109
+ "model": "rule-based local fallback",
110
+ "warning": f"llama.cpp unavailable: {exc.__class__.__name__}",
111
+ "narration": f"{prefix}{text}".strip(),
112
+ }
113
+
114
+
115
+ def describe_image_core(image_id: str, caption: str | None, prompt: str | None) -> dict[str, Any]:
116
+ # The first committed slice keeps the API stable while the MiniCPM-V runtime lands next.
117
+ # The frontend passes deterministic image ids so cached descriptions can replace this later.
118
+ descriptions = {
119
+ "desk-reader": (
120
+ "A person reads a long article on a laptop while an accessibility toolbar "
121
+ "highlights the current paragraph."
122
+ ),
123
+ "model-map": (
124
+ "A compact diagram showing four small AI models working together: vision, "
125
+ "reader brain, speech, and image generation."
126
+ ),
127
+ "field-notes": (
128
+ "A notebook page with short build notes, model sizes, and accessibility checks."
129
+ ),
130
+ }
131
+ alt_text = descriptions.get(
132
+ image_id,
133
+ caption or prompt or "A generated article image awaiting model description.",
134
+ )
135
+ return {
136
+ "ok": True,
137
+ "runtime": "MiniCPM-V placeholder",
138
+ "model": "OpenBMB MiniCPM-V-2",
139
+ "alt_text": alt_text,
140
+ }
141
+
142
+
143
+ def _silent_wav(path: Path, seconds: float = 0.35, sample_rate: int = 24000) -> None:
144
+ frames = int(seconds * sample_rate)
145
+ with wave.open(str(path), "wb") as wav:
146
+ wav.setnchannels(1)
147
+ wav.setsampwidth(2)
148
+ wav.setframerate(sample_rate)
149
+ wav.writeframes(b"\x00\x00" * frames)
150
+
151
+
152
+ def speak_core(text: str, voice: str, speed: float) -> dict[str, Any]:
153
+ try:
154
+ from kokoro import KPipeline
155
+ import soundfile as sf
156
+
157
+ pipeline = KPipeline(lang_code="a")
158
+ generator = pipeline(text, voice=voice, speed=speed)
159
+ _, _, audio = next(generator)
160
+ output_path = OUTPUT_DIR / f"speech-{uuid4().hex}.wav"
161
+ sf.write(output_path, audio, 24000)
162
+ runtime = "kokoro"
163
+ warning = None
164
+ except Exception as exc:
165
+ output_path = OUTPUT_DIR / f"speech-fallback-{uuid4().hex}.wav"
166
+ _silent_wav(output_path)
167
+ runtime = "fallback"
168
+ warning = f"Kokoro unavailable: {exc.__class__.__name__}"
169
+
170
+ return {
171
+ "ok": True,
172
+ "runtime": runtime,
173
+ "model": "hexgrad/Kokoro-82M",
174
+ "warning": warning,
175
+ "audio_url": f"/outputs/{output_path.name}",
176
+ "transcript": text,
177
+ }
178
+
179
+
180
+ def generate_image_core(prompt: str, seed: int | None) -> dict[str, Any]:
181
+ return {
182
+ "ok": True,
183
+ "runtime": "placeholder",
184
+ "model": "black-forest-labs/FLUX.2-klein-4B",
185
+ "image_url": f"/static/generated/{'field-notes.svg' if seed == 3 else 'model-map.svg'}",
186
+ "prompt": prompt,
187
+ "seed": seed,
188
+ }
189
+
190
+
191
+ @app.get("/", response_class=HTMLResponse)
192
+ async def home() -> str:
193
+ return (STATIC_DIR / "index.html").read_text(encoding="utf-8")
194
+
195
+
196
+ @app.get("/api/health")
197
+ async def health() -> JSONResponse:
198
+ return _json(
199
+ {
200
+ "ok": True,
201
+ "app": "Tiny Narrator",
202
+ "models": {
203
+ "reader_brain": "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF via llama.cpp",
204
+ "vision": "openbmb/MiniCPM-V-2",
205
+ "speech": "hexgrad/Kokoro-82M",
206
+ "image_generation": "black-forest-labs/FLUX.2-klein-4B",
207
+ },
208
+ }
209
+ )
210
+
211
+
212
+ @app.post("/api/reader-brain")
213
+ async def reader_brain_endpoint(payload: ReaderBrainRequest) -> JSONResponse:
214
+ return _json(reader_brain_core(payload.node_type, payload.text, payload.position, payload.mode))
215
+
216
+
217
+ @app.post("/api/describe-image")
218
+ async def describe_image_endpoint(payload: ImageDescriptionRequest) -> JSONResponse:
219
+ return _json(describe_image_core(payload.image_id, payload.caption, payload.prompt))
220
+
221
+
222
+ @app.post("/api/speak")
223
+ async def speak_endpoint(payload: SpeechRequest) -> JSONResponse:
224
+ return _json(speak_core(payload.text, payload.voice, payload.speed))
225
+
226
+
227
+ @app.post("/api/generate-image")
228
+ async def generate_image_endpoint(payload: ImageGenerationRequest) -> JSONResponse:
229
+ return _json(generate_image_core(payload.prompt, payload.seed))
230
+
231
+
232
+ @app.api(name="reader_brain")
233
+ def reader_brain_api(node_type: str, text: str, position: str = "", mode: str = "narrate") -> str:
234
+ return json.dumps(reader_brain_core(node_type, text, position, mode))
235
+
236
+
237
+ @app.api(name="describe_image")
238
+ def describe_image_api(image_id: str, caption: str = "", prompt: str = "") -> str:
239
+ return json.dumps(describe_image_core(image_id, caption, prompt))
240
+
241
+
242
+ @app.api(name="speak")
243
+ def speak_api(text: str, voice: str = "af_heart", speed: float = 1.0) -> str:
244
+ return json.dumps(speak_core(text, voice, speed))
245
+
246
+
247
+ @app.api(name="generate_image")
248
+ def generate_image_api(prompt: str, seed: int | None = None) -> str:
249
+ return json.dumps(generate_image_core(prompt, seed))
250
+
251
+
252
+ @app.exception_handler(Exception)
253
+ async def handle_exception(_: Request, exc: Exception) -> JSONResponse:
254
+ return _json({"ok": False, "error": exc.__class__.__name__, "detail": str(exc)}, status_code=500)
255
+
256
+
257
+ if __name__ == "__main__":
258
+ app.launch()
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=5.34.0
2
+ requests>=2.32.0
3
+ pydantic>=2.7.0
4
+ kokoro>=0.9.4
5
+ soundfile>=0.12.1
static/app.css ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: light;
3
+ --ink: #202124;
4
+ --muted: #62696f;
5
+ --paper: #fbfaf7;
6
+ --surface: #ffffff;
7
+ --line: #d8d3ca;
8
+ --accent: #1f6f68;
9
+ --accent-strong: #184d48;
10
+ --focus: #c5452c;
11
+ --shadow: 0 18px 45px rgba(32, 33, 36, 0.12);
12
+ }
13
+
14
+ * {
15
+ box-sizing: border-box;
16
+ }
17
+
18
+ html {
19
+ scroll-behavior: smooth;
20
+ }
21
+
22
+ body {
23
+ margin: 0;
24
+ background: var(--paper);
25
+ color: var(--ink);
26
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
27
+ line-height: 1.55;
28
+ }
29
+
30
+ button,
31
+ input {
32
+ font: inherit;
33
+ }
34
+
35
+ button {
36
+ border: 1px solid var(--line);
37
+ background: var(--surface);
38
+ color: var(--ink);
39
+ cursor: pointer;
40
+ }
41
+
42
+ button:hover {
43
+ border-color: var(--accent);
44
+ }
45
+
46
+ button:focus-visible,
47
+ a:focus-visible,
48
+ input:focus-visible {
49
+ outline: 3px solid var(--focus);
50
+ outline-offset: 3px;
51
+ }
52
+
53
+ .topbar {
54
+ position: sticky;
55
+ top: 0;
56
+ z-index: 20;
57
+ display: grid;
58
+ grid-template-columns: max-content 1fr max-content;
59
+ align-items: center;
60
+ gap: 24px;
61
+ min-height: 72px;
62
+ padding: 12px clamp(18px, 4vw, 48px);
63
+ border-bottom: 1px solid rgba(216, 211, 202, 0.88);
64
+ background: rgba(251, 250, 247, 0.92);
65
+ backdrop-filter: blur(16px);
66
+ }
67
+
68
+ .brand {
69
+ display: inline-flex;
70
+ align-items: center;
71
+ gap: 10px;
72
+ color: var(--ink);
73
+ font-weight: 750;
74
+ text-decoration: none;
75
+ }
76
+
77
+ .brand-mark {
78
+ display: grid;
79
+ width: 38px;
80
+ height: 38px;
81
+ place-items: center;
82
+ border-radius: 6px;
83
+ background: var(--ink);
84
+ color: var(--paper);
85
+ font-size: 0.82rem;
86
+ letter-spacing: 0;
87
+ }
88
+
89
+ .topnav {
90
+ display: flex;
91
+ justify-content: center;
92
+ gap: clamp(12px, 3vw, 28px);
93
+ }
94
+
95
+ .topnav a {
96
+ color: var(--muted);
97
+ font-size: 0.92rem;
98
+ font-weight: 650;
99
+ text-decoration: none;
100
+ }
101
+
102
+ .topnav a:hover {
103
+ color: var(--accent-strong);
104
+ }
105
+
106
+ .reader-toggle {
107
+ display: inline-flex;
108
+ align-items: center;
109
+ justify-content: center;
110
+ min-width: 174px;
111
+ min-height: 42px;
112
+ gap: 10px;
113
+ border-radius: 999px;
114
+ padding: 0 16px;
115
+ font-weight: 700;
116
+ }
117
+
118
+ .toggle-dot {
119
+ width: 12px;
120
+ height: 12px;
121
+ border-radius: 999px;
122
+ background: #8b949e;
123
+ }
124
+
125
+ .reader-toggle[aria-pressed="true"] {
126
+ border-color: var(--accent);
127
+ background: var(--accent);
128
+ color: white;
129
+ }
130
+
131
+ .reader-toggle[aria-pressed="true"] .toggle-dot {
132
+ background: #f4d35e;
133
+ }
134
+
135
+ .article-shell {
136
+ display: grid;
137
+ grid-template-columns: minmax(0, 760px) minmax(260px, 340px);
138
+ gap: clamp(28px, 5vw, 64px);
139
+ width: min(1180px, calc(100% - 36px));
140
+ margin: 46px auto 130px;
141
+ align-items: start;
142
+ }
143
+
144
+ .article {
145
+ min-width: 0;
146
+ }
147
+
148
+ .kicker {
149
+ margin: 0 0 14px;
150
+ color: var(--accent-strong);
151
+ font-size: 0.88rem;
152
+ font-weight: 800;
153
+ letter-spacing: 0;
154
+ text-transform: uppercase;
155
+ }
156
+
157
+ h1,
158
+ h2 {
159
+ line-height: 1.08;
160
+ letter-spacing: 0;
161
+ }
162
+
163
+ h1 {
164
+ max-width: 11ch;
165
+ margin: 0;
166
+ font-family: Georgia, "Times New Roman", serif;
167
+ font-size: clamp(3.4rem, 10vw, 6.7rem);
168
+ font-weight: 540;
169
+ }
170
+
171
+ h2 {
172
+ margin: 72px 0 16px;
173
+ font-size: clamp(1.8rem, 3.6vw, 3rem);
174
+ }
175
+
176
+ .dek {
177
+ max-width: 680px;
178
+ margin: 24px 0 34px;
179
+ color: #3d4247;
180
+ font-size: clamp(1.18rem, 2.4vw, 1.5rem);
181
+ }
182
+
183
+ p,
184
+ blockquote,
185
+ figcaption {
186
+ font-size: 1.05rem;
187
+ }
188
+
189
+ blockquote {
190
+ margin: 32px 0;
191
+ padding: 0 0 0 24px;
192
+ border-left: 5px solid var(--accent);
193
+ color: #33383c;
194
+ font-size: 1.35rem;
195
+ font-family: Georgia, "Times New Roman", serif;
196
+ }
197
+
198
+ figure {
199
+ margin: 0;
200
+ }
201
+
202
+ img {
203
+ display: block;
204
+ max-width: 100%;
205
+ }
206
+
207
+ .hero-figure,
208
+ .inline-figure {
209
+ overflow: hidden;
210
+ border: 1px solid var(--line);
211
+ border-radius: 8px;
212
+ background: var(--surface);
213
+ box-shadow: var(--shadow);
214
+ }
215
+
216
+ .hero-figure img,
217
+ .inline-figure img {
218
+ width: 100%;
219
+ aspect-ratio: 16 / 9;
220
+ object-fit: cover;
221
+ }
222
+
223
+ figcaption {
224
+ padding: 14px 16px 16px;
225
+ color: var(--muted);
226
+ }
227
+
228
+ .inline-figure {
229
+ margin-top: 24px;
230
+ box-shadow: none;
231
+ }
232
+
233
+ .status-panel {
234
+ position: sticky;
235
+ top: 96px;
236
+ border-left: 1px solid var(--line);
237
+ padding-left: 24px;
238
+ }
239
+
240
+ .status-panel h2 {
241
+ margin: 0 0 18px;
242
+ font-size: 1rem;
243
+ text-transform: uppercase;
244
+ }
245
+
246
+ dl {
247
+ display: grid;
248
+ gap: 12px;
249
+ margin: 0;
250
+ }
251
+
252
+ dt {
253
+ color: var(--muted);
254
+ font-size: 0.78rem;
255
+ font-weight: 800;
256
+ text-transform: uppercase;
257
+ }
258
+
259
+ dd {
260
+ margin: 2px 0 0;
261
+ font-weight: 650;
262
+ }
263
+
264
+ .live-narration {
265
+ margin-top: 22px;
266
+ padding: 16px;
267
+ border: 1px solid var(--line);
268
+ border-radius: 8px;
269
+ background: var(--surface);
270
+ }
271
+
272
+ .reader-bar {
273
+ position: fixed;
274
+ right: clamp(14px, 3vw, 32px);
275
+ bottom: clamp(14px, 3vw, 32px);
276
+ left: clamp(14px, 3vw, 32px);
277
+ z-index: 30;
278
+ display: flex;
279
+ align-items: center;
280
+ justify-content: center;
281
+ gap: 10px;
282
+ min-height: 72px;
283
+ padding: 12px;
284
+ border: 1px solid var(--line);
285
+ border-radius: 8px;
286
+ background: rgba(255, 255, 255, 0.96);
287
+ box-shadow: var(--shadow);
288
+ }
289
+
290
+ .reader-bar[hidden] {
291
+ display: none;
292
+ }
293
+
294
+ .reader-bar button {
295
+ min-width: 88px;
296
+ min-height: 42px;
297
+ border-radius: 6px;
298
+ font-weight: 750;
299
+ }
300
+
301
+ .reader-bar label {
302
+ display: inline-flex;
303
+ align-items: center;
304
+ gap: 8px;
305
+ color: var(--muted);
306
+ font-size: 0.92rem;
307
+ font-weight: 750;
308
+ }
309
+
310
+ .speakable {
311
+ scroll-margin: 112px;
312
+ }
313
+
314
+ .reader-active {
315
+ outline: 4px solid var(--focus);
316
+ outline-offset: 8px;
317
+ background: rgba(197, 69, 44, 0.05);
318
+ }
319
+
320
+ @media (max-width: 900px) {
321
+ .topbar {
322
+ grid-template-columns: 1fr;
323
+ gap: 12px;
324
+ }
325
+
326
+ .topnav {
327
+ justify-content: flex-start;
328
+ overflow-x: auto;
329
+ }
330
+
331
+ .reader-toggle {
332
+ width: 100%;
333
+ }
334
+
335
+ .article-shell {
336
+ grid-template-columns: 1fr;
337
+ }
338
+
339
+ h1 {
340
+ max-width: 12ch;
341
+ font-size: clamp(3rem, 17vw, 5rem);
342
+ }
343
+
344
+ .status-panel {
345
+ position: static;
346
+ border-left: 0;
347
+ border-top: 1px solid var(--line);
348
+ padding: 24px 0 0;
349
+ }
350
+
351
+ .reader-bar {
352
+ flex-wrap: wrap;
353
+ justify-content: flex-start;
354
+ }
355
+
356
+ .reader-bar button {
357
+ min-width: 76px;
358
+ flex: 1 1 28%;
359
+ }
360
+ }
static/app.js ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const toggle = document.querySelector("#readerToggle");
2
+ const readerBar = document.querySelector(".reader-bar");
3
+ const modeStatus = document.querySelector("#modeStatus");
4
+ const currentStatus = document.querySelector("#currentStatus");
5
+ const runtimeStatus = document.querySelector("#runtimeStatus");
6
+ const liveNarration = document.querySelector("#liveNarration");
7
+ const audio = document.querySelector("#speechAudio");
8
+ const speedControl = document.querySelector("#speedControl");
9
+
10
+ const controls = {
11
+ prev: document.querySelector("#prevButton"),
12
+ play: document.querySelector("#playButton"),
13
+ next: document.querySelector("#nextButton"),
14
+ heading: document.querySelector("#headingButton"),
15
+ image: document.querySelector("#imageButton"),
16
+ };
17
+
18
+ const nodes = [...document.querySelectorAll(".speakable")].map((element, index) => ({
19
+ element,
20
+ index,
21
+ type: element.dataset.readerType || "paragraph",
22
+ imageId: element.dataset.imageId || null,
23
+ get text() {
24
+ return element.innerText.replace(/\s+/g, " ").trim();
25
+ },
26
+ }));
27
+
28
+ let enabled = false;
29
+ let currentIndex = -1;
30
+ let playing = false;
31
+
32
+ function setEnabled(nextValue) {
33
+ enabled = nextValue;
34
+ toggle.setAttribute("aria-pressed", String(enabled));
35
+ toggle.lastChild.textContent = enabled ? " Screen reader on" : " Screen reader off";
36
+ readerBar.hidden = !enabled;
37
+ modeStatus.textContent = enabled ? "Reader on" : "Reader off";
38
+ if (enabled) {
39
+ announceIntro();
40
+ } else {
41
+ stopAudio();
42
+ setActive(-1);
43
+ liveNarration.textContent = "Turn on screen reader mode to begin.";
44
+ }
45
+ }
46
+
47
+ function setActive(index) {
48
+ nodes.forEach((node) => node.element.classList.remove("reader-active"));
49
+ currentIndex = index;
50
+ const node = nodes[currentIndex];
51
+ if (!node) {
52
+ currentStatus.textContent = "No item selected";
53
+ return;
54
+ }
55
+ node.element.classList.add("reader-active");
56
+ node.element.setAttribute("tabindex", "-1");
57
+ node.element.focus({ preventScroll: true });
58
+ node.element.scrollIntoView({ block: "center", behavior: "smooth" });
59
+ currentStatus.textContent = `${node.type}, item ${currentIndex + 1} of ${nodes.length}`;
60
+ }
61
+
62
+ function stopAudio() {
63
+ audio.pause();
64
+ audio.removeAttribute("src");
65
+ playing = false;
66
+ controls.play.textContent = "Play";
67
+ }
68
+
69
+ async function postJson(url, payload) {
70
+ const response = await fetch(url, {
71
+ method: "POST",
72
+ headers: { "Content-Type": "application/json" },
73
+ body: JSON.stringify(payload),
74
+ });
75
+ if (!response.ok) {
76
+ throw new Error(`${response.status} ${response.statusText}`);
77
+ }
78
+ return response.json();
79
+ }
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
+
119
+ function announceIntro() {
120
+ const headings = nodes.filter((node) => node.type === "heading").length;
121
+ const images = nodes.filter((node) => node.type === "image").length;
122
+ liveNarration.textContent =
123
+ `Screen reader mode on. Article contains ${headings} headings, ${images} images, and ${nodes.length} readable items. Press N for next, H for heading, I for image, or Space to begin.`;
124
+ runtimeStatus.textContent = "Ready";
125
+ }
126
+
127
+ function nextByType(type) {
128
+ const start = Math.max(currentIndex + 1, 0);
129
+ const found = nodes.find((node) => node.index >= start && node.type === type);
130
+ if (found) return narrate(found.index);
131
+ const wrapped = nodes.find((node) => node.type === type);
132
+ if (wrapped) return narrate(wrapped.index);
133
+ }
134
+
135
+ toggle.addEventListener("click", () => setEnabled(!enabled));
136
+ controls.next.addEventListener("click", () => narrate(Math.min(currentIndex + 1, nodes.length - 1)));
137
+ controls.prev.addEventListener("click", () => narrate(Math.max(currentIndex - 1, 0)));
138
+ controls.heading.addEventListener("click", () => nextByType("heading"));
139
+ controls.image.addEventListener("click", () => nextByType("image"));
140
+ controls.play.addEventListener("click", () => {
141
+ if (!enabled) return;
142
+ if (currentIndex < 0) {
143
+ narrate(0);
144
+ return;
145
+ }
146
+ if (playing) {
147
+ audio.pause();
148
+ playing = false;
149
+ controls.play.textContent = "Play";
150
+ } else if (audio.src) {
151
+ audio.play();
152
+ playing = true;
153
+ controls.play.textContent = "Pause";
154
+ } else {
155
+ narrate(currentIndex);
156
+ }
157
+ });
158
+
159
+ audio.addEventListener("ended", () => {
160
+ playing = false;
161
+ controls.play.textContent = "Play";
162
+ });
163
+
164
+ document.addEventListener("keydown", (event) => {
165
+ if (!enabled) return;
166
+ const key = event.key.toLowerCase();
167
+ if ([" ", "n", "p", "h", "i", "r", "escape"].includes(key)) {
168
+ event.preventDefault();
169
+ }
170
+ if (key === " ") controls.play.click();
171
+ if (key === "n") controls.next.click();
172
+ if (key === "p") controls.prev.click();
173
+ if (key === "h") controls.heading.click();
174
+ if (key === "i") controls.image.click();
175
+ if (key === "r" && currentIndex >= 0) narrate(currentIndex);
176
+ if (key === "escape") {
177
+ stopAudio();
178
+ liveNarration.textContent = "Reading stopped.";
179
+ }
180
+ });
static/generated/desk-reader.svg ADDED
static/generated/field-notes.svg ADDED
static/generated/model-map.svg ADDED
static/index.html ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Tiny Narrator</title>
7
+ <link rel="stylesheet" href="/static/app.css" />
8
+ </head>
9
+ <body>
10
+ <header class="topbar">
11
+ <a class="brand" href="#article" aria-label="Tiny Narrator home">
12
+ <span class="brand-mark" aria-hidden="true">TN</span>
13
+ <span>Tiny Narrator</span>
14
+ </a>
15
+ <nav class="topnav" aria-label="Article sections">
16
+ <a href="#why">Why</a>
17
+ <a href="#models">Models</a>
18
+ <a href="#notes">Field Notes</a>
19
+ </nav>
20
+ <button id="readerToggle" class="reader-toggle" type="button" aria-pressed="false">
21
+ <span class="toggle-dot" aria-hidden="true"></span>
22
+ Screen reader off
23
+ </button>
24
+ </header>
25
+
26
+ <main id="article" class="article-shell">
27
+ <article class="article" aria-labelledby="articleTitle">
28
+ <p class="kicker speakable" data-reader-type="paragraph">Build Small Hackathon concept</p>
29
+ <h1 id="articleTitle" class="speakable" data-reader-type="heading">
30
+ A tiny model reader that turns articles into guided narration
31
+ </h1>
32
+ <p class="dek speakable" data-reader-type="paragraph">
33
+ Tiny Narrator is an accessibility-first article experience. It can describe generated images,
34
+ build a reading path, and speak each part of the page with a lightweight local voice.
35
+ </p>
36
+
37
+ <figure class="hero-figure speakable" data-reader-type="image" data-image-id="desk-reader">
38
+ <img src="/static/generated/desk-reader.svg" alt="" />
39
+ <figcaption>
40
+ The article view doubles as the demo surface, so every feature has a real reading task.
41
+ </figcaption>
42
+ </figure>
43
+
44
+ <section id="why" aria-labelledby="whyTitle">
45
+ <h2 id="whyTitle" class="speakable" data-reader-type="heading">Why it belongs in a tiny-model hackathon</h2>
46
+ <p class="speakable" data-reader-type="paragraph">
47
+ Accessibility tools should feel immediate, private, and personal. Small models help because they
48
+ can run closer to the reader, keep latency low, and make the experience easier to inspect.
49
+ </p>
50
+ <blockquote class="speakable" data-reader-type="quote">
51
+ The goal is not to read the whole page at the user. The goal is to make the page navigable by sound.
52
+ </blockquote>
53
+ </section>
54
+
55
+ <section id="models" aria-labelledby="modelsTitle">
56
+ <h2 id="modelsTitle" class="speakable" data-reader-type="heading">The model map</h2>
57
+ <p class="speakable" data-reader-type="paragraph">
58
+ The reader brain runs through llama.cpp, the vision model writes practical alt text, Kokoro speaks
59
+ the final narration, and a four-billion-parameter image model creates article illustrations.
60
+ </p>
61
+ <figure class="inline-figure speakable" data-reader-type="image" data-image-id="model-map">
62
+ <img src="/static/generated/model-map.svg" alt="" />
63
+ <figcaption>Each model stays at or below four billion parameters for Tiny Titan eligibility.</figcaption>
64
+ </figure>
65
+ </section>
66
+
67
+ <section id="notes" aria-labelledby="notesTitle">
68
+ <h2 id="notesTitle" class="speakable" data-reader-type="heading">Field notes as a feature</h2>
69
+ <p class="speakable" data-reader-type="paragraph">
70
+ The build report will show parameter counts, latency notes, keyboard decisions, and where the app
71
+ uses deterministic fallbacks so the accessibility layer remains dependable during a live demo.
72
+ </p>
73
+ <figure class="inline-figure speakable" data-reader-type="image" data-image-id="field-notes">
74
+ <img src="/static/generated/field-notes.svg" alt="" />
75
+ <figcaption>Field notes document the choices behind the screen-reader behavior.</figcaption>
76
+ </figure>
77
+ </section>
78
+ </article>
79
+
80
+ <aside class="status-panel" aria-label="Tiny Narrator status">
81
+ <h2>Session</h2>
82
+ <dl>
83
+ <div>
84
+ <dt>Mode</dt>
85
+ <dd id="modeStatus">Reader off</dd>
86
+ </div>
87
+ <div>
88
+ <dt>Current</dt>
89
+ <dd id="currentStatus">No item selected</dd>
90
+ </div>
91
+ <div>
92
+ <dt>Runtime</dt>
93
+ <dd id="runtimeStatus">Waiting</dd>
94
+ </div>
95
+ </dl>
96
+ <p id="liveNarration" class="live-narration" aria-live="polite">
97
+ Turn on screen reader mode to begin.
98
+ </p>
99
+ </aside>
100
+ </main>
101
+
102
+ <section class="reader-bar" aria-label="Screen reader controls" hidden>
103
+ <button id="prevButton" type="button" title="Previous item">Prev</button>
104
+ <button id="playButton" type="button" title="Play or pause">Play</button>
105
+ <button id="nextButton" type="button" title="Next item">Next</button>
106
+ <button id="headingButton" type="button" title="Next heading">Heading</button>
107
+ <button id="imageButton" type="button" title="Next image">Image</button>
108
+ <label>
109
+ Speed
110
+ <input id="speedControl" type="range" min="0.75" max="1.35" value="1" step="0.05" />
111
+ </label>
112
+ <audio id="speechAudio"></audio>
113
+ </section>
114
+
115
+ <script src="/static/app.js" type="module"></script>
116
+ </body>
117
+ </html>