mattkevan's picture
Fix exporter
186f4a4
Raw
History Blame Contribute Delete
56.3 kB
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 &amp; 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 &amp; 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 &amp; 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function escapeAttr(value) {
return escapeHtml(value);
}
main();