cvpfus Codex commited on
Commit ·
a95f296
1
Parent(s): 1ca8e19
Add reader transcript and manifest
Browse filesCo-authored-by: Codex <codex@openai.com>
- FIELD_NOTES.md +2 -0
- README.md +2 -0
- app.py +48 -22
- scripts/verify.py +10 -0
- static/app.css +55 -0
- static/app.js +68 -2
- 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
|
| 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 |
+
"&": "&",
|
| 41 |
+
"<": "<",
|
| 42 |
+
">": ">",
|
| 43 |
+
'"': """,
|
| 44 |
+
"'": "'",
|
| 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 |
|