| 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 = ` |
| <div class="app-shell"> |
| <div class="sheet"> |
| <div class="page"> |
| ${renderMasthead()} |
| ${state.errorMessage ? `<div class="error-banner">${escapeHtml(state.errorMessage)}</div>` : ""} |
| ${state.statusMessage ? `<div class="status-banner">${escapeHtml(state.statusMessage)}</div>` : ""} |
| ${state.step === "configure" ? renderConfigure() : ""} |
| ${state.step === "generate" ? renderGenerate() : ""} |
| ${state.step === "export" ? renderExport() : ""} |
| </div> |
| </div> |
| </div> |
| `; |
| 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 ` |
| <div class="mast"> |
| <div class="brand"> |
| <div class="plaque">📖</div> |
| <div> |
| <div class="wordmark">Scriptorium</div> |
| <div class="tagline">Bind your library into spoken word · OmniVoice + Magpie TTS</div> |
| </div> |
| </div> |
| <div class="steps"> |
| ${["Upload & Configure", "Generate", "Export"].map((label, index) => { |
| const cls = index < activeIndex ? "done" : index === activeIndex ? "now" : ""; |
| const prefix = index < activeIndex ? "✓ " : ""; |
| return `${index ? `<span class="sep">—</span>` : ""}<span class="step ${cls}">${prefix}${label}</span>`; |
| }).join("")} |
| </div> |
| </div> |
| `; |
| } |
|
|
| function renderConfigure() { |
| const chapter = currentChapter(); |
| const included = includedChapters(); |
| const chapterCount = included.length; |
| const runtime = formatRuntime(totalMinutes()); |
| return ` |
| <div class="hero"> |
| ${renderCover()} |
| <div class="hero-meta"> |
| <div class="hero-title">${escapeHtml(state.book?.title || "Upload an EPUB to begin")}</div> |
| <div class="hero-subtitle">${escapeHtml(state.book ? `by ${state.book.author}` : "A custom Gradio Space for audiobook binding")}</div> |
| <div class="hero-kicker">${escapeHtml(state.book?.meta || "Public Space · GPU-backed · session-only exports")}</div> |
| <div style="margin-top:15px; display:flex; gap:10px; flex-wrap:wrap;"> |
| <button class="ghost-btn" data-action="chooseUpload">Change book</button> |
| <input id="epub-input" type="file" accept=".epub" class="hide" /> |
| </div> |
| </div> |
| <div class="hero-tally"> |
| <div class="hero-big">${chapterCount}<span>/${state.chapters.length || 0}</span></div> |
| <div class="smallcaps">chapters on the shelf</div> |
| <div class="hero-run">≈ <strong>${runtime}</strong> runtime</div> |
| </div> |
| </div> |
| |
| <div class="cols"> |
| <div> |
| <div class="panel-head"> |
| <h2 class="panel-title">Select chapters to bind</h2> |
| <span class="right">${chapterCount ? `${chapterCount} selected` : "Upload an EPUB"}</span> |
| </div> |
| <div class="queue"> |
| <div class="list-head"> |
| Include in audiobook |
| <span class="count">${chapterCount} of ${state.chapters.length}</span> |
| </div> |
| <div class="chapter-list"> |
| ${state.chapters.length ? state.chapters.map(renderChapterRow).join("") : renderUploadPlaceholder()} |
| </div> |
| </div> |
| </div> |
| |
| <div> |
| <div class="panel-head"> |
| <h2 class="panel-title">Now reading</h2> |
| </div> |
| <div class="reading-panel"> |
| <div class="reading-head"> |
| <div> |
| <h3>${escapeHtml(chapter?.title || "Waiting for an EPUB")}</h3> |
| <div class="chapter-meta">${chapter ? `${chapter.chars.toLocaleString()} chars · ≈ ${chapter.est_minutes} min · chapter ${chapter.n}` : "Upload a book to inspect chapters"}</div> |
| </div> |
| <div class="chips"> |
| <button class="chip" data-action="previewChapter" data-id="${chapter?.id || ""}" ${chapter ? "" : "disabled"}>Preview</button> |
| <button class="chip warn" data-action="focusChapter" data-id="${chapter?.id || ""}" ${chapter ? "" : "disabled"}>${chapter?.included === false ? "Skipped" : "Selected"}</button> |
| </div> |
| </div> |
| <div class="verse reading-scroll" data-reading-scroll data-chapter-id="${escapeAttr(chapter?.id || "")}">${renderChapterBody(chapter?.text || "")}</div> |
| ${state.previewUrl ? `<div style="margin-top: 16px;"><audio controls src="${state.previewUrl}"></audio></div>` : ""} |
| </div> |
| |
| <div class="voice-panel"> |
| <div class="smallcaps" style="margin-bottom:9px;">Speech model</div> |
| <div class="seg"> |
| ${SYNTHESIS_MODELS.map((model) => ` |
| <button type="button" data-action="setModel" data-model="${model.id}" class="${state.model === model.id ? "active" : ""}"> |
| ${escapeHtml(model.name)} |
| </button> |
| `).join("")} |
| </div> |
| <div class="chapter-meta" style="margin:10px 0 14px;">${escapeHtml(activeModelDescription())}</div> |
| <div class="smallcaps" style="margin-bottom:9px;">Execution backend</div> |
| <div class="seg"> |
| ${SYNTHESIS_BACKENDS.map((backend) => ` |
| <button type="button" data-action="setBackend" data-backend="${backend.id}" class="${state.backendSelection === backend.id ? "active" : ""}"> |
| ${escapeHtml(backend.name)} |
| </button> |
| `).join("")} |
| </div> |
| <div class="chapter-meta" style="margin:10px 0 14px;">${escapeHtml(activeBackendDescription())}</div> |
| ${state.model === "omnivoice" ? ` |
| <div class="seg"> |
| ${["auto", "clone", "design"].map((mode) => ` |
| <button type="button" data-action="setVoiceMode" data-mode="${mode}" class="${state.voiceMode === mode ? "active" : ""}"> |
| ${mode} |
| </button> |
| `).join("")} |
| </div> |
| ` : ""} |
| ${renderVoiceMode()} |
| ${state.model === "omnivoice" ? ` |
| <div class="dial-grid"> |
| <div> |
| <div class="dial-head"> |
| <span class="smallcaps">Diffusion steps</span> |
| <span class="dial-value">${state.diffusionSteps}</span> |
| </div> |
| <input id="diffusion-steps" class="range" type="range" min="8" max="64" value="${state.diffusionSteps}" /> |
| </div> |
| <div> |
| <div class="dial-head"> |
| <span class="smallcaps">Reading speed</span> |
| <span class="dial-value">${state.speed.toFixed(1)}×</span> |
| </div> |
| <input id="reading-speed" class="range" type="range" min="0.5" max="2" step="0.1" value="${state.speed}" /> |
| </div> |
| </div> |
| ` : ` |
| <div class="design-preview" style="margin-top:14px;"> |
| <div class="smallcaps" style="margin-bottom:7px;">Magpie controls</div> |
| <div class="field design-output">Speaker, language, and text normalization are controlled directly for Magpie. Speed and diffusion controls are OmniVoice-only.</div> |
| </div> |
| `} |
| </div> |
| </div> |
| </div> |
| |
| <div class="footer"> |
| <div class="footer-note">Binding <b>${chapterCount} chapters</b> · approx <b>${runtime}</b> · exports as <b>.m4a</b></div> |
| <div class="spacer"></div> |
| <button class="btn-ghost" data-action="previewChapter" data-id="${chapter?.id || ""}" ${chapter ? "" : "disabled"}>Hear a preview</button> |
| <button class="btn-primary" data-action="startRender" ${canStartRender() ? "" : "disabled"}>Generate full audiobook</button> |
| </div> |
| `; |
| } |
|
|
| 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 ` |
| <div class="hero"> |
| ${renderCover({ w: 92, h: 134 })} |
| <div class="hero-meta"> |
| <div class="smallcaps" style="display:flex; align-items:center; gap:9px; color:var(--leather);"><span class="status-dot"></span> Now binding · ${escapeHtml(currentRenderStackLabel())}</div> |
| <div class="screen-title" style="font-size:31px; font-weight:800; margin:6px 0 14px;"> |
| Narrating <em style="color:var(--leather); font-style:italic;">${escapeHtml(activeChapter?.title || "your book")}</em>… |
| </div> |
| <div class="progress-bar"><div class="progress-fill" style="width:${percent}%"></div></div> |
| <div class="stats-hero"> |
| <span><b>${doneCount} of ${included.length}</b> chapters bound</span> |
| <span>${formatRuntime(totalMinutes())} target runtime</span> |
| </div> |
| </div> |
| <div class="hero-stats"> |
| <div class="pct">${percent}<span style="font-size:24px;">%</span></div> |
| <div class="smallcaps">complete</div> |
| <div style="margin-top:10px; display:flex; gap:10px; justify-content:flex-end; flex-wrap:wrap;"> |
| ${renderSupportsPause() ? `<button class="small-btn" data-action="pauseRender">Pause</button>` : ""} |
| <button class="small-btn danger" data-action="cancelRender">Cancel</button> |
| </div> |
| </div> |
| </div> |
| |
| <div class="cols"> |
| <div> |
| <div class="panel-head"> |
| <h2 class="panel-title">Render queue</h2> |
| <span class="right">${doneCount}/${included.length} done</span> |
| </div> |
| <div class="queue"> |
| <div class="queue-head">Chapter · status <span class="count">${state.renderStatus?.position ? `queue ${state.renderStatus.position}` : ""}</span></div> |
| ${state.chapters.map((chapter) => renderQueueRow(chapter, currentId)).join("")} |
| </div> |
| </div> |
| <div> |
| <div class="panel-head"> |
| <h2 class="panel-title">Now narrating</h2> |
| </div> |
| <div class="player"> |
| <div class="reading-head" style="border-bottom:none; margin-bottom:8px;"> |
| <div> |
| <div class="now-title" style="font-size:23px; font-weight:700;">${escapeHtml(activeChapter?.title || "Waiting in queue")}</div> |
| <div class="chapter-meta">${activeChapter ? `chapter ${activeChapter.n} · ${activeChapter.chars.toLocaleString()} chars` : ""}</div> |
| </div> |
| <div class="pill">${escapeHtml(activeNarratorName())}</div> |
| </div> |
| <div class="wave">${new Array(25).fill(0).map((_, index) => `<i style="height:${40 + ((index * 13) % 55)}%; animation-delay:${index * 0.045}s"></i>`).join("")}</div> |
| <div class="spoken"> |
| <span class="lit">${escapeHtml(excerpt(activeChapter?.text || "", 120))}</span> |
| <span class="dim">${escapeHtml(excerpt(activeChapter?.text || "", 220, 120))}</span> |
| </div> |
| </div> |
| <div class="panel-head" style="margin-top:18px;"> |
| <h2 class="panel-title" style="font-size:19px;">Activity</h2> |
| </div> |
| <div class="log"> |
| ${state.renderEvents.slice(-8).reverse().map(renderLogRow).join("") || `<div class="log-row"><span class="time">—</span><span>Waiting for render events…</span></div>`} |
| </div> |
| <div class="panel-head" style="margin-top:18px;"> |
| <h2 class="panel-title" style="font-size:19px;">Completed chapters</h2> |
| </div> |
| <div class="queue"> |
| <div class="queue-head">Listen while rendering continues <span class="count">${completedChapters.length} ready</span></div> |
| ${completedChapters.length ? completedChapters.map(renderCompletedChapterRow).join("") : `<div class="log-row"><span class="time">—</span><span>No chapter audio ready yet.</span></div>`} |
| </div> |
| </div> |
| </div> |
| |
| <div class="footer"> |
| <div class="footer-note">Binding <b>${included.length} chapters</b> · <b>${doneCount} done</b> · Export unlocks when complete</div> |
| <div class="spacer"></div> |
| ${renderSupportsPause() ? `<button class="btn-ghost" data-action="pauseRender">Pause binding</button>` : ""} |
| <button class="btn-primary" data-action="goExport" ${state.renderResult ? "" : "disabled"}>Continue to export</button> |
| </div> |
| `; |
| } |
|
|
| 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 ` |
| <div class="hero"> |
| ${renderCover({ w: 96, h: 140 })} |
| <div class="hero-meta"> |
| <div class="smallcaps" style="color:var(--green); display:flex; align-items:center; gap:9px;">✓ Audiobook bound · ready to export</div> |
| <div class="hero-title" style="font-size:33px;">${escapeHtml(state.book?.title || "Audiobook ready")}</div> |
| <div class="hero-subtitle">narrated by ${escapeHtml(activeNarratorName())}</div> |
| <div class="spec-strip"> |
| <div class="spec"><div class="spec-value">${formatRuntime(totalSeconds / 60)}</div><div class="smallcaps">runtime</div></div> |
| <div class="spec"><div class="spec-value">${tracks.length}</div><div class="smallcaps">chapters</div></div> |
| <div class="spec"><div class="spec-value">.${state.exportFormat}</div><div class="smallcaps">format</div></div> |
| <div class="spec"><div class="spec-value">${state.exportFile ? humanBytes(state.exportFile.size_bytes) : "Pending"}</div><div class="smallcaps">size</div></div> |
| </div> |
| </div> |
| <div> |
| <button class="btn-primary" data-action="exportBook">${state.exportFile ? "Download again" : "Download audiobook"}</button> |
| ${state.exportFile ? `<div style="margin-top:10px;"><a href="${escapeAttr(buildDownloadUrl(state.exportFile.url))}" target="_blank" rel="noopener" download="${escapeAttr(exportFilename())}">Download exported file</a></div>` : `<div style="margin-top:10px; color:var(--faint); font-size:12.5px;">with embedded chapter markers</div>`} |
| </div> |
| </div> |
| |
| <div class="cols"> |
| <div> |
| <div class="panel-head"> |
| <h2 class="panel-title">Chapters & markers</h2> |
| <span class="right">${tracks.length} tracks</span> |
| </div> |
| <div class="queue"> |
| <div class="list-head">Chapter <span class="count">${formatRuntime(totalSeconds / 60)} total</span></div> |
| <div class="track-list"> |
| ${trackRows.map(({ track, start, playing }, index) => ` |
| <div class="track-row ${playing ? "playing" : ""}"> |
| <span class="roman">${escapeHtml(track.n)}</span> |
| <button class="circle-btn" data-action="playRenderedChapter" data-track-index="${index}">${playing ? "❚❚" : "▶"}</button> |
| <div class="track-main"> |
| <div class="track-name">${escapeHtml(track.title)}</div> |
| <div class="track-meta">starts at ${formatStamp(start)}</div> |
| </div> |
| <div>${formatStamp(Number(track.duration_seconds || track.est_minutes * 60 || 0))}</div> |
| </div> |
| `).join("")} |
| </div> |
| </div> |
| </div> |
| <div> |
| <div class="panel-head"> |
| <h2 class="panel-title">Preview & export</h2> |
| </div> |
| <div class="player"> |
| <div style="display:flex; gap:18px; align-items:center; margin-bottom:18px;"> |
| ${renderCover({ w: 70, h: 102 })} |
| <div> |
| <div class="smallcaps">Now playing · chapter ${currentTrack?.n || "i"}</div> |
| <div class="player-title" style="font-size:24px; font-weight:700;">${escapeHtml(currentTrack?.title || state.book?.title || "Preview")}</div> |
| <div class="chapter-meta">narrated by ${escapeHtml(activeNarratorName())} · 1.0×</div> |
| </div> |
| </div> |
| <div class="scrubber" data-export-scrubber> |
| <div class="scrub-fill" data-export-scrub-fill style="width:${percent}%"></div> |
| ${trackRows.map(({ start }, index) => index ? `<span class="scrub-tick" style="left:${(start / totalSeconds) * 100}%"></span>` : "").join("")} |
| <span class="scrub-head" data-export-scrub-head style="left:${percent}%"></span> |
| </div> |
| <div class="track-meta" style="display:flex; justify-content:space-between;"> <span data-export-elapsed>${formatStamp(playbackSeconds)} elapsed</span> <span data-export-remaining>−${formatRuntime((totalSeconds - playbackSeconds) / 60)}</span></div> |
| <div class="transport"> |
| <button class="transport-btn" data-action="exportJumpToTrack" data-direction="-1">⏮</button> |
| <button class="transport-btn" data-action="exportSeek" data-seconds="-15">↺15</button> |
| <button class="transport-btn main" data-action="playExportPreview" data-export-play>▶</button> |
| <button class="transport-btn" data-action="exportSeek" data-seconds="30">30↻</button> |
| <button class="transport-btn" data-action="exportJumpToTrack" data-direction="1">⏭</button> |
| </div> |
| ${exportUrl ? `<audio id="preview-audio" src="${escapeAttr(exportUrl)}" preload="metadata"></audio>` : ""} |
| </div> |
| <div class="options"> |
| <div class="smallcaps" style="margin-bottom:9px;">Export format</div> |
| <div class="format-grid" style="grid-template-columns:repeat(3, minmax(0, 1fr));"> |
| ${["m4a", "mp3", "zip"].map((format) => ` |
| <label class="format-card ${state.exportFormat === format ? "active" : ""}"> |
| <input type="radio" class="hide" name="format" data-format="${format}" ${state.exportFormat === format ? "checked" : ""} /> |
| <div class="spec-value" style="font-size:20px;">.${format}</div> |
| <div class="chapter-meta">${format === "zip" ? "one file per chapter" : "single file export"}</div> |
| </label> |
| `).join("")} |
| </div> |
| <div class="smallcaps" style="margin:18px 0 9px;">Audiobook metadata</div> |
| <div class="meta-grid"> |
| ${Object.entries(state.exportMetadata).map(([key, value]) => ` |
| <label> |
| <div class="chapter-meta" style="margin-bottom:5px; text-transform:uppercase;">${escapeHtml(key)}</div> |
| <input class="field" data-meta="${key}" value="${escapeAttr(value)}" /> |
| </label> |
| `).join("")} |
| </div> |
| <label style="display:flex; align-items:center; gap:11px; margin-top:16px;"> |
| <input id="embed-markers" type="checkbox" ${state.embedMarkers ? "checked" : ""} /> |
| Embed chapter markers & cover art into the file |
| </label> |
| </div> |
| </div> |
| </div> |
| |
| <div class="footer"> |
| <div class="footer-note">${escapeHtml(state.book?.title || "Audiobook")} · ${formatRuntime(totalSeconds / 60)} · ${tracks.length} chapters</div> |
| <div class="spacer"></div> |
| <button class="btn-ghost" data-action="goConfigure">Back to configure</button> |
| <button class="btn-primary" data-action="exportBook">${state.exportFile ? "Download audiobook" : "Prepare export"}</button> |
| </div> |
| `; |
| } |
|
|
| 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 ` |
| <div class="cover" style="${style}"> |
| <span class="cover-corner top"></span> |
| <span class="cover-corner bottom"></span> |
| <div class="cover-title" style="font-size:${18 * scale}px;">${escapeHtml(state.book?.title || "Scriptorium")}</div> |
| <div class="cover-rule" style="width:${38 * scale}px;"></div> |
| <div class="cover-author" style="font-size:${13 * scale}px;">${escapeHtml(state.book?.author || activeModelName())}</div> |
| </div> |
| `; |
| } |
|
|
| function renderUploadPlaceholder() { |
| return ` |
| <div style="padding: 22px 18px;"> |
| <p style="margin-top:0;">Upload an EPUB to unlock chapter selection, narrator controls, preview, and render/export steps.</p> |
| <button class="btn-primary" data-action="chooseUpload">Choose EPUB</button> |
| <input id="epub-input" type="file" accept=".epub" class="hide" /> |
| </div> |
| `; |
| } |
|
|
| function renderChapterRow(chapter) { |
| const active = chapter.id === state.currentChapterId ? "focus" : ""; |
| const off = chapter.included ? "" : "off"; |
| return ` |
| <label class="chapter-row ${active} ${off}"> |
| <input type="checkbox" data-chapter-toggle="${chapter.id}" ${chapter.included ? "checked" : ""} /> |
| <span class="roman">${escapeHtml(chapter.n)}</span> |
| <div class="chapter-main" data-action="focusChapter" data-id="${chapter.id}"> |
| <div class="chapter-name">${escapeHtml(chapter.title)}</div> |
| <div class="chapter-meta">${chapter.chars.toLocaleString()} chars · ≈${chapter.est_minutes} min ${chapter.included ? "" : "· skipped"}</div> |
| </div> |
| <button class="circle-btn" type="button" data-action="previewChapter" data-id="${chapter.id}">▶</button> |
| </label> |
| `; |
| } |
|
|
| function renderVersePreview(text) { |
| if (!text) { |
| return `<p>Choose a chapter to inspect the reading pane.</p>`; |
| } |
| const lines = excerpt(text, 420).split(/\n+/).slice(0, 10); |
| return `<span class="dropcap">${escapeHtml(lines[0]?.[0] || "W")}</span>${lines |
| .map((line, index) => { |
| const body = index === 0 ? line.slice(1) : line; |
| return `<p>${escapeHtml(body)}</p>`; |
| }) |
| .join("")}`; |
| } |
|
|
| function renderChapterBody(text) { |
| if (!text) { |
| return `<p>Choose a chapter to inspect the reading pane.</p>`; |
| } |
| const paragraphs = text |
| .split(/\n{2,}/) |
| .map((chunk) => chunk.trim()) |
| .filter(Boolean); |
| if (!paragraphs.length) { |
| return `<p>${escapeHtml(text)}</p>`; |
| } |
| const [first, ...rest] = paragraphs; |
| const firstLead = first[0] || "W"; |
| const firstBody = first.slice(1); |
| return ` |
| <p><span class="dropcap">${escapeHtml(firstLead)}</span>${escapeHtml(firstBody)}</p> |
| ${rest.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`).join("")} |
| `; |
| } |
|
|
| function renderVoiceMode() { |
| if (state.model === "magpie") { |
| return ` |
| <div class="design-grid"> |
| <label style="display:block;"> |
| <div class="smallcaps" style="margin-bottom:7px;">Speaker</div> |
| <select id="magpie-speaker" class="field"> |
| ${MAGPIE_OPTIONS.speakers.map((speaker) => ` |
| <option value="${escapeAttr(speaker.value)}" ${state.magpieSpeaker === speaker.value ? "selected" : ""}> |
| ${escapeHtml(speaker.label)} |
| </option> |
| `).join("")} |
| </select> |
| </label> |
| <label style="display:block;"> |
| <div class="smallcaps" style="margin-bottom:7px;">Language</div> |
| <select id="magpie-language" class="field"> |
| ${MAGPIE_OPTIONS.languages.map((language) => ` |
| <option value="${escapeAttr(language.value)}" ${state.magpieLanguage === language.value ? "selected" : ""}> |
| ${escapeHtml(language.label)} |
| </option> |
| `).join("")} |
| </select> |
| </label> |
| </div> |
| <label style="display:flex; gap:10px; align-items:flex-start; margin-top:14px;"> |
| <input id="magpie-tn" type="checkbox" ${state.magpieApplyTextNormalization ? "checked" : ""} /> |
| <span>Apply text normalization for numbers, abbreviations, and special characters when supported by the selected language.</span> |
| </label> |
| `; |
| } |
| if (state.voiceMode === "clone") { |
| return ` |
| <div class="drop-zone"> |
| <div class="panel-title" style="font-size:21px; margin-bottom:6px;">Record your own narrator</div> |
| <div class="chapter-meta" style="margin-bottom:14px;">Recommended: 3–10 seconds of clean speech.</div> |
| <input id="clone-audio" class="field" type="file" accept="audio/*" /> |
| <div class="chapter-meta" style="margin-top:10px;">${state.cloneSampleFile ? escapeHtml(state.cloneSampleFile.name) : "No sample selected yet."}</div> |
| </div> |
| <label style="display:block; margin-top:15px;"> |
| <div class="smallcaps" style="margin-bottom:7px;">Reference text spoken (optional)</div> |
| <input id="clone-ref-text" class="field" value="${escapeAttr(state.cloneReferenceText)}" placeholder="Type exactly what is said in the sample…" /> |
| </label> |
| <label style="display:flex; gap:10px; align-items:flex-start; margin-top:14px;"> |
| <input id="clone-consent" type="checkbox" ${state.cloneConsent ? "checked" : ""} /> |
| <span>${escapeHtml(VOICE_WARNING)}</span> |
| </label> |
| `; |
| } |
| if (state.voiceMode === "design") { |
| const prompt = buildDesignPrompt(); |
| return ` |
| <div class="smallcaps" style="margin-bottom:10px;">Build a supported OmniVoice design prompt</div> |
| <div class="design-grid"> |
| ${VOICE_DESIGN_OPTIONS.map((category) => ` |
| <label style="display:block;"> |
| <div class="smallcaps" style="margin-bottom:7px;">${escapeHtml(category.label)}</div> |
| <select class="field" data-design-field="${escapeAttr(category.key)}"> |
| <option value="">None</option> |
| ${category.options.map((option) => ` |
| <option value="${escapeAttr(option.value)}" ${state.designSelections[category.key] === option.value ? "selected" : ""}> |
| ${escapeHtml(option.label)} |
| </option> |
| `).join("")} |
| </select> |
| </label> |
| `).join("")} |
| </div> |
| <div class="design-preview"> |
| <div class="smallcaps" style="margin-bottom:7px;">Resulting prompt</div> |
| <div class="field design-output">${escapeHtml(prompt || "Choose one or more supported voice traits.")}</div> |
| <div class="chapter-meta" style="margin-top:8px;">Use English-only OmniVoice design tokens. Accent affects English speech only.</div> |
| </div> |
| `; |
| } |
| return ` |
| <div class="narrator-grid"> |
| ${NARRATORS.map((narrator) => ` |
| <button class="narrator-card ${state.narratorId === narrator.id ? "active" : ""}" data-action="pickNarrator" data-id="${narrator.id}"> |
| <div class="spec-value" style="font-size:17px;">${escapeHtml(narrator.name)}</div> |
| <div class="chapter-meta">${escapeHtml(narrator.desc)}</div> |
| </button> |
| `).join("")} |
| </div> |
| `; |
| } |
|
|
| 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 ` |
| <div class="queue-row ${status}"> |
| <div class="queue-status ${status}">${status === "done" ? "✓" : status === "rendering" ? "●" : status === "queued" ? "○" : "–"}</div> |
| <span class="roman">${escapeHtml(chapter.n)}</span> |
| <div class="queue-main"> |
| <div class="queue-name">${escapeHtml(chapter.title)}</div> |
| <div class="queue-meta">${escapeHtml(label)}</div> |
| ${rendering ? `<div class="mini-bar"><div class="mini-fill" style="width:68%"></div></div>` : ""} |
| </div> |
| <div>${skipped ? "—" : `≈${chapter.est_minutes}m`}</div> |
| </div> |
| `; |
| } |
|
|
| function renderLogRow(event) { |
| const stamp = new Date(event.received_at || Date.now()).toLocaleTimeString(); |
| return `<div class="log-row ${event.type === "chapter_started" ? "current" : ""}"><span class="time">${stamp}</span><span>${escapeHtml(logMessage(event))}</span></div>`; |
| } |
|
|
| 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 ` |
| <div class="completed-row"> |
| <div> |
| <div class="queue-name">${escapeHtml(chapter.title)}</div> |
| <div class="queue-meta">${escapeHtml(`chapter ${chapter.n} · ${formatStamp(Number(chapter.duration_seconds || 0))}`)}</div> |
| </div> |
| <audio controls preload="metadata" src="${chapter.render_url}"></audio> |
| </div> |
| `; |
| } |
|
|
| 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(); |
|
|