cvpfus Codex commited on
Commit ·
4ed989e
1
Parent(s): b958872
Add reader voice controls
Browse filesCo-authored-by: Codex <codex@openai.com>
- FIELD_NOTES.md +2 -0
- README.md +2 -0
- app.py +13 -0
- scripts/verify.py +10 -0
- static/app.css +20 -2
- static/app.js +29 -0
- static/index.html +5 -0
FIELD_NOTES.md
CHANGED
|
@@ -48,3 +48,5 @@ Live accessibility demos need dependable behavior. If llama.cpp is not available
|
|
| 48 |
The app preloads image descriptions through `/api/image-descriptions`, caches them in the frontend, and writes the descriptions back into each image's `alt` attribute. That keeps the visible article, transcript, and spoken image narration aligned.
|
| 49 |
|
| 50 |
Kokoro is still the intended model-backed TTS path. For local resilience, the frontend can use the browser speech engine when Kokoro is unavailable, and the session panel labels that as a browser fallback instead of model audio.
|
|
|
|
|
|
|
|
|
| 48 |
The app preloads image descriptions through `/api/image-descriptions`, caches them in the frontend, and writes the descriptions back into each image's `alt` attribute. That keeps the visible article, transcript, and spoken image narration aligned.
|
| 49 |
|
| 50 |
Kokoro is still the intended model-backed TTS path. For local resilience, the frontend can use the browser speech engine when Kokoro is unavailable, and the session panel labels that as a browser fallback instead of model audio.
|
| 51 |
+
|
| 52 |
+
Reader settings are manifest-backed: the app exposes known Kokoro voices and speed bounds through `/api/article-manifest`, then the custom frontend renders the controls and passes the selected voice to `/api/speak`.
|
README.md
CHANGED
|
@@ -89,3 +89,5 @@ The session panel keeps a transcript of recent narration with copy and clear con
|
|
| 89 |
Image descriptions are preloaded into a local cache and written into the page's real `img alt` attributes. When the planned MiniCPM-V runtime is unavailable, deterministic alt-text fallbacks keep the screen-reader path usable.
|
| 90 |
|
| 91 |
Kokoro remains the planned tiny-model TTS path. During local demos, if the server-side Kokoro call falls back, the browser speech engine can read the same transcript so screen-reader mode still produces audible feedback.
|
|
|
|
|
|
|
|
|
| 89 |
Image descriptions are preloaded into a local cache and written into the page's real `img alt` attributes. When the planned MiniCPM-V runtime is unavailable, deterministic alt-text fallbacks keep the screen-reader path usable.
|
| 90 |
|
| 91 |
Kokoro remains the planned tiny-model TTS path. During local demos, if the server-side Kokoro call falls back, the browser speech engine can read the same transcript so screen-reader mode still produces audible feedback.
|
| 92 |
+
|
| 93 |
+
The reader bar exposes Kokoro voice selection and speaking speed controls. Defaults come from `/api/article-manifest` so the UI, docs, and backend stay aligned.
|
app.py
CHANGED
|
@@ -74,6 +74,18 @@ ARTICLE_IMAGES: list[dict[str, str]] = [
|
|
| 74 |
},
|
| 75 |
]
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
ARTICLE_MANIFEST: dict[str, Any] = {
|
| 78 |
"title": "A tiny model reader that turns articles into guided narration",
|
| 79 |
"reader_controls": [
|
|
@@ -89,6 +101,7 @@ ARTICLE_MANIFEST: dict[str, Any] = {
|
|
| 89 |
"bonus_targets": ["Tiny Titan", "Llama Champion", "Off-Brand", "Field Notes"],
|
| 90 |
"images": ARTICLE_IMAGES,
|
| 91 |
"models": MODEL_MANIFEST,
|
|
|
|
| 92 |
}
|
| 93 |
|
| 94 |
app = Server(title="Tiny Narrator")
|
|
|
|
| 74 |
},
|
| 75 |
]
|
| 76 |
|
| 77 |
+
READER_SETTINGS: dict[str, Any] = {
|
| 78 |
+
"default_voice": "af_heart",
|
| 79 |
+
"default_speed": 1.0,
|
| 80 |
+
"voices": [
|
| 81 |
+
{"id": "af_heart", "label": "Heart"},
|
| 82 |
+
{"id": "af_bella", "label": "Bella"},
|
| 83 |
+
{"id": "am_adam", "label": "Adam"},
|
| 84 |
+
{"id": "am_michael", "label": "Michael"},
|
| 85 |
+
],
|
| 86 |
+
"speed": {"min": 0.75, "max": 1.35, "step": 0.05},
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
ARTICLE_MANIFEST: dict[str, Any] = {
|
| 90 |
"title": "A tiny model reader that turns articles into guided narration",
|
| 91 |
"reader_controls": [
|
|
|
|
| 101 |
"bonus_targets": ["Tiny Titan", "Llama Champion", "Off-Brand", "Field Notes"],
|
| 102 |
"images": ARTICLE_IMAGES,
|
| 103 |
"models": MODEL_MANIFEST,
|
| 104 |
+
"reader_settings": READER_SETTINGS,
|
| 105 |
}
|
| 106 |
|
| 107 |
app = Server(title="Tiny Narrator")
|
scripts/verify.py
CHANGED
|
@@ -77,6 +77,8 @@ def verify_routes() -> None:
|
|
| 77 |
assert_true("summaryButton" in home.text, "Home route should include summary control")
|
| 78 |
assert_true("imageStatus" in home.text, "Home route should include image status")
|
| 79 |
assert_true("voiceStatus" in home.text, "Home route should include voice status")
|
|
|
|
|
|
|
| 80 |
assert_true("transcriptLog" in home.text, "Home route should include transcript log")
|
| 81 |
|
| 82 |
health = client.get("/api/health")
|
|
@@ -100,6 +102,14 @@ def verify_routes() -> None:
|
|
| 100 |
manifest_payload["models"]["speech"]["id"] == "hexgrad/Kokoro-82M",
|
| 101 |
"Manifest should document Kokoro speech model",
|
| 102 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
image_descriptions = client.get("/api/image-descriptions")
|
| 105 |
assert_true(image_descriptions.status_code == 200, "Image descriptions route should return 200")
|
|
|
|
| 77 |
assert_true("summaryButton" in home.text, "Home route should include summary control")
|
| 78 |
assert_true("imageStatus" in home.text, "Home route should include image status")
|
| 79 |
assert_true("voiceStatus" in home.text, "Home route should include voice status")
|
| 80 |
+
assert_true("voiceControl" in home.text, "Home route should include voice control")
|
| 81 |
+
assert_true("speedValue" in home.text, "Home route should include speed value output")
|
| 82 |
assert_true("transcriptLog" in home.text, "Home route should include transcript log")
|
| 83 |
|
| 84 |
health = client.get("/api/health")
|
|
|
|
| 102 |
manifest_payload["models"]["speech"]["id"] == "hexgrad/Kokoro-82M",
|
| 103 |
"Manifest should document Kokoro speech model",
|
| 104 |
)
|
| 105 |
+
assert_true(
|
| 106 |
+
manifest_payload["reader_settings"]["default_voice"] == "af_heart",
|
| 107 |
+
"Manifest should document default Kokoro voice",
|
| 108 |
+
)
|
| 109 |
+
assert_true(
|
| 110 |
+
len(manifest_payload["reader_settings"]["voices"]) >= 4,
|
| 111 |
+
"Manifest should expose multiple Kokoro voice choices",
|
| 112 |
+
)
|
| 113 |
|
| 114 |
image_descriptions = client.get("/api/image-descriptions")
|
| 115 |
assert_true(image_descriptions.status_code == 200, "Image descriptions route should return 200")
|
static/app.css
CHANGED
|
@@ -28,7 +28,9 @@ body {
|
|
| 28 |
}
|
| 29 |
|
| 30 |
button,
|
| 31 |
-
input
|
|
|
|
|
|
|
| 32 |
font: inherit;
|
| 33 |
}
|
| 34 |
|
|
@@ -45,7 +47,8 @@ button:hover {
|
|
| 45 |
|
| 46 |
button:focus-visible,
|
| 47 |
a:focus-visible,
|
| 48 |
-
input:focus-visible
|
|
|
|
| 49 |
outline: 3px solid var(--focus);
|
| 50 |
outline-offset: 3px;
|
| 51 |
}
|
|
@@ -362,6 +365,21 @@ dd {
|
|
| 362 |
font-weight: 750;
|
| 363 |
}
|
| 364 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
.speakable {
|
| 366 |
scroll-margin: 112px;
|
| 367 |
}
|
|
|
|
| 28 |
}
|
| 29 |
|
| 30 |
button,
|
| 31 |
+
input,
|
| 32 |
+
select,
|
| 33 |
+
output {
|
| 34 |
font: inherit;
|
| 35 |
}
|
| 36 |
|
|
|
|
| 47 |
|
| 48 |
button:focus-visible,
|
| 49 |
a:focus-visible,
|
| 50 |
+
input:focus-visible,
|
| 51 |
+
select:focus-visible {
|
| 52 |
outline: 3px solid var(--focus);
|
| 53 |
outline-offset: 3px;
|
| 54 |
}
|
|
|
|
| 365 |
font-weight: 750;
|
| 366 |
}
|
| 367 |
|
| 368 |
+
.reader-bar select {
|
| 369 |
+
min-height: 42px;
|
| 370 |
+
border: 1px solid var(--line);
|
| 371 |
+
border-radius: 6px;
|
| 372 |
+
background: var(--surface);
|
| 373 |
+
color: var(--ink);
|
| 374 |
+
padding: 0 10px;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.reader-bar output {
|
| 378 |
+
min-width: 48px;
|
| 379 |
+
color: var(--ink);
|
| 380 |
+
font-weight: 750;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
.speakable {
|
| 384 |
scroll-margin: 112px;
|
| 385 |
}
|
static/app.js
CHANGED
|
@@ -8,7 +8,9 @@ const imageStatus = document.querySelector("#imageStatus");
|
|
| 8 |
const voiceStatus = document.querySelector("#voiceStatus");
|
| 9 |
const liveNarration = document.querySelector("#liveNarration");
|
| 10 |
const audio = document.querySelector("#speechAudio");
|
|
|
|
| 11 |
const speedControl = document.querySelector("#speedControl");
|
|
|
|
| 12 |
const transcriptLog = document.querySelector("#transcriptLog");
|
| 13 |
const copyTranscriptButton = document.querySelector("#copyTranscriptButton");
|
| 14 |
const clearTranscriptButton = document.querySelector("#clearTranscriptButton");
|
|
@@ -40,6 +42,10 @@ let transcriptEntries = [];
|
|
| 40 |
let imageDescriptions = new Map();
|
| 41 |
let lastSpokenText = "";
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
function escapeHtml(value) {
|
| 44 |
return value.replace(/[&<>"']/g, (char) => ({
|
| 45 |
"&": "&",
|
|
@@ -89,6 +95,21 @@ async function loadManifest() {
|
|
| 89 |
const manifest = await postJson("/api/article-manifest");
|
| 90 |
const modelCount = Object.keys(manifest.models || {}).length;
|
| 91 |
modelStatus.textContent = `${modelCount} tiny models, ${manifest.bonus_targets.join(", ")}`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
} catch {
|
| 93 |
modelStatus.textContent = "Manifest unavailable";
|
| 94 |
}
|
|
@@ -174,6 +195,7 @@ async function speakNarration(text, serial) {
|
|
| 174 |
lastSpokenText = text;
|
| 175 |
const speech = await postJson("/api/speak", {
|
| 176 |
text,
|
|
|
|
| 177 |
speed: Number(speedControl.value),
|
| 178 |
});
|
| 179 |
|
|
@@ -319,6 +341,12 @@ controls.prev.addEventListener("click", () => narrate(Math.max(currentIndex - 1,
|
|
| 319 |
controls.heading.addEventListener("click", () => nextByType("heading"));
|
| 320 |
controls.image.addEventListener("click", () => nextByType("image"));
|
| 321 |
controls.summary.addEventListener("click", () => summarizeCurrentSection());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
copyTranscriptButton.addEventListener("click", async () => {
|
| 323 |
const text = transcriptEntries
|
| 324 |
.slice()
|
|
@@ -361,6 +389,7 @@ controls.play.addEventListener("click", () => {
|
|
| 361 |
|
| 362 |
loadManifest();
|
| 363 |
loadImageDescriptions();
|
|
|
|
| 364 |
|
| 365 |
audio.addEventListener("ended", () => {
|
| 366 |
playing = false;
|
|
|
|
| 8 |
const voiceStatus = document.querySelector("#voiceStatus");
|
| 9 |
const liveNarration = document.querySelector("#liveNarration");
|
| 10 |
const audio = document.querySelector("#speechAudio");
|
| 11 |
+
const voiceControl = document.querySelector("#voiceControl");
|
| 12 |
const speedControl = document.querySelector("#speedControl");
|
| 13 |
+
const speedValue = document.querySelector("#speedValue");
|
| 14 |
const transcriptLog = document.querySelector("#transcriptLog");
|
| 15 |
const copyTranscriptButton = document.querySelector("#copyTranscriptButton");
|
| 16 |
const clearTranscriptButton = document.querySelector("#clearTranscriptButton");
|
|
|
|
| 42 |
let imageDescriptions = new Map();
|
| 43 |
let lastSpokenText = "";
|
| 44 |
|
| 45 |
+
function updateSpeedValue() {
|
| 46 |
+
speedValue.textContent = `${Number(speedControl.value).toFixed(2)}x`;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
function escapeHtml(value) {
|
| 50 |
return value.replace(/[&<>"']/g, (char) => ({
|
| 51 |
"&": "&",
|
|
|
|
| 95 |
const manifest = await postJson("/api/article-manifest");
|
| 96 |
const modelCount = Object.keys(manifest.models || {}).length;
|
| 97 |
modelStatus.textContent = `${modelCount} tiny models, ${manifest.bonus_targets.join(", ")}`;
|
| 98 |
+
const settings = manifest.reader_settings;
|
| 99 |
+
if (settings?.voices?.length) {
|
| 100 |
+
voiceControl.innerHTML = settings.voices
|
| 101 |
+
.map((voice) => `<option value="${escapeHtml(voice.id)}">${escapeHtml(voice.label)}</option>`)
|
| 102 |
+
.join("");
|
| 103 |
+
voiceControl.value = settings.default_voice;
|
| 104 |
+
voiceStatus.textContent = voiceControl.selectedOptions[0]?.textContent || "Ready";
|
| 105 |
+
}
|
| 106 |
+
if (settings?.speed) {
|
| 107 |
+
speedControl.min = settings.speed.min;
|
| 108 |
+
speedControl.max = settings.speed.max;
|
| 109 |
+
speedControl.step = settings.speed.step;
|
| 110 |
+
speedControl.value = settings.default_speed;
|
| 111 |
+
updateSpeedValue();
|
| 112 |
+
}
|
| 113 |
} catch {
|
| 114 |
modelStatus.textContent = "Manifest unavailable";
|
| 115 |
}
|
|
|
|
| 195 |
lastSpokenText = text;
|
| 196 |
const speech = await postJson("/api/speak", {
|
| 197 |
text,
|
| 198 |
+
voice: voiceControl.value || "af_heart",
|
| 199 |
speed: Number(speedControl.value),
|
| 200 |
});
|
| 201 |
|
|
|
|
| 341 |
controls.heading.addEventListener("click", () => nextByType("heading"));
|
| 342 |
controls.image.addEventListener("click", () => nextByType("image"));
|
| 343 |
controls.summary.addEventListener("click", () => summarizeCurrentSection());
|
| 344 |
+
voiceControl.addEventListener("change", () => {
|
| 345 |
+
voiceStatus.textContent = voiceControl.selectedOptions[0]?.textContent || "Custom voice";
|
| 346 |
+
});
|
| 347 |
+
speedControl.addEventListener("input", () => {
|
| 348 |
+
updateSpeedValue();
|
| 349 |
+
});
|
| 350 |
copyTranscriptButton.addEventListener("click", async () => {
|
| 351 |
const text = transcriptEntries
|
| 352 |
.slice()
|
|
|
|
| 389 |
|
| 390 |
loadManifest();
|
| 391 |
loadImageDescriptions();
|
| 392 |
+
updateSpeedValue();
|
| 393 |
|
| 394 |
audio.addEventListener("ended", () => {
|
| 395 |
playing = false;
|
static/index.html
CHANGED
|
@@ -124,9 +124,14 @@
|
|
| 124 |
<button id="headingButton" type="button" title="Next heading">Heading</button>
|
| 125 |
<button id="imageButton" type="button" title="Next image">Image</button>
|
| 126 |
<button id="summaryButton" type="button" title="Summarize current section">Summary</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
<label>
|
| 128 |
Speed
|
| 129 |
<input id="speedControl" type="range" min="0.75" max="1.35" value="1" step="0.05" />
|
|
|
|
| 130 |
</label>
|
| 131 |
<audio id="speechAudio"></audio>
|
| 132 |
</section>
|
|
|
|
| 124 |
<button id="headingButton" type="button" title="Next heading">Heading</button>
|
| 125 |
<button id="imageButton" type="button" title="Next image">Image</button>
|
| 126 |
<button id="summaryButton" type="button" title="Summarize current section">Summary</button>
|
| 127 |
+
<label>
|
| 128 |
+
Voice
|
| 129 |
+
<select id="voiceControl" aria-label="Reader voice"></select>
|
| 130 |
+
</label>
|
| 131 |
<label>
|
| 132 |
Speed
|
| 133 |
<input id="speedControl" type="range" min="0.75" max="1.35" value="1" step="0.05" />
|
| 134 |
+
<output id="speedValue" for="speedControl">1.00x</output>
|
| 135 |
</label>
|
| 136 |
<audio id="speechAudio"></audio>
|
| 137 |
</section>
|