cvpfus Codex commited on
Commit ·
b958872
1
Parent(s): d19068b
Add audible fallback for reader voice
Browse filesCo-authored-by: Codex <codex@openai.com>
- FIELD_NOTES.md +2 -0
- README.md +2 -0
- scripts/verify.py +1 -0
- static/app.js +62 -30
- static/index.html +4 -0
FIELD_NOTES.md
CHANGED
|
@@ -46,3 +46,5 @@ Keyboard map:
|
|
| 46 |
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.
|
| 47 |
|
| 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.
|
|
|
|
|
|
|
|
|
| 46 |
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.
|
| 47 |
|
| 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.
|
README.md
CHANGED
|
@@ -87,3 +87,5 @@ Each readable node is sent to the reader brain for concise narration, then Kokor
|
|
| 87 |
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.
|
| 88 |
|
| 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.
|
|
|
|
|
|
|
|
|
| 87 |
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.
|
| 88 |
|
| 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.
|
scripts/verify.py
CHANGED
|
@@ -76,6 +76,7 @@ def verify_routes() -> None:
|
|
| 76 |
assert_true("readerToggle" in home.text, "Home route should include reader toggle")
|
| 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("transcriptLog" in home.text, "Home route should include transcript log")
|
| 80 |
|
| 81 |
health = client.get("/api/health")
|
|
|
|
| 76 |
assert_true("readerToggle" in home.text, "Home route should include reader toggle")
|
| 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")
|
static/app.js
CHANGED
|
@@ -5,6 +5,7 @@ const currentStatus = document.querySelector("#currentStatus");
|
|
| 5 |
const runtimeStatus = document.querySelector("#runtimeStatus");
|
| 6 |
const modelStatus = document.querySelector("#modelStatus");
|
| 7 |
const imageStatus = document.querySelector("#imageStatus");
|
|
|
|
| 8 |
const liveNarration = document.querySelector("#liveNarration");
|
| 9 |
const audio = document.querySelector("#speechAudio");
|
| 10 |
const speedControl = document.querySelector("#speedControl");
|
|
@@ -37,6 +38,7 @@ let playing = false;
|
|
| 37 |
let requestSerial = 0;
|
| 38 |
let transcriptEntries = [];
|
| 39 |
let imageDescriptions = new Map();
|
|
|
|
| 40 |
|
| 41 |
function escapeHtml(value) {
|
| 42 |
return value.replace(/[&<>"']/g, (char) => ({
|
|
@@ -146,10 +148,61 @@ function setActive(index) {
|
|
| 146 |
function stopAudio() {
|
| 147 |
audio.pause();
|
| 148 |
audio.removeAttribute("src");
|
|
|
|
| 149 |
playing = false;
|
| 150 |
controls.play.textContent = "Play";
|
| 151 |
}
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
async function postJson(url, payload) {
|
| 154 |
const options = payload === undefined ? {} : {
|
| 155 |
method: "POST",
|
|
@@ -201,21 +254,7 @@ async function narrate(index) {
|
|
| 201 |
text: result.narration,
|
| 202 |
});
|
| 203 |
|
| 204 |
-
|
| 205 |
-
text: result.narration,
|
| 206 |
-
speed: Number(speedControl.value),
|
| 207 |
-
});
|
| 208 |
-
|
| 209 |
-
if (serial !== requestSerial) return;
|
| 210 |
-
|
| 211 |
-
if (speech.audio_url) {
|
| 212 |
-
audio.src = speech.audio_url;
|
| 213 |
-
await audio.play().catch(() => {
|
| 214 |
-
liveNarration.textContent = `${result.narration} Audio is ready. Press Play to hear it.`;
|
| 215 |
-
});
|
| 216 |
-
playing = !audio.paused;
|
| 217 |
-
controls.play.textContent = playing ? "Pause" : "Play";
|
| 218 |
-
}
|
| 219 |
} catch (error) {
|
| 220 |
runtimeStatus.textContent = "Error";
|
| 221 |
liveNarration.textContent = `Narration failed: ${error.message}`;
|
|
@@ -249,21 +288,7 @@ async function summarizeCurrentSection() {
|
|
| 249 |
text: result.narration,
|
| 250 |
});
|
| 251 |
|
| 252 |
-
|
| 253 |
-
text: result.narration,
|
| 254 |
-
speed: Number(speedControl.value),
|
| 255 |
-
});
|
| 256 |
-
|
| 257 |
-
if (serial !== requestSerial) return;
|
| 258 |
-
|
| 259 |
-
if (speech.audio_url) {
|
| 260 |
-
audio.src = speech.audio_url;
|
| 261 |
-
await audio.play().catch(() => {
|
| 262 |
-
liveNarration.textContent = `${result.narration} Audio is ready. Press Play to hear it.`;
|
| 263 |
-
});
|
| 264 |
-
playing = !audio.paused;
|
| 265 |
-
controls.play.textContent = playing ? "Pause" : "Play";
|
| 266 |
-
}
|
| 267 |
} catch (error) {
|
| 268 |
runtimeStatus.textContent = "Error";
|
| 269 |
liveNarration.textContent = `Summary failed: ${error.message}`;
|
|
@@ -316,12 +341,19 @@ controls.play.addEventListener("click", () => {
|
|
| 316 |
}
|
| 317 |
if (playing) {
|
| 318 |
audio.pause();
|
|
|
|
| 319 |
playing = false;
|
| 320 |
controls.play.textContent = "Play";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
} else if (audio.src) {
|
| 322 |
audio.play();
|
| 323 |
playing = true;
|
| 324 |
controls.play.textContent = "Pause";
|
|
|
|
|
|
|
| 325 |
} else {
|
| 326 |
narrate(currentIndex);
|
| 327 |
}
|
|
|
|
| 5 |
const runtimeStatus = document.querySelector("#runtimeStatus");
|
| 6 |
const modelStatus = document.querySelector("#modelStatus");
|
| 7 |
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");
|
|
|
|
| 38 |
let requestSerial = 0;
|
| 39 |
let transcriptEntries = [];
|
| 40 |
let imageDescriptions = new Map();
|
| 41 |
+
let lastSpokenText = "";
|
| 42 |
|
| 43 |
function escapeHtml(value) {
|
| 44 |
return value.replace(/[&<>"']/g, (char) => ({
|
|
|
|
| 148 |
function stopAudio() {
|
| 149 |
audio.pause();
|
| 150 |
audio.removeAttribute("src");
|
| 151 |
+
window.speechSynthesis?.cancel();
|
| 152 |
playing = false;
|
| 153 |
controls.play.textContent = "Play";
|
| 154 |
}
|
| 155 |
|
| 156 |
+
function browserSpeak(text) {
|
| 157 |
+
if (!("speechSynthesis" in window)) {
|
| 158 |
+
return false;
|
| 159 |
+
}
|
| 160 |
+
window.speechSynthesis.cancel();
|
| 161 |
+
const utterance = new SpeechSynthesisUtterance(text);
|
| 162 |
+
utterance.rate = Number(speedControl.value);
|
| 163 |
+
utterance.onend = () => {
|
| 164 |
+
playing = false;
|
| 165 |
+
controls.play.textContent = "Play";
|
| 166 |
+
};
|
| 167 |
+
window.speechSynthesis.speak(utterance);
|
| 168 |
+
playing = true;
|
| 169 |
+
controls.play.textContent = "Pause";
|
| 170 |
+
return true;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
async function speakNarration(text, serial) {
|
| 174 |
+
lastSpokenText = text;
|
| 175 |
+
const speech = await postJson("/api/speak", {
|
| 176 |
+
text,
|
| 177 |
+
speed: Number(speedControl.value),
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
if (serial !== requestSerial) return;
|
| 181 |
+
|
| 182 |
+
if (speech.runtime === "fallback") {
|
| 183 |
+
const spoke = browserSpeak(text);
|
| 184 |
+
voiceStatus.textContent = spoke ? "Browser fallback" : "Transcript only";
|
| 185 |
+
if (!spoke) {
|
| 186 |
+
liveNarration.textContent = `${text} Audio fallback is unavailable. Transcript is visible.`;
|
| 187 |
+
}
|
| 188 |
+
return;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
if (speech.audio_url) {
|
| 192 |
+
audio.src = speech.audio_url;
|
| 193 |
+
await audio.play().catch(() => {
|
| 194 |
+
const spoke = browserSpeak(text);
|
| 195 |
+
voiceStatus.textContent = spoke ? "Browser fallback" : "Audio ready";
|
| 196 |
+
liveNarration.textContent = spoke ? text : `${text} Audio is ready. Press Play to hear it.`;
|
| 197 |
+
});
|
| 198 |
+
playing = !audio.paused || window.speechSynthesis?.speaking;
|
| 199 |
+
controls.play.textContent = playing ? "Pause" : "Play";
|
| 200 |
+
if (!window.speechSynthesis?.speaking) {
|
| 201 |
+
voiceStatus.textContent = speech.runtime;
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
async function postJson(url, payload) {
|
| 207 |
const options = payload === undefined ? {} : {
|
| 208 |
method: "POST",
|
|
|
|
| 254 |
text: result.narration,
|
| 255 |
});
|
| 256 |
|
| 257 |
+
await speakNarration(result.narration, serial);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
} catch (error) {
|
| 259 |
runtimeStatus.textContent = "Error";
|
| 260 |
liveNarration.textContent = `Narration failed: ${error.message}`;
|
|
|
|
| 288 |
text: result.narration,
|
| 289 |
});
|
| 290 |
|
| 291 |
+
await speakNarration(result.narration, serial);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
} catch (error) {
|
| 293 |
runtimeStatus.textContent = "Error";
|
| 294 |
liveNarration.textContent = `Summary failed: ${error.message}`;
|
|
|
|
| 341 |
}
|
| 342 |
if (playing) {
|
| 343 |
audio.pause();
|
| 344 |
+
window.speechSynthesis?.pause();
|
| 345 |
playing = false;
|
| 346 |
controls.play.textContent = "Play";
|
| 347 |
+
} else if (window.speechSynthesis?.paused) {
|
| 348 |
+
window.speechSynthesis.resume();
|
| 349 |
+
playing = true;
|
| 350 |
+
controls.play.textContent = "Pause";
|
| 351 |
} else if (audio.src) {
|
| 352 |
audio.play();
|
| 353 |
playing = true;
|
| 354 |
controls.play.textContent = "Pause";
|
| 355 |
+
} else if (lastSpokenText) {
|
| 356 |
+
browserSpeak(lastSpokenText);
|
| 357 |
} else {
|
| 358 |
narrate(currentIndex);
|
| 359 |
}
|
static/index.html
CHANGED
|
@@ -100,6 +100,10 @@
|
|
| 100 |
<dt>Image Alt</dt>
|
| 101 |
<dd id="imageStatus">Waiting</dd>
|
| 102 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
</dl>
|
| 104 |
<p id="liveNarration" class="live-narration" aria-live="polite">
|
| 105 |
Turn on screen reader mode to begin.
|
|
|
|
| 100 |
<dt>Image Alt</dt>
|
| 101 |
<dd id="imageStatus">Waiting</dd>
|
| 102 |
</div>
|
| 103 |
+
<div>
|
| 104 |
+
<dt>Voice</dt>
|
| 105 |
+
<dd id="voiceStatus">Waiting</dd>
|
| 106 |
+
</div>
|
| 107 |
</dl>
|
| 108 |
<p id="liveNarration" class="live-narration" aria-live="polite">
|
| 109 |
Turn on screen reader mode to begin.
|