cvpfus Codex commited on
Commit ·
1ca8e19
1
Parent(s): d05b639
Prepare Space-ready demo foundation
Browse filesCo-authored-by: Codex <codex@openai.com>
- FIELD_NOTES.md +43 -0
- README.md +29 -0
- app.py +25 -5
- scripts/verify.py +84 -0
- 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":
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
});
|
| 93 |
-
sourceText = description.alt_text;
|
| 94 |
-
}
|
| 95 |
|
| 96 |
-
|
| 97 |
-
node_type: node.type,
|
| 98 |
-
text: sourceText,
|
| 99 |
-
position: `item ${index + 1} of ${nodes.length}`,
|
| 100 |
-
mode: "narrate",
|
| 101 |
-
});
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
| 105 |
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|