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()}
${escapeHtml(state.book?.title || "Upload an EPUB to begin")}
${escapeHtml(state.book ? `by ${state.book.author}` : "A custom Gradio Space for audiobook binding")}
${escapeHtml(state.book?.meta || "Public Space · GPU-backed · session-only exports")}
${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" ? `
Diffusion steps ${state.diffusionSteps}
Reading speed ${state.speed.toFixed(1)}×
` : `
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 })}
Now binding · ${escapeHtml(currentRenderStackLabel())}
Narrating ${escapeHtml(activeChapter?.title || "your book")}
${doneCount} of ${included.length} chapters bound ${formatRuntime(totalMinutes())} target runtime
${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 })}
✓ Audiobook bound · ready to export
${escapeHtml(state.book?.title || "Audiobook ready")}
narrated by ${escapeHtml(activeNarratorName())}
${formatRuntime(totalSeconds / 60)}
runtime
${tracks.length}
chapters
.${state.exportFormat}
format
${state.exportFile ? humanBytes(state.exportFile.size_bytes) : "Pending"}
size
${state.exportFile ? `
Download exported file
` : `
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 ? `` : ""}
Export format
${["m4a", "mp3", "zip"].map((format) => ` `).join("")}
Audiobook metadata
${Object.entries(state.exportMetadata).map(([key, value]) => ` `).join("")}
`; } 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 `

Upload an EPUB to unlock chapter selection, narrator controls, preview, and render/export steps.

`; } 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();