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

Add reader voice controls

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 +13 -0
  4. scripts/verify.py +10 -0
  5. static/app.css +20 -2
  6. static/app.js +29 -0
  7. 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
  "&": "&amp;",
@@ -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
  "&": "&amp;",
 
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>