const toggle = document.querySelector("#readerToggle");
const readerBar = document.querySelector(".reader-bar");
const modeStatus = document.querySelector("#modeStatus");
const currentStatus = document.querySelector("#currentStatus");
const runtimeStatus = document.querySelector("#runtimeStatus");
const modelStatus = document.querySelector("#modelStatus");
const readinessStatus = document.querySelector("#readinessStatus");
const imageStatus = document.querySelector("#imageStatus");
const voiceStatus = document.querySelector("#voiceStatus");
const latencyStatus = document.querySelector("#latencyStatus");
const liveNarration = document.querySelector("#liveNarration");
const audio = document.querySelector("#speechAudio");
const voiceControl = document.querySelector("#voiceControl");
const speedControl = document.querySelector("#speedControl");
const speedValue = document.querySelector("#speedValue");
const autoAdvanceControl = document.querySelector("#autoAdvanceControl");
const transcriptLog = document.querySelector("#transcriptLog");
const copyTranscriptButton = document.querySelector("#copyTranscriptButton");
const clearTranscriptButton = document.querySelector("#clearTranscriptButton");
const awardEvidenceList = document.querySelector("#awardEvidenceList");
const controls = {
prev: document.querySelector("#prevButton"),
play: document.querySelector("#playButton"),
next: document.querySelector("#nextButton"),
heading: document.querySelector("#headingButton"),
image: document.querySelector("#imageButton"),
summary: document.querySelector("#summaryButton"),
};
const nodes = [...document.querySelectorAll(".speakable")].map((element, index) => ({
element,
index,
type: element.dataset.readerType || "paragraph",
imageId: element.dataset.imageId || null,
get text() {
return element.innerText.replace(/\s+/g, " ").trim();
},
}));
let enabled = false;
let currentIndex = -1;
let playing = false;
let requestSerial = 0;
let transcriptEntries = [];
let imageDescriptions = new Map();
let lastSpokenText = "";
let activeAutoAdvance = false;
let activeSpeechSerial = 0;
function updateSpeedValue() {
speedValue.textContent = `${Number(speedControl.value).toFixed(2)}x`;
}
function formatElapsed(ms) {
if (!Number.isFinite(ms)) {
return "n/a";
}
return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`;
}
function escapeHtml(value) {
return value.replace(/[&<>"']/g, (char) => ({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
}[char]));
}
function renderTranscript() {
transcriptLog.innerHTML = transcriptEntries
.map((entry) => `
${escapeHtml(entry.type)} (${escapeHtml(formatElapsed(entry.elapsedMs))})
${escapeHtml(entry.runtime)}
${escapeHtml(entry.text)}
`)
.join("");
}
function addTranscriptEntry(entry) {
transcriptEntries = [entry, ...transcriptEntries].slice(0, 12);
renderTranscript();
}
function currentSection() {
const activeNode = nodes[currentIndex]?.element;
return activeNode?.closest("section") || document.querySelector("#article article");
}
function currentSectionPayload() {
const section = currentSection();
const title = section.querySelector("h1, h2, h3")?.innerText.trim() || "Article introduction";
const text = [...section.querySelectorAll(".speakable")]
.map((element) => element.innerText.replace(/\s+/g, " ").trim())
.filter(Boolean)
.join(" ");
return { title, text };
}
async function loadManifest() {
try {
const manifest = await postJson("/api/article-manifest");
const modelCount = Object.keys(manifest.models || {}).length;
modelStatus.textContent = `${modelCount} tiny models, ${manifest.bonus_targets.join(", ")}`;
const settings = manifest.reader_settings;
if (settings?.voices?.length) {
voiceControl.innerHTML = settings.voices
.map((voice) => ``)
.join("");
voiceControl.value = settings.default_voice;
voiceStatus.textContent = voiceControl.selectedOptions[0]?.textContent || "Ready";
}
if (settings?.speed) {
speedControl.min = settings.speed.min;
speedControl.max = settings.speed.max;
speedControl.step = settings.speed.step;
speedControl.value = settings.default_speed;
updateSpeedValue();
}
autoAdvanceControl.checked = Boolean(settings?.default_auto_advance);
} catch {
modelStatus.textContent = "Manifest unavailable";
}
}
async function loadAwardEvidence() {
try {
const payload = await postJson("/api/award-evidence");
awardEvidenceList.innerHTML = payload.items
.map((item) => `
${escapeHtml(item.label)}
${escapeHtml(item.status)}
${escapeHtml(item.evidence)}
`)
.join("");
} catch {
awardEvidenceList.innerHTML = `
Checklist
offline
Award evidence is unavailable.
`;
}
}
async function loadRuntimeStatus() {
try {
const payload = await postJson("/api/runtime-status");
const brain = payload.reader_brain;
const speech = payload.speech;
const brainLabel = brain.available ? "llama.cpp online" : "llama.cpp fallback";
const speechLabel = speech.available ? "Kokoro online" : "voice fallback";
readinessStatus.textContent = `${brainLabel}, ${speechLabel}`;
} catch {
readinessStatus.textContent = "Fallback ready";
}
}
async function loadImageDescriptions() {
imageStatus.textContent = "Describing";
try {
const payload = await postJson("/api/image-descriptions");
imageDescriptions = new Map(
payload.descriptions.map((description) => [description.id, description]),
);
for (const node of nodes.filter((item) => item.type === "image")) {
const description = imageDescriptions.get(node.imageId);
const image = node.element.querySelector("img");
if (description?.alt_text && image) {
image.alt = description.alt_text;
node.element.dataset.altReady = "true";
}
}
imageStatus.textContent = `${imageDescriptions.size} ready`;
} catch {
imageStatus.textContent = "Fallback on demand";
}
}
function setEnabled(nextValue) {
enabled = nextValue;
toggle.setAttribute("aria-pressed", String(enabled));
toggle.lastChild.textContent = enabled ? " Screen reader on" : " Screen reader off";
readerBar.hidden = !enabled;
modeStatus.textContent = enabled ? "Reader on" : "Reader off";
if (enabled) {
announceIntro();
} else {
stopAudio();
setActive(-1);
liveNarration.textContent = "Turn on screen reader mode to begin.";
}
}
function setActive(index) {
nodes.forEach((node) => node.element.classList.remove("reader-active"));
currentIndex = index;
const node = nodes[currentIndex];
if (!node) {
currentStatus.textContent = "No item selected";
return;
}
node.element.classList.add("reader-active");
node.element.setAttribute("tabindex", "-1");
node.element.focus({ preventScroll: true });
node.element.scrollIntoView({ block: "center", behavior: "smooth" });
currentStatus.textContent = `${node.type}, item ${currentIndex + 1} of ${nodes.length}`;
}
function haltPlayback({ clearAutoAdvance = true } = {}) {
audio.pause();
audio.removeAttribute("src");
window.speechSynthesis?.cancel();
if (clearAutoAdvance) {
activeAutoAdvance = false;
}
activeSpeechSerial = 0;
playing = false;
controls.play.textContent = "Play";
}
function stopAudio() {
haltPlayback();
}
function maybeAutoAdvance(serial) {
if (!enabled || !autoAdvanceControl.checked || !activeAutoAdvance || serial !== requestSerial) {
return;
}
const nextIndex = currentIndex + 1;
if (nextIndex < nodes.length) {
window.setTimeout(() => narrate(nextIndex), 250);
} else {
liveNarration.textContent = "End of article.";
activeAutoAdvance = false;
}
}
function browserSpeak(text, serial) {
if (!("speechSynthesis" in window)) {
return false;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = Number(speedControl.value);
utterance.onend = () => {
playing = false;
controls.play.textContent = "Play";
maybeAutoAdvance(serial);
};
window.speechSynthesis.speak(utterance);
playing = true;
controls.play.textContent = "Pause";
return true;
}
async function speakNarration(text, serial, autoAdvance = false) {
lastSpokenText = text;
activeAutoAdvance = autoAdvance;
activeSpeechSerial = serial;
const speech = await postJson("/api/speak", {
text,
voice: voiceControl.value || "af_heart",
speed: Number(speedControl.value),
});
if (serial !== requestSerial) return;
if (speech.runtime === "fallback") {
const spoke = browserSpeak(text, serial);
voiceStatus.textContent = spoke ? "Browser fallback" : "Transcript only";
if (!spoke) {
liveNarration.textContent = `${text} Audio fallback is unavailable. Transcript is visible.`;
}
return speech;
}
if (speech.audio_url) {
audio.src = speech.audio_url;
await audio.play().catch(() => {
const spoke = browserSpeak(text, serial);
voiceStatus.textContent = spoke ? "Browser fallback" : "Audio ready";
liveNarration.textContent = spoke ? text : `${text} Audio is ready. Press Play to hear it.`;
});
playing = !audio.paused || window.speechSynthesis?.speaking;
controls.play.textContent = playing ? "Pause" : "Play";
if (!window.speechSynthesis?.speaking) {
voiceStatus.textContent = speech.runtime;
}
}
return speech;
}
async function postJson(url, payload) {
const options = payload === undefined ? {} : {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
};
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return response.json();
}
async function narrate(index) {
if (!enabled || !nodes[index]) return;
const serial = ++requestSerial;
haltPlayback({ clearAutoAdvance: false });
setActive(index);
const node = nodes[index];
runtimeStatus.textContent = "Thinking";
controls.play.disabled = true;
try {
let sourceText = node.text;
if (node.type === "image") {
let description = imageDescriptions.get(node.imageId);
if (!description) {
description = await postJson("/api/describe-image", {
image_id: node.imageId,
caption: node.text,
});
}
sourceText = description.alt_text;
}
const result = await postJson("/api/reader-brain", {
node_type: node.type,
text: sourceText,
position: `item ${index + 1} of ${nodes.length}`,
mode: "narrate",
});
if (serial !== requestSerial) return;
runtimeStatus.textContent = result.runtime;
liveNarration.textContent = result.narration;
const speech = await speakNarration(result.narration, serial, true);
const elapsedMs = (result.elapsed_ms || 0) + (speech?.elapsed_ms || 0);
latencyStatus.textContent = formatElapsed(elapsedMs);
addTranscriptEntry({
type: node.type,
runtime: `${result.runtime}/${speech?.runtime || "voice"}`,
text: result.narration,
elapsedMs,
});
} catch (error) {
runtimeStatus.textContent = "Error";
liveNarration.textContent = `Narration failed: ${error.message}`;
} finally {
controls.play.disabled = false;
}
}
async function summarizeCurrentSection() {
if (!enabled) return;
const serial = ++requestSerial;
haltPlayback({ clearAutoAdvance: false });
const section = currentSectionPayload();
runtimeStatus.textContent = "Summarizing";
controls.summary.disabled = true;
try {
const result = await postJson("/api/reader-brain", {
node_type: "section",
text: section.text,
position: section.title,
mode: "summarize",
});
if (serial !== requestSerial) return;
runtimeStatus.textContent = result.runtime;
liveNarration.textContent = result.narration;
const speech = await speakNarration(result.narration, serial, false);
const elapsedMs = (result.elapsed_ms || 0) + (speech?.elapsed_ms || 0);
latencyStatus.textContent = formatElapsed(elapsedMs);
addTranscriptEntry({
type: "summary",
runtime: `${result.runtime}/${speech?.runtime || "voice"}`,
text: result.narration,
elapsedMs,
});
} catch (error) {
runtimeStatus.textContent = "Error";
liveNarration.textContent = `Summary failed: ${error.message}`;
} finally {
controls.summary.disabled = false;
}
}
function announceIntro() {
const headings = nodes.filter((node) => node.type === "heading").length;
const images = nodes.filter((node) => node.type === "image").length;
liveNarration.textContent =
`Screen reader mode on. Article contains ${headings} headings, ${images} images, and ${nodes.length} readable items. Press N for next, H for heading, I for image, S for section summary, or Space to begin.`;
runtimeStatus.textContent = "Ready";
}
function nextByType(type) {
const start = Math.max(currentIndex + 1, 0);
const found = nodes.find((node) => node.index >= start && node.type === type);
if (found) return narrate(found.index);
const wrapped = nodes.find((node) => node.type === type);
if (wrapped) return narrate(wrapped.index);
}
toggle.addEventListener("click", () => setEnabled(!enabled));
controls.next.addEventListener("click", () => narrate(Math.min(currentIndex + 1, nodes.length - 1)));
controls.prev.addEventListener("click", () => narrate(Math.max(currentIndex - 1, 0)));
controls.heading.addEventListener("click", () => nextByType("heading"));
controls.image.addEventListener("click", () => nextByType("image"));
controls.summary.addEventListener("click", () => summarizeCurrentSection());
voiceControl.addEventListener("change", () => {
voiceStatus.textContent = voiceControl.selectedOptions[0]?.textContent || "Custom voice";
});
speedControl.addEventListener("input", () => {
updateSpeedValue();
});
copyTranscriptButton.addEventListener("click", async () => {
const text = transcriptEntries
.slice()
.reverse()
.map((entry) => `[${entry.type} / ${entry.runtime}] ${entry.text}`)
.join("\n");
await navigator.clipboard?.writeText(text);
liveNarration.textContent = text ? "Transcript copied." : "Transcript is empty.";
});
clearTranscriptButton.addEventListener("click", () => {
transcriptEntries = [];
renderTranscript();
liveNarration.textContent = "Transcript cleared.";
});
controls.play.addEventListener("click", () => {
if (!enabled) return;
if (currentIndex < 0) {
narrate(0);
return;
}
if (playing) {
audio.pause();
window.speechSynthesis?.pause();
playing = false;
controls.play.textContent = "Play";
} else if (window.speechSynthesis?.paused) {
window.speechSynthesis.resume();
playing = true;
controls.play.textContent = "Pause";
} else if (audio.src) {
audio.play();
playing = true;
controls.play.textContent = "Pause";
} else if (lastSpokenText) {
activeAutoAdvance = false;
browserSpeak(lastSpokenText, requestSerial);
} else {
narrate(currentIndex);
}
});
loadManifest();
loadAwardEvidence();
loadRuntimeStatus();
loadImageDescriptions();
updateSpeedValue();
audio.addEventListener("ended", () => {
playing = false;
controls.play.textContent = "Play";
maybeAutoAdvance(activeSpeechSerial);
});
document.addEventListener("keydown", (event) => {
if (!enabled) return;
const key = event.key.toLowerCase();
if ([" ", "n", "p", "h", "i", "s", "r", "escape"].includes(key)) {
event.preventDefault();
}
if (key === " ") controls.play.click();
if (key === "n") controls.next.click();
if (key === "p") controls.prev.click();
if (key === "h") controls.heading.click();
if (key === "i") controls.image.click();
if (key === "s") controls.summary.click();
if (key === "r" && currentIndex >= 0) narrate(currentIndex);
if (key === "escape") {
stopAudio();
liveNarration.textContent = "Reading stopped.";
}
});