import { Client, handle_file } from "https://esm.sh/@gradio/client";
const NARRATORS = globalThis.__VOICE_PRESETS__ || [];
const VOICE_DESIGN_OPTIONS = globalThis.__VOICE_DESIGN_OPTIONS__ || [];
const MAGPIE_OPTIONS = globalThis.__MAGPIE_OPTIONS__ || {
speakers: [
{ value: "Sofia", label: "Sofia" },
{ value: "Aria", label: "Aria" },
{ value: "Jason", label: "Jason" },
{ value: "Leo", label: "Leo" },
{ value: "John", label: "John" },
],
languages: [
{ value: "en", label: "English" },
],
};
const SYNTHESIS_MODELS = globalThis.__SYNTHESIS_MODELS__ || [
{ id: "omnivoice", name: "OmniVoice", desc: "Presets, design prompts, and voice cloning" },
{ id: "magpie", name: "Magpie TTS", desc: "Multilingual speaker presets with text normalization" },
];
const SYNTHESIS_BACKENDS = globalThis.__SYNTHESIS_BACKENDS__ || [
{ id: "local", name: "HF/local", desc: "Run synthesis inside this Space runtime" },
{ id: "modal", name: "Modal", desc: "Offload synthesis to a deployed Modal worker" },
];
const DEFAULT_NARRATOR = NARRATORS[0] || {
id: "the-archivist",
name: "The Archivist",
desc: "Voice design preset · elderly monk · low pitch · british accent",
initial: "A",
};
const VOICE_WARNING =
"Use clone mode only with audio you have the right to use. Unauthorized voice cloning, impersonation, fraud, or scams are prohibited.";
const state = {
client: null,
sessionId: crypto.randomUUID ? crypto.randomUUID() : `session-${Date.now()}`,
step: "configure",
book: null,
chapters: [],
currentChapterId: null,
model: "omnivoice",
backendSelection: "local",
voiceMode: "auto",
narratorId: DEFAULT_NARRATOR.id,
cloneConsent: false,
cloneSampleFile: null,
cloneReferenceText: "",
magpieSpeaker: MAGPIE_OPTIONS.speakers?.[0]?.value || "Sofia",
magpieLanguage: MAGPIE_OPTIONS.languages?.[0]?.value || "en",
magpieApplyTextNormalization: false,
designSelections: {
gender: "",
age: "",
pitch: "",
style: "",
accent: "",
},
diffusionSteps: 32,
speed: 1,
statusMessage: "",
errorMessage: "",
renderStatus: null,
renderEvents: [],
renderEventKeys: new Set(),
renderResult: null,
renderSubmission: null,
activeRenderBackend: "local",
activeRenderModel: "omnivoice",
exportPlayerTrackIndex: 0,
previewUrl: "",
exportFormat: "m4a",
embedMarkers: true,
exportFile: null,
exportMetadata: {
title: "",
author: "",
narrator: `${DEFAULT_NARRATOR.name} (OmniVoice)`,
genre: "Audiobook",
},
};
const appRoot = document.getElementById("app");
function main() {
bindGlobalEvents();
connectClient()
.catch((error) => {
state.errorMessage = error.message;
})
.finally(() => {
render();
});
}
async function connectClient() {
state.statusMessage = "Connecting to the Gradio backend…";
render();
state.client = await Client.connect(window.location.origin, {
events: ["data", "status"],
});
state.statusMessage = "Connected. Upload an EPUB to begin.";
}
function bindGlobalEvents() {
window.addEventListener("click", handleClick);
window.addEventListener("change", handleChange);
window.addEventListener("input", handleInput);
}
function handleClick(event) {
const target = event.target.closest("[data-action]");
if (!target) return;
const action = target.dataset.action;
void actionHandlers[action]?.(target, event);
}
function handleChange(event) {
const target = event.target;
if (target.matches("[data-chapter-toggle]")) {
const id = target.dataset.chapterToggle;
const chapter = state.chapters.find((item) => item.id === id);
if (chapter) {
chapter.included = target.checked;
render();
}
return;
}
if (target.matches("#epub-input")) {
void uploadEpub(target.files?.[0]);
return;
}
if (target.matches("#clone-audio")) {
state.cloneSampleFile = target.files?.[0] || null;
render();
return;
}
if (target.matches("[data-format]")) {
state.exportFormat = target.dataset.format;
render();
return;
}
if (target.matches("#embed-markers")) {
state.embedMarkers = target.checked;
render();
return;
}
if (target.matches("[data-design-field]")) {
state.designSelections[target.dataset.designField] = target.value;
render();
return;
}
if (target.matches("#magpie-speaker")) {
state.magpieSpeaker = target.value;
syncNarratorMetadata();
render();
return;
}
if (target.matches("#magpie-language")) {
state.magpieLanguage = target.value;
render();
return;
}
if (target.matches("#magpie-tn")) {
state.magpieApplyTextNormalization = target.checked;
render();
}
}
function handleInput(event) {
const target = event.target;
if (target.matches("#diffusion-steps")) {
state.diffusionSteps = Number(target.value);
render();
return;
}
if (target.matches("#reading-speed")) {
state.speed = Number(target.value);
render();
return;
}
if (target.matches("#clone-ref-text")) {
state.cloneReferenceText = target.value;
return;
}
if (target.matches("#clone-consent")) {
state.cloneConsent = target.checked;
render();
return;
}
if (target.matches("[data-meta]")) {
state.exportMetadata[target.dataset.meta] = target.value;
}
}
const actionHandlers = {
async pickNarrator(target) {
state.narratorId = target.dataset.id;
syncNarratorMetadata();
render();
},
setModel(target) {
state.model = target.dataset.model;
if (state.model !== "omnivoice") {
state.voiceMode = "auto";
}
syncNarratorMetadata();
render();
},
setBackend(target) {
state.backendSelection = target.dataset.backend;
render();
},
setVoiceMode(target) {
state.voiceMode = target.dataset.mode;
syncNarratorMetadata();
render();
},
focusChapter(target) {
state.currentChapterId = target.dataset.id;
render();
},
async previewChapter(target) {
const chapterId = target.dataset.id;
await requestPreview(chapterId);
},
async startRender() {
await submitRender();
},
async pauseRender() {
await callPredict("/pause_render", { session_id: state.sessionId });
state.statusMessage = "Render paused. Resume when ready.";
render();
},
async resumeRender() {
await callPredict("/resume_render", { session_id: state.sessionId });
state.statusMessage = "Render resumed.";
render();
},
async cancelRender() {
await callPredict("/cancel_render", { session_id: state.sessionId });
state.renderSubmission?.cancel?.();
state.renderSubmission = null;
state.renderStatus = { status: "cancelled" };
state.step = "configure";
state.statusMessage = "Render cancelled.";
render();
},
async exportBook() {
if (state.exportFile) {
downloadCurrentExport();
return;
}
await requestExport({ autoDownload: true });
},
goConfigure() {
state.step = "configure";
render();
},
goGenerate() {
if (!state.book) return;
state.step = "generate";
render();
},
goExport() {
if (!state.renderResult) return;
state.step = "export";
render();
},
chooseUpload() {
document.getElementById("epub-input")?.click();
},
chooseCloneAudio() {
document.getElementById("clone-audio")?.click();
},
playExportPreview(target) {
const audio = exportAudioElement();
if (!audio) return;
if (!audio.getAttribute("src")) {
playExportTrack(state.exportPlayerTrackIndex);
return;
}
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
syncExportControls();
},
playRenderedChapter(target) {
const trackIndex = Number(target.dataset.trackIndex || 0);
playExportTrack(trackIndex);
},
exportJumpToTrack(target) {
const direction = Number(target.dataset.direction || 0);
const tracks = playableTracks();
if (!tracks.length) return;
const nextIndex = clampTrackIndex(state.exportPlayerTrackIndex + direction, tracks.length);
playExportTrack(nextIndex);
},
exportSeek(target) {
const audio = exportAudioElement();
if (!audio) return;
const delta = Number(target.dataset.seconds || 0);
audio.currentTime = Math.max(0, Math.min(audio.duration || Infinity, audio.currentTime + delta));
syncExportControls();
},
};
async function uploadEpub(file) {
if (!file || !state.client) return;
state.errorMessage = "";
state.statusMessage = `Uploading ${file.name}…`;
render();
try {
const payload = await callPredict("/parse_epub", {
session_id: state.sessionId,
epub_file: handle_file(file),
});
state.book = payload;
state.chapters = payload.chapters;
state.currentChapterId = payload.chapters[0]?.id || null;
state.exportMetadata.title = payload.title;
state.exportMetadata.author = payload.author;
syncNarratorMetadata();
state.statusMessage = `Parsed ${payload.chapters.length} chapters from ${payload.title}.`;
} catch (error) {
state.errorMessage = error.message;
}
render();
}
async function requestPreview(chapterId) {
if (!state.client || !state.book) return;
state.errorMessage = "";
state.statusMessage = "Rendering preview…";
render();
try {
const payload = await callPredict("/generate_preview", {
session_id: state.sessionId,
chapter_id: chapterId,
voice_config: buildVoiceConfig(),
diffusion_steps: state.diffusionSteps,
speed: state.speed,
});
state.previewUrl = payload.url;
state.statusMessage = "Preview ready.";
} catch (error) {
state.errorMessage = error.message;
}
render();
}
async function submitRender() {
if (!state.client || !state.book) return;
state.errorMessage = "";
state.statusMessage = "Submitting audiobook render…";
state.step = "generate";
state.renderEvents = [];
state.renderEventKeys = new Set();
state.renderResult = null;
state.activeRenderBackend = state.backendSelection;
state.activeRenderModel = state.model;
render();
const submission = state.client.submit("/start_render", {
session_id: state.sessionId,
selected_chapter_ids: selectedChapterIds(),
voice_config: buildVoiceConfig(),
diffusion_steps: state.diffusionSteps,
speed: state.speed,
});
state.renderSubmission = submission;
try {
for await (const event of submission) {
if (event.type === "status") {
state.renderStatus = event;
if (event.position && event.status === "pending") {
state.statusMessage = `Queued in position ${event.position} of ${event.queue_size}.`;
}
render();
continue;
}
if (event.type !== "data") continue;
const payload = event.data?.[0] ?? event.data;
if (!payload) continue;
appendRenderEvent(payload);
updateRenderState(payload);
render();
}
} catch (error) {
state.errorMessage = error.message;
render();
}
}
function updateRenderState(payload) {
if (payload.backend) state.activeRenderBackend = payload.backend;
if (payload.model) state.activeRenderModel = payload.model;
switch (payload.type) {
case "started":
state.statusMessage = `Binding ${payload.total_chapters} chapters…`;
break;
case "chapter_started":
state.statusMessage = `Narrating ${payload.chapter_title}…`;
break;
case "chapter_done":
updateChapterDuration(payload.chapter_id, payload.duration_seconds);
updateChapterRenderUrl(payload.chapter_id, payload.url);
state.previewUrl = payload.url || state.previewUrl;
state.statusMessage = `Bound ${chapterTitle(payload.chapter_id)}. Listen while the next chapter renders.`;
break;
case "completed":
state.renderResult = payload;
state.step = "export";
state.statusMessage = "Audiobook render completed. Export is unlocked.";
break;
case "cancelled":
state.statusMessage = "Render cancelled.";
break;
case "failed":
state.errorMessage = payload.message || "Render failed.";
break;
default:
break;
}
}
async function requestExport(options = {}) {
if (!state.client) return;
state.errorMessage = "";
state.statusMessage = `Preparing ${state.exportFormat.toUpperCase()} export…`;
render();
try {
const payload = await callPredict("/export_audiobook", {
session_id: state.sessionId,
format: state.exportFormat,
metadata: {
title: state.exportMetadata.title,
artist: state.exportMetadata.author,
narrator: state.exportMetadata.narrator,
genre: state.exportMetadata.genre,
},
embed_markers: state.embedMarkers,
});
state.exportFile = payload;
state.exportPlayerTrackIndex = clampTrackIndex(state.exportPlayerTrackIndex, Math.max(1, includedChapters().length));
state.previewUrl = payload.url || state.previewUrl;
if (options.autoDownload && payload.url) {
downloadCurrentExport(payload);
state.statusMessage = `${state.exportFormat.toUpperCase()} export ready. Download starting…`;
} else {
state.statusMessage = `${state.exportFormat.toUpperCase()} export ready.`;
}
} catch (error) {
state.errorMessage = error.message;
}
render();
}
async function callPredict(apiName, payload) {
if (!state.client) throw new Error("Client not connected");
const result = await state.client.predict(apiName, payload);
return result.data?.[0] ?? result;
}
function buildVoiceConfig() {
if (state.model === "magpie") {
return {
model: "magpie",
backend: state.backendSelection,
mode: "auto",
speaker: state.magpieSpeaker,
language: state.magpieLanguage,
applyTextNormalization: state.magpieApplyTextNormalization,
};
}
if (state.voiceMode === "clone") {
return {
model: "omnivoice",
backend: state.backendSelection,
mode: "clone",
referenceText: state.cloneReferenceText,
cloneConsent: state.cloneConsent,
samplePath: state.cloneSampleFile ? handle_file(state.cloneSampleFile) : null,
};
}
if (state.voiceMode === "design") {
return {
model: "omnivoice",
backend: state.backendSelection,
mode: "design",
designPrompt: buildDesignPrompt(),
};
}
return {
model: "omnivoice",
backend: state.backendSelection,
mode: "auto",
narratorId: state.narratorId,
};
}
function selectedChapterIds() {
return state.chapters.filter((chapter) => chapter.included).map((chapter) => chapter.id);
}
function currentChapter() {
return state.chapters.find((chapter) => chapter.id === state.currentChapterId) || state.chapters[0];
}
function includedChapters() {
return state.chapters.filter((chapter) => chapter.included);
}
function totalMinutes() {
return includedChapters().reduce((sum, chapter) => sum + Number(chapter.est_minutes || chapter.estMinutes || 0), 0);
}
function formatRuntime(minutes) {
const total = Math.max(0, Math.round(minutes));
const hours = Math.floor(total / 60);
const mins = total % 60;
return hours ? `${hours}h ${String(mins).padStart(2, "0")}m` : `${mins}m`;
}
function formatStamp(totalSeconds) {
const seconds = Math.max(0, Math.round(totalSeconds));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
function updateChapterDuration(chapterId, seconds) {
const chapter = state.chapters.find((item) => item.id === chapterId);
if (chapter) {
chapter.duration_seconds = seconds;
}
}
function updateChapterRenderUrl(chapterId, url) {
const chapter = state.chapters.find((item) => item.id === chapterId);
if (chapter && url) {
chapter.render_url = url;
}
}
function appendRenderEvent(payload) {
const stamped = {
...payload,
received_at: payload.received_at || new Date().toISOString(),
};
const key = renderEventKey(stamped);
if (state.renderEventKeys.has(key)) {
return;
}
state.renderEventKeys.add(key);
state.renderEvents.push(stamped);
}
function renderEventKey(event) {
return JSON.stringify({
type: event.type,
chapter_id: event.chapter_id,
chapter_title: event.chapter_title,
percent: event.percent,
duration_seconds: event.duration_seconds,
output_path: event.output_path,
url: event.url,
overall_progress: event.overall_progress,
backend: event.backend,
model: event.model,
});
}
function render() {
const previousChapterListScrollTop = document.querySelector(".chapter-list")?.scrollTop ?? 0;
const previousReadingContainer = document.querySelector("[data-reading-scroll]");
const previousReadingScrollTop = previousReadingContainer?.scrollTop ?? 0;
const previousReadingChapterId = previousReadingContainer?.dataset.chapterId ?? "";
const nextReadingChapterId = String(currentChapter()?.id || "");
appRoot.innerHTML = `
${renderMasthead()}
${state.errorMessage ? `
${escapeHtml(state.errorMessage)}
` : ""}
${state.statusMessage ? `
${escapeHtml(state.statusMessage)}
` : ""}
${state.step === "configure" ? renderConfigure() : ""}
${state.step === "generate" ? renderGenerate() : ""}
${state.step === "export" ? renderExport() : ""}
`;
const chapterList = document.querySelector(".chapter-list");
if (chapterList) {
chapterList.scrollTop = previousChapterListScrollTop;
}
const nextReadingContainer = document.querySelector("[data-reading-scroll]");
if (nextReadingContainer && previousReadingChapterId === nextReadingChapterId) {
nextReadingContainer.scrollTop = previousReadingScrollTop;
}
bindExportAudio();
syncExportControls();
}
function renderMasthead() {
const activeIndex = { configure: 0, generate: 1, export: 2 }[state.step] ?? 0;
return `
📖
Scriptorium
Bind your library into spoken word · OmniVoice + Magpie TTS
${["Upload & Configure", "Generate", "Export"].map((label, index) => {
const cls = index < activeIndex ? "done" : index === activeIndex ? "now" : "";
const prefix = index < activeIndex ? "✓ " : "";
return `${index ? `—` : ""}${prefix}${label}`;
}).join("")}
`;
}
function renderConfigure() {
const chapter = currentChapter();
const included = includedChapters();
const chapterCount = included.length;
const runtime = formatRuntime(totalMinutes());
return `
${renderCover()}
${chapterCount}/${state.chapters.length || 0}
chapters on the shelf
≈ ${runtime} runtime
Select chapters to bind
${chapterCount ? `${chapterCount} selected` : "Upload an EPUB"}
Include in audiobook
${chapterCount} of ${state.chapters.length}
${state.chapters.length ? state.chapters.map(renderChapterRow).join("") : renderUploadPlaceholder()}
Now reading
${escapeHtml(chapter?.title || "Waiting for an EPUB")}
${chapter ? `${chapter.chars.toLocaleString()} chars · ≈ ${chapter.est_minutes} min · chapter ${chapter.n}` : "Upload a book to inspect chapters"}
${renderChapterBody(chapter?.text || "")}
${state.previewUrl ? `
` : ""}
Speech model
${SYNTHESIS_MODELS.map((model) => `
`).join("")}
${escapeHtml(activeModelDescription())}
Execution backend
${SYNTHESIS_BACKENDS.map((backend) => `
`).join("")}
${escapeHtml(activeBackendDescription())}
${state.model === "omnivoice" ? `
${["auto", "clone", "design"].map((mode) => `
`).join("")}
` : ""}
${renderVoiceMode()}
${state.model === "omnivoice" ? `
` : `
Magpie controls
Speaker, language, and text normalization are controlled directly for Magpie. Speed and diffusion controls are OmniVoice-only.
`}
`;
}
function renderGenerate() {
const included = includedChapters();
const currentEvent = [...state.renderEvents].reverse().find((event) => event.type === "chapter_started");
const completedChapters = included.filter((chapter) => chapter.render_url);
const currentId = currentEvent?.chapter_id;
const doneCount = state.renderEvents.filter((event) => event.type === "chapter_done").length;
const percent = currentEvent ? Math.round((currentEvent.overall_progress || 0) * 100) : state.renderResult ? 100 : 0;
const activeChapter = state.chapters.find((chapter) => chapter.id === currentId) || included[0];
return `
${renderCover({ w: 92, h: 134 })}
${percent}%
complete
${renderSupportsPause() ? `` : ""}
Render queue
${doneCount}/${included.length} done
Chapter · status ${state.renderStatus?.position ? `queue ${state.renderStatus.position}` : ""}
${state.chapters.map((chapter) => renderQueueRow(chapter, currentId)).join("")}
Now narrating
${escapeHtml(activeChapter?.title || "Waiting in queue")}
${activeChapter ? `chapter ${activeChapter.n} · ${activeChapter.chars.toLocaleString()} chars` : ""}
${escapeHtml(activeNarratorName())}
${new Array(25).fill(0).map((_, index) => ``).join("")}
${escapeHtml(excerpt(activeChapter?.text || "", 120))}
${escapeHtml(excerpt(activeChapter?.text || "", 220, 120))}
Activity
${state.renderEvents.slice(-8).reverse().map(renderLogRow).join("") || `
—Waiting for render events…
`}
Completed chapters
Listen while rendering continues ${completedChapters.length} ready
${completedChapters.length ? completedChapters.map(renderCompletedChapterRow).join("") : `
—No chapter audio ready yet.
`}
`;
}
function renderExport() {
const tracks = includedChapters();
const activeTrackIndex = clampTrackIndex(state.exportPlayerTrackIndex, Math.max(1, tracks.length));
state.exportPlayerTrackIndex = activeTrackIndex;
let elapsed = 0;
const trackRows = tracks.map((track, index) => {
const start = elapsed;
elapsed += Number(track.duration_seconds || track.est_minutes * 60 || 0);
return { track, start, playing: index === activeTrackIndex };
});
const totalSeconds = elapsed || Math.round(totalMinutes() * 60);
const playbackSeconds = Math.min(totalSeconds, trackRows[activeTrackIndex]?.start || 0);
const percent = totalSeconds ? (playbackSeconds / totalSeconds) * 100 : 0;
const currentTrack = trackRows[activeTrackIndex]?.track;
const exportUrl = currentExportAudioUrl();
return `
${renderCover({ w: 96, h: 140 })}
${state.exportFile ? `
` : `
with embedded chapter markers
`}
Chapters & markers
${tracks.length} tracks
Chapter ${formatRuntime(totalSeconds / 60)} total
${trackRows.map(({ track, start, playing }, index) => `
${escapeHtml(track.n)}
${escapeHtml(track.title)}
starts at ${formatStamp(start)}
${formatStamp(Number(track.duration_seconds || track.est_minutes * 60 || 0))}
`).join("")}
Preview & export
${renderCover({ w: 70, h: 102 })}
Now playing · chapter ${currentTrack?.n || "i"}
${escapeHtml(currentTrack?.title || state.book?.title || "Preview")}
narrated by ${escapeHtml(activeNarratorName())} · 1.0×
${trackRows.map(({ start }, index) => index ? `
` : "").join("")}
${formatStamp(playbackSeconds)} elapsed −${formatRuntime((totalSeconds - playbackSeconds) / 60)}
${exportUrl ? `
` : ""}
`;
}
function renderCover(options = {}) {
const w = options.w || 124;
const h = options.h || 182;
const scale = w / 124;
const style = `width:${w}px;height:${h}px;padding:${16 * scale}px ${12 * scale}px;`;
return `
${escapeHtml(state.book?.title || "Scriptorium")}
${escapeHtml(state.book?.author || activeModelName())}
`;
}
function renderUploadPlaceholder() {
return `
`;
}
function renderChapterRow(chapter) {
const active = chapter.id === state.currentChapterId ? "focus" : "";
const off = chapter.included ? "" : "off";
return `
`;
}
function renderVersePreview(text) {
if (!text) {
return `Choose a chapter to inspect the reading pane.
`;
}
const lines = excerpt(text, 420).split(/\n+/).slice(0, 10);
return `${escapeHtml(lines[0]?.[0] || "W")}${lines
.map((line, index) => {
const body = index === 0 ? line.slice(1) : line;
return `${escapeHtml(body)}
`;
})
.join("")}`;
}
function renderChapterBody(text) {
if (!text) {
return `Choose a chapter to inspect the reading pane.
`;
}
const paragraphs = text
.split(/\n{2,}/)
.map((chunk) => chunk.trim())
.filter(Boolean);
if (!paragraphs.length) {
return `${escapeHtml(text)}
`;
}
const [first, ...rest] = paragraphs;
const firstLead = first[0] || "W";
const firstBody = first.slice(1);
return `
${escapeHtml(firstLead)}${escapeHtml(firstBody)}
${rest.map((paragraph) => `${escapeHtml(paragraph)}
`).join("")}
`;
}
function renderVoiceMode() {
if (state.model === "magpie") {
return `
`;
}
if (state.voiceMode === "clone") {
return `
Record your own narrator
Recommended: 3–10 seconds of clean speech.
${state.cloneSampleFile ? escapeHtml(state.cloneSampleFile.name) : "No sample selected yet."}
`;
}
if (state.voiceMode === "design") {
const prompt = buildDesignPrompt();
return `
Build a supported OmniVoice design prompt
${VOICE_DESIGN_OPTIONS.map((category) => `
`).join("")}
Resulting prompt
${escapeHtml(prompt || "Choose one or more supported voice traits.")}
Use English-only OmniVoice design tokens. Accent affects English speech only.
`;
}
return `
${NARRATORS.map((narrator) => `
`).join("")}
`;
}
function renderQueueRow(chapter, currentId) {
const skipped = !chapter.included;
const done = state.renderEvents.some((event) => event.type === "chapter_done" && event.chapter_id === chapter.id);
const rendering = chapter.id === currentId && !done;
const status = skipped ? "skipped" : done ? "done" : rendering ? "rendering" : "queued";
const label = skipped
? "Skipped · not in audiobook"
: done
? `Bound · ${chapter.duration_seconds || chapter.est_minutes * 60}s`
: rendering
? "Rendering now…"
: `Queued · ≈${chapter.est_minutes} min`;
return `
${status === "done" ? "✓" : status === "rendering" ? "●" : status === "queued" ? "○" : "–"}
${escapeHtml(chapter.n)}
${escapeHtml(chapter.title)}
${escapeHtml(label)}
${rendering ? `
` : ""}
${skipped ? "—" : `≈${chapter.est_minutes}m`}
`;
}
function renderLogRow(event) {
const stamp = new Date(event.received_at || Date.now()).toLocaleTimeString();
return `${stamp}${escapeHtml(logMessage(event))}
`;
}
function logMessage(event) {
const suffix = event.backend === "modal" ? " via Modal" : "";
switch (event.type) {
case "started":
return `Began binding ${event.total_chapters} chapters${suffix}`;
case "chapter_started":
return `Synthesising ${event.chapter_title}${suffix}`;
case "chapter_progress":
return `${chapterTitle(event.chapter_id)} is still rendering${suffix}`;
case "chapter_done":
return `Bound ${chapterTitle(event.chapter_id)} · ${event.duration_seconds}s${suffix}`;
case "completed":
return `Completed audiobook render${suffix}`;
case "cancelled":
return `Cancelled render${suffix}`;
default:
return JSON.stringify(event);
}
}
function renderCompletedChapterRow(chapter) {
return `
${escapeHtml(chapter.title)}
${escapeHtml(`chapter ${chapter.n} · ${formatStamp(Number(chapter.duration_seconds || 0))}`)}
`;
}
function bindExportAudio() {
const audio = exportAudioElement();
if (!audio || audio.dataset.bound === "true") return;
audio.dataset.bound = "true";
audio.addEventListener("play", syncExportControls);
audio.addEventListener("pause", syncExportControls);
audio.addEventListener("loadedmetadata", syncExportControls);
audio.addEventListener("timeupdate", syncExportControls);
audio.addEventListener("ended", handleExportAudioEnded);
}
function handleExportAudioEnded() {
const audio = exportAudioElement();
if (!audio) return;
const tracks = playableTracks();
if (!tracks.length) {
syncExportControls();
return;
}
if (usingMergedExportAudio()) {
const nextIndex = state.exportPlayerTrackIndex + 1;
if (nextIndex < tracks.length) {
playExportTrack(nextIndex);
return;
}
} else {
const nextIndex = state.exportPlayerTrackIndex + 1;
if (nextIndex < tracks.length) {
playExportTrack(nextIndex);
return;
}
}
syncExportControls();
}
function exportAudioElement() {
return document.getElementById("preview-audio");
}
function playableTracks() {
return includedChapters();
}
function clampTrackIndex(index, length) {
if (!length) return 0;
return Math.max(0, Math.min(length - 1, index));
}
function usingMergedExportAudio() {
return Boolean(state.exportFile && state.exportFormat !== "zip");
}
function exportTrackStartSeconds(trackIndex) {
const tracks = playableTracks();
let elapsed = 0;
for (let index = 0; index < tracks.length; index += 1) {
if (index === trackIndex) return elapsed;
elapsed += Number(tracks[index].duration_seconds || tracks[index].est_minutes * 60 || 0);
}
return 0;
}
function currentExportAudioUrl() {
if (usingMergedExportAudio()) return state.exportFile?.url || "";
const tracks = playableTracks();
return tracks[state.exportPlayerTrackIndex]?.render_url || state.previewUrl || "";
}
function playExportTrack(trackIndex) {
const tracks = playableTracks();
if (!tracks.length) return;
const nextIndex = clampTrackIndex(trackIndex, tracks.length);
state.exportPlayerTrackIndex = nextIndex;
const audio = exportAudioElement();
const nextUrl = usingMergedExportAudio()
? state.exportFile?.url || ""
: tracks[nextIndex]?.render_url || state.previewUrl || "";
if (!audio || !nextUrl) return;
if (audio.getAttribute("src") !== nextUrl) {
audio.setAttribute("src", nextUrl);
audio.load();
}
const startSeconds = usingMergedExportAudio() ? exportTrackStartSeconds(nextIndex) : 0;
const playWhenReady = () => {
audio.currentTime = startSeconds;
void audio.play();
audio.removeEventListener("loadedmetadata", playWhenReady);
syncExportControls();
};
if (audio.readyState < 1) {
audio.addEventListener("loadedmetadata", playWhenReady);
} else {
audio.currentTime = startSeconds;
void audio.play();
}
syncExportControls();
}
function syncExportControls() {
const audio = exportAudioElement();
const playButton = document.querySelector("[data-export-play]");
if (playButton) {
playButton.textContent = audio && !audio.paused ? "❚❚" : "▶";
}
const fill = document.querySelector("[data-export-scrub-fill]");
const head = document.querySelector("[data-export-scrub-head]");
const elapsed = document.querySelector("[data-export-elapsed]");
const remaining = document.querySelector("[data-export-remaining]");
if (!audio || !fill || !head || !elapsed || !remaining) return;
const percent = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0;
fill.style.width = `${percent}%`;
head.style.left = `${percent}%`;
elapsed.textContent = `${formatStamp(audio.currentTime)} elapsed`;
remaining.textContent = audio.duration
? `−${formatStamp(Math.max(0, audio.duration - audio.currentTime))}`
: "−0:00:00";
if (usingMergedExportAudio()) {
const trackIndex = currentTrackIndexForTime(audio.currentTime);
if (trackIndex !== state.exportPlayerTrackIndex) {
state.exportPlayerTrackIndex = trackIndex;
}
}
}
function currentTrackIndexForTime(seconds) {
const tracks = playableTracks();
let elapsed = 0;
for (let index = 0; index < tracks.length; index += 1) {
elapsed += Number(tracks[index].duration_seconds || tracks[index].est_minutes * 60 || 0);
if (seconds < elapsed) return index;
}
return clampTrackIndex(tracks.length - 1, Math.max(1, tracks.length));
}
function exportFilename() {
const file = state.exportFile?.file || "";
if (file) {
const pieces = String(file).split("/");
return pieces[pieces.length - 1];
}
const base = slugify(state.book?.title || "scriptorium-audiobook");
return `${base}.${state.exportFormat}`;
}
function buildDownloadUrl(url) {
const downloadUrl = new URL(url, window.location.origin);
downloadUrl.searchParams.set("download", "1");
return downloadUrl.toString();
}
function downloadCurrentExport(exportFile = state.exportFile) {
if (!exportFile?.url) return;
document.getElementById("download-frame")?.remove();
const frame = document.createElement("iframe");
frame.id = "download-frame";
frame.hidden = true;
frame.src = buildDownloadUrl(exportFile.url);
document.body.appendChild(frame);
}
function canStartRender() {
if (!state.book) return false;
if (!selectedChapterIds().length) return false;
if (state.model !== "omnivoice") return true;
if (state.voiceMode === "clone" && (!state.cloneSampleFile || !state.cloneConsent)) return false;
if (state.voiceMode === "design" && !buildDesignPrompt()) return false;
return true;
}
function buildDesignPrompt() {
return [
state.designSelections.gender,
state.designSelections.age,
state.designSelections.pitch,
state.designSelections.style,
state.designSelections.accent,
]
.filter(Boolean)
.join(", ");
}
function activeNarratorName() {
if (state.model === "magpie") {
return `${state.magpieSpeaker} (${activeLanguageLabel()})`;
}
const narrator = NARRATORS.find((item) => item.id === state.narratorId);
return narrator?.name || "Custom narrator";
}
function chapterTitle(chapterId) {
const chapter = state.chapters.find((item) => item.id === chapterId);
return chapter?.title || chapterId || "chapter";
}
function syncNarratorMetadata() {
if (state.model === "magpie") {
state.exportMetadata.narrator = `${activeNarratorName()} (Magpie TTS)`;
return;
}
if (state.voiceMode === "clone") {
state.exportMetadata.narrator = "Cloned Narrator (OmniVoice)";
return;
}
if (state.voiceMode === "design") {
state.exportMetadata.narrator = "Designed Narrator (OmniVoice)";
return;
}
state.exportMetadata.narrator = `${activeNarratorName()} (OmniVoice)`;
}
function activeModel() {
return SYNTHESIS_MODELS.find((item) => item.id === state.model) || SYNTHESIS_MODELS[0];
}
function activeModelName() {
return activeModel()?.name || "Speech model";
}
function activeModelDescription() {
return activeModel()?.desc || "";
}
function activeBackend() {
return SYNTHESIS_BACKENDS.find((item) => item.id === state.backendSelection) || SYNTHESIS_BACKENDS[0];
}
function activeBackendDescription() {
return activeBackend()?.desc || "";
}
function activeLanguageLabel() {
const language = MAGPIE_OPTIONS.languages.find((item) => item.value === state.magpieLanguage);
return language?.label || state.magpieLanguage;
}
function currentRenderStackLabel() {
const modelName =
(SYNTHESIS_MODELS.find((item) => item.id === state.activeRenderModel)?.name || activeModelName());
const backendName =
(SYNTHESIS_BACKENDS.find((item) => item.id === state.activeRenderBackend)?.name || activeBackend()?.name || "HF/local");
return `${modelName} · ${backendName}`;
}
function renderSupportsPause() {
return state.activeRenderBackend !== "modal";
}
function excerpt(text, length, start = 0) {
if (!text) return "";
return text.slice(start, start + length).trim();
}
function humanBytes(size) {
if (!size) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let value = size;
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(index ? 1 : 0)} ${units[index]}`;
}
function slugify(value) {
return String(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "scriptorium-audiobook";
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function escapeAttr(value) {
return escapeHtml(value);
}
main();