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

Add reader transcript and manifest

Browse files

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

Files changed (7) hide show
  1. FIELD_NOTES.md +2 -0
  2. README.md +2 -0
  3. app.py +48 -22
  4. scripts/verify.py +10 -0
  5. static/app.css +55 -0
  6. static/app.js +68 -2
  7. static/index.html +10 -0
FIELD_NOTES.md CHANGED
@@ -28,6 +28,8 @@ Tiny Narrator is a small-model accessibility article reader. It is not a generic
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.
 
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
+ Each narration is also added to a visible transcript log. This gives the demo a second accessible surface: users can review what was spoken, copy the session transcript, or clear it before exploring another part of the article.
32
+
33
  Keyboard map:
34
 
35
  - `Space`: play or pause.
README.md CHANGED
@@ -82,3 +82,5 @@ The frontend builds a reading queue from semantic article nodes. When screen-rea
82
  - `Esc` stops the current audio.
83
 
84
  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.
 
 
 
82
  - `Esc` stops the current audio.
83
 
84
  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.
85
+
86
+ The session panel keeps a transcript of recent narration with copy and clear controls, making the spoken path inspectable during demos and useful for the Field Notes write-up.
app.py CHANGED
@@ -26,6 +26,48 @@ 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")
31
  app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs")
@@ -208,32 +250,16 @@ async def health() -> JSONResponse:
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
  )
235
 
236
 
 
 
 
 
 
237
  @app.post("/api/reader-brain")
238
  async def reader_brain_endpoint(payload: ReaderBrainRequest) -> JSONResponse:
239
  return _json(reader_brain_core(payload.node_type, payload.text, payload.position, payload.mode))
 
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
+ MODEL_MANIFEST: dict[str, dict[str, str]] = {
30
+ "reader_brain": {
31
+ "id": "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF",
32
+ "params": "3.97B",
33
+ "runtime": "llama.cpp",
34
+ "role": "Plans concise narration and reading-order phrasing.",
35
+ },
36
+ "vision": {
37
+ "id": "openbmb/MiniCPM-V-2",
38
+ "params": "3B",
39
+ "runtime": "Python integration planned",
40
+ "role": "Describes images and OCR-like visual details.",
41
+ },
42
+ "speech": {
43
+ "id": "hexgrad/Kokoro-82M",
44
+ "params": "82M",
45
+ "runtime": "Python",
46
+ "role": "Speaks the final narration.",
47
+ },
48
+ "image_generation": {
49
+ "id": "black-forest-labs/FLUX.2-klein-4B",
50
+ "params": "4B",
51
+ "runtime": "Python integration planned",
52
+ "role": "Creates article illustrations.",
53
+ },
54
+ }
55
+
56
+ ARTICLE_MANIFEST: dict[str, Any] = {
57
+ "title": "A tiny model reader that turns articles into guided narration",
58
+ "reader_controls": [
59
+ {"key": "Space", "action": "Play or pause"},
60
+ {"key": "N", "action": "Next item"},
61
+ {"key": "P", "action": "Previous item"},
62
+ {"key": "H", "action": "Next heading"},
63
+ {"key": "I", "action": "Next image"},
64
+ {"key": "R", "action": "Repeat current item"},
65
+ {"key": "Esc", "action": "Stop audio"},
66
+ ],
67
+ "bonus_targets": ["Tiny Titan", "Llama Champion", "Off-Brand", "Field Notes"],
68
+ "models": MODEL_MANIFEST,
69
+ }
70
+
71
  app = Server(title="Tiny Narrator")
72
  app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
73
  app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs")
 
250
  "app": "Tiny Narrator",
251
  "frontend": "custom Gradio Server HTML/CSS/JS",
252
  "llama_cpp_base_url": LLAMA_CPP_BASE_URL,
253
+ "models": MODEL_MANIFEST,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  }
255
  )
256
 
257
 
258
+ @app.get("/api/article-manifest")
259
+ async def article_manifest() -> JSONResponse:
260
+ return _json({"ok": True, **ARTICLE_MANIFEST})
261
+
262
+
263
  @app.post("/api/reader-brain")
264
  async def reader_brain_endpoint(payload: ReaderBrainRequest) -> JSONResponse:
265
  return _json(reader_brain_core(payload.node_type, payload.text, payload.position, payload.mode))
scripts/verify.py CHANGED
@@ -61,6 +61,7 @@ def verify_routes() -> None:
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")
@@ -71,6 +72,15 @@ def verify_routes() -> None:
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)
 
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
+ assert_true("transcriptLog" in home.text, "Home route should include transcript log")
65
 
66
  health = client.get("/api/health")
67
  assert_true(health.status_code == 200, "Health route should return 200")
 
72
  "Health route should document llama.cpp reader-brain runtime",
73
  )
74
 
75
+ manifest = client.get("/api/article-manifest")
76
+ assert_true(manifest.status_code == 200, "Article manifest route should return 200")
77
+ manifest_payload = manifest.json()
78
+ assert_true("Tiny Titan" in manifest_payload["bonus_targets"], "Manifest should include Tiny Titan target")
79
+ assert_true(
80
+ manifest_payload["models"]["speech"]["id"] == "hexgrad/Kokoro-82M",
81
+ "Manifest should document Kokoro speech model",
82
+ )
83
+
84
 
85
  def main() -> None:
86
  py_compile.compile(str(ROOT / "app.py"), doraise=True)
static/app.css CHANGED
@@ -243,6 +243,12 @@ figcaption {
243
  text-transform: uppercase;
244
  }
245
 
 
 
 
 
 
 
246
  dl {
247
  display: grid;
248
  gap: 12px;
@@ -269,6 +275,55 @@ dd {
269
  background: var(--surface);
270
  }
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  .reader-bar {
273
  position: fixed;
274
  right: clamp(14px, 3vw, 32px);
 
243
  text-transform: uppercase;
244
  }
245
 
246
+ .status-panel h3 {
247
+ margin: 0;
248
+ font-size: 0.86rem;
249
+ text-transform: uppercase;
250
+ }
251
+
252
  dl {
253
  display: grid;
254
  gap: 12px;
 
275
  background: var(--surface);
276
  }
277
 
278
+ .history-header {
279
+ display: grid;
280
+ grid-template-columns: 1fr max-content max-content;
281
+ gap: 8px;
282
+ align-items: center;
283
+ margin-top: 22px;
284
+ }
285
+
286
+ .history-header button {
287
+ min-height: 34px;
288
+ border-radius: 6px;
289
+ padding: 0 10px;
290
+ color: var(--muted);
291
+ font-size: 0.82rem;
292
+ font-weight: 750;
293
+ }
294
+
295
+ .transcript-log {
296
+ display: grid;
297
+ gap: 10px;
298
+ max-height: 340px;
299
+ margin: 12px 0 0;
300
+ padding: 0;
301
+ overflow: auto;
302
+ list-style: none;
303
+ }
304
+
305
+ .transcript-log li {
306
+ border: 1px solid var(--line);
307
+ border-radius: 8px;
308
+ padding: 10px 12px;
309
+ background: var(--surface);
310
+ }
311
+
312
+ .transcript-meta {
313
+ display: flex;
314
+ justify-content: space-between;
315
+ gap: 8px;
316
+ color: var(--muted);
317
+ font-size: 0.75rem;
318
+ font-weight: 800;
319
+ text-transform: uppercase;
320
+ }
321
+
322
+ .transcript-text {
323
+ margin: 6px 0 0;
324
+ font-size: 0.92rem;
325
+ }
326
+
327
  .reader-bar {
328
  position: fixed;
329
  right: clamp(14px, 3vw, 32px);
static/app.js CHANGED
@@ -3,9 +3,13 @@ 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"),
@@ -29,6 +33,46 @@ let enabled = false;
29
  let currentIndex = -1;
30
  let playing = false;
31
  let requestSerial = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  function setEnabled(nextValue) {
34
  enabled = nextValue;
@@ -68,11 +112,12 @@ function stopAudio() {
68
  }
69
 
70
  async function postJson(url, payload) {
71
- const response = await fetch(url, {
72
  method: "POST",
73
  headers: { "Content-Type": "application/json" },
74
  body: JSON.stringify(payload),
75
- });
 
76
  if (!response.ok) {
77
  throw new Error(`${response.status} ${response.statusText}`);
78
  }
@@ -108,6 +153,11 @@ async function narrate(index) {
108
 
109
  runtimeStatus.textContent = result.runtime;
110
  liveNarration.textContent = result.narration;
 
 
 
 
 
111
 
112
  const speech = await postJson("/api/speak", {
113
  text: result.narration,
@@ -153,6 +203,20 @@ controls.next.addEventListener("click", () => narrate(Math.min(currentIndex + 1,
153
  controls.prev.addEventListener("click", () => narrate(Math.max(currentIndex - 1, 0)));
154
  controls.heading.addEventListener("click", () => nextByType("heading"));
155
  controls.image.addEventListener("click", () => nextByType("image"));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  controls.play.addEventListener("click", () => {
157
  if (!enabled) return;
158
  if (currentIndex < 0) {
@@ -172,6 +236,8 @@ controls.play.addEventListener("click", () => {
172
  }
173
  });
174
 
 
 
175
  audio.addEventListener("ended", () => {
176
  playing = false;
177
  controls.play.textContent = "Play";
 
3
  const modeStatus = document.querySelector("#modeStatus");
4
  const currentStatus = document.querySelector("#currentStatus");
5
  const runtimeStatus = document.querySelector("#runtimeStatus");
6
+ const modelStatus = document.querySelector("#modelStatus");
7
  const liveNarration = document.querySelector("#liveNarration");
8
  const audio = document.querySelector("#speechAudio");
9
  const speedControl = document.querySelector("#speedControl");
10
+ const transcriptLog = document.querySelector("#transcriptLog");
11
+ const copyTranscriptButton = document.querySelector("#copyTranscriptButton");
12
+ const clearTranscriptButton = document.querySelector("#clearTranscriptButton");
13
 
14
  const controls = {
15
  prev: document.querySelector("#prevButton"),
 
33
  let currentIndex = -1;
34
  let playing = false;
35
  let requestSerial = 0;
36
+ let transcriptEntries = [];
37
+
38
+ function escapeHtml(value) {
39
+ return value.replace(/[&<>"']/g, (char) => ({
40
+ "&": "&amp;",
41
+ "<": "&lt;",
42
+ ">": "&gt;",
43
+ '"': "&quot;",
44
+ "'": "&#39;",
45
+ }[char]));
46
+ }
47
+
48
+ function renderTranscript() {
49
+ transcriptLog.innerHTML = transcriptEntries
50
+ .map((entry) => `
51
+ <li>
52
+ <div class="transcript-meta">
53
+ <span>${escapeHtml(entry.type)}</span>
54
+ <span>${escapeHtml(entry.runtime)}</span>
55
+ </div>
56
+ <p class="transcript-text">${escapeHtml(entry.text)}</p>
57
+ </li>
58
+ `)
59
+ .join("");
60
+ }
61
+
62
+ function addTranscriptEntry(entry) {
63
+ transcriptEntries = [entry, ...transcriptEntries].slice(0, 12);
64
+ renderTranscript();
65
+ }
66
+
67
+ async function loadManifest() {
68
+ try {
69
+ const manifest = await postJson("/api/article-manifest");
70
+ const modelCount = Object.keys(manifest.models || {}).length;
71
+ modelStatus.textContent = `${modelCount} tiny models, ${manifest.bonus_targets.join(", ")}`;
72
+ } catch {
73
+ modelStatus.textContent = "Manifest unavailable";
74
+ }
75
+ }
76
 
77
  function setEnabled(nextValue) {
78
  enabled = nextValue;
 
112
  }
113
 
114
  async function postJson(url, payload) {
115
+ const options = payload === undefined ? {} : {
116
  method: "POST",
117
  headers: { "Content-Type": "application/json" },
118
  body: JSON.stringify(payload),
119
+ };
120
+ const response = await fetch(url, options);
121
  if (!response.ok) {
122
  throw new Error(`${response.status} ${response.statusText}`);
123
  }
 
153
 
154
  runtimeStatus.textContent = result.runtime;
155
  liveNarration.textContent = result.narration;
156
+ addTranscriptEntry({
157
+ type: node.type,
158
+ runtime: result.runtime,
159
+ text: result.narration,
160
+ });
161
 
162
  const speech = await postJson("/api/speak", {
163
  text: result.narration,
 
203
  controls.prev.addEventListener("click", () => narrate(Math.max(currentIndex - 1, 0)));
204
  controls.heading.addEventListener("click", () => nextByType("heading"));
205
  controls.image.addEventListener("click", () => nextByType("image"));
206
+ copyTranscriptButton.addEventListener("click", async () => {
207
+ const text = transcriptEntries
208
+ .slice()
209
+ .reverse()
210
+ .map((entry) => `[${entry.type} / ${entry.runtime}] ${entry.text}`)
211
+ .join("\n");
212
+ await navigator.clipboard?.writeText(text);
213
+ liveNarration.textContent = text ? "Transcript copied." : "Transcript is empty.";
214
+ });
215
+ clearTranscriptButton.addEventListener("click", () => {
216
+ transcriptEntries = [];
217
+ renderTranscript();
218
+ liveNarration.textContent = "Transcript cleared.";
219
+ });
220
  controls.play.addEventListener("click", () => {
221
  if (!enabled) return;
222
  if (currentIndex < 0) {
 
236
  }
237
  });
238
 
239
+ loadManifest();
240
+
241
  audio.addEventListener("ended", () => {
242
  playing = false;
243
  controls.play.textContent = "Play";
static/index.html CHANGED
@@ -92,10 +92,20 @@
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
 
 
92
  <dt>Runtime</dt>
93
  <dd id="runtimeStatus">Waiting</dd>
94
  </div>
95
+ <div>
96
+ <dt>Model Stack</dt>
97
+ <dd id="modelStatus">Loading</dd>
98
+ </div>
99
  </dl>
100
  <p id="liveNarration" class="live-narration" aria-live="polite">
101
  Turn on screen reader mode to begin.
102
  </p>
103
+ <div class="history-header">
104
+ <h3>Transcript</h3>
105
+ <button id="copyTranscriptButton" type="button">Copy</button>
106
+ <button id="clearTranscriptButton" type="button">Clear</button>
107
+ </div>
108
+ <ol id="transcriptLog" class="transcript-log" aria-label="Narration transcript"></ol>
109
  </aside>
110
  </main>
111