cvpfus Codex commited on
Commit
b958872
·
1 Parent(s): d19068b

Add audible fallback for reader voice

Browse files

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

Files changed (5) hide show
  1. FIELD_NOTES.md +2 -0
  2. README.md +2 -0
  3. scripts/verify.py +1 -0
  4. static/app.js +62 -30
  5. 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
- const speech = await postJson("/api/speak", {
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
- const speech = await postJson("/api/speak", {
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.