abogen
Advanced tools
| import { initReaderUI } from "./reader.js"; | ||
| import { initWizard } from "./wizard.js"; | ||
| const dashboardState = (window.AbogenDashboardState = window.AbogenDashboardState || { | ||
| boundKeydown: false, | ||
| boundBeforeUnload: false, | ||
| }); | ||
| const initDashboard = () => { | ||
| const uploadModal = | ||
| document.querySelector('[data-role="new-job-modal"]') || | ||
| document.querySelector('[data-role="upload-modal"]'); | ||
| const openModalButtons = document.querySelectorAll('[data-role="open-upload-modal"]'); | ||
| const scope = uploadModal || document; | ||
| const sourceFileInput = scope.querySelector('#source_file'); | ||
| const dropzone = document.querySelector('[data-role="upload-dropzone"]'); | ||
| const dropzoneFilename = document.querySelector('[data-role="upload-dropzone-filename"]'); | ||
| const parseJSONScript = (id) => { | ||
| const element = document.getElementById(id); | ||
| if (!element) return null; | ||
| try { | ||
| const raw = element.textContent || ""; | ||
| return raw ? JSON.parse(raw) : null; | ||
| } catch (error) { | ||
| console.warn(`Failed to parse JSON script: ${id}`, error); | ||
| return null; | ||
| } | ||
| }; | ||
| const profileSelect = scope.querySelector('[data-role="voice-profile"]'); | ||
| const voiceField = scope.querySelector('[data-role="voice-field"]'); | ||
| const voiceSelect = scope.querySelector('[data-role="voice-select"]'); | ||
| const formulaField = scope.querySelector('[data-role="formula-field"]'); | ||
| const formulaInput = scope.querySelector('[data-role="voice-formula"]'); | ||
| const languageSelect = uploadModal?.querySelector("#language") || document.getElementById("language"); | ||
| const speedInput = uploadModal?.querySelector('#speed') || document.getElementById('speed'); | ||
| const previewButton = scope.querySelector('[data-role="voice-preview-button"]'); | ||
| const previewStatus = scope.querySelector('[data-role="voice-preview-status"]'); | ||
| const previewAudio = scope.querySelector('[data-role="voice-preview-audio"]'); | ||
| const sampleVoiceTexts = parseJSONScript('voice-sample-texts') || {}; | ||
| const setDropzoneStatus = (message, state = "") => { | ||
| if (!dropzoneFilename) return; | ||
| if (!message) { | ||
| dropzoneFilename.hidden = true; | ||
| dropzoneFilename.textContent = ""; | ||
| dropzoneFilename.removeAttribute("data-state"); | ||
| return; | ||
| } | ||
| dropzoneFilename.hidden = false; | ||
| dropzoneFilename.textContent = message; | ||
| if (state) { | ||
| dropzoneFilename.dataset.state = state; | ||
| } else { | ||
| dropzoneFilename.removeAttribute("data-state"); | ||
| } | ||
| }; | ||
| const updateDropzoneFilename = () => { | ||
| if (!sourceFileInput) { | ||
| setDropzoneStatus(""); | ||
| return; | ||
| } | ||
| const file = sourceFileInput.files && sourceFileInput.files[0]; | ||
| if (file) { | ||
| setDropzoneStatus(`Selected: ${file.name}`); | ||
| } else { | ||
| setDropzoneStatus(""); | ||
| } | ||
| }; | ||
| const assignDroppedFile = (file) => { | ||
| if (!sourceFileInput || !file) { | ||
| return false; | ||
| } | ||
| try { | ||
| if (typeof DataTransfer === "undefined") { | ||
| throw new Error("DataTransfer API unavailable"); | ||
| } | ||
| const transfer = new DataTransfer(); | ||
| transfer.items.add(file); | ||
| sourceFileInput.files = transfer.files; | ||
| sourceFileInput.dispatchEvent(new Event("change", { bubbles: true })); | ||
| try { | ||
| sourceFileInput.focus({ preventScroll: true }); | ||
| } catch (error) { | ||
| // Ignore focus errors | ||
| } | ||
| return true; | ||
| } catch (error) { | ||
| console.warn("Unable to assign dropped file to input", error); | ||
| setDropzoneStatus("Drag & drop isn't supported here. Click to choose a file instead.", "error"); | ||
| return false; | ||
| } | ||
| }; | ||
| const setDropzoneActive = (isActive) => { | ||
| if (!dropzone) return; | ||
| dropzone.classList.toggle("is-dragging", isActive); | ||
| if (isActive) { | ||
| dropzone.dataset.state = "drag"; | ||
| } else { | ||
| delete dropzone.dataset.state; | ||
| } | ||
| }; | ||
| let lastTrigger = null; | ||
| let previewAbortController = null; | ||
| let previewObjectUrl = null; | ||
| let suppressPauseStatus = false; | ||
| const dispatchUploadModalEvent = (type, detail = {}) => { | ||
| const eventName = `upload-modal:${type}`; | ||
| if (uploadModal) { | ||
| uploadModal.dispatchEvent(new CustomEvent(eventName, { detail, bubbles: true })); | ||
| return; | ||
| } | ||
| document.dispatchEvent(new CustomEvent(eventName, { detail })); | ||
| }; | ||
| const openUploadModal = (trigger) => { | ||
| if (!uploadModal) return; | ||
| lastTrigger = trigger || null; | ||
| uploadModal.hidden = false; | ||
| uploadModal.dataset.open = "true"; | ||
| document.body.classList.add("modal-open"); | ||
| const focusTarget = uploadModal.querySelector("#source_file") || uploadModal.querySelector("#source_text") || uploadModal; | ||
| if (focusTarget instanceof HTMLElement) { | ||
| focusTarget.focus({ preventScroll: true }); | ||
| } | ||
| dispatchUploadModalEvent("open", { trigger: lastTrigger }); | ||
| }; | ||
| const closeUploadModal = () => { | ||
| if (!uploadModal || uploadModal.hidden) { | ||
| return; | ||
| } | ||
| uploadModal.hidden = true; | ||
| delete uploadModal.dataset.open; | ||
| document.body.classList.remove("modal-open"); | ||
| if (lastTrigger && lastTrigger instanceof HTMLElement) { | ||
| lastTrigger.focus({ preventScroll: true }); | ||
| } | ||
| dispatchUploadModalEvent("close", { trigger: lastTrigger }); | ||
| }; | ||
| openModalButtons.forEach((button) => { | ||
| if (!button || button.dataset.dashboardBound === "true") { | ||
| return; | ||
| } | ||
| button.dataset.dashboardBound = "true"; | ||
| button.addEventListener("click", (event) => { | ||
| event.preventDefault(); | ||
| openUploadModal(button); | ||
| }); | ||
| }); | ||
| if (uploadModal && uploadModal.dataset.dashboardCloseBound !== "true") { | ||
| uploadModal.dataset.dashboardCloseBound = "true"; | ||
| uploadModal.addEventListener("click", (event) => { | ||
| const target = event.target; | ||
| if ( | ||
| target instanceof Element && | ||
| (target.closest('[data-role="new-job-modal-close"]') || | ||
| target.closest('[data-role="upload-modal-close"]') || | ||
| target.closest('[data-role="wizard-close"]') || | ||
| target.closest('[data-role="wizard-cancel"]')) | ||
| ) { | ||
| event.preventDefault(); | ||
| closeUploadModal(); | ||
| } | ||
| }); | ||
| } | ||
| if (!dashboardState.boundKeydown) { | ||
| dashboardState.boundKeydown = true; | ||
| document.addEventListener("keydown", (event) => { | ||
| if (event.key === "Escape") { | ||
| if (uploadModal && !uploadModal.hidden) { | ||
| closeUploadModal(); | ||
| return; | ||
| } | ||
| } | ||
| }); | ||
| } | ||
| initReaderUI({ onBeforeOpen: closeUploadModal }); | ||
| if (sourceFileInput) { | ||
| if (sourceFileInput.dataset.dashboardChangeBound !== "true") { | ||
| sourceFileInput.dataset.dashboardChangeBound = "true"; | ||
| sourceFileInput.addEventListener("change", updateDropzoneFilename); | ||
| } | ||
| updateDropzoneFilename(); | ||
| } else { | ||
| setDropzoneStatus(""); | ||
| } | ||
| const resolveSampleText = (language) => { | ||
| const fallback = typeof sampleVoiceTexts === "object" && sampleVoiceTexts?.a | ||
| ? sampleVoiceTexts.a | ||
| : "This is a sample of the selected voice."; | ||
| if (!language || typeof sampleVoiceTexts !== "object" || !sampleVoiceTexts) { | ||
| return fallback; | ||
| } | ||
| const normalizedKey = language.toLowerCase(); | ||
| if (typeof sampleVoiceTexts[normalizedKey] === "string" && sampleVoiceTexts[normalizedKey].trim()) { | ||
| return sampleVoiceTexts[normalizedKey]; | ||
| } | ||
| const baseKey = normalizedKey.split(/[_.-]/)[0]; | ||
| if (baseKey && typeof sampleVoiceTexts[baseKey] === "string" && sampleVoiceTexts[baseKey].trim()) { | ||
| return sampleVoiceTexts[baseKey]; | ||
| } | ||
| return fallback; | ||
| }; | ||
| const getSelectedLanguage = () => { | ||
| const value = languageSelect?.value || "a"; | ||
| return (value || "a").trim() || "a"; | ||
| }; | ||
| const getSelectedSpeed = () => { | ||
| const raw = speedInput?.value || "1"; | ||
| const parsed = Number.parseFloat(raw); | ||
| return Number.isFinite(parsed) ? parsed : 1; | ||
| }; | ||
| const cancelPreviewRequest = () => { | ||
| if (!previewAbortController) return; | ||
| previewAbortController.abort(); | ||
| previewAbortController = null; | ||
| }; | ||
| const stopPreviewAudio = () => { | ||
| if (previewAudio) { | ||
| suppressPauseStatus = true; | ||
| try { | ||
| previewAudio.pause(); | ||
| } catch (error) { | ||
| // Ignore pause errors | ||
| } | ||
| previewAudio.removeAttribute("src"); | ||
| previewAudio.load(); | ||
| previewAudio.hidden = true; | ||
| suppressPauseStatus = false; | ||
| } | ||
| if (previewObjectUrl) { | ||
| URL.revokeObjectURL(previewObjectUrl); | ||
| previewObjectUrl = null; | ||
| } | ||
| }; | ||
| const setPreviewStatus = (message, state = "") => { | ||
| if (!previewStatus) return; | ||
| if (!message) { | ||
| previewStatus.textContent = ""; | ||
| previewStatus.hidden = true; | ||
| previewStatus.removeAttribute("data-state"); | ||
| return; | ||
| } | ||
| previewStatus.textContent = message; | ||
| previewStatus.hidden = false; | ||
| if (state) { | ||
| previewStatus.dataset.state = state; | ||
| } else { | ||
| previewStatus.removeAttribute("data-state"); | ||
| } | ||
| }; | ||
| const setPreviewLoading = (isLoading) => { | ||
| if (!previewButton) return; | ||
| previewButton.disabled = isLoading; | ||
| if (isLoading) { | ||
| previewButton.dataset.loading = "true"; | ||
| } else { | ||
| previewButton.removeAttribute("data-loading"); | ||
| } | ||
| }; | ||
| const buildPreviewRequest = () => { | ||
| const language = getSelectedLanguage(); | ||
| const speed = getSelectedSpeed(); | ||
| const basePayload = { | ||
| language, | ||
| speed, | ||
| max_seconds: 8, | ||
| text: resolveSampleText(language), | ||
| }; | ||
| const profileValue = profileSelect?.value || "__standard"; | ||
| if (profileValue && profileValue !== "__standard") { | ||
| if (profileValue === "__formula") { | ||
| const formulaValue = (formulaInput?.value || "").trim(); | ||
| if (!formulaValue) { | ||
| return { error: "Enter a custom voice formula to preview." }; | ||
| } | ||
| return { | ||
| endpoint: "/api/voice-profiles/preview", | ||
| payload: { ...basePayload, formula: formulaValue }, | ||
| }; | ||
| } | ||
| return { | ||
| endpoint: "/api/voice-profiles/preview", | ||
| payload: { ...basePayload, profile: profileValue }, | ||
| }; | ||
| } | ||
| const selectedVoice = (voiceSelect?.value || voiceSelect?.dataset.default || "").trim(); | ||
| if (!selectedVoice) { | ||
| return { error: "Select a narrator voice to preview." }; | ||
| } | ||
| return { | ||
| endpoint: "/api/speaker-preview", | ||
| payload: { ...basePayload, voice: selectedVoice }, | ||
| }; | ||
| }; | ||
| const resetPreview = () => { | ||
| cancelPreviewRequest(); | ||
| stopPreviewAudio(); | ||
| setPreviewStatus("", ""); | ||
| }; | ||
| if (previewAudio) { | ||
| previewAudio.addEventListener("ended", () => { | ||
| setPreviewStatus("Preview finished", "info"); | ||
| }); | ||
| previewAudio.addEventListener("pause", () => { | ||
| if (suppressPauseStatus || previewAudio.ended || previewAudio.currentTime === 0) { | ||
| return; | ||
| } | ||
| setPreviewStatus("Preview paused", "info"); | ||
| }); | ||
| } | ||
| const handleVoicePreview = async () => { | ||
| if (!previewButton) return; | ||
| const request = buildPreviewRequest(); | ||
| if (!request) { | ||
| return; | ||
| } | ||
| if (request.error) { | ||
| setPreviewStatus(request.error, "error"); | ||
| cancelPreviewRequest(); | ||
| stopPreviewAudio(); | ||
| return; | ||
| } | ||
| cancelPreviewRequest(); | ||
| stopPreviewAudio(); | ||
| previewAbortController = new AbortController(); | ||
| setPreviewLoading(true); | ||
| setPreviewStatus("Generating preview…", "loading"); | ||
| try { | ||
| const response = await fetch(request.endpoint, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify(request.payload), | ||
| signal: previewAbortController.signal, | ||
| }); | ||
| if (!response.ok) { | ||
| const message = await response.text(); | ||
| throw new Error(message || `Preview failed (status ${response.status})`); | ||
| } | ||
| const blob = await response.blob(); | ||
| previewObjectUrl = URL.createObjectURL(blob); | ||
| if (previewAudio) { | ||
| previewAudio.src = previewObjectUrl; | ||
| previewAudio.hidden = false; | ||
| try { | ||
| await previewAudio.play(); | ||
| setPreviewStatus("Preview playing", "success"); | ||
| } catch (error) { | ||
| setPreviewStatus("Preview ready. Press play to listen.", "success"); | ||
| } | ||
| } else { | ||
| setPreviewStatus("Preview ready.", "success"); | ||
| } | ||
| } catch (error) { | ||
| if (error.name === "AbortError") { | ||
| return; | ||
| } | ||
| console.error("Voice preview failed", error); | ||
| setPreviewStatus(error.message || "Preview failed", "error"); | ||
| stopPreviewAudio(); | ||
| } finally { | ||
| setPreviewLoading(false); | ||
| } | ||
| }; | ||
| if (previewButton && previewButton.dataset.dashboardBound !== "true") { | ||
| previewButton.dataset.dashboardBound = "true"; | ||
| previewButton.addEventListener("click", (event) => { | ||
| event.preventDefault(); | ||
| handleVoicePreview(); | ||
| }); | ||
| } | ||
| if (dropzone && dropzone.dataset.dashboardDragBound !== "true") { | ||
| dropzone.dataset.dashboardDragBound = "true"; | ||
| let dragDepth = 0; | ||
| dropzone.addEventListener("dragenter", (event) => { | ||
| event.preventDefault(); | ||
| dragDepth += 1; | ||
| setDropzoneActive(true); | ||
| }); | ||
| dropzone.addEventListener("dragover", (event) => { | ||
| event.preventDefault(); | ||
| if (event.dataTransfer) { | ||
| event.dataTransfer.dropEffect = "copy"; | ||
| } | ||
| }); | ||
| const handleDragLeave = (event) => { | ||
| if (event && dropzone.contains(event.relatedTarget)) { | ||
| return; | ||
| } | ||
| dragDepth = Math.max(0, dragDepth - 1); | ||
| if (dragDepth === 0) { | ||
| setDropzoneActive(false); | ||
| } | ||
| }; | ||
| dropzone.addEventListener("dragleave", (event) => { | ||
| handleDragLeave(event); | ||
| }); | ||
| dropzone.addEventListener("dragend", () => { | ||
| dragDepth = 0; | ||
| setDropzoneActive(false); | ||
| }); | ||
| dropzone.addEventListener("drop", (event) => { | ||
| event.preventDefault(); | ||
| dragDepth = 0; | ||
| setDropzoneActive(false); | ||
| const files = event.dataTransfer && event.dataTransfer.files; | ||
| if (!files || !files.length) { | ||
| return; | ||
| } | ||
| openUploadModal(dropzone); | ||
| assignDroppedFile(files[0]); | ||
| }); | ||
| dropzone.addEventListener("click", (event) => { | ||
| if (event.target.closest('[data-role="open-upload-modal"]')) { | ||
| return; | ||
| } | ||
| openUploadModal(dropzone); | ||
| }); | ||
| dropzone.addEventListener("keydown", (event) => { | ||
| if (event.key === "Enter" || event.key === " ") { | ||
| event.preventDefault(); | ||
| openUploadModal(dropzone); | ||
| } | ||
| }); | ||
| } | ||
| [voiceSelect, profileSelect, formulaInput, languageSelect, speedInput].forEach((input) => { | ||
| if (!input) return; | ||
| const eventName = input === formulaInput ? "input" : "change"; | ||
| input.addEventListener(eventName, () => { | ||
| resetPreview(); | ||
| }); | ||
| }); | ||
| const hydrateDefaultVoice = () => { | ||
| if (!voiceSelect) return; | ||
| const defaultVoice = voiceSelect.dataset.default; | ||
| if (!defaultVoice) return; | ||
| const option = voiceSelect.querySelector(`option[value="${defaultVoice}"]`); | ||
| if (option) { | ||
| voiceSelect.value = defaultVoice; | ||
| } | ||
| }; | ||
| const applySavedProfile = (option) => { | ||
| if (!option) return; | ||
| const presetFormula = option.dataset.formula || ""; | ||
| const profileLang = option.dataset.language || ""; | ||
| if (formulaInput) { | ||
| formulaInput.value = presetFormula; | ||
| formulaInput.readOnly = true; | ||
| formulaInput.dataset.state = "locked"; | ||
| } | ||
| if (profileLang && languageSelect) { | ||
| languageSelect.value = profileLang; | ||
| } | ||
| }; | ||
| const updateVoiceControls = () => { | ||
| if (!profileSelect) { | ||
| return; | ||
| } | ||
| const value = profileSelect.value || "__standard"; | ||
| const isStandard = value === "__standard"; | ||
| const isFormula = value === "__formula"; | ||
| const isSavedProfile = !isStandard && !isFormula; | ||
| const showVoiceField = isStandard; | ||
| if (voiceField) { | ||
| voiceField.hidden = !showVoiceField; | ||
| voiceField.setAttribute("aria-hidden", showVoiceField ? "false" : "true"); | ||
| voiceField.dataset.state = showVoiceField ? "visible" : "hidden"; | ||
| } | ||
| if (voiceSelect) { | ||
| voiceSelect.disabled = !isStandard; | ||
| voiceSelect.dataset.state = isStandard ? "editable" : "locked"; | ||
| if (isStandard) { | ||
| hydrateDefaultVoice(); | ||
| } | ||
| } | ||
| if (isSavedProfile) { | ||
| applySavedProfile(profileSelect.selectedOptions[0] || null); | ||
| } else if (!isFormula && formulaInput) { | ||
| formulaInput.value = ""; | ||
| } | ||
| const showFormulaField = isFormula; | ||
| if (formulaField) { | ||
| const shouldShow = showFormulaField; | ||
| formulaField.hidden = !shouldShow; | ||
| formulaField.setAttribute("aria-hidden", shouldShow ? "false" : "true"); | ||
| formulaField.dataset.state = shouldShow ? "visible" : "hidden"; | ||
| } | ||
| if (formulaInput) { | ||
| if (isFormula) { | ||
| formulaInput.disabled = false; | ||
| formulaInput.readOnly = false; | ||
| formulaInput.dataset.state = "editable"; | ||
| } else if (isSavedProfile) { | ||
| formulaInput.disabled = false; | ||
| formulaInput.readOnly = true; | ||
| formulaInput.dataset.state = "locked"; | ||
| } else { | ||
| formulaInput.disabled = true; | ||
| formulaInput.readOnly = true; | ||
| formulaInput.value = ""; | ||
| formulaInput.dataset.state = "editable"; | ||
| } | ||
| } | ||
| }; | ||
| if (profileSelect) { | ||
| if (profileSelect.dataset.dashboardBound !== "true") { | ||
| profileSelect.dataset.dashboardBound = "true"; | ||
| profileSelect.addEventListener("change", updateVoiceControls); | ||
| } | ||
| updateVoiceControls(); | ||
| } else { | ||
| hydrateDefaultVoice(); | ||
| } | ||
| if (!dashboardState.boundBeforeUnload) { | ||
| dashboardState.boundBeforeUnload = true; | ||
| window.addEventListener("beforeunload", () => { | ||
| cancelPreviewRequest(); | ||
| stopPreviewAudio(); | ||
| }); | ||
| } | ||
| }; | ||
| window.AbogenDashboard = window.AbogenDashboard || {}; | ||
| window.AbogenDashboard.init = initDashboard; | ||
| const bootDashboard = () => { | ||
| initDashboard(); | ||
| initWizard(); | ||
| }; | ||
| if (document.readyState === "loading") { | ||
| document.addEventListener("DOMContentLoaded", bootDashboard, { once: true }); | ||
| } else { | ||
| bootDashboard(); | ||
| } |
| (function () { | ||
| const root = document.querySelector('[data-override-root]'); | ||
| if (!root) { | ||
| return; | ||
| } | ||
| const previewUrl = root.dataset.previewUrl || ""; | ||
| const defaultLanguage = root.dataset.language || "a"; | ||
| const table = root.querySelector('[data-role="override-table"]'); | ||
| const rows = table ? Array.from(table.querySelectorAll('[data-role="override-row"]')) : []; | ||
| const filterInput = root.querySelector('[data-role="override-filter"]'); | ||
| const filterClearButton = root.querySelector('[data-role="override-filter-clear"]'); | ||
| const filterEmptyMessage = root.querySelector('[data-role="filter-empty"]'); | ||
| function base64ToBlob(base64, mimeType) { | ||
| const binary = atob(base64); | ||
| const length = binary.length; | ||
| const bytes = new Uint8Array(length); | ||
| for (let index = 0; index < length; index += 1) { | ||
| bytes[index] = binary.charCodeAt(index); | ||
| } | ||
| return new Blob([bytes], { type: mimeType }); | ||
| } | ||
| function getControl(form, selector) { | ||
| if (!form) { | ||
| return null; | ||
| } | ||
| const direct = form.querySelector(selector); | ||
| if (direct) { | ||
| return direct; | ||
| } | ||
| if (!form.id) { | ||
| return null; | ||
| } | ||
| return root.querySelector(`${selector}[form="${form.id}"]`) || document.querySelector(`${selector}[form="${form.id}"]`); | ||
| } | ||
| function resetPreview(container) { | ||
| if (!container) { | ||
| return; | ||
| } | ||
| const messageEl = container.querySelector('[data-role="preview-message"]'); | ||
| const audioEl = container.querySelector('[data-role="preview-audio"]'); | ||
| if (messageEl) { | ||
| messageEl.textContent = ""; | ||
| messageEl.removeAttribute('data-state'); | ||
| } | ||
| if (audioEl) { | ||
| const priorUrl = audioEl.dataset.objectUrl; | ||
| if (priorUrl) { | ||
| URL.revokeObjectURL(priorUrl); | ||
| delete audioEl.dataset.objectUrl; | ||
| } | ||
| audioEl.pause(); | ||
| audioEl.removeAttribute('src'); | ||
| audioEl.hidden = true; | ||
| } | ||
| } | ||
| function buildPreviewPayload(form) { | ||
| if (!form) { | ||
| return null; | ||
| } | ||
| const tokenInput = getControl(form, 'input[name="token"]'); | ||
| const pronunciationInput = getControl(form, 'input[name="pronunciation"]'); | ||
| const voiceSelect = getControl(form, 'select[name="voice"]'); | ||
| const languageInput = getControl(form, 'input[name="lang"]'); | ||
| const token = tokenInput && 'value' in tokenInput ? tokenInput.value.trim() : ""; | ||
| const pronunciation = pronunciationInput && 'value' in pronunciationInput ? pronunciationInput.value.trim() : ""; | ||
| const voice = voiceSelect && 'value' in voiceSelect ? voiceSelect.value.trim() : ""; | ||
| const language = languageInput && 'value' in languageInput ? languageInput.value.trim() : defaultLanguage; | ||
| if (!token && !pronunciation) { | ||
| return null; | ||
| } | ||
| return { | ||
| token, | ||
| pronunciation, | ||
| voice, | ||
| language, | ||
| }; | ||
| } | ||
| async function requestPreview(button) { | ||
| if (!previewUrl) { | ||
| return; | ||
| } | ||
| const formId = button.dataset.formId || ""; | ||
| const form = formId ? document.getElementById(formId) : button.closest('form'); | ||
| const container = button.closest('[data-role="preview-container"]'); | ||
| const messageEl = container ? container.querySelector('[data-role="preview-message"]') : null; | ||
| const audioEl = container ? container.querySelector('[data-role="preview-audio"]') : null; | ||
| resetPreview(container); | ||
| const payload = buildPreviewPayload(form); | ||
| if (!payload) { | ||
| if (messageEl) { | ||
| messageEl.textContent = "Enter a token or pronunciation first."; | ||
| messageEl.dataset.state = "error"; | ||
| } | ||
| return; | ||
| } | ||
| button.disabled = true; | ||
| button.setAttribute('data-loading', 'true'); | ||
| try { | ||
| const response = await fetch(previewUrl, { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| body: JSON.stringify(payload), | ||
| }); | ||
| const contentType = response.headers.get('Content-Type') || ''; | ||
| let data = null; | ||
| if (contentType.includes('application/json')) { | ||
| try { | ||
| data = await response.json(); | ||
| } catch (parseError) { | ||
| if (!response.ok) { | ||
| throw new Error('Preview failed.'); | ||
| } | ||
| throw parseError instanceof Error ? parseError : new Error('Preview failed.'); | ||
| } | ||
| } else { | ||
| if (!response.ok) { | ||
| const fallback = await response.text().catch(() => ''); | ||
| throw new Error(fallback || 'Preview failed.'); | ||
| } | ||
| throw new Error('Preview failed.'); | ||
| } | ||
| if (!response.ok || (data && data.error)) { | ||
| throw new Error((data && data.error) || 'Preview failed.'); | ||
| } | ||
| if (!data || typeof data !== 'object') { | ||
| throw new Error('Preview failed.'); | ||
| } | ||
| if (!data.audio_base64) { | ||
| throw new Error('Preview did not return audio.'); | ||
| } | ||
| if (audioEl) { | ||
| const blob = base64ToBlob(data.audio_base64, 'audio/wav'); | ||
| const objectUrl = URL.createObjectURL(blob); | ||
| audioEl.src = objectUrl; | ||
| audioEl.dataset.objectUrl = objectUrl; | ||
| audioEl.hidden = false; | ||
| audioEl.load(); | ||
| audioEl.play().catch(() => { | ||
| /* playback might require user interaction; ignore */ | ||
| }); | ||
| } | ||
| if (messageEl) { | ||
| messageEl.textContent = data.normalized_text || data.text || 'Preview ready.'; | ||
| messageEl.dataset.state = "success"; | ||
| } | ||
| } catch (error) { | ||
| if (messageEl) { | ||
| messageEl.textContent = error instanceof Error ? error.message : 'Preview failed.'; | ||
| messageEl.dataset.state = "error"; | ||
| } | ||
| } finally { | ||
| button.disabled = false; | ||
| button.removeAttribute('data-loading'); | ||
| } | ||
| } | ||
| function attachPreviewHandlers() { | ||
| const previewButtons = root.querySelectorAll('[data-role="preview-button"]'); | ||
| previewButtons.forEach((button) => { | ||
| button.addEventListener('click', () => { | ||
| requestPreview(button); | ||
| }); | ||
| }); | ||
| } | ||
| function applyFilter() { | ||
| if (!filterInput || rows.length === 0) { | ||
| return; | ||
| } | ||
| const term = filterInput.value.trim().toLowerCase(); | ||
| let visibleCount = 0; | ||
| rows.forEach((row) => { | ||
| const token = row.dataset.token || ""; | ||
| const pronunciationInput = row.querySelector('input[name="pronunciation"]'); | ||
| const voiceSelect = row.querySelector('select[name="voice"]'); | ||
| const pronunciationValue = pronunciationInput && 'value' in pronunciationInput | ||
| ? pronunciationInput.value.trim().toLowerCase() | ||
| : ""; | ||
| const voiceOption = voiceSelect && 'selectedIndex' in voiceSelect && voiceSelect.selectedIndex >= 0 | ||
| ? voiceSelect.options[voiceSelect.selectedIndex] | ||
| : null; | ||
| const voiceValue = voiceOption && voiceOption.textContent | ||
| ? voiceOption.textContent.trim().toLowerCase() | ||
| : ""; | ||
| if (!term || token.includes(term) || pronunciationValue.includes(term) || voiceValue.includes(term)) { | ||
| row.hidden = false; | ||
| visibleCount += 1; | ||
| } else { | ||
| row.hidden = true; | ||
| } | ||
| }); | ||
| if (filterEmptyMessage) { | ||
| filterEmptyMessage.hidden = visibleCount !== 0; | ||
| } | ||
| } | ||
| if (filterInput) { | ||
| filterInput.addEventListener('input', applyFilter); | ||
| } | ||
| if (filterClearButton && filterInput) { | ||
| filterClearButton.addEventListener('click', () => { | ||
| filterInput.value = ""; | ||
| applyFilter(); | ||
| filterInput.focus(); | ||
| }); | ||
| } | ||
| if (table) { | ||
| table.addEventListener('input', (event) => { | ||
| const target = event.target; | ||
| if (target && (target.matches('input[name="pronunciation"]') || target.matches('select[name="voice"]'))) { | ||
| applyFilter(); | ||
| } | ||
| }); | ||
| table.addEventListener('change', (event) => { | ||
| const target = event.target; | ||
| if (target && target.matches('select[name="voice"]')) { | ||
| applyFilter(); | ||
| } | ||
| }); | ||
| } | ||
| attachPreviewHandlers(); | ||
| applyFilter(); | ||
| })(); |
| const modal = document.querySelector('[data-role="opds-modal"]'); | ||
| const browser = modal?.querySelector('[data-role="opds-browser"]') || null; | ||
| if (modal && browser) { | ||
| const statusEl = browser.querySelector('[data-role="opds-status"]'); | ||
| const resultsEl = browser.querySelector('[data-role="opds-results"]'); | ||
| const navEl = browser.querySelector('[data-role="opds-nav"]'); | ||
| const navBottomEl = browser.querySelector('[data-role="opds-nav-bottom"]'); | ||
| const alphaPickerEl = browser.querySelector('[data-role="opds-alpha-picker"]'); | ||
| const tabsEl = modal.querySelector('[data-role="opds-tabs"]'); | ||
| const searchForm = modal.querySelector('[data-role="opds-search"]'); | ||
| const searchInput = searchForm?.querySelector('input[name="q"]'); | ||
| const refreshButton = searchForm?.querySelector('[data-action="opds-refresh"]'); | ||
| const openButtons = document.querySelectorAll('[data-action="open-opds-modal"]'); | ||
| const closeTargets = modal.querySelectorAll('[data-role="opds-modal-close"]'); | ||
| const TabIds = { | ||
| ROOT: 'root', | ||
| SEARCH: 'search', | ||
| CUSTOM: 'custom', | ||
| }; | ||
| const EntryTypes = { | ||
| BOOK: 'book', | ||
| NAVIGATION: 'navigation', | ||
| OTHER: 'other', | ||
| }; | ||
| const LETTER_ALL = 'ALL'; | ||
| const LETTER_NUMERIC = '#'; | ||
| const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); | ||
| const state = { | ||
| query: '', | ||
| currentHref: '', | ||
| activeTab: TabIds.ROOT, | ||
| tabs: [], | ||
| tabsReady: false, | ||
| requestToken: 0, | ||
| feedTitle: '', | ||
| lastEntries: [], | ||
| activeLetter: LETTER_ALL, | ||
| availableLetters: new Set(), | ||
| totalStats: null, | ||
| filteredStats: null, | ||
| status: { message: '', level: null }, | ||
| baseStatus: null, | ||
| lastContextKey: '', | ||
| currentLinks: {}, | ||
| alphabetBaseHref: '', | ||
| }; | ||
| let isOpen = false; | ||
| let lastTrigger = null; | ||
| const truncate = (text, limit = 160) => { | ||
| if (!text || typeof text !== 'string') { | ||
| return ''; | ||
| } | ||
| if (text.length <= limit) { | ||
| return text; | ||
| } | ||
| return `${text.slice(0, limit - 1).trim()}…`; | ||
| }; | ||
| const formatAuthors = (authors) => { | ||
| if (!Array.isArray(authors) || !authors.length) { | ||
| return ''; | ||
| } | ||
| return authors.filter((author) => !!author).join(', '); | ||
| }; | ||
| const formatSeriesIndex = (position) => { | ||
| if (position === null || position === undefined) { | ||
| return ''; | ||
| } | ||
| const numeric = Number(position); | ||
| if (Number.isFinite(numeric)) { | ||
| if (Math.abs(numeric - Math.round(numeric)) < 0.01) { | ||
| return String(Math.round(numeric)); | ||
| } | ||
| return numeric.toLocaleString(undefined, { | ||
| minimumFractionDigits: 1, | ||
| maximumFractionDigits: 2, | ||
| }); | ||
| } | ||
| if (typeof position === 'string') { | ||
| const trimmed = position.trim(); | ||
| if (trimmed) { | ||
| return trimmed; | ||
| } | ||
| } | ||
| return ''; | ||
| }; | ||
| const formatSeriesLabel = (entry) => { | ||
| if (!entry) { | ||
| return ''; | ||
| } | ||
| const rawSeries = typeof entry.series === 'string' ? entry.series.trim() : ''; | ||
| const rawIndex = | ||
| entry.series_index ?? | ||
| entry.seriesIndex ?? | ||
| entry.series_position ?? | ||
| entry.seriesPosition ?? | ||
| entry.book_number ?? | ||
| entry.bookNumber ?? | ||
| null; | ||
| const indexLabel = formatSeriesIndex(rawIndex); | ||
| if (rawSeries && indexLabel) { | ||
| return `${rawSeries} · Book ${indexLabel}`; | ||
| } | ||
| if (rawSeries) { | ||
| return rawSeries; | ||
| } | ||
| if (indexLabel) { | ||
| return `Book ${indexLabel}`; | ||
| } | ||
| return ''; | ||
| }; | ||
| const deriveBrowseMode = () => { | ||
| const title = (state.feedTitle || '').toLowerCase(); | ||
| if (!title) { | ||
| return 'generic'; | ||
| } | ||
| if (title.includes('author')) { | ||
| return 'author'; | ||
| } | ||
| if (title.includes('series')) { | ||
| return 'series'; | ||
| } | ||
| if (title.includes('title')) { | ||
| return 'title'; | ||
| } | ||
| if (title.includes('books')) { | ||
| return 'title'; | ||
| } | ||
| return 'generic'; | ||
| }; | ||
| const stripLeadingArticle = (text) => text.replace(/^(?:the|a|an)\s+/i, '').trim(); | ||
| const extractAlphabetSource = (entry) => { | ||
| if (!entry) { | ||
| return ''; | ||
| } | ||
| const mode = deriveBrowseMode(); | ||
| if (mode === 'author') { | ||
| if (Array.isArray(entry.authors) && entry.authors.length) { | ||
| return entry.authors[0] || ''; | ||
| } | ||
| } | ||
| if (mode === 'series' && entry.series) { | ||
| return entry.series; | ||
| } | ||
| if (entry.title) { | ||
| return entry.title; | ||
| } | ||
| if (entry.series) { | ||
| return entry.series; | ||
| } | ||
| const navLink = findNavigationLink(entry); | ||
| if (navLink?.title) { | ||
| return navLink.title; | ||
| } | ||
| return ''; | ||
| }; | ||
| const deriveAlphabetKey = (entry) => { | ||
| const mode = deriveBrowseMode(); | ||
| let source = (extractAlphabetSource(entry) || '').trim(); | ||
| if (!source) { | ||
| return ''; | ||
| } | ||
| if (mode === 'author') { | ||
| if (source.includes(',')) { | ||
| source = source.split(',')[0]; | ||
| } else { | ||
| const parts = source.split(/\s+/); | ||
| if (parts.length > 1) { | ||
| source = parts[parts.length - 1]; | ||
| } | ||
| } | ||
| } else if (mode === 'title') { | ||
| source = stripLeadingArticle(source); | ||
| } | ||
| source = source.replace(/^[^\p{L}\p{N}]+/u, ''); | ||
| return source; | ||
| }; | ||
| const deriveAlphabetLetter = (entry) => { | ||
| const key = deriveAlphabetKey(entry); | ||
| if (!key) { | ||
| return LETTER_NUMERIC; | ||
| } | ||
| const initial = key.charAt(0).toUpperCase(); | ||
| if (initial >= 'A' && initial <= 'Z') { | ||
| return initial; | ||
| } | ||
| return LETTER_NUMERIC; | ||
| }; | ||
| const collectAlphabetCounts = (entries) => { | ||
| const counts = new Map(); | ||
| if (!Array.isArray(entries)) { | ||
| return counts; | ||
| } | ||
| entries.forEach((entry) => { | ||
| const letter = deriveAlphabetLetter(entry); | ||
| counts.set(letter, (counts.get(letter) || 0) + 1); | ||
| }); | ||
| return counts; | ||
| }; | ||
| const detectEntryType = (entry, navigationLink) => { | ||
| if (!entry) { | ||
| return EntryTypes.OTHER; | ||
| } | ||
| const downloadLink = entry.download && entry.download.href ? entry.download.href : null; | ||
| if (downloadLink) { | ||
| return EntryTypes.BOOK; | ||
| } | ||
| const navLink = navigationLink === undefined ? findNavigationLink(entry) : navigationLink; | ||
| if (navLink && navLink.href) { | ||
| return EntryTypes.NAVIGATION; | ||
| } | ||
| return EntryTypes.OTHER; | ||
| }; | ||
| const computeEntryStats = (entries) => { | ||
| const stats = { | ||
| [EntryTypes.BOOK]: 0, | ||
| [EntryTypes.NAVIGATION]: 0, | ||
| [EntryTypes.OTHER]: 0, | ||
| }; | ||
| if (!Array.isArray(entries)) { | ||
| return stats; | ||
| } | ||
| entries.forEach((entry) => { | ||
| const type = detectEntryType(entry); | ||
| stats[type] += 1; | ||
| }); | ||
| return stats; | ||
| }; | ||
| const shouldShowAlphabetPicker = (entries, stats) => { | ||
| if (!alphaPickerEl || state.query) { | ||
| return false; | ||
| } | ||
| if (!Array.isArray(entries) || entries.length === 0) { | ||
| return false; | ||
| } | ||
| return true; | ||
| }; | ||
| const refreshAlphabetActiveState = () => { | ||
| if (!alphaPickerEl) { | ||
| return; | ||
| } | ||
| const buttons = alphaPickerEl.querySelectorAll('button[data-letter]'); | ||
| buttons.forEach((button) => { | ||
| const value = button.dataset.letter || LETTER_ALL; | ||
| const isActive = value === state.activeLetter; | ||
| button.classList.toggle('is-active', isActive); | ||
| button.setAttribute('aria-pressed', isActive ? 'true' : 'false'); | ||
| }); | ||
| }; | ||
| const describeAlphabetLetter = (letter) => { | ||
| if (letter === LETTER_ALL) { | ||
| return 'all entries'; | ||
| } | ||
| if (letter === LETTER_NUMERIC) { | ||
| return 'numbers or symbols'; | ||
| } | ||
| return `letter ${letter}`; | ||
| }; | ||
| const updateAlphabetPicker = (entries, { reset = false, stats = null } = {}) => { | ||
| if (!alphaPickerEl) { | ||
| return; | ||
| } | ||
| const list = Array.isArray(entries) ? entries : []; | ||
| if (reset) { | ||
| state.activeLetter = LETTER_ALL; | ||
| } | ||
| const counts = collectAlphabetCounts(list); | ||
| state.availableLetters = new Set(counts.keys()); | ||
| const shouldShow = shouldShowAlphabetPicker(list, stats); | ||
| if (!shouldShow) { | ||
| alphaPickerEl.innerHTML = ''; | ||
| alphaPickerEl.hidden = true; | ||
| state.activeLetter = LETTER_ALL; | ||
| return; | ||
| } | ||
| alphaPickerEl.hidden = false; | ||
| alphaPickerEl.innerHTML = ''; | ||
| const letters = [LETTER_ALL, ...ALPHABET, LETTER_NUMERIC]; | ||
| letters.forEach((letter) => { | ||
| const button = document.createElement('button'); | ||
| button.type = 'button'; | ||
| button.className = 'opds-alpha-picker__button'; | ||
| button.dataset.letter = letter; | ||
| button.textContent = letter === LETTER_ALL ? 'All' : letter === LETTER_NUMERIC ? '# / 0-9' : letter; | ||
| const enabledCount = letter === LETTER_ALL ? list.length : counts.get(letter) || 0; | ||
| button.title = `Show entries for ${describeAlphabetLetter(letter)} (${enabledCount} in view)`; | ||
| button.addEventListener('click', () => { | ||
| handleAlphabetSelect(letter).catch((error) => { | ||
| console.error('Alphabet picker failed', error); | ||
| }); | ||
| }); | ||
| alphaPickerEl.appendChild(button); | ||
| }); | ||
| refreshAlphabetActiveState(); | ||
| }; | ||
| const handleAlphabetSelect = async (letter) => { | ||
| const normalized = letter || LETTER_ALL; | ||
| if (normalized === LETTER_ALL) { | ||
| state.activeLetter = LETTER_ALL; | ||
| refreshAlphabetActiveState(); | ||
| const startLink = resolveRelLink(state.currentLinks, 'start') || resolveRelLink(state.currentLinks, '/start'); | ||
| const upLink = resolveRelLink(state.currentLinks, 'up') || resolveRelLink(state.currentLinks, '/up'); | ||
| const baseHref = startLink?.href || state.alphabetBaseHref || upLink?.href || state.currentHref || ''; | ||
| await loadFeed({ href: baseHref, query: '', letter: '', activeTab: baseHref ? TabIds.CUSTOM : TabIds.ROOT, updateTabs: true }); | ||
| return; | ||
| } | ||
| state.activeLetter = normalized; | ||
| refreshAlphabetActiveState(); | ||
| const startLink = resolveRelLink(state.currentLinks, 'start') || resolveRelLink(state.currentLinks, '/start'); | ||
| const baseHref = startLink?.href || state.alphabetBaseHref || state.currentHref || ''; | ||
| await loadFeed({ href: baseHref, query: '', letter: normalized, activeTab: TabIds.CUSTOM, updateTabs: true }); | ||
| }; | ||
| const applyAlphabetFilter = (entries) => { | ||
| if (!Array.isArray(entries) || !entries.length) { | ||
| return []; | ||
| } | ||
| if (state.activeLetter === LETTER_ALL) { | ||
| return entries.slice(); | ||
| } | ||
| return entries.filter((entry) => { | ||
| const letter = deriveAlphabetLetter(entry); | ||
| if (state.activeLetter === LETTER_NUMERIC) { | ||
| return letter === LETTER_NUMERIC; | ||
| } | ||
| return letter === state.activeLetter; | ||
| }); | ||
| }; | ||
| const setEntries = (entries, { resetAlphabet = false, activeLetter = null } = {}) => { | ||
| const list = Array.isArray(entries) ? entries.slice() : []; | ||
| state.lastEntries = list; | ||
| const totalStats = computeEntryStats(list); | ||
| state.totalStats = totalStats; | ||
| if (resetAlphabet) { | ||
| state.activeLetter = LETTER_ALL; | ||
| } else if (activeLetter && activeLetter !== LETTER_ALL) { | ||
| state.activeLetter = activeLetter; | ||
| } | ||
| const filtered = applyAlphabetFilter(list); | ||
| const filteredStats = renderEntries(filtered); | ||
| state.filteredStats = filteredStats; | ||
| updateAlphabetPicker(list, { reset: resetAlphabet, stats: totalStats }); | ||
| return { stats: totalStats, filteredStats }; | ||
| }; | ||
| const setStatus = (message, level, { persist = false } = {}) => { | ||
| if (!statusEl) { | ||
| return; | ||
| } | ||
| statusEl.textContent = message || ''; | ||
| if (level) { | ||
| statusEl.dataset.state = level; | ||
| } else { | ||
| delete statusEl.dataset.state; | ||
| } | ||
| state.status = { message: message || '', level: level || null }; | ||
| if (persist) { | ||
| state.baseStatus = { ...state.status }; | ||
| } | ||
| }; | ||
| const clearStatus = () => setStatus('', null); | ||
| const restoreBaseStatus = () => { | ||
| if (state.baseStatus) { | ||
| setStatus(state.baseStatus.message, state.baseStatus.level); | ||
| } else { | ||
| clearStatus(); | ||
| } | ||
| }; | ||
| const focusSearch = () => { | ||
| if (!searchInput) { | ||
| return; | ||
| } | ||
| window.requestAnimationFrame(() => { | ||
| try { | ||
| searchInput.focus({ preventScroll: true }); | ||
| } catch (error) { | ||
| // Ignore focus issues | ||
| } | ||
| }); | ||
| }; | ||
| const resolveRelLink = (links, rel) => { | ||
| if (!links) { | ||
| return null; | ||
| } | ||
| if (links[rel]) { | ||
| return links[rel]; | ||
| } | ||
| const key = Object.keys(links).find((entry) => entry === rel || entry.endsWith(rel)); | ||
| return key ? links[key] : null; | ||
| }; | ||
| const findNavigationLink = (entry) => { | ||
| if (!entry || !Array.isArray(entry.links)) { | ||
| return null; | ||
| } | ||
| const candidates = entry.links.filter((link) => link && link.href); | ||
| return ( | ||
| candidates.find((link) => { | ||
| const rel = (link.rel || '').toLowerCase(); | ||
| const type = (link.type || '').toLowerCase(); | ||
| if (!link.href) { | ||
| return false; | ||
| } | ||
| if (rel.includes('acquisition')) { | ||
| return false; | ||
| } | ||
| if (rel === 'self') { | ||
| return false; | ||
| } | ||
| if (type.includes('opds-catalog')) { | ||
| return true; | ||
| } | ||
| if (rel.includes('subsection') || rel.includes('collection')) { | ||
| return true; | ||
| } | ||
| if (rel.startsWith('http://opds-spec.org/sort') || rel.startsWith('http://opds-spec.org/group')) { | ||
| return true; | ||
| } | ||
| return false; | ||
| }) || null | ||
| ); | ||
| }; | ||
| const resolveTabIdForHref = (href) => { | ||
| if (!href) { | ||
| return TabIds.ROOT; | ||
| } | ||
| const matching = state.tabs.find((tab) => tab.href === href); | ||
| return matching ? matching.id : null; | ||
| }; | ||
| const buildTabsFromFeed = (feed) => { | ||
| if (!feed || !Array.isArray(feed.entries)) { | ||
| return; | ||
| } | ||
| const seen = new Set(); | ||
| const nextTabs = []; | ||
| feed.entries.forEach((entry) => { | ||
| const navLink = findNavigationLink(entry); | ||
| if (!navLink || !navLink.href) { | ||
| return; | ||
| } | ||
| if (seen.has(navLink.href)) { | ||
| return; | ||
| } | ||
| seen.add(navLink.href); | ||
| const label = entry.title || navLink.title || 'Catalog view'; | ||
| nextTabs.push({ | ||
| id: navLink.href, | ||
| label, | ||
| href: navLink.href, | ||
| }); | ||
| }); | ||
| state.tabs = nextTabs; | ||
| state.tabsReady = true; | ||
| renderTabs(); | ||
| }; | ||
| const renderTabs = () => { | ||
| if (!tabsEl) { | ||
| return; | ||
| } | ||
| tabsEl.innerHTML = ''; | ||
| const tabs = []; | ||
| tabs.push({ id: TabIds.ROOT, label: 'Catalog home', href: '' }); | ||
| state.tabs.forEach((tab) => tabs.push(tab)); | ||
| if (state.activeTab === TabIds.SEARCH && state.query) { | ||
| tabs.push({ | ||
| id: TabIds.SEARCH, | ||
| label: `Search: "${truncate(state.query, 32)}"`, | ||
| href: '', | ||
| isSearch: true, | ||
| }); | ||
| } | ||
| tabs.forEach((tab) => { | ||
| const button = document.createElement('button'); | ||
| button.type = 'button'; | ||
| button.className = 'opds-tab'; | ||
| if (tab.isSearch) { | ||
| button.classList.add('opds-tab--search'); | ||
| } | ||
| if (state.activeTab === tab.id || (tab.id !== TabIds.SEARCH && state.activeTab === tab.href)) { | ||
| button.classList.add('is-active'); | ||
| } | ||
| button.textContent = tab.label; | ||
| button.addEventListener('click', () => { | ||
| if (tab.id === TabIds.SEARCH) { | ||
| loadFeed({ href: '', query: state.query, activeTab: TabIds.SEARCH }); | ||
| return; | ||
| } | ||
| if (tab.id === TabIds.ROOT) { | ||
| loadFeed({ href: '', query: '', activeTab: TabIds.ROOT, updateTabs: true }); | ||
| return; | ||
| } | ||
| loadFeed({ href: tab.href, query: '', activeTab: tab.id }); | ||
| }); | ||
| tabsEl.appendChild(button); | ||
| }); | ||
| tabsEl.classList.toggle('is-empty', tabs.length <= 1); | ||
| }; | ||
| const renderNav = (links) => { | ||
| const targets = [navEl, navBottomEl].filter(Boolean); | ||
| if (!targets.length) { | ||
| return; | ||
| } | ||
| targets.forEach((el) => { | ||
| el.innerHTML = ''; | ||
| }); | ||
| const descriptors = [ | ||
| { key: 'up', label: 'Up one level' }, | ||
| { key: 'previous', label: 'Previous page' }, | ||
| { key: 'next', label: 'Next page' }, | ||
| ]; | ||
| descriptors.forEach(({ key, label }) => { | ||
| if (state.activeLetter !== LETTER_ALL && key !== 'up') { | ||
| return; | ||
| } | ||
| const link = resolveRelLink(links, key) || resolveRelLink(links, `/${key}`); | ||
| const hasLink = Boolean(link && link.href); | ||
| if (!hasLink && key !== 'previous') { | ||
| return; | ||
| } | ||
| targets.forEach((targetEl) => { | ||
| const button = document.createElement('button'); | ||
| button.type = 'button'; | ||
| button.className = 'button button--ghost'; | ||
| button.textContent = label; | ||
| if (hasLink) { | ||
| button.addEventListener('click', () => { | ||
| const targetQuery = key === 'up' ? '' : state.query; | ||
| const tabId = resolveTabIdForHref(link.href); | ||
| loadFeed({ href: link.href, query: targetQuery, activeTab: tabId || (targetQuery ? TabIds.SEARCH : TabIds.CUSTOM) }); | ||
| }); | ||
| } else if (key === 'previous') { | ||
| button.disabled = true; | ||
| button.setAttribute('aria-disabled', 'true'); | ||
| } | ||
| targetEl.appendChild(button); | ||
| }); | ||
| }); | ||
| targets.forEach((el) => { | ||
| el.hidden = !el.childElementCount; | ||
| }); | ||
| }; | ||
| const createEntry = (entry) => { | ||
| const item = document.createElement('li'); | ||
| item.className = 'opds-browser__entry'; | ||
| const header = document.createElement('div'); | ||
| header.className = 'opds-browser__entry-head'; | ||
| const title = document.createElement('h3'); | ||
| title.className = 'opds-browser__title'; | ||
| const positionLabel = Number.isFinite(entry?.position) ? Number(entry.position) : null; | ||
| const baseTitle = entry.title || 'Untitled'; | ||
| title.textContent = positionLabel !== null ? `${positionLabel}. ${baseTitle}` : baseTitle; | ||
| header.appendChild(title); | ||
| const authors = formatAuthors(entry.authors); | ||
| if (authors) { | ||
| const meta = document.createElement('p'); | ||
| meta.className = 'opds-browser__meta'; | ||
| meta.textContent = authors; | ||
| header.appendChild(meta); | ||
| } | ||
| const seriesMetaText = formatSeriesLabel(entry); | ||
| if (seriesMetaText) { | ||
| const seriesMeta = document.createElement('p'); | ||
| seriesMeta.className = 'opds-browser__meta'; | ||
| seriesMeta.textContent = seriesMetaText; | ||
| header.appendChild(seriesMeta); | ||
| } | ||
| if (entry.rating !== null && entry.rating !== undefined && entry.rating !== '') { | ||
| const ratingMeta = document.createElement('p'); | ||
| ratingMeta.className = 'opds-browser__meta'; | ||
| const ratingMax = entry.rating_max ?? entry.ratingMax ?? 5; | ||
| ratingMeta.textContent = `Rating: ${entry.rating}${ratingMax ? ` / ${ratingMax}` : ''}`; | ||
| header.appendChild(ratingMeta); | ||
| } | ||
| if (Array.isArray(entry.tags) && entry.tags.length > 0) { | ||
| const tagsMeta = document.createElement('p'); | ||
| tagsMeta.className = 'opds-browser__meta'; | ||
| tagsMeta.textContent = `Tags: ${entry.tags.join(', ')}`; | ||
| header.appendChild(tagsMeta); | ||
| } | ||
| item.appendChild(header); | ||
| const summarySource = entry.summary || entry?.alternate?.title || entry?.download?.title || ''; | ||
| if (summarySource) { | ||
| const summary = document.createElement('p'); | ||
| summary.className = 'opds-browser__summary'; | ||
| summary.textContent = truncate(summarySource, 420); | ||
| item.appendChild(summary); | ||
| } | ||
| const actions = document.createElement('div'); | ||
| actions.className = 'opds-browser__actions'; | ||
| const downloadLink = entry.download && entry.download.href ? entry.download.href : null; | ||
| const alternateLink = entry.alternate && entry.alternate.href ? entry.alternate.href : null; | ||
| const navigationLink = findNavigationLink(entry); | ||
| const entryType = detectEntryType(entry, navigationLink); | ||
| if (entryType === EntryTypes.NAVIGATION && navigationLink) { | ||
| item.classList.add('opds-browser__entry--navigation'); | ||
| } | ||
| if (entryType === EntryTypes.BOOK) { | ||
| const queueButton = document.createElement('button'); | ||
| queueButton.type = 'button'; | ||
| queueButton.className = 'button'; | ||
| queueButton.textContent = 'Configure conversion'; | ||
| queueButton.addEventListener('click', () => importEntry(entry, queueButton)); | ||
| actions.appendChild(queueButton); | ||
| } else if (entryType === EntryTypes.NAVIGATION && navigationLink) { | ||
| const browseButton = document.createElement('button'); | ||
| browseButton.type = 'button'; | ||
| browseButton.className = 'button button--ghost'; | ||
| browseButton.textContent = 'Browse view'; | ||
| browseButton.addEventListener('click', () => { | ||
| clearStatus(); | ||
| const tabId = resolveTabIdForHref(navigationLink.href); | ||
| loadFeed({ href: navigationLink.href, query: '', activeTab: tabId || TabIds.CUSTOM }); | ||
| }); | ||
| actions.appendChild(browseButton); | ||
| } | ||
| if (alternateLink && entryType !== EntryTypes.NAVIGATION) { | ||
| const previewLink = document.createElement('a'); | ||
| previewLink.className = 'button button--ghost'; | ||
| previewLink.href = alternateLink; | ||
| previewLink.target = '_blank'; | ||
| previewLink.rel = 'noreferrer'; | ||
| previewLink.textContent = 'Open in Calibre'; | ||
| actions.appendChild(previewLink); | ||
| } | ||
| if (!actions.childElementCount) { | ||
| const fallback = document.createElement('span'); | ||
| fallback.className = 'opds-browser__hint'; | ||
| fallback.textContent = 'No downloadable formats exposed.'; | ||
| actions.appendChild(fallback); | ||
| } | ||
| item.appendChild(actions); | ||
| return { element: item, type: entryType }; | ||
| }; | ||
| const renderEntries = (entries) => { | ||
| if (!resultsEl) { | ||
| return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 }; | ||
| } | ||
| resultsEl.innerHTML = ''; | ||
| const list = Array.isArray(entries) ? entries : []; | ||
| if (!list.length) { | ||
| const empty = document.createElement('li'); | ||
| empty.className = 'opds-browser__empty'; | ||
| if (state.activeLetter !== LETTER_ALL) { | ||
| empty.textContent = `No entries start with ${describeAlphabetLetter(state.activeLetter)}.`; | ||
| } else if (state.query) { | ||
| empty.textContent = 'No results returned for this view yet.'; | ||
| } else { | ||
| empty.textContent = 'No catalog entries found here yet.'; | ||
| } | ||
| resultsEl.appendChild(empty); | ||
| return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 }; | ||
| } | ||
| const fragment = document.createDocumentFragment(); | ||
| const stats = { | ||
| [EntryTypes.BOOK]: 0, | ||
| [EntryTypes.NAVIGATION]: 0, | ||
| [EntryTypes.OTHER]: 0, | ||
| }; | ||
| list.forEach((entry) => { | ||
| const { element, type } = createEntry(entry); | ||
| stats[type] += 1; | ||
| fragment.appendChild(element); | ||
| }); | ||
| resultsEl.appendChild(fragment); | ||
| return stats; | ||
| }; | ||
| const importEntry = async (entry, trigger) => { | ||
| if (!entry?.download?.href) { | ||
| setStatus('This entry cannot be imported automatically.', 'error'); | ||
| return; | ||
| } | ||
| const button = trigger; | ||
| const originalLabel = button ? button.textContent : ''; | ||
| if (button) { | ||
| button.disabled = true; | ||
| button.dataset.loading = 'true'; | ||
| button.textContent = 'Preparing…'; | ||
| } | ||
| setStatus('Downloading book from Calibre. This can take a minute…', 'loading'); | ||
| try { | ||
| const requestPayload = { | ||
| href: entry.download.href, | ||
| title: entry.title || '', | ||
| }; | ||
| const metadata = {}; | ||
| if (entry.series) { | ||
| metadata.series = entry.series; | ||
| metadata.series_name = entry.series; | ||
| } | ||
| const seriesIndex = entry.series_index ?? entry.seriesIndex ?? null; | ||
| if (seriesIndex !== null && seriesIndex !== undefined && seriesIndex !== '') { | ||
| metadata.series_index = seriesIndex; | ||
| metadata.series_position = seriesIndex; | ||
| metadata.book_number = seriesIndex; | ||
| } | ||
| if (Array.isArray(entry.tags) && entry.tags.length > 0) { | ||
| const tagsText = entry.tags.join(', '); | ||
| metadata.tags = tagsText; | ||
| metadata.keywords = tagsText; | ||
| metadata.genre = tagsText; | ||
| } | ||
| if (typeof entry.summary === 'string' && entry.summary.trim()) { | ||
| metadata.description = entry.summary; | ||
| metadata.summary = entry.summary; | ||
| } | ||
| if (Array.isArray(entry.authors) && entry.authors.length > 0) { | ||
| const authorsText = entry.authors.map((name) => String(name || '').trim()).filter(Boolean).join(', '); | ||
| if (authorsText) { | ||
| metadata.authors = authorsText; | ||
| metadata.author = authorsText; | ||
| } | ||
| } | ||
| if (typeof entry.subtitle === 'string' && entry.subtitle.trim()) { | ||
| metadata.subtitle = entry.subtitle.trim(); | ||
| } | ||
| if (entry.rating !== null && entry.rating !== undefined && entry.rating !== '') { | ||
| metadata.rating = String(entry.rating); | ||
| } | ||
| if (entry.rating_max !== null && entry.rating_max !== undefined && entry.rating_max !== '') { | ||
| metadata.rating_max = String(entry.rating_max); | ||
| } | ||
| if (entry.published) { | ||
| metadata.published = entry.published; | ||
| metadata.publication_date = entry.published; | ||
| try { | ||
| const publishedDate = new Date(entry.published); | ||
| if (!Number.isNaN(publishedDate.getTime())) { | ||
| const year = String(publishedDate.getUTCFullYear()); | ||
| metadata.publication_year = year; | ||
| metadata.year = year; | ||
| } | ||
| } catch (error) { | ||
| // Ignore invalid date parsing issues | ||
| } | ||
| } | ||
| if (Object.keys(metadata).length > 0) { | ||
| requestPayload.metadata = metadata; | ||
| } | ||
| const response = await fetch('/api/integrations/calibre-opds/import', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify(requestPayload), | ||
| }); | ||
| const payload = await response.json(); | ||
| if (!response.ok) { | ||
| throw new Error(payload.error || 'Unable to queue this book.'); | ||
| } | ||
| setStatus('Preparing the conversion wizard…', 'success'); | ||
| closeModal(); | ||
| const redirectUrl = payload.redirect_url || ''; | ||
| if (redirectUrl) { | ||
| const wizard = window.AbogenWizard; | ||
| if (wizard?.requestStep) { | ||
| try { | ||
| const target = new URL(redirectUrl, window.location.origin); | ||
| if (payload.pending_id && !target.searchParams.has('pending_id')) { | ||
| target.searchParams.set('pending_id', payload.pending_id); | ||
| } | ||
| target.searchParams.set('format', 'json'); | ||
| if (!target.searchParams.has('step')) { | ||
| target.searchParams.set('step', 'book'); | ||
| } | ||
| await wizard.requestStep(target.toString(), { method: 'GET' }); | ||
| } catch (wizardError) { | ||
| console.error('Unable to open wizard via JSON payload', wizardError); | ||
| window.location.assign(redirectUrl); | ||
| } | ||
| } else { | ||
| window.location.assign(redirectUrl); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| setStatus(error instanceof Error ? error.message : 'Unable to queue this book.', 'error'); | ||
| } finally { | ||
| if (button) { | ||
| button.disabled = false; | ||
| delete button.dataset.loading; | ||
| if (originalLabel) { | ||
| button.textContent = originalLabel; | ||
| } | ||
| } | ||
| } | ||
| }; | ||
| const loadFeed = async ({ href = '', query = '', letter = '', activeTab = null, updateTabs = false } = {}) => { | ||
| const params = new URLSearchParams(); | ||
| const normalizedHref = href || ''; | ||
| const normalizedQuery = (query || '').trim(); | ||
| let normalizedLetter = (letter || '').trim(); | ||
| if (normalizedLetter === LETTER_ALL) { | ||
| normalizedLetter = ''; | ||
| } | ||
| if (normalizedQuery) { | ||
| normalizedLetter = ''; | ||
| } | ||
| if (normalizedLetter && normalizedLetter !== LETTER_NUMERIC) { | ||
| normalizedLetter = normalizedLetter.toUpperCase(); | ||
| } | ||
| if (normalizedHref) { | ||
| params.set('href', normalizedHref); | ||
| } | ||
| if (normalizedQuery) { | ||
| params.set('q', normalizedQuery); | ||
| } | ||
| if (!normalizedQuery && normalizedLetter) { | ||
| params.set('letter', normalizedLetter); | ||
| } | ||
| const requestId = ++state.requestToken; | ||
| setStatus('Loading catalog…', 'loading'); | ||
| try { | ||
| const url = `/api/integrations/calibre-opds/feed${params.toString() ? `?${params.toString()}` : ''}`; | ||
| const response = await fetch(url); | ||
| const payload = await response.json(); | ||
| if (requestId !== state.requestToken) { | ||
| return; | ||
| } | ||
| if (!response.ok) { | ||
| throw new Error(payload.error || 'Unable to load the Calibre catalog.'); | ||
| } | ||
| const feed = payload.feed || {}; | ||
| state.feedTitle = feed.title || ''; | ||
| state.currentHref = normalizedHref; | ||
| state.currentLinks = feed.links || {}; | ||
| const selfLink = resolveRelLink(state.currentLinks, 'self'); | ||
| if (selfLink?.href) { | ||
| state.currentHref = selfLink.href; | ||
| } | ||
| state.query = normalizedLetter ? '' : normalizedQuery; | ||
| if (!normalizedLetter) { | ||
| const startLink = resolveRelLink(state.currentLinks, 'start') || resolveRelLink(state.currentLinks, '/start'); | ||
| if (startLink?.href) { | ||
| state.alphabetBaseHref = startLink.href; | ||
| } else if (state.currentHref) { | ||
| state.alphabetBaseHref = state.currentHref; | ||
| } | ||
| } | ||
| if (typeof activeTab === 'string') { | ||
| state.activeTab = activeTab; | ||
| } else if (normalizedQuery) { | ||
| state.activeTab = TabIds.SEARCH; | ||
| } else if (normalizedLetter) { | ||
| state.activeTab = TabIds.CUSTOM; | ||
| } else if (normalizedHref) { | ||
| state.activeTab = resolveTabIdForHref(normalizedHref) || TabIds.CUSTOM; | ||
| } else { | ||
| state.activeTab = TabIds.ROOT; | ||
| } | ||
| if (searchInput) { | ||
| searchInput.value = state.query || ''; | ||
| } | ||
| if (updateTabs || !state.tabsReady) { | ||
| buildTabsFromFeed(feed); | ||
| } else { | ||
| renderTabs(); | ||
| } | ||
| renderNav(feed.links); | ||
| const { stats } = setEntries(feed.entries || [], { | ||
| resetAlphabet: !normalizedLetter, | ||
| activeLetter: normalizedLetter || null, | ||
| }); | ||
| const books = stats?.[EntryTypes.BOOK] || 0; | ||
| const views = stats?.[EntryTypes.NAVIGATION] || 0; | ||
| if (normalizedLetter) { | ||
| const letterDescription = describeAlphabetLetter(normalizedLetter); | ||
| if (books && views) { | ||
| setStatus( | ||
| `Showing ${books} book${books === 1 ? '' : 's'} and ${views} catalog view${views === 1 ? '' : 's'} for ${letterDescription}.`, | ||
| 'success', | ||
| { persist: true }, | ||
| ); | ||
| } else if (books) { | ||
| setStatus(`Found ${books} book${books === 1 ? '' : 's'} for ${letterDescription}.`, 'success', { persist: true }); | ||
| } else if (views) { | ||
| setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} for ${letterDescription}.`, 'info', { persist: true }); | ||
| } else { | ||
| setStatus(`No catalog entries found for ${letterDescription}.`, 'info', { persist: true }); | ||
| } | ||
| return; | ||
| } | ||
| if (normalizedQuery) { | ||
| if (books) { | ||
| setStatus(`Found ${books} book${books === 1 ? '' : 's'} for "${normalizedQuery}".`, 'success', { persist: true }); | ||
| } else if (views) { | ||
| setStatus( | ||
| `Browse ${views} catalog view${views === 1 ? '' : 's'} related to "${normalizedQuery}".`, | ||
| 'info', | ||
| { persist: true }, | ||
| ); | ||
| } else { | ||
| setStatus(`No results for "${normalizedQuery}".`, 'error', { persist: true }); | ||
| } | ||
| return; | ||
| } | ||
| if (books && views) { | ||
| setStatus(`Showing ${books} book${books === 1 ? '' : 's'} and ${views} catalog view${views === 1 ? '' : 's'}.`, 'success', { persist: true }); | ||
| } else if (books) { | ||
| setStatus(`Found ${books} book${books === 1 ? '' : 's'} in this view.`, 'success', { persist: true }); | ||
| } else if (views) { | ||
| setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} to drill deeper.`, 'info', { persist: true }); | ||
| } else { | ||
| setStatus('No catalog entries found here yet.', 'info', { persist: true }); | ||
| } | ||
| } catch (error) { | ||
| if (requestId !== state.requestToken) { | ||
| return; | ||
| } | ||
| setStatus(error instanceof Error ? error.message : 'Unable to load the Calibre catalog.', 'error', { persist: true }); | ||
| setEntries([], { resetAlphabet: true }); | ||
| if (navEl) { | ||
| navEl.innerHTML = ''; | ||
| } | ||
| state.currentLinks = {}; | ||
| } | ||
| }; | ||
| const openModal = (trigger) => { | ||
| if (isOpen) { | ||
| focusSearch(); | ||
| return; | ||
| } | ||
| isOpen = true; | ||
| lastTrigger = trigger || null; | ||
| modal.hidden = false; | ||
| modal.dataset.open = 'true'; | ||
| document.body.classList.add('modal-open'); | ||
| focusSearch(); | ||
| loadFeed({ href: state.currentHref || '', query: state.query || '', activeTab: state.activeTab || TabIds.ROOT, updateTabs: !state.tabsReady }); | ||
| }; | ||
| const closeModal = () => { | ||
| if (!isOpen) { | ||
| return; | ||
| } | ||
| isOpen = false; | ||
| modal.hidden = true; | ||
| delete modal.dataset.open; | ||
| document.body.classList.remove('modal-open'); | ||
| if (lastTrigger instanceof HTMLElement) { | ||
| lastTrigger.focus({ preventScroll: true }); | ||
| } | ||
| }; | ||
| const handleKeydown = (event) => { | ||
| if (event.key === 'Escape' && isOpen) { | ||
| event.preventDefault(); | ||
| closeModal(); | ||
| } | ||
| }; | ||
| document.addEventListener('keydown', handleKeydown); | ||
| openButtons.forEach((button) => { | ||
| button.addEventListener('click', (event) => { | ||
| event.preventDefault(); | ||
| openModal(button); | ||
| }); | ||
| }); | ||
| closeTargets.forEach((target) => { | ||
| target.addEventListener('click', (event) => { | ||
| event.preventDefault(); | ||
| closeModal(); | ||
| }); | ||
| }); | ||
| modal.addEventListener('click', (event) => { | ||
| if (event.target === modal) { | ||
| closeModal(); | ||
| } | ||
| }); | ||
| if (searchForm && searchInput) { | ||
| searchForm.addEventListener('submit', (event) => { | ||
| event.preventDefault(); | ||
| const query = searchInput.value.trim(); | ||
| if (!query) { | ||
| loadFeed({ href: '', query: '', activeTab: TabIds.ROOT, updateTabs: true }); | ||
| } else { | ||
| loadFeed({ href: '', query, activeTab: TabIds.SEARCH }); | ||
| } | ||
| }); | ||
| } | ||
| if (refreshButton && searchInput) { | ||
| refreshButton.addEventListener('click', () => { | ||
| searchInput.value = ''; | ||
| loadFeed({ href: '', query: '', activeTab: TabIds.ROOT, updateTabs: true }); | ||
| }); | ||
| } | ||
| } |
Sorry, the diff of this file is too big to display
| import { initReaderUI } from "./reader.js"; | ||
| const queueState = (window.AbogenQueueState = window.AbogenQueueState || { | ||
| boundOverwritePrompt: false, | ||
| }); | ||
| const handleOverwritePrompt = (event) => { | ||
| const detail = event?.detail || {}; | ||
| const title = detail.title || "this item"; | ||
| const message = detail.message || `Audiobookshelf already has "${title}". Overwrite?`; | ||
| if (!window.confirm(message)) { | ||
| return; | ||
| } | ||
| const url = detail.url; | ||
| if (!url || typeof htmx === "undefined") { | ||
| return; | ||
| } | ||
| const target = detail.target || "#jobs-panel"; | ||
| const values = { overwrite: "true" }; | ||
| if (detail.values && typeof detail.values === "object") { | ||
| Object.assign(values, detail.values); | ||
| } | ||
| htmx.ajax("POST", url, { | ||
| target, | ||
| swap: "innerHTML", | ||
| values, | ||
| }); | ||
| }; | ||
| const initQueuePage = () => { | ||
| initReaderUI(); | ||
| if (!queueState.boundOverwritePrompt) { | ||
| queueState.boundOverwritePrompt = true; | ||
| document.addEventListener("audiobookshelf-overwrite-prompt", handleOverwritePrompt); | ||
| } | ||
| }; | ||
| if (document.readyState === "loading") { | ||
| document.addEventListener("DOMContentLoaded", initQueuePage, { once: true }); | ||
| } else { | ||
| initQueuePage(); | ||
| } |
| const readerButtonRegistry = new WeakSet(); | ||
| let initialized = false; | ||
| let readerModal = null; | ||
| let readerFrame = null; | ||
| let readerHint = null; | ||
| let readerTitle = null; | ||
| let readerTrigger = null; | ||
| let defaultReaderHint = ""; | ||
| const resolveEventMatch = (event, selector) => { | ||
| const target = event.target; | ||
| if (target instanceof Element) { | ||
| const match = target.closest(selector); | ||
| if (match) { | ||
| return match; | ||
| } | ||
| } | ||
| const path = typeof event.composedPath === "function" ? event.composedPath() : []; | ||
| for (const node of path) { | ||
| if (node instanceof Element) { | ||
| if (node.matches(selector)) { | ||
| return node; | ||
| } | ||
| const match = node.closest(selector); | ||
| if (match) { | ||
| return match; | ||
| } | ||
| } | ||
| } | ||
| return null; | ||
| }; | ||
| const closeReaderModal = () => { | ||
| if (!readerModal) { | ||
| return; | ||
| } | ||
| if (readerModal.hidden) { | ||
| return; | ||
| } | ||
| readerModal.hidden = true; | ||
| readerModal.removeAttribute("data-open"); | ||
| document.body.classList.remove("modal-open"); | ||
| if (readerFrame) { | ||
| const frameWindow = readerFrame.contentWindow; | ||
| if (frameWindow) { | ||
| try { | ||
| frameWindow.postMessage({ type: "abogen:reader:pause", currentTime: 0 }, window.location.origin); | ||
| } catch (error) { | ||
| // Ignore cross-origin messaging errors. | ||
| } | ||
| } | ||
| window.setTimeout(() => { | ||
| readerFrame.src = "about:blank"; | ||
| }, 75); | ||
| } | ||
| if (readerHint && defaultReaderHint) { | ||
| readerHint.textContent = defaultReaderHint; | ||
| } | ||
| if (readerTitle) { | ||
| readerTitle.textContent = "Read & listen"; | ||
| } | ||
| if (readerTrigger instanceof HTMLElement) { | ||
| try { | ||
| readerTrigger.focus({ preventScroll: true }); | ||
| } catch (error) { | ||
| // Ignore focus errors. | ||
| } | ||
| } | ||
| readerTrigger = null; | ||
| }; | ||
| const createBindReaderButtons = (openReaderModal) => { | ||
| return (root) => { | ||
| const context = root instanceof Element ? root : document; | ||
| const buttons = context.querySelectorAll('[data-role="open-reader"]'); | ||
| buttons.forEach((button) => { | ||
| if (!(button instanceof HTMLElement)) { | ||
| return; | ||
| } | ||
| if (readerButtonRegistry.has(button)) { | ||
| return; | ||
| } | ||
| button.addEventListener("click", (event) => { | ||
| event.preventDefault(); | ||
| openReaderModal(button); | ||
| }); | ||
| readerButtonRegistry.add(button); | ||
| }); | ||
| }; | ||
| }; | ||
| export const initReaderUI = (options = {}) => { | ||
| if (initialized) { | ||
| return { | ||
| bindReaderButtons: createBindReaderButtons((trigger) => { | ||
| if (typeof options.onBeforeOpen === "function") { | ||
| options.onBeforeOpen(); | ||
| } | ||
| openReader(trigger, options); | ||
| }), | ||
| closeReaderModal, | ||
| }; | ||
| } | ||
| readerModal = document.querySelector('[data-role="reader-modal"]'); | ||
| readerFrame = readerModal?.querySelector('[data-role="reader-frame"]') || null; | ||
| readerHint = readerModal?.querySelector('[data-role="reader-modal-hint"]') || null; | ||
| readerTitle = readerModal?.querySelector('#reader-modal-title') || null; | ||
| defaultReaderHint = readerHint?.textContent || ""; | ||
| const openReaderModal = (trigger) => { | ||
| if (typeof options.onBeforeOpen === "function") { | ||
| options.onBeforeOpen(); | ||
| } | ||
| if (!(trigger instanceof HTMLElement)) { | ||
| return; | ||
| } | ||
| const url = trigger.dataset.readerUrl || ""; | ||
| if (!url) { | ||
| return; | ||
| } | ||
| if (!readerModal || !readerFrame) { | ||
| window.open(url, "_blank", "noopener,noreferrer"); | ||
| return; | ||
| } | ||
| readerTrigger = trigger; | ||
| const bookTitle = trigger.dataset.bookTitle || ""; | ||
| if (readerTitle) { | ||
| readerTitle.textContent = bookTitle ? `${bookTitle} · reader` : "Read & listen"; | ||
| } | ||
| if (readerHint) { | ||
| readerHint.textContent = bookTitle | ||
| ? `Preview ${bookTitle} directly in your browser.` | ||
| : defaultReaderHint; | ||
| } | ||
| readerModal.hidden = false; | ||
| readerModal.dataset.open = "true"; | ||
| document.body.classList.add("modal-open"); | ||
| readerFrame.src = url; | ||
| try { | ||
| readerFrame.focus({ preventScroll: true }); | ||
| } catch (error) { | ||
| // Ignore focus errors. | ||
| } | ||
| }; | ||
| const bindReaderButtons = createBindReaderButtons(openReaderModal); | ||
| bindReaderButtons(); | ||
| document.addEventListener("click", (event) => { | ||
| const closeButton = resolveEventMatch(event, '[data-role="reader-modal-close"]'); | ||
| if (closeButton) { | ||
| event.preventDefault(); | ||
| closeReaderModal(); | ||
| return; | ||
| } | ||
| const trigger = resolveEventMatch(event, '[data-role="open-reader"]'); | ||
| if (trigger instanceof HTMLElement) { | ||
| event.preventDefault(); | ||
| openReaderModal(trigger); | ||
| } | ||
| }); | ||
| document.addEventListener("keydown", (event) => { | ||
| if (event.key === "Escape") { | ||
| closeReaderModal(); | ||
| } | ||
| }); | ||
| document.addEventListener("htmx:afterSwap", (event) => { | ||
| const fragment = event?.detail?.target; | ||
| if (fragment instanceof Element) { | ||
| bindReaderButtons(fragment); | ||
| } else { | ||
| bindReaderButtons(); | ||
| } | ||
| }); | ||
| initialized = true; | ||
| return { | ||
| bindReaderButtons, | ||
| closeReaderModal, | ||
| }; | ||
| }; | ||
| const openReader = (trigger, options) => { | ||
| if (typeof options.onBeforeOpen === "function") { | ||
| options.onBeforeOpen(); | ||
| } | ||
| if (!(trigger instanceof HTMLElement)) { | ||
| return; | ||
| } | ||
| const url = trigger.dataset.readerUrl || ""; | ||
| if (!url) { | ||
| return; | ||
| } | ||
| if (!readerModal || !readerFrame) { | ||
| window.open(url, "_blank", "noopener,noreferrer"); | ||
| return; | ||
| } | ||
| readerTrigger = trigger; | ||
| const bookTitle = trigger.dataset.bookTitle || ""; | ||
| if (readerTitle) { | ||
| readerTitle.textContent = bookTitle ? `${bookTitle} · reader` : "Read & listen"; | ||
| } | ||
| if (readerHint) { | ||
| readerHint.textContent = bookTitle | ||
| ? `Preview ${bookTitle} directly in your browser.` | ||
| : defaultReaderHint; | ||
| } | ||
| readerModal.hidden = false; | ||
| readerModal.dataset.open = "true"; | ||
| document.body.classList.add("modal-open"); | ||
| readerFrame.src = url; | ||
| try { | ||
| readerFrame.focus({ preventScroll: true }); | ||
| } catch (error) { | ||
| // Ignore focus errors. | ||
| } | ||
| }; |
| const form = document.querySelector('.settings__form'); | ||
| const navButtons = Array.from(document.querySelectorAll('.settings-nav__item')); | ||
| const panels = Array.from(document.querySelectorAll('.settings-panel')); | ||
| const llmNavButton = navButtons.find((button) => button.dataset.section === 'llm'); | ||
| const statusSelectors = { | ||
| llm: document.querySelector('[data-role="llm-preview-status"]'), | ||
| normalization: document.querySelector('[data-role="normalization-preview-status"]'), | ||
| calibre: document.querySelector('[data-role="calibre-test-status"]'), | ||
| audiobookshelf: document.querySelector('[data-role="audiobookshelf-test-status"]'), | ||
| }; | ||
| const outputAreas = { | ||
| llm: document.querySelector('[data-role="llm-preview-output"]'), | ||
| normalization: document.querySelector('[data-role="normalization-preview-output"]'), | ||
| }; | ||
| const normalizationAudio = document.querySelector('[data-role="normalization-preview-audio"]'); | ||
| const folderModal = document.querySelector('[data-role="audiobookshelf-folder-modal"]'); | ||
| const folderModalOverlay = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-overlay"]') : null; | ||
| const folderList = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-list"]') : null; | ||
| const folderStatusMessage = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-status"]') : null; | ||
| const folderFilter = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-filter"]') : null; | ||
| const folderEmptyState = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-empty"]') : null; | ||
| const defaultFolderEmptyMessage = folderEmptyState ? folderEmptyState.textContent : 'No folders match your filter.'; | ||
| let folderModalOpener = null; | ||
| let folderModalPreviousFocus = null; | ||
| let audiobookshelfFolderSource = []; | ||
| const contractionModal = document.querySelector('[data-role="contraction-modal"]'); | ||
| const contractionModalOverlay = contractionModal ? contractionModal.querySelector('[data-role="contraction-modal-overlay"]') : null; | ||
| let contractionModalOpener = null; | ||
| let contractionModalPreviousFocus = null; | ||
| function setStatus(target, message, state) { | ||
| if (!target) { | ||
| return; | ||
| } | ||
| target.textContent = message || ''; | ||
| if (state) { | ||
| target.dataset.state = state; | ||
| } else { | ||
| delete target.dataset.state; | ||
| } | ||
| } | ||
| function clearStatus(target) { | ||
| setStatus(target, '', null); | ||
| } | ||
| function activatePanel(section) { | ||
| if (!section) { | ||
| return; | ||
| } | ||
| navButtons.forEach((button) => { | ||
| const isActive = button.dataset.section === section; | ||
| button.classList.toggle('is-active', isActive); | ||
| }); | ||
| let activePanel = null; | ||
| panels.forEach((panel) => { | ||
| const isActive = panel.dataset.section === section; | ||
| panel.classList.toggle('is-active', isActive); | ||
| if (isActive) { | ||
| activePanel = panel; | ||
| } | ||
| }); | ||
| if (activePanel) { | ||
| const focusable = activePanel.querySelector('input, select, textarea'); | ||
| if (focusable) { | ||
| window.requestAnimationFrame(() => { | ||
| focusable.focus({ preventScroll: false }); | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| function initNavigation() { | ||
| if (!navButtons.length || !panels.length) { | ||
| return; | ||
| } | ||
| navButtons.forEach((button) => { | ||
| button.addEventListener('click', () => { | ||
| activatePanel(button.dataset.section); | ||
| if (button.dataset.section) { | ||
| window.history.replaceState(null, '', `#${button.dataset.section}`); | ||
| } | ||
| }); | ||
| }); | ||
| const hash = window.location.hash.replace('#', ''); | ||
| if (hash && panels.some((panel) => panel.dataset.section === hash)) { | ||
| activatePanel(hash); | ||
| } else { | ||
| const current = navButtons.find((button) => button.classList.contains('is-active')); | ||
| if (current) { | ||
| activatePanel(current.dataset.section); | ||
| } | ||
| } | ||
| window.addEventListener('hashchange', () => { | ||
| const section = window.location.hash.replace('#', ''); | ||
| if (section) { | ||
| activatePanel(section); | ||
| } | ||
| }); | ||
| } | ||
| function parseNumber(value, fallback) { | ||
| const parsed = Number.parseFloat(value); | ||
| return Number.isFinite(parsed) ? parsed : fallback; | ||
| } | ||
| function normalizeFolderToken(value) { | ||
| return String(value || '').trim().toLowerCase(); | ||
| } | ||
| function setFolderModalStatus(message, state) { | ||
| if (!folderStatusMessage) { | ||
| return; | ||
| } | ||
| folderStatusMessage.textContent = message || ''; | ||
| if (state) { | ||
| folderStatusMessage.dataset.state = state; | ||
| folderStatusMessage.hidden = false; | ||
| } else { | ||
| delete folderStatusMessage.dataset.state; | ||
| folderStatusMessage.hidden = !message; | ||
| } | ||
| } | ||
| function clearFolderModalContents() { | ||
| if (folderList) { | ||
| folderList.innerHTML = ''; | ||
| } | ||
| if (folderEmptyState) { | ||
| folderEmptyState.textContent = defaultFolderEmptyMessage; | ||
| folderEmptyState.hidden = true; | ||
| } | ||
| } | ||
| function openFolderModal(opener) { | ||
| if (!folderModal) { | ||
| return; | ||
| } | ||
| folderModalOpener = opener || null; | ||
| folderModalPreviousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; | ||
| folderModal.hidden = false; | ||
| folderModal.dataset.open = 'true'; | ||
| document.body.classList.add('modal-open'); | ||
| if (folderFilter) { | ||
| folderFilter.value = ''; | ||
| folderFilter.disabled = true; | ||
| } | ||
| clearFolderModalContents(); | ||
| setFolderModalStatus('Loading folders...', 'loading'); | ||
| } | ||
| function closeFolderModal(event) { | ||
| if (event) { | ||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
| } | ||
| if (!folderModal || folderModal.hidden) { | ||
| return; | ||
| } | ||
| folderModal.dataset.open = 'false'; | ||
| folderModal.hidden = true; | ||
| document.body.classList.remove('modal-open'); | ||
| audiobookshelfFolderSource = []; | ||
| if (folderFilter) { | ||
| folderFilter.value = ''; | ||
| folderFilter.disabled = false; | ||
| } | ||
| clearFolderModalContents(); | ||
| setFolderModalStatus('', null); | ||
| const focusTarget = folderModalPreviousFocus && typeof folderModalPreviousFocus.focus === 'function' | ||
| ? folderModalPreviousFocus | ||
| : folderModalOpener; | ||
| if (focusTarget && typeof focusTarget.focus === 'function') { | ||
| focusTarget.focus({ preventScroll: false }); | ||
| } | ||
| folderModalPreviousFocus = null; | ||
| folderModalOpener = null; | ||
| } | ||
| function openContractionModal(opener) { | ||
| if (!contractionModal) { | ||
| return; | ||
| } | ||
| contractionModalOpener = opener || null; | ||
| contractionModalPreviousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; | ||
| contractionModal.hidden = false; | ||
| contractionModal.dataset.open = 'true'; | ||
| document.body.classList.add('modal-open'); | ||
| const focusTarget = contractionModal.querySelector('input, button, select, textarea'); | ||
| if (focusTarget instanceof HTMLElement) { | ||
| focusTarget.focus({ preventScroll: true }); | ||
| } | ||
| } | ||
| function closeContractionModal(event) { | ||
| if (event) { | ||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
| } | ||
| if (!contractionModal || contractionModal.hidden) { | ||
| return; | ||
| } | ||
| contractionModal.dataset.open = 'false'; | ||
| contractionModal.hidden = true; | ||
| document.body.classList.remove('modal-open'); | ||
| const focusTarget = | ||
| (contractionModalPreviousFocus && typeof contractionModalPreviousFocus.focus === 'function' | ||
| ? contractionModalPreviousFocus | ||
| : contractionModalOpener) || null; | ||
| if (focusTarget && typeof focusTarget.focus === 'function') { | ||
| focusTarget.focus({ preventScroll: true }); | ||
| } | ||
| contractionModalPreviousFocus = null; | ||
| contractionModalOpener = null; | ||
| } | ||
| function initContractionModal() { | ||
| if (!contractionModal) { | ||
| return; | ||
| } | ||
| const openButton = document.querySelector('[data-action="contraction-modal-open"]'); | ||
| if (openButton) { | ||
| openButton.addEventListener('click', () => openContractionModal(openButton)); | ||
| } | ||
| const closeButtons = contractionModal.querySelectorAll('[data-action="contraction-modal-close"]'); | ||
| closeButtons.forEach((button) => { | ||
| button.addEventListener('click', closeContractionModal); | ||
| }); | ||
| if (contractionModalOverlay) { | ||
| contractionModalOverlay.addEventListener('click', closeContractionModal); | ||
| } | ||
| contractionModal.addEventListener('keydown', (event) => { | ||
| if (event.key === 'Escape') { | ||
| event.preventDefault(); | ||
| closeContractionModal(event); | ||
| } | ||
| }); | ||
| } | ||
| function renderFolderList(query) { | ||
| if (!folderList) { | ||
| return; | ||
| } | ||
| folderList.innerHTML = ''; | ||
| const normalizedQuery = normalizeFolderToken(query); | ||
| const matches = audiobookshelfFolderSource.filter((entry) => { | ||
| const tokens = [ | ||
| normalizeFolderToken(entry.name), | ||
| normalizeFolderToken(entry.path), | ||
| normalizeFolderToken(entry.id), | ||
| ]; | ||
| return !normalizedQuery || tokens.some((token) => token.includes(normalizedQuery)); | ||
| }); | ||
| if (!matches.length) { | ||
| if (folderEmptyState) { | ||
| folderEmptyState.textContent = normalizedQuery ? defaultFolderEmptyMessage : 'No folders found for this library.'; | ||
| folderEmptyState.hidden = false; | ||
| } | ||
| return; | ||
| } | ||
| if (folderEmptyState) { | ||
| folderEmptyState.textContent = defaultFolderEmptyMessage; | ||
| folderEmptyState.hidden = true; | ||
| } | ||
| matches.forEach((entry) => { | ||
| const button = document.createElement('button'); | ||
| button.type = 'button'; | ||
| button.className = 'folder-picker__item'; | ||
| button.setAttribute('role', 'option'); | ||
| if (entry.id) { | ||
| button.dataset.folderId = entry.id; | ||
| } | ||
| const displayName = entry.name || entry.path || entry.id || 'Unnamed folder'; | ||
| const nameEl = document.createElement('span'); | ||
| nameEl.className = 'folder-picker__item-name'; | ||
| nameEl.textContent = displayName; | ||
| button.appendChild(nameEl); | ||
| if (entry.path && (!entry.name || entry.path.toLowerCase() !== entry.name.toLowerCase())) { | ||
| const pathEl = document.createElement('span'); | ||
| pathEl.className = 'folder-picker__item-path'; | ||
| pathEl.textContent = entry.path; | ||
| button.appendChild(pathEl); | ||
| } | ||
| if (entry.id) { | ||
| const idEl = document.createElement('span'); | ||
| idEl.className = 'folder-picker__item-id'; | ||
| idEl.textContent = entry.id; | ||
| button.appendChild(idEl); | ||
| } | ||
| button.addEventListener('click', () => handleFolderSelection(entry)); | ||
| folderList.appendChild(button); | ||
| }); | ||
| } | ||
| function populateFolderPicker(entries) { | ||
| audiobookshelfFolderSource = Array.isArray(entries) ? entries : []; | ||
| if (!audiobookshelfFolderSource.length) { | ||
| if (folderFilter) { | ||
| folderFilter.value = ''; | ||
| folderFilter.disabled = true; | ||
| } | ||
| setFolderModalStatus('No folders found for this library.', 'info'); | ||
| if (folderEmptyState) { | ||
| folderEmptyState.textContent = 'No folders found for this library.'; | ||
| folderEmptyState.hidden = false; | ||
| } | ||
| return; | ||
| } | ||
| if (folderFilter) { | ||
| folderFilter.disabled = false; | ||
| folderFilter.value = ''; | ||
| folderFilter.focus({ preventScroll: true }); | ||
| } | ||
| setFolderModalStatus('', null); | ||
| if (folderEmptyState) { | ||
| folderEmptyState.textContent = defaultFolderEmptyMessage; | ||
| folderEmptyState.hidden = true; | ||
| } | ||
| renderFolderList(''); | ||
| } | ||
| function handleFolderSelection(entry) { | ||
| const folderInput = form ? form.querySelector('#audiobookshelf_folder_id') : null; | ||
| if (folderInput) { | ||
| folderInput.value = entry.id || ''; | ||
| folderInput.dispatchEvent(new Event('input', { bubbles: true })); | ||
| } | ||
| closeFolderModal(); | ||
| const status = statusSelectors.audiobookshelf; | ||
| if (status) { | ||
| const label = entry.name || entry.path || entry.id || 'selected folder'; | ||
| setStatus(status, `Selected folder '${label}'.`, 'success'); | ||
| } | ||
| } | ||
| function initFolderPicker() { | ||
| if (!folderModal) { | ||
| return; | ||
| } | ||
| const closeButtons = folderModal.querySelectorAll('[data-action="audiobookshelf-folder-close"]'); | ||
| closeButtons.forEach((button) => { | ||
| button.addEventListener('click', closeFolderModal); | ||
| }); | ||
| if (folderModalOverlay) { | ||
| folderModalOverlay.addEventListener('click', closeFolderModal); | ||
| } | ||
| if (folderFilter) { | ||
| folderFilter.addEventListener('input', () => renderFolderList(folderFilter.value)); | ||
| } | ||
| folderModal.addEventListener('keydown', (event) => { | ||
| if (event.key === 'Escape') { | ||
| event.preventDefault(); | ||
| closeFolderModal(); | ||
| } | ||
| }); | ||
| } | ||
| function collectLLMFields() { | ||
| const baseUrl = form.querySelector('#llm_base_url'); | ||
| const apiKey = form.querySelector('#llm_api_key'); | ||
| const model = form.querySelector('#llm_model'); | ||
| const prompt = form.querySelector('#llm_prompt'); | ||
| const timeout = form.querySelector('#llm_timeout'); | ||
| const context = form.querySelector('input[name="llm_context_mode"]:checked'); | ||
| return { | ||
| base_url: baseUrl ? baseUrl.value.trim() : '', | ||
| api_key: apiKey ? apiKey.value.trim() : '', | ||
| model: model ? model.value.trim() : '', | ||
| prompt: prompt ? prompt.value : '', | ||
| context_mode: context ? context.value : 'sentence', | ||
| timeout: timeout ? parseNumber(timeout.value, 30) : 30, | ||
| }; | ||
| } | ||
| function updateModelOptions(models) { | ||
| const select = form.querySelector('#llm_model'); | ||
| if (!select) { | ||
| return; | ||
| } | ||
| const current = select.dataset.currentModel || select.value; | ||
| select.innerHTML = ''; | ||
| if (!Array.isArray(models) || !models.length) { | ||
| const option = document.createElement('option'); | ||
| option.value = ''; | ||
| option.textContent = 'No models found'; | ||
| select.appendChild(option); | ||
| select.dataset.currentModel = ''; | ||
| select.disabled = true; | ||
| return; | ||
| } | ||
| const fragment = document.createDocumentFragment(); | ||
| let matchedCurrent = false; | ||
| models.forEach((entry) => { | ||
| let identifier = ''; | ||
| let label = ''; | ||
| if (typeof entry === 'string') { | ||
| identifier = entry; | ||
| label = entry; | ||
| } else if (entry && typeof entry === 'object') { | ||
| identifier = String(entry.id || entry.name || entry.label || '').trim(); | ||
| label = String(entry.label || entry.name || identifier || '').trim(); | ||
| } | ||
| if (!identifier) { | ||
| return; | ||
| } | ||
| if (!label) { | ||
| label = identifier; | ||
| } | ||
| const option = document.createElement('option'); | ||
| option.value = identifier; | ||
| option.textContent = label; | ||
| if (identifier === current) { | ||
| option.selected = true; | ||
| matchedCurrent = true; | ||
| } | ||
| fragment.appendChild(option); | ||
| }); | ||
| select.appendChild(fragment); | ||
| if (!matchedCurrent && select.options.length) { | ||
| select.selectedIndex = 0; | ||
| } | ||
| select.dataset.currentModel = select.value || ''; | ||
| select.disabled = false; | ||
| } | ||
| async function refreshModels(button) { | ||
| const status = statusSelectors.llm; | ||
| const llmFields = collectLLMFields(); | ||
| if (!llmFields.base_url) { | ||
| setStatus(status, 'Enter a base URL before refreshing models.', 'error'); | ||
| return; | ||
| } | ||
| clearStatus(status); | ||
| setStatus(status, 'Fetching models…'); | ||
| button.disabled = true; | ||
| try { | ||
| const response = await fetch('/api/llm/models', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| base_url: llmFields.base_url, | ||
| api_key: llmFields.api_key, | ||
| timeout: llmFields.timeout, | ||
| }), | ||
| }); | ||
| const payload = await response.json(); | ||
| if (!response.ok) { | ||
| throw new Error(payload.error || 'Unable to load models.'); | ||
| } | ||
| updateModelOptions(payload.models || []); | ||
| const count = Array.isArray(payload.models) ? payload.models.length : 0; | ||
| if (count) { | ||
| setStatus(status, `Loaded ${count} model${count === 1 ? '' : 's'}.`, 'success'); | ||
| } else { | ||
| setStatus(status, 'No models were returned.', 'error'); | ||
| } | ||
| } catch (error) { | ||
| setStatus(status, error instanceof Error ? error.message : 'Failed to load models.', 'error'); | ||
| } finally { | ||
| button.disabled = false; | ||
| } | ||
| } | ||
| async function previewLLM(button) { | ||
| const status = statusSelectors.llm; | ||
| const output = outputAreas.llm; | ||
| const previewText = document.querySelector('#llm_preview_text'); | ||
| if (!previewText) { | ||
| return; | ||
| } | ||
| const llmFields = collectLLMFields(); | ||
| if (!llmFields.base_url) { | ||
| setStatus(status, 'Enter a base URL to preview.', 'error'); | ||
| return; | ||
| } | ||
| if (!llmFields.model) { | ||
| setStatus(status, 'Select a model to preview.', 'error'); | ||
| return; | ||
| } | ||
| const sample = previewText.value.trim(); | ||
| if (!sample) { | ||
| setStatus(status, 'Add some sample text first.', 'error'); | ||
| return; | ||
| } | ||
| clearStatus(status); | ||
| if (output) { | ||
| output.textContent = ''; | ||
| } | ||
| setStatus(status, 'Generating preview…'); | ||
| button.disabled = true; | ||
| try { | ||
| const response = await fetch('/api/llm/preview', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| text: sample, | ||
| base_url: llmFields.base_url, | ||
| api_key: llmFields.api_key, | ||
| model: llmFields.model, | ||
| prompt: llmFields.prompt, | ||
| context_mode: llmFields.context_mode, | ||
| timeout: llmFields.timeout, | ||
| }), | ||
| }); | ||
| const payload = await response.json(); | ||
| if (!response.ok) { | ||
| throw new Error(payload.error || 'Preview failed.'); | ||
| } | ||
| if (output) { | ||
| output.textContent = payload.normalized_text || ''; | ||
| } | ||
| setStatus(status, 'Preview ready.', 'success'); | ||
| } catch (error) { | ||
| if (output) { | ||
| output.textContent = ''; | ||
| } | ||
| setStatus(status, error instanceof Error ? error.message : 'Preview failed.', 'error'); | ||
| } finally { | ||
| button.disabled = false; | ||
| } | ||
| } | ||
| function collectNormalizationSettings() { | ||
| if (!form) { | ||
| return null; | ||
| } | ||
| const normalization = { | ||
| normalization_numbers: Boolean(form.querySelector('input[name="normalization_numbers"]')?.checked), | ||
| normalization_currency: Boolean(form.querySelector('input[name="normalization_currency"]')?.checked), | ||
| normalization_titles: Boolean(form.querySelector('input[name="normalization_titles"]')?.checked), | ||
| normalization_footnotes: Boolean(form.querySelector('input[name="normalization_footnotes"]')?.checked), | ||
| normalization_terminal: Boolean(form.querySelector('input[name="normalization_terminal"]')?.checked), | ||
| normalization_caps_quotes: Boolean(form.querySelector('input[name="normalization_caps_quotes"]')?.checked), | ||
| normalization_phoneme_hints: Boolean(form.querySelector('input[name="normalization_phoneme_hints"]')?.checked), | ||
| normalization_apostrophes_contractions: Boolean(form.querySelector('input[name="normalization_apostrophes_contractions"]')?.checked), | ||
| normalization_apostrophes_plural_possessives: Boolean(form.querySelector('input[name="normalization_apostrophes_plural_possessives"]')?.checked), | ||
| normalization_apostrophes_sibilant_possessives: Boolean(form.querySelector('input[name="normalization_apostrophes_sibilant_possessives"]')?.checked), | ||
| normalization_apostrophes_decades: Boolean(form.querySelector('input[name="normalization_apostrophes_decades"]')?.checked), | ||
| normalization_apostrophes_leading_elisions: Boolean(form.querySelector('input[name="normalization_apostrophes_leading_elisions"]')?.checked), | ||
| normalization_contraction_aux_be: Boolean(form.querySelector('input[name="normalization_contraction_aux_be"]')?.checked), | ||
| normalization_contraction_aux_have: Boolean(form.querySelector('input[name="normalization_contraction_aux_have"]')?.checked), | ||
| normalization_contraction_modal_will: Boolean(form.querySelector('input[name="normalization_contraction_modal_will"]')?.checked), | ||
| normalization_contraction_modal_would: Boolean(form.querySelector('input[name="normalization_contraction_modal_would"]')?.checked), | ||
| normalization_contraction_negation_not: Boolean(form.querySelector('input[name="normalization_contraction_negation_not"]')?.checked), | ||
| normalization_contraction_let_us: Boolean(form.querySelector('input[name="normalization_contraction_let_us"]')?.checked), | ||
| normalization_apostrophe_mode: form.querySelector('input[name="normalization_apostrophe_mode"]:checked')?.value || 'spacy', | ||
| }; | ||
| return normalization; | ||
| } | ||
| function collectCalibreFields() { | ||
| if (!form) { | ||
| return {}; | ||
| } | ||
| const enabled = Boolean(form.querySelector('input[name="calibre_opds_enabled"]')?.checked); | ||
| const baseUrl = form.querySelector('#calibre_opds_base_url')?.value?.trim() || ''; | ||
| const username = form.querySelector('#calibre_opds_username')?.value?.trim() || ''; | ||
| const passwordInput = form.querySelector('#calibre_opds_password'); | ||
| const password = passwordInput ? passwordInput.value : ''; | ||
| const hasSecret = passwordInput?.dataset.hasSecret === 'true'; | ||
| const clearSaved = Boolean(form.querySelector('input[name="calibre_opds_password_clear"]')?.checked); | ||
| const useSavedPassword = !password && hasSecret && !clearSaved; | ||
| const verify = Boolean(form.querySelector('input[name="calibre_opds_verify_ssl"]')?.checked); | ||
| return { | ||
| enabled, | ||
| base_url: baseUrl, | ||
| username, | ||
| password, | ||
| verify_ssl: verify, | ||
| use_saved_password: useSavedPassword, | ||
| clear_saved_password: clearSaved, | ||
| }; | ||
| } | ||
| function collectAudiobookshelfFields() { | ||
| if (!form) { | ||
| return {}; | ||
| } | ||
| const baseUrl = form.querySelector('#audiobookshelf_base_url')?.value?.trim() || ''; | ||
| const libraryId = form.querySelector('#audiobookshelf_library_id')?.value?.trim() || ''; | ||
| const collectionId = form.querySelector('#audiobookshelf_collection_id')?.value?.trim() || ''; | ||
| const folderId = form.querySelector('#audiobookshelf_folder_id')?.value?.trim() || ''; | ||
| const tokenInput = form.querySelector('#audiobookshelf_api_token'); | ||
| const apiToken = tokenInput?.value?.trim() || ''; | ||
| const hasSecret = tokenInput?.dataset.hasSecret === 'true'; | ||
| const clearToken = Boolean(form.querySelector('input[name="audiobookshelf_api_token_clear"]')?.checked); | ||
| const useSavedToken = !apiToken && hasSecret && !clearToken; | ||
| const timeoutInput = form.querySelector('#audiobookshelf_timeout'); | ||
| const timeout = parseNumber(timeoutInput?.value, 30); | ||
| return { | ||
| enabled: Boolean(form.querySelector('input[name="audiobookshelf_enabled"]')?.checked), | ||
| auto_send: Boolean(form.querySelector('input[name="audiobookshelf_auto_send"]')?.checked), | ||
| verify_ssl: Boolean(form.querySelector('input[name="audiobookshelf_verify_ssl"]')?.checked), | ||
| base_url: baseUrl, | ||
| library_id: libraryId, | ||
| collection_id: collectionId, | ||
| folder_id: folderId, | ||
| api_token: apiToken, | ||
| use_saved_token: useSavedToken, | ||
| clear_saved_token: clearToken, | ||
| timeout, | ||
| send_cover: Boolean(form.querySelector('input[name="audiobookshelf_send_cover"]')?.checked), | ||
| send_chapters: Boolean(form.querySelector('input[name="audiobookshelf_send_chapters"]')?.checked), | ||
| send_subtitles: Boolean(form.querySelector('input[name="audiobookshelf_send_subtitles"]')?.checked), | ||
| }; | ||
| } | ||
| function updateLLMNavState() { | ||
| if (!llmNavButton) { | ||
| return; | ||
| } | ||
| const fields = collectLLMFields(); | ||
| if (fields.base_url && fields.api_key) { | ||
| llmNavButton.classList.remove('is-disabled'); | ||
| } else { | ||
| llmNavButton.classList.add('is-disabled'); | ||
| } | ||
| } | ||
| async function testCalibre(button) { | ||
| const status = statusSelectors.calibre; | ||
| const fields = collectCalibreFields(); | ||
| if (!status) { | ||
| return; | ||
| } | ||
| if (!fields.base_url) { | ||
| setStatus(status, 'Enter a Calibre OPDS base URL to test.', 'error'); | ||
| return; | ||
| } | ||
| clearStatus(status); | ||
| setStatus(status, 'Testing connection…'); | ||
| button.disabled = true; | ||
| try { | ||
| const response = await fetch('/api/integrations/calibre-opds/test', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify(fields), | ||
| }); | ||
| const payload = await response.json(); | ||
| if (!response.ok) { | ||
| throw new Error(payload.error || 'Calibre test failed.'); | ||
| } | ||
| setStatus(status, payload.message || 'Connection successful.', 'success'); | ||
| } catch (error) { | ||
| setStatus(status, error instanceof Error ? error.message : 'Calibre test failed.', 'error'); | ||
| } finally { | ||
| button.disabled = false; | ||
| } | ||
| } | ||
| async function testAudiobookshelf(button) { | ||
| const status = statusSelectors.audiobookshelf; | ||
| const fields = collectAudiobookshelfFields(); | ||
| if (!status) { | ||
| return; | ||
| } | ||
| const hasToken = Boolean(fields.api_token) || Boolean(fields.use_saved_token); | ||
| if (!fields.base_url || !hasToken || !fields.library_id || !fields.folder_id) { | ||
| setStatus(status, 'Enter the base URL, API token, library ID, and folder name or ID to test.', 'error'); | ||
| return; | ||
| } | ||
| clearStatus(status); | ||
| setStatus(status, 'Testing connection…'); | ||
| button.disabled = true; | ||
| try { | ||
| const response = await fetch('/api/integrations/audiobookshelf/test', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify(fields), | ||
| }); | ||
| const payload = await response.json(); | ||
| if (!response.ok) { | ||
| throw new Error(payload.error || 'Audiobookshelf test failed.'); | ||
| } | ||
| setStatus(status, payload.message || 'Connection successful.', 'success'); | ||
| } catch (error) { | ||
| setStatus(status, error instanceof Error ? error.message : 'Audiobookshelf test failed.', 'error'); | ||
| } finally { | ||
| button.disabled = false; | ||
| } | ||
| } | ||
| async function browseAudiobookshelfFolders(button) { | ||
| const status = statusSelectors.audiobookshelf; | ||
| const fields = collectAudiobookshelfFields(); | ||
| if (!status) { | ||
| return; | ||
| } | ||
| const hasToken = Boolean(fields.api_token) || Boolean(fields.use_saved_token); | ||
| if (!fields.base_url || !hasToken || !fields.library_id) { | ||
| setStatus(status, 'Enter the base URL, API token, and library ID before browsing folders.', 'error'); | ||
| return; | ||
| } | ||
| clearStatus(status); | ||
| openFolderModal(button); | ||
| if (!folderModal) { | ||
| setStatus(status, 'Folder picker is unavailable in this view.', 'error'); | ||
| return; | ||
| } | ||
| button.disabled = true; | ||
| try { | ||
| const response = await fetch('/api/integrations/audiobookshelf/folders', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify(fields), | ||
| }); | ||
| const payload = await response.json(); | ||
| if (!response.ok) { | ||
| throw new Error(payload.error || 'Folder lookup failed.'); | ||
| } | ||
| const folders = Array.isArray(payload.folders) ? payload.folders : []; | ||
| const modalActive = folderModal && !folderModal.hidden; | ||
| if (!folders.length) { | ||
| const message = payload.message || 'No folders found for this library.'; | ||
| setStatus(status, message, 'info'); | ||
| if (modalActive) { | ||
| clearFolderModalContents(); | ||
| setFolderModalStatus(message, 'info'); | ||
| } | ||
| return; | ||
| } | ||
| if (!modalActive) { | ||
| setStatus(status, 'Folders loaded.', 'info'); | ||
| return; | ||
| } | ||
| populateFolderPicker(folders); | ||
| setStatus(status, 'Choose a folder below.', 'info'); | ||
| } catch (error) { | ||
| const message = error instanceof Error ? error.message : 'Folder lookup failed.'; | ||
| setStatus(status, message, 'error'); | ||
| if (folderModal && !folderModal.hidden) { | ||
| clearFolderModalContents(); | ||
| setFolderModalStatus(message, 'error'); | ||
| } | ||
| } finally { | ||
| button.disabled = false; | ||
| } | ||
| } | ||
| async function previewNormalization(button) { | ||
| const status = statusSelectors.normalization; | ||
| const output = outputAreas.normalization; | ||
| const textArea = document.querySelector('#normalization_sample_text'); | ||
| const voiceSelect = document.querySelector('#normalization_sample_voice'); | ||
| if (!textArea) { | ||
| return; | ||
| } | ||
| const sample = textArea.value.trim(); | ||
| if (!sample) { | ||
| setStatus(status, 'Enter some text to preview.', 'error'); | ||
| return; | ||
| } | ||
| clearStatus(status); | ||
| if (output) { | ||
| output.textContent = ''; | ||
| } | ||
| if (normalizationAudio) { | ||
| normalizationAudio.hidden = true; | ||
| normalizationAudio.removeAttribute('src'); | ||
| } | ||
| setStatus(status, 'Building preview…'); | ||
| const normalization = collectNormalizationSettings(); | ||
| if (!normalization) { | ||
| setStatus(status, 'Unable to gather normalization settings.', 'error'); | ||
| return; | ||
| } | ||
| const llmFields = collectLLMFields(); | ||
| try { | ||
| const response = await fetch('/api/normalization/preview', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| text: sample, | ||
| voice: voiceSelect ? voiceSelect.value : undefined, | ||
| normalization, | ||
| llm: { | ||
| llm_base_url: llmFields.base_url, | ||
| llm_api_key: llmFields.api_key, | ||
| llm_model: llmFields.model, | ||
| llm_prompt: llmFields.prompt, | ||
| llm_context_mode: llmFields.context_mode, | ||
| llm_timeout: llmFields.timeout, | ||
| }, | ||
| max_seconds: 8, | ||
| }), | ||
| }); | ||
| const payload = await response.json(); | ||
| if (!response.ok) { | ||
| throw new Error(payload.error || 'Preview failed.'); | ||
| } | ||
| if (output) { | ||
| output.textContent = payload.normalized_text || ''; | ||
| } | ||
| if (payload.audio_base64 && normalizationAudio) { | ||
| normalizationAudio.src = `data:audio/wav;base64,${payload.audio_base64}`; | ||
| normalizationAudio.hidden = false; | ||
| normalizationAudio.load(); | ||
| normalizationAudio.play().catch(() => { | ||
| /* autoplay can fail; ignore */ | ||
| }); | ||
| } | ||
| setStatus(status, 'Preview updated.', 'success'); | ||
| } catch (error) { | ||
| if (output) { | ||
| output.textContent = ''; | ||
| } | ||
| if (normalizationAudio) { | ||
| normalizationAudio.hidden = true; | ||
| normalizationAudio.removeAttribute('src'); | ||
| } | ||
| setStatus(status, error instanceof Error ? error.message : 'Preview failed.', 'error'); | ||
| } finally { | ||
| button.disabled = false; | ||
| } | ||
| } | ||
| function initSampleSelector() { | ||
| const select = document.querySelector('#normalization_sample_select'); | ||
| const textArea = document.querySelector('#normalization_sample_text'); | ||
| if (!select || !textArea) { | ||
| return; | ||
| } | ||
| select.addEventListener('change', () => { | ||
| const option = select.selectedOptions[0]; | ||
| if (option) { | ||
| textArea.value = option.value; | ||
| } | ||
| }); | ||
| } | ||
| function initActions() { | ||
| const refreshButton = document.querySelector('[data-action="llm-refresh-models"]'); | ||
| if (refreshButton) { | ||
| refreshButton.addEventListener('click', () => refreshModels(refreshButton)); | ||
| } | ||
| const llmPreviewButton = document.querySelector('[data-action="llm-preview"]'); | ||
| if (llmPreviewButton) { | ||
| llmPreviewButton.addEventListener('click', () => previewLLM(llmPreviewButton)); | ||
| } | ||
| const normalizationButton = document.querySelector('[data-action="normalization-preview"]'); | ||
| if (normalizationButton) { | ||
| normalizationButton.addEventListener('click', () => previewNormalization(normalizationButton)); | ||
| } | ||
| const calibreTestButton = document.querySelector('[data-action="calibre-test"]'); | ||
| if (calibreTestButton) { | ||
| calibreTestButton.addEventListener('click', () => testCalibre(calibreTestButton)); | ||
| } | ||
| const audiobookshelfTestButton = document.querySelector('[data-action="audiobookshelf-test"]'); | ||
| if (audiobookshelfTestButton) { | ||
| audiobookshelfTestButton.addEventListener('click', () => testAudiobookshelf(audiobookshelfTestButton)); | ||
| } | ||
| const audiobookshelfBrowseButton = document.querySelector('[data-action="audiobookshelf-list-folders"]'); | ||
| if (audiobookshelfBrowseButton) { | ||
| audiobookshelfBrowseButton.addEventListener('click', () => browseAudiobookshelfFolders(audiobookshelfBrowseButton)); | ||
| } | ||
| } | ||
| function initLLMStateWatchers() { | ||
| const baseUrlInput = form.querySelector('#llm_base_url'); | ||
| const apiKeyInput = form.querySelector('#llm_api_key'); | ||
| if (!baseUrlInput || !apiKeyInput) { | ||
| return; | ||
| } | ||
| const handler = () => updateLLMNavState(); | ||
| baseUrlInput.addEventListener('input', handler); | ||
| apiKeyInput.addEventListener('input', handler); | ||
| updateLLMNavState(); | ||
| } | ||
| if (form) { | ||
| initNavigation(); | ||
| initSampleSelector(); | ||
| initActions(); | ||
| initFolderPicker(); | ||
| initContractionModal(); | ||
| initLLMStateWatchers(); | ||
| } |
| const initSpeakerConfigsPage = () => { | ||
| const form = document.getElementById("speaker-config-form"); | ||
| if (!form) return; | ||
| const rowsContainer = form.querySelector('[data-role="speaker-rows"]'); | ||
| const template = document.getElementById("speaker-row-template"); | ||
| const addButtons = form.querySelectorAll('[data-action="add-speaker"]'); | ||
| const ensureEmptyState = () => { | ||
| if (!rowsContainer) return; | ||
| const hasRows = rowsContainer.querySelector('[data-role="speaker-row"]'); | ||
| let emptyState = rowsContainer.querySelector('[data-role="empty-state"]'); | ||
| if (hasRows && emptyState) { | ||
| emptyState.remove(); | ||
| emptyState = null; | ||
| } | ||
| if (!hasRows && !emptyState) { | ||
| const placeholder = document.createElement("div"); | ||
| placeholder.className = "speaker-config-rows__empty"; | ||
| placeholder.dataset.role = "empty-state"; | ||
| placeholder.textContent = "No speakers yet. Add your first character."; | ||
| rowsContainer.appendChild(placeholder); | ||
| } | ||
| }; | ||
| const hydrateRow = (fragment, key) => { | ||
| const elements = fragment.querySelectorAll("[name], [id], label[for], [data-row-id]"); | ||
| elements.forEach((el) => { | ||
| if (el.name) { | ||
| el.name = el.name.replace(/__ROW__/g, key); | ||
| } | ||
| if (el.id) { | ||
| el.id = el.id.replace(/__ROW__/g, key); | ||
| } | ||
| if (el.tagName === "LABEL") { | ||
| const forValue = el.getAttribute("for"); | ||
| if (forValue) { | ||
| el.setAttribute("for", forValue.replace(/__ROW__/g, key)); | ||
| } | ||
| } | ||
| if (el.dataset && el.dataset.rowId) { | ||
| el.dataset.rowId = key; | ||
| } | ||
| }); | ||
| const hiddenId = fragment.querySelector(`input[name="speaker-${key}-id"]`); | ||
| if (hiddenId && !hiddenId.value) { | ||
| hiddenId.value = key; | ||
| } | ||
| const rowMarkers = fragment.querySelectorAll('input[name="speaker_rows"]'); | ||
| rowMarkers.forEach((marker) => { | ||
| marker.value = key; | ||
| }); | ||
| }; | ||
| const addRow = () => { | ||
| if (!template || !rowsContainer) return; | ||
| const key = `row-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; | ||
| const fragment = template.content.cloneNode(true); | ||
| hydrateRow(fragment, key); | ||
| rowsContainer.appendChild(fragment); | ||
| ensureEmptyState(); | ||
| const newRow = rowsContainer.querySelector(`[data-row-id="${key}"]`); | ||
| if (newRow) { | ||
| const input = newRow.querySelector("input[type=text]"); | ||
| if (input) { | ||
| input.focus(); | ||
| } | ||
| } | ||
| }; | ||
| addButtons.forEach((button) => { | ||
| button.addEventListener("click", (event) => { | ||
| event.preventDefault(); | ||
| addRow(); | ||
| }); | ||
| }); | ||
| rowsContainer?.addEventListener("click", (event) => { | ||
| const removeButton = event.target.closest('[data-action="remove-speaker"]'); | ||
| if (!removeButton) return; | ||
| event.preventDefault(); | ||
| const row = removeButton.closest('[data-role="speaker-row"]'); | ||
| if (row) { | ||
| row.remove(); | ||
| ensureEmptyState(); | ||
| } | ||
| }); | ||
| ensureEmptyState(); | ||
| }; | ||
| if (document.readyState === "loading") { | ||
| document.addEventListener("DOMContentLoaded", initSpeakerConfigsPage, { once: true }); | ||
| } else { | ||
| initSpeakerConfigsPage(); | ||
| } |
| const audioElement = new Audio(); | ||
| let activeButton = null; | ||
| let activeUrl = null; | ||
| const setLoadingState = (button, isLoading) => { | ||
| if (!button) return; | ||
| button.disabled = isLoading; | ||
| if (isLoading) { | ||
| button.setAttribute("data-loading", "true"); | ||
| } else { | ||
| button.removeAttribute("data-loading"); | ||
| } | ||
| }; | ||
| const stopCurrentPlayback = () => { | ||
| if (audioElement && !audioElement.paused) { | ||
| audioElement.pause(); | ||
| } | ||
| if (activeUrl) { | ||
| URL.revokeObjectURL(activeUrl); | ||
| activeUrl = null; | ||
| } | ||
| if (activeButton) { | ||
| setLoadingState(activeButton, false); | ||
| activeButton = null; | ||
| } | ||
| }; | ||
| const resolvePreviewText = (button) => { | ||
| const source = (button.dataset.previewSource || "").toLowerCase(); | ||
| if (source === "pronunciation") { | ||
| const container = button.closest(".speaker-list__item"); | ||
| if (container) { | ||
| const input = container.querySelector('[data-role="speaker-pronunciation"]'); | ||
| const fallback = (container.dataset.defaultPronunciation || "").trim(); | ||
| const value = (input?.value || "").trim() || fallback; | ||
| button.dataset.previewText = value; | ||
| return value; | ||
| } | ||
| } | ||
| return (button.dataset.previewText || "").trim(); | ||
| }; | ||
| audioElement.addEventListener("ended", () => { | ||
| stopCurrentPlayback(); | ||
| }); | ||
| audioElement.addEventListener("pause", () => { | ||
| if (audioElement.currentTime === 0 || audioElement.currentTime >= audioElement.duration) { | ||
| stopCurrentPlayback(); | ||
| } | ||
| }); | ||
| const playPreview = async (button) => { | ||
| const text = resolvePreviewText(button); | ||
| const voice = (button.dataset.voice || "").trim(); | ||
| const language = (button.dataset.language || "a").trim() || "a"; | ||
| const speedRaw = button.dataset.speed || "1"; | ||
| const useGpu = (button.dataset.useGpu || "true") !== "false"; | ||
| const speed = Number.parseFloat(speedRaw); | ||
| if (!text) { | ||
| console.warn("Skipping speaker preview: no text provided"); | ||
| return; | ||
| } | ||
| if (!voice) { | ||
| console.warn("Skipping speaker preview: no voice provided"); | ||
| return; | ||
| } | ||
| const payload = { | ||
| text, | ||
| voice, | ||
| language, | ||
| speed: Number.isFinite(speed) ? speed : 1.0, | ||
| use_gpu: useGpu, | ||
| max_seconds: 8, | ||
| }; | ||
| const pendingId = | ||
| button.dataset.pendingId || | ||
| button.closest("[data-pending-id]")?.dataset.pendingId || | ||
| document.querySelector('[data-role="prepare-form"]')?.dataset.pendingId || | ||
| ""; | ||
| if (pendingId) { | ||
| payload.pending_id = pendingId; | ||
| } | ||
| stopCurrentPlayback(); | ||
| activeButton = button; | ||
| setLoadingState(button, true); | ||
| try { | ||
| const response = await fetch("/api/speaker-preview", { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify(payload), | ||
| }); | ||
| if (!response.ok) { | ||
| const message = await response.text(); | ||
| throw new Error(message || `Preview failed with status ${response.status}`); | ||
| } | ||
| const blob = await response.blob(); | ||
| activeUrl = URL.createObjectURL(blob); | ||
| audioElement.src = activeUrl; | ||
| await audioElement.play(); | ||
| } catch (error) { | ||
| console.error("Failed to play speaker preview", error); | ||
| stopCurrentPlayback(); | ||
| } finally { | ||
| setLoadingState(button, false); | ||
| } | ||
| }; | ||
| document.addEventListener("click", (event) => { | ||
| const trigger = event.target.closest('[data-role="speaker-preview"]'); | ||
| if (!trigger) return; | ||
| event.preventDefault(); | ||
| if (trigger.disabled) return; | ||
| playPreview(trigger); | ||
| }); |
Sorry, the diff of this file is too big to display
| const setupVoiceMixer = () => { | ||
| const data = window.ABOGEN_VOICE_MIXER_DATA || {}; | ||
| const languages = data.languages || {}; | ||
| const voiceCatalog = Array.isArray(data.voice_catalog) ? data.voice_catalog : []; | ||
| const samples = data.sample_voice_texts || {}; | ||
| let profiles = data.voice_profiles_data || {}; | ||
| const app = document.getElementById("voice-mixer-app"); | ||
| if (!app) { | ||
| return; | ||
| } | ||
| const profileListEl = app.querySelector('[data-role="profile-list"]'); | ||
| const statusEl = app.querySelector('[data-role="status"]'); | ||
| const saveBtn = app.querySelector('[data-role="save-profile"]'); | ||
| const duplicateBtn = app.querySelector('[data-role="duplicate-profile"]'); | ||
| const deleteBtn = app.querySelector('[data-role="delete-profile"]'); | ||
| const previewBtn = app.querySelector('[data-role="preview-button"]'); | ||
| const loadSampleBtn = app.querySelector('[data-role="load-sample"]'); | ||
| const previewTextEl = app.querySelector('[data-role="preview-text"]'); | ||
| const previewAudio = app.querySelector('[data-role="preview-audio"]'); | ||
| const previewSpeedLabel = app.querySelector('[data-role="preview-speed-display"]'); | ||
| const profileSummaryEl = app.querySelector('[data-role="profile-summary"]'); | ||
| const mixTotalEl = app.querySelector('[data-role="mix-total"]'); | ||
| const nameInput = document.getElementById("profile-name"); | ||
| const languageSelect = document.getElementById("profile-language"); | ||
| const languageField = app.querySelector(".voice-editor__language"); | ||
| const providerSelect = document.getElementById("profile-provider"); | ||
| const kokoroMixerEl = app.querySelector('[data-role="kokoro-mixer"]'); | ||
| const supertonicPanelEl = app.querySelector('[data-role="supertonic-panel"]'); | ||
| const supertonicVoiceSelect = app.querySelector('[data-role="supertonic-voice"]'); | ||
| const supertonicStepsInput = app.querySelector('[data-role="supertonic-steps"]'); | ||
| const supertonicSpeedInput = app.querySelector('[data-role="supertonic-speed"]'); | ||
| const supertonicStepsLabel = app.querySelector('[data-role="supertonic-steps-display"]'); | ||
| const supertonicSpeedLabel = app.querySelector('[data-role="supertonic-speed-display"]'); | ||
| const speedInput = document.getElementById("preview-speed"); | ||
| const importInput = document.getElementById("voice-import-input"); | ||
| const headerActions = document.querySelector(".voice-mixer__header-actions"); | ||
| const availableListEl = app.querySelector('[data-role="available-voices"]'); | ||
| const selectedListEl = app.querySelector('[data-role="selected-voices"]'); | ||
| const dropzoneEl = app.querySelector('[data-role="dropzone"]'); | ||
| const emptyStateEl = app.querySelector('[data-role="mix-empty"]'); | ||
| const voiceFilterSelect = app.querySelector('[data-role="voice-filter"]'); | ||
| const genderFilterEl = app.querySelector('[data-role="gender-filter"]'); | ||
| const providerPickerModal = document.querySelector('[data-role="provider-picker-modal"]'); | ||
| const providerPickerOverlay = document.querySelector('[data-role="provider-picker-overlay"]'); | ||
| const providerPickerClose = document.querySelector('[data-role="provider-picker-close"]'); | ||
| const providerPickerCancel = document.querySelector('[data-role="provider-picker-cancel"]'); | ||
| const providerPickerConfirm = document.querySelector('[data-role="provider-picker-confirm"]'); | ||
| const providerPickerOptions = document.querySelector('[data-role="provider-picker-options"]'); | ||
| if (previewBtn && !previewBtn.dataset.label) { | ||
| previewBtn.dataset.label = previewBtn.textContent.trim(); | ||
| } | ||
| if (!profileListEl || !availableListEl || !selectedListEl) { | ||
| return; | ||
| } | ||
| const voiceLookup = new Map(); | ||
| voiceCatalog.forEach((voice) => { | ||
| if (voice && voice.id) { | ||
| voiceLookup.set(voice.id, voice); | ||
| } | ||
| }); | ||
| const availableCards = new Map(); | ||
| const selectedControls = new Map(); | ||
| const state = { | ||
| selectedProfile: null, | ||
| originalName: null, | ||
| dirty: false, | ||
| previewUrl: null, | ||
| draft: { | ||
| name: "", | ||
| provider: "kokoro", | ||
| language: "a", | ||
| voices: new Map(), | ||
| supertonic: { | ||
| voice: "M1", | ||
| total_steps: 5, | ||
| speed: 1.0, | ||
| }, | ||
| }, | ||
| languageFilter: voiceFilterSelect ? voiceFilterSelect.value : "", | ||
| genderFilter: "", | ||
| }; | ||
| let statusTimeout = null; | ||
| const clamp = (value, min, max) => Math.min(Math.max(value, min), max); | ||
| const formatWeight = (value) => value.toFixed(2); | ||
| const setSliderFill = (slider, weight) => { | ||
| const percent = Math.round(clamp(weight, 0, 1) * 100); | ||
| slider.style.background = `linear-gradient(90deg, var(--accent) 0%, var(--accent) ${percent}%, rgba(148, 163, 184, 0.25) ${percent}%, rgba(148, 163, 184, 0.25) 100%)`; | ||
| }; | ||
| const setRangeFill = (slider) => { | ||
| if (!slider) return; | ||
| const min = parseFloat(slider.min || "0"); | ||
| const max = parseFloat(slider.max || "1"); | ||
| const value = parseFloat(slider.value || String(min)); | ||
| const percent = max === min ? 0 : Math.round(((value - min) / (max - min)) * 100); | ||
| slider.style.background = `linear-gradient(90deg, var(--accent) 0%, var(--accent) ${percent}%, rgba(148, 163, 184, 0.25) ${percent}%, rgba(148, 163, 184, 0.25) 100%)`; | ||
| }; | ||
| const voiceGenderIcon = (gender) => { | ||
| if (!gender) return "•"; | ||
| const initial = gender[0].toLowerCase(); | ||
| if (initial === "f") return "♀"; | ||
| if (initial === "m") return "♂"; | ||
| return "•"; | ||
| }; | ||
| const voiceLanguageLabel = (code) => languages[code] || code?.toUpperCase() || ""; | ||
| const clearStatus = () => { | ||
| if (statusTimeout) { | ||
| clearTimeout(statusTimeout); | ||
| statusTimeout = null; | ||
| } | ||
| if (statusEl) { | ||
| statusEl.textContent = ""; | ||
| statusEl.className = "voice-status"; | ||
| } | ||
| }; | ||
| const setStatus = (message, tone = "info", timeout = 4000) => { | ||
| if (!statusEl) return; | ||
| clearStatus(); | ||
| statusEl.textContent = message; | ||
| statusEl.className = `voice-status voice-status--${tone}`; | ||
| if (timeout > 0) { | ||
| statusTimeout = window.setTimeout(() => { | ||
| clearStatus(); | ||
| }, timeout); | ||
| } | ||
| }; | ||
| const mixTotal = () => { | ||
| let total = 0; | ||
| state.draft.voices.forEach((weight) => { | ||
| total += weight; | ||
| }); | ||
| return total; | ||
| }; | ||
| const normalizeProvider = (value) => { | ||
| const candidate = String(value || "").trim().toLowerCase(); | ||
| return candidate === "supertonic" ? "supertonic" : "kokoro"; | ||
| }; | ||
| const getProviderCatalog = () => { | ||
| if (!providerSelect) { | ||
| return [ | ||
| { id: "kokoro", label: "Kokoro" }, | ||
| { id: "supertonic", label: "Supertonic" }, | ||
| ]; | ||
| } | ||
| return Array.from(providerSelect.options || []).map((option) => ({ | ||
| id: normalizeProvider(option.value), | ||
| label: option.textContent?.trim() || option.value, | ||
| })); | ||
| }; | ||
| const providerDescription = (providerId) => { | ||
| const provider = normalizeProvider(providerId); | ||
| if (provider === "supertonic") { | ||
| return "Voice selection + quality/speed per speaker."; | ||
| } | ||
| return "Voice mixing supported via the Kokoro mixer."; | ||
| }; | ||
| const openProviderPicker = (defaultProvider = "kokoro") => { | ||
| if (!providerPickerModal || !providerPickerOptions || !providerPickerConfirm) { | ||
| return Promise.resolve(normalizeProvider(defaultProvider)); | ||
| } | ||
| providerPickerOptions.innerHTML = ""; | ||
| const catalog = getProviderCatalog(); | ||
| const normalizedDefault = normalizeProvider(defaultProvider); | ||
| catalog.forEach((item) => { | ||
| const label = document.createElement("label"); | ||
| label.className = "toggle-pill"; | ||
| const input = document.createElement("input"); | ||
| input.type = "radio"; | ||
| input.name = "provider-picker"; | ||
| input.value = item.id; | ||
| input.checked = item.id === normalizedDefault; | ||
| const span = document.createElement("span"); | ||
| const title = document.createElement("strong"); | ||
| title.textContent = item.label; | ||
| const detail = document.createElement("span"); | ||
| detail.className = "muted"; | ||
| detail.textContent = ` — ${providerDescription(item.id)}`; | ||
| span.appendChild(title); | ||
| span.appendChild(detail); | ||
| label.appendChild(input); | ||
| label.appendChild(span); | ||
| providerPickerOptions.appendChild(label); | ||
| }); | ||
| const selectedRadio = providerPickerOptions.querySelector('input[name="provider-picker"]:checked'); | ||
| providerPickerConfirm.disabled = !selectedRadio; | ||
| return new Promise((resolve) => { | ||
| let resolved = false; | ||
| const teardown = () => { | ||
| providerPickerModal.dataset.open = "false"; | ||
| providerPickerModal.hidden = true; | ||
| document.body.classList.remove("modal-open"); | ||
| document.removeEventListener("keydown", onKeydown); | ||
| providerPickerOptions.removeEventListener("change", onChange); | ||
| providerPickerOverlay?.removeEventListener("click", onCancel); | ||
| providerPickerClose?.removeEventListener("click", onCancel); | ||
| providerPickerCancel?.removeEventListener("click", onCancel); | ||
| providerPickerConfirm?.removeEventListener("click", onConfirm); | ||
| }; | ||
| const finish = (value) => { | ||
| if (resolved) return; | ||
| resolved = true; | ||
| teardown(); | ||
| resolve(value); | ||
| }; | ||
| const onCancel = () => finish(null); | ||
| const onConfirm = () => { | ||
| const selected = providerPickerOptions.querySelector('input[name="provider-picker"]:checked'); | ||
| finish(selected ? normalizeProvider(selected.value) : null); | ||
| }; | ||
| const onChange = () => { | ||
| const selected = providerPickerOptions.querySelector('input[name="provider-picker"]:checked'); | ||
| providerPickerConfirm.disabled = !selected; | ||
| }; | ||
| const onKeydown = (event) => { | ||
| if (event.key === "Escape") { | ||
| event.preventDefault(); | ||
| onCancel(); | ||
| } | ||
| }; | ||
| providerPickerModal.hidden = false; | ||
| providerPickerModal.dataset.open = "true"; | ||
| document.body.classList.add("modal-open"); | ||
| document.addEventListener("keydown", onKeydown); | ||
| providerPickerOptions.addEventListener("change", onChange); | ||
| providerPickerOverlay?.addEventListener("click", onCancel); | ||
| providerPickerClose?.addEventListener("click", onCancel); | ||
| providerPickerCancel?.addEventListener("click", onCancel); | ||
| providerPickerConfirm?.addEventListener("click", onConfirm); | ||
| const focusTarget = providerPickerOptions.querySelector('input[name="provider-picker"]:checked') | ||
| || providerPickerOptions.querySelector('input[name="provider-picker"]'); | ||
| if (focusTarget instanceof HTMLElement) { | ||
| focusTarget.focus(); | ||
| } | ||
| }); | ||
| }; | ||
| const applyProviderToUI = () => { | ||
| const provider = normalizeProvider(state.draft.provider); | ||
| const isSupertonic = provider === "supertonic"; | ||
| if (providerSelect) { | ||
| providerSelect.value = provider; | ||
| } | ||
| if (languageField) { | ||
| languageField.hidden = isSupertonic; | ||
| } | ||
| if (kokoroMixerEl) { | ||
| kokoroMixerEl.hidden = isSupertonic; | ||
| } | ||
| if (supertonicPanelEl) { | ||
| supertonicPanelEl.hidden = !isSupertonic; | ||
| } | ||
| if (mixTotalEl) { | ||
| mixTotalEl.hidden = isSupertonic; | ||
| } | ||
| if (previewBtn) { | ||
| previewBtn.dataset.label = isSupertonic ? "Preview speaker" : (previewBtn.dataset.label || "Preview speaker"); | ||
| } | ||
| // Keep preview speed aligned with the Supertonic speaker speed. | ||
| if (isSupertonic && speedInput) { | ||
| const desired = Number(state.draft.supertonic?.speed ?? 1.0); | ||
| if (!Number.isNaN(desired)) { | ||
| speedInput.value = String(desired); | ||
| setRangeFill(speedInput); | ||
| } | ||
| } | ||
| }; | ||
| const updateMixSummary = () => { | ||
| const provider = normalizeProvider(state.draft.provider); | ||
| const isSupertonic = provider === "supertonic"; | ||
| if (mixTotalEl && !isSupertonic) { | ||
| mixTotalEl.textContent = `Total weight: ${formatWeight(mixTotal())}`; | ||
| } | ||
| if (profileSummaryEl) { | ||
| const voiceCount = state.draft.voices.size; | ||
| if (!state.draft.name && !voiceCount) { | ||
| profileSummaryEl.textContent = "Select or create a speaker to begin."; | ||
| } else { | ||
| const profileLabel = state.draft.name ? `Editing: ${state.draft.name}` : "Unsaved speaker"; | ||
| if (isSupertonic) { | ||
| profileSummaryEl.textContent = `${profileLabel} · Supertonic`; | ||
| } else { | ||
| profileSummaryEl.textContent = `${profileLabel} · ${voiceCount} voice${voiceCount === 1 ? "" : "s"}`; | ||
| } | ||
| } | ||
| } | ||
| }; | ||
| const markDirty = () => { | ||
| state.dirty = true; | ||
| if (saveBtn) { | ||
| saveBtn.disabled = false; | ||
| } | ||
| }; | ||
| const resetDirty = () => { | ||
| state.dirty = false; | ||
| if (saveBtn) { | ||
| saveBtn.disabled = true; | ||
| } | ||
| }; | ||
| const ensureEmptyState = () => { | ||
| if (!emptyStateEl) return; | ||
| emptyStateEl.hidden = state.draft.voices.size > 0; | ||
| }; | ||
| const updateAvailableState = () => { | ||
| availableCards.forEach(({ card, addButton }, voiceId) => { | ||
| const isActive = state.draft.voices.has(voiceId); | ||
| card.classList.toggle("is-active", isActive); | ||
| if (addButton) { | ||
| addButton.disabled = isActive; | ||
| addButton.textContent = isActive ? "Added" : "Add"; | ||
| } | ||
| }); | ||
| }; | ||
| const updateGenderFilterButtons = () => { | ||
| if (!genderFilterEl) return; | ||
| const buttons = genderFilterEl.querySelectorAll("[data-value]"); | ||
| buttons.forEach((button) => { | ||
| const value = button.getAttribute("data-value") || ""; | ||
| const pressed = value === state.genderFilter; | ||
| button.setAttribute("aria-pressed", pressed ? "true" : "false"); | ||
| }); | ||
| }; | ||
| const setSliderFocus = (voiceId) => { | ||
| const control = selectedControls.get(voiceId); | ||
| if (control?.slider) { | ||
| control.slider.focus({ preventScroll: false }); | ||
| } | ||
| }; | ||
| const renderSelectedVoices = () => { | ||
| selectedControls.clear(); | ||
| selectedListEl.innerHTML = ""; | ||
| state.draft.voices.forEach((weight, voiceId) => { | ||
| const meta = voiceLookup.get(voiceId) || {}; | ||
| const card = document.createElement("div"); | ||
| card.className = "mix-voice"; | ||
| card.dataset.voiceId = voiceId; | ||
| const header = document.createElement("div"); | ||
| header.className = "mix-voice__header"; | ||
| const titleWrap = document.createElement("div"); | ||
| titleWrap.className = "mix-voice__info"; | ||
| const title = document.createElement("div"); | ||
| title.className = "mix-voice__title"; | ||
| title.textContent = meta.display_name || meta.name || voiceId; | ||
| const metaLabel = document.createElement("div"); | ||
| metaLabel.className = "mix-voice__meta"; | ||
| const languageCode = meta.language || voiceId.charAt(0) || "a"; | ||
| metaLabel.textContent = `${voiceLanguageLabel(languageCode)} · ${voiceGenderIcon(meta.gender)}`; | ||
| titleWrap.appendChild(title); | ||
| titleWrap.appendChild(metaLabel); | ||
| const weightLabel = document.createElement("span"); | ||
| weightLabel.className = "mix-voice__weight"; | ||
| weightLabel.textContent = formatWeight(weight); | ||
| const removeBtn = document.createElement("button"); | ||
| removeBtn.type = "button"; | ||
| removeBtn.className = "mix-voice__remove"; | ||
| removeBtn.setAttribute("aria-label", `Remove ${title.textContent} from mix`); | ||
| removeBtn.innerHTML = "×"; | ||
| removeBtn.addEventListener("click", () => { | ||
| state.draft.voices.delete(voiceId); | ||
| renderSelectedVoices(); | ||
| updateAvailableState(); | ||
| updateMixSummary(); | ||
| markDirty(); | ||
| }); | ||
| header.appendChild(titleWrap); | ||
| header.appendChild(weightLabel); | ||
| header.appendChild(removeBtn); | ||
| const slider = document.createElement("input"); | ||
| slider.type = "range"; | ||
| slider.min = "5"; | ||
| slider.max = "100"; | ||
| slider.step = "1"; | ||
| slider.className = "mix-slider"; | ||
| const normalizedWeight = clamp(weight, 0.05, 1); | ||
| slider.value = String(Math.round(normalizedWeight * 100)); | ||
| setSliderFill(slider, normalizedWeight); | ||
| slider.addEventListener("input", () => { | ||
| const value = clamp(Number(slider.value) / 100, 0.05, 1); | ||
| slider.value = String(Math.round(value * 100)); | ||
| state.draft.voices.set(voiceId, value); | ||
| weightLabel.textContent = formatWeight(value); | ||
| setSliderFill(slider, value); | ||
| updateMixSummary(); | ||
| markDirty(); | ||
| }); | ||
| card.appendChild(header); | ||
| card.appendChild(slider); | ||
| selectedListEl.appendChild(card); | ||
| selectedControls.set(voiceId, { slider, weightLabel }); | ||
| }); | ||
| ensureEmptyState(); | ||
| }; | ||
| const renderAvailableVoices = () => { | ||
| availableCards.clear(); | ||
| availableListEl.innerHTML = ""; | ||
| const sortedVoices = voiceCatalog | ||
| .slice() | ||
| .sort((a, b) => (a.display_name || a.id).localeCompare(b.display_name || b.id)); | ||
| const filteredVoices = sortedVoices.filter((voice) => { | ||
| const languageCode = voice.language || voice.id?.charAt(0) || "a"; | ||
| const languageMatch = !state.languageFilter || state.languageFilter === languageCode; | ||
| const genderCode = (voice.gender || "").charAt(0).toLowerCase(); | ||
| const genderMatch = !state.genderFilter || state.genderFilter === genderCode; | ||
| return languageMatch && genderMatch; | ||
| }); | ||
| if (!filteredVoices.length) { | ||
| const empty = document.createElement("p"); | ||
| empty.className = "voice-available__empty"; | ||
| const filters = []; | ||
| if (state.languageFilter) { | ||
| filters.push(voiceLanguageLabel(state.languageFilter) || state.languageFilter.toUpperCase()); | ||
| } | ||
| if (state.genderFilter) { | ||
| filters.push(state.genderFilter === "f" ? "♀ Female" : "♂ Male"); | ||
| } | ||
| if (filters.length) { | ||
| empty.innerHTML = `No voices match <strong>${filters.join(" · ")}</strong>.`; | ||
| } else { | ||
| empty.textContent = "No voices available."; | ||
| } | ||
| availableListEl.appendChild(empty); | ||
| updateAvailableState(); | ||
| updateGenderFilterButtons(); | ||
| return; | ||
| } | ||
| filteredVoices.forEach((voice) => { | ||
| if (!voice?.id) { | ||
| return; | ||
| } | ||
| const card = document.createElement("article"); | ||
| card.className = "voice-available__card"; | ||
| card.draggable = true; | ||
| card.dataset.voiceId = voice.id; | ||
| card.tabIndex = 0; | ||
| card.addEventListener("dragstart", (event) => { | ||
| card.classList.add("is-dragging"); | ||
| if (event.dataTransfer) { | ||
| event.dataTransfer.effectAllowed = "copy"; | ||
| event.dataTransfer.setData("text/plain", voice.id); | ||
| } | ||
| }); | ||
| card.addEventListener("dragend", () => { | ||
| card.classList.remove("is-dragging"); | ||
| }); | ||
| card.addEventListener("dblclick", () => { | ||
| addVoiceToDraft(voice.id); | ||
| }); | ||
| card.addEventListener("keydown", (event) => { | ||
| if (event.key === "Enter" || event.key === " ") { | ||
| event.preventDefault(); | ||
| addVoiceToDraft(voice.id); | ||
| } | ||
| }); | ||
| const info = document.createElement("div"); | ||
| info.className = "voice-available__info"; | ||
| const name = document.createElement("div"); | ||
| name.className = "voice-available__name"; | ||
| name.textContent = voice.display_name || voice.id; | ||
| const meta = document.createElement("div"); | ||
| meta.className = "voice-available__meta"; | ||
| const languageCode = voice.language || voice.id.charAt(0) || "a"; | ||
| meta.textContent = `${voiceLanguageLabel(languageCode)} · ${voiceGenderIcon(voice.gender)}`; | ||
| info.appendChild(name); | ||
| info.appendChild(meta); | ||
| const addButton = document.createElement("button"); | ||
| addButton.type = "button"; | ||
| addButton.className = "voice-available__add"; | ||
| addButton.textContent = "Add"; | ||
| addButton.addEventListener("click", (event) => { | ||
| event.stopPropagation(); | ||
| addVoiceToDraft(voice.id); | ||
| }); | ||
| card.appendChild(info); | ||
| card.appendChild(addButton); | ||
| availableListEl.appendChild(card); | ||
| availableCards.set(voice.id, { card, addButton }); | ||
| }); | ||
| updateAvailableState(); | ||
| updateGenderFilterButtons(); | ||
| availableListEl.scrollTop = 0; | ||
| }; | ||
| const addVoiceToDraft = (voiceId, weight = 0.6) => { | ||
| if (!voiceLookup.has(voiceId)) { | ||
| return; | ||
| } | ||
| if (state.draft.voices.has(voiceId)) { | ||
| setSliderFocus(voiceId); | ||
| return; | ||
| } | ||
| state.draft.voices.set(voiceId, clamp(weight, 0.05, 1)); | ||
| renderSelectedVoices(); | ||
| updateAvailableState(); | ||
| updateMixSummary(); | ||
| markDirty(); | ||
| setSliderFocus(voiceId); | ||
| }; | ||
| const buildProfilePayload = () => | ||
| Array.from(state.draft.voices.entries()).map(([voiceId, weight]) => ({ | ||
| id: voiceId, | ||
| weight, | ||
| enabled: weight > 0, | ||
| })); | ||
| const updateActionButtons = () => { | ||
| const hasSelection = Boolean(state.selectedProfile && profiles[state.selectedProfile]); | ||
| if (duplicateBtn) { | ||
| duplicateBtn.disabled = !hasSelection; | ||
| } | ||
| if (deleteBtn) { | ||
| deleteBtn.disabled = !hasSelection; | ||
| } | ||
| }; | ||
| const applyDraftToControls = () => { | ||
| if (nameInput) { | ||
| nameInput.value = state.draft.name || ""; | ||
| } | ||
| if (languageSelect) { | ||
| languageSelect.value = state.draft.language || "a"; | ||
| } | ||
| if (supertonicVoiceSelect) { | ||
| supertonicVoiceSelect.value = state.draft.supertonic?.voice || "M1"; | ||
| } | ||
| if (supertonicStepsInput) { | ||
| supertonicStepsInput.value = String(state.draft.supertonic?.total_steps ?? 5); | ||
| setRangeFill(supertonicStepsInput); | ||
| } | ||
| if (supertonicSpeedInput) { | ||
| supertonicSpeedInput.value = String(state.draft.supertonic?.speed ?? 1.0); | ||
| setRangeFill(supertonicSpeedInput); | ||
| } | ||
| if (supertonicStepsLabel) { | ||
| supertonicStepsLabel.textContent = String(state.draft.supertonic?.total_steps ?? 5); | ||
| } | ||
| if (supertonicSpeedLabel) { | ||
| const speed = Number(state.draft.supertonic?.speed ?? 1.0); | ||
| supertonicSpeedLabel.textContent = `${(Number.isFinite(speed) ? speed : 1.0).toFixed(2)}×`; | ||
| } | ||
| applyProviderToUI(); | ||
| renderSelectedVoices(); | ||
| updateMixSummary(); | ||
| updateAvailableState(); | ||
| updateActionButtons(); | ||
| resetDirty(); | ||
| }; | ||
| const renderProfileList = () => { | ||
| profileListEl.innerHTML = ""; | ||
| const header = document.createElement("div"); | ||
| header.className = "voice-list__header"; | ||
| const heading = document.createElement("h2"); | ||
| heading.textContent = "Saved speakers"; | ||
| header.appendChild(heading); | ||
| profileListEl.appendChild(header); | ||
| const names = Object.keys(profiles).sort((a, b) => a.localeCompare(b)); | ||
| if (!names.length) { | ||
| const empty = document.createElement("p"); | ||
| empty.className = "tag"; | ||
| empty.textContent = "No speakers yet. Create one on the right."; | ||
| profileListEl.appendChild(empty); | ||
| return; | ||
| } | ||
| const list = document.createElement("ul"); | ||
| list.className = "voice-list"; | ||
| names.forEach((name) => { | ||
| const li = document.createElement("li"); | ||
| li.className = "voice-list__item"; | ||
| if (state.selectedProfile === name) { | ||
| li.classList.add("is-selected"); | ||
| } | ||
| const selectBtn = document.createElement("button"); | ||
| selectBtn.type = "button"; | ||
| selectBtn.className = "voice-list__select"; | ||
| selectBtn.dataset.name = name; | ||
| const profile = profiles[name] || {}; | ||
| const provider = normalizeProvider(profile.provider); | ||
| const providerLabel = provider === "supertonic" ? "Supertonic" : "Kokoro"; | ||
| selectBtn.innerHTML = ` | ||
| <span class="voice-list__name">${name}</span> | ||
| <span class="voice-list__meta"><span class="tag">${providerLabel}</span> ${voiceLanguageLabel(profile.language || "a")}</span> | ||
| `; | ||
| selectBtn.addEventListener("click", () => selectProfile(name)); | ||
| const actions = document.createElement("div"); | ||
| actions.className = "voice-list__actions"; | ||
| const duplicateAction = document.createElement("button"); | ||
| duplicateAction.type = "button"; | ||
| duplicateAction.className = "voice-list__action"; | ||
| duplicateAction.textContent = "Duplicate"; | ||
| duplicateAction.addEventListener("click", (event) => { | ||
| event.stopPropagation(); | ||
| runDuplicate(name); | ||
| }); | ||
| const deleteAction = document.createElement("button"); | ||
| deleteAction.type = "button"; | ||
| deleteAction.className = "voice-list__action voice-list__action--danger"; | ||
| deleteAction.textContent = "Delete"; | ||
| deleteAction.addEventListener("click", (event) => { | ||
| event.stopPropagation(); | ||
| runDelete(name); | ||
| }); | ||
| actions.appendChild(duplicateAction); | ||
| actions.appendChild(deleteAction); | ||
| li.appendChild(selectBtn); | ||
| li.appendChild(actions); | ||
| list.appendChild(li); | ||
| }); | ||
| profileListEl.appendChild(list); | ||
| }; | ||
| const selectProfile = (name) => { | ||
| state.selectedProfile = name; | ||
| state.originalName = name; | ||
| const profile = profiles[name]; | ||
| const provider = normalizeProvider(profile?.provider); | ||
| state.draft = { | ||
| name, | ||
| provider, | ||
| language: profile?.language || "a", | ||
| voices: new Map(), | ||
| supertonic: { | ||
| voice: profile?.voice || "M1", | ||
| total_steps: Number(profile?.total_steps ?? 5), | ||
| speed: Number(profile?.speed ?? 1.0), | ||
| }, | ||
| }; | ||
| if (provider === "kokoro" && Array.isArray(profile?.voices)) { | ||
| profile.voices.forEach((entry) => { | ||
| if (Array.isArray(entry) && entry.length >= 2) { | ||
| const [voiceId, weight] = entry; | ||
| const value = clamp(parseFloat(weight), 0, 1); | ||
| if (!Number.isNaN(value) && value > 0) { | ||
| const normalized = clamp(value, 0.05, 1); | ||
| state.draft.voices.set(String(voiceId), normalized); | ||
| } | ||
| } | ||
| }); | ||
| } | ||
| applyDraftToControls(); | ||
| renderProfileList(); | ||
| loadSampleText(); | ||
| setStatus(`Loaded speaker “${name}”.`, "info", 2500); | ||
| }; | ||
| const startNewProfile = (provider = "kokoro") => { | ||
| state.selectedProfile = null; | ||
| state.originalName = null; | ||
| state.draft = { | ||
| name: "", | ||
| provider: normalizeProvider(provider), | ||
| language: languageSelect ? languageSelect.value || "a" : "a", | ||
| voices: new Map(), | ||
| supertonic: { | ||
| voice: "M1", | ||
| total_steps: 5, | ||
| speed: 1.0, | ||
| }, | ||
| }; | ||
| applyDraftToControls(); | ||
| renderProfileList(); | ||
| loadSampleText(); | ||
| }; | ||
| const requestNewProfile = async () => { | ||
| const chosen = await openProviderPicker(normalizeProvider(state.draft.provider)); | ||
| if (!chosen) { | ||
| return; | ||
| } | ||
| startNewProfile(chosen); | ||
| setStatus("New speaker ready.", "info"); | ||
| }; | ||
| const refreshProfiles = (nextProfiles, selectedName = null) => { | ||
| profiles = nextProfiles || {}; | ||
| renderProfileList(); | ||
| if (selectedName && profiles[selectedName]) { | ||
| selectProfile(selectedName); | ||
| } else if (state.selectedProfile && profiles[state.selectedProfile]) { | ||
| selectProfile(state.selectedProfile); | ||
| } else { | ||
| const names = Object.keys(profiles); | ||
| if (names.length) { | ||
| selectProfile(names[0]); | ||
| } else { | ||
| startNewProfile("kokoro"); | ||
| } | ||
| } | ||
| updateActionButtons(); | ||
| }; | ||
| const loadSampleText = () => { | ||
| if (!previewTextEl || !languageSelect) return; | ||
| const lang = languageSelect.value || "a"; | ||
| previewTextEl.value = samples[lang] || samples.a || "This is a sample of the selected voice."; | ||
| }; | ||
| const withJson = async (response) => { | ||
| if (response.ok) { | ||
| return response.json(); | ||
| } | ||
| let message = "Unexpected error"; | ||
| try { | ||
| const data = await response.json(); | ||
| message = data.error || data.message || message; | ||
| } catch (err) { | ||
| message = await response.text(); | ||
| } | ||
| throw new Error(message); | ||
| }; | ||
| const runSave = async () => { | ||
| if (!nameInput) return; | ||
| const name = nameInput.value.trim(); | ||
| if (!name) { | ||
| setStatus("Give your profile a name first.", "warning"); | ||
| return; | ||
| } | ||
| const payload = { | ||
| name, | ||
| originalName: state.originalName, | ||
| provider: normalizeProvider(state.draft.provider), | ||
| language: normalizeProvider(state.draft.provider) === "kokoro" ? (languageSelect ? languageSelect.value : "a") : "a", | ||
| voices: normalizeProvider(state.draft.provider) === "kokoro" ? buildProfilePayload() : [], | ||
| voice: state.draft.supertonic?.voice, | ||
| total_steps: state.draft.supertonic?.total_steps, | ||
| speed: state.draft.supertonic?.speed, | ||
| }; | ||
| try { | ||
| const response = await fetch("/api/voice-profiles", { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify(payload), | ||
| }); | ||
| const result = await withJson(response); | ||
| refreshProfiles(result.profiles, result.profile); | ||
| resetDirty(); | ||
| setStatus(`Saved speaker “${result.profile}”.`, "success"); | ||
| } catch (error) { | ||
| setStatus(error.message || "Failed to save profile", "danger", 7000); | ||
| } | ||
| }; | ||
| const runDelete = async (targetName = null) => { | ||
| const name = targetName || state.selectedProfile; | ||
| if (!name) { | ||
| setStatus("Select a profile to delete.", "warning"); | ||
| return; | ||
| } | ||
| const confirmed = window.confirm(`Delete speaker “${name}”?`); | ||
| if (!confirmed) return; | ||
| try { | ||
| const response = await fetch(`/api/voice-profiles/${encodeURIComponent(name)}`, { | ||
| method: "DELETE", | ||
| }); | ||
| const result = await withJson(response); | ||
| refreshProfiles(result.profiles); | ||
| setStatus(`Deleted speaker “${name}”.`, "info"); | ||
| } catch (error) { | ||
| setStatus(error.message || "Failed to delete profile", "danger", 7000); | ||
| } | ||
| }; | ||
| const runDuplicate = async (targetName = null) => { | ||
| const name = targetName || state.selectedProfile; | ||
| if (!name) { | ||
| setStatus("Select a profile to duplicate.", "warning"); | ||
| return; | ||
| } | ||
| const newName = window.prompt("Duplicate speaker as…", `${name} copy`); | ||
| if (!newName) return; | ||
| try { | ||
| const response = await fetch(`/api/voice-profiles/${encodeURIComponent(name)}/duplicate`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ name: newName }), | ||
| }); | ||
| const result = await withJson(response); | ||
| refreshProfiles(result.profiles, result.profile); | ||
| setStatus(`Duplicated to “${result.profile}”.`, "success"); | ||
| } catch (error) { | ||
| setStatus(error.message || "Failed to duplicate profile", "danger", 7000); | ||
| } | ||
| }; | ||
| const runImport = async (file) => { | ||
| try { | ||
| const text = await file.text(); | ||
| const parsed = JSON.parse(text); | ||
| const replace = window.confirm("Replace existing speakers if duplicates are found?"); | ||
| const response = await fetch("/api/voice-profiles/import", { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ data: parsed, replace_existing: replace }), | ||
| }); | ||
| const result = await withJson(response); | ||
| refreshProfiles(result.profiles); | ||
| setStatus(`Imported ${result.imported.length} speaker${result.imported.length === 1 ? "" : "s"}.`, "success"); | ||
| } catch (error) { | ||
| setStatus(error.message || "Import failed", "danger", 7000); | ||
| } finally { | ||
| importInput.value = ""; | ||
| } | ||
| }; | ||
| const runExport = async () => { | ||
| const name = state.selectedProfile; | ||
| const query = name ? `?names=${encodeURIComponent(name)}` : ""; | ||
| try { | ||
| const response = await fetch(`/api/voice-profiles/export${query}`); | ||
| if (!response.ok) { | ||
| throw new Error("Export failed"); | ||
| } | ||
| const blob = await response.blob(); | ||
| const url = URL.createObjectURL(blob); | ||
| const anchor = document.createElement("a"); | ||
| anchor.href = url; | ||
| anchor.download = name ? `${name}.json` : "voice_profiles.json"; | ||
| document.body.appendChild(anchor); | ||
| anchor.click(); | ||
| document.body.removeChild(anchor); | ||
| URL.revokeObjectURL(url); | ||
| setStatus("Export complete.", "success"); | ||
| } catch (error) { | ||
| setStatus(error.message || "Export failed", "danger", 7000); | ||
| } | ||
| }; | ||
| const runPreview = async () => { | ||
| if (!previewBtn) return; | ||
| const provider = normalizeProvider(state.draft.provider); | ||
| const payload = { | ||
| provider, | ||
| language: languageSelect ? languageSelect.value : "a", | ||
| voices: provider === "kokoro" ? buildProfilePayload() : [], | ||
| voice: state.draft.supertonic?.voice, | ||
| total_steps: state.draft.supertonic?.total_steps, | ||
| text: previewTextEl ? previewTextEl.value : "", | ||
| speed: speedInput ? parseFloat(speedInput.value || "1") : 1, | ||
| max_seconds: 8, | ||
| }; | ||
| if (provider === "kokoro") { | ||
| const enabledVoices = payload.voices.filter((entry) => entry.enabled && entry.weight > 0); | ||
| if (!enabledVoices.length) { | ||
| setStatus("Enable at least one voice to preview.", "warning"); | ||
| return; | ||
| } | ||
| } else { | ||
| if (!payload.voice) { | ||
| setStatus("Select a Supertonic voice to preview.", "warning"); | ||
| return; | ||
| } | ||
| payload.supertonic_total_steps = payload.total_steps; | ||
| payload.tts_provider = "supertonic"; | ||
| } | ||
| previewBtn.disabled = true; | ||
| previewBtn.dataset.loading = "true"; | ||
| previewBtn.setAttribute("aria-busy", "true"); | ||
| previewBtn.textContent = "Previewing…"; | ||
| setStatus("Generating preview…", "info", 0); | ||
| try { | ||
| const response = await fetch("/api/voice-profiles/preview", { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify(payload), | ||
| }); | ||
| if (!response.ok) { | ||
| throw new Error(await response.text()); | ||
| } | ||
| const blob = await response.blob(); | ||
| if (state.previewUrl) { | ||
| URL.revokeObjectURL(state.previewUrl); | ||
| } | ||
| state.previewUrl = URL.createObjectURL(blob); | ||
| if (previewAudio) { | ||
| previewAudio.src = state.previewUrl; | ||
| previewAudio.play().catch(() => {}); | ||
| } | ||
| setStatus("Preview ready.", "success"); | ||
| } catch (error) { | ||
| setStatus(error.message || "Preview failed", "danger", 7000); | ||
| } finally { | ||
| previewBtn.disabled = false; | ||
| previewBtn.dataset.loading = "false"; | ||
| previewBtn.textContent = previewBtn.dataset.label || "Preview speaker"; | ||
| previewBtn.removeAttribute("aria-busy"); | ||
| } | ||
| }; | ||
| if (saveBtn) { | ||
| const form = saveBtn.closest("form"); | ||
| if (form) { | ||
| form.addEventListener("submit", (event) => { | ||
| event.preventDefault(); | ||
| runSave(); | ||
| }); | ||
| } | ||
| } | ||
| if (duplicateBtn) { | ||
| duplicateBtn.addEventListener("click", () => runDuplicate()); | ||
| } | ||
| if (deleteBtn) { | ||
| deleteBtn.addEventListener("click", () => runDelete()); | ||
| } | ||
| if (previewBtn) { | ||
| previewBtn.addEventListener("click", () => runPreview()); | ||
| } | ||
| if (loadSampleBtn) { | ||
| loadSampleBtn.addEventListener("click", loadSampleText); | ||
| } | ||
| if (languageSelect) { | ||
| languageSelect.addEventListener("change", () => { | ||
| state.draft.language = languageSelect.value; | ||
| markDirty(); | ||
| loadSampleText(); | ||
| }); | ||
| } | ||
| if (providerSelect) { | ||
| providerSelect.addEventListener("change", () => { | ||
| state.draft.provider = normalizeProvider(providerSelect.value); | ||
| // When switching to Supertonic, clear Kokoro mix. | ||
| if (state.draft.provider === "supertonic") { | ||
| state.draft.voices = new Map(); | ||
| } | ||
| applyDraftToControls(); | ||
| markDirty(); | ||
| loadSampleText(); | ||
| setStatus("Provider updated.", "info", 1500); | ||
| }); | ||
| } | ||
| if (supertonicVoiceSelect) { | ||
| supertonicVoiceSelect.addEventListener("change", () => { | ||
| state.draft.supertonic.voice = supertonicVoiceSelect.value; | ||
| markDirty(); | ||
| updateMixSummary(); | ||
| }); | ||
| } | ||
| if (supertonicStepsInput) { | ||
| supertonicStepsInput.addEventListener("input", () => { | ||
| const value = Number(supertonicStepsInput.value || "5"); | ||
| state.draft.supertonic.total_steps = clamp(value, 2, 15); | ||
| supertonicStepsInput.value = String(Math.round(state.draft.supertonic.total_steps)); | ||
| if (supertonicStepsLabel) { | ||
| supertonicStepsLabel.textContent = supertonicStepsInput.value; | ||
| } | ||
| setRangeFill(supertonicStepsInput); | ||
| markDirty(); | ||
| }); | ||
| setRangeFill(supertonicStepsInput); | ||
| } | ||
| if (supertonicSpeedInput) { | ||
| supertonicSpeedInput.addEventListener("input", () => { | ||
| const value = parseFloat(supertonicSpeedInput.value || "1"); | ||
| const normalized = clamp(value, 0.7, 2.0); | ||
| state.draft.supertonic.speed = normalized; | ||
| supertonicSpeedInput.value = normalized.toFixed(2); | ||
| if (supertonicSpeedLabel) { | ||
| supertonicSpeedLabel.textContent = `${normalized.toFixed(2)}×`; | ||
| } | ||
| setRangeFill(supertonicSpeedInput); | ||
| if (speedInput) { | ||
| speedInput.value = String(normalized); | ||
| setRangeFill(speedInput); | ||
| } | ||
| markDirty(); | ||
| }); | ||
| setRangeFill(supertonicSpeedInput); | ||
| } | ||
| if (voiceFilterSelect) { | ||
| voiceFilterSelect.addEventListener("change", () => { | ||
| state.languageFilter = voiceFilterSelect.value; | ||
| renderAvailableVoices(); | ||
| }); | ||
| } | ||
| if (speedInput) { | ||
| const updatePreviewSpeedLabel = () => { | ||
| const speed = parseFloat(speedInput.value || "1"); | ||
| if (previewSpeedLabel) { | ||
| previewSpeedLabel.textContent = `${speed.toFixed(2)}×`; | ||
| } | ||
| setRangeFill(speedInput); | ||
| if (normalizeProvider(state.draft.provider) === "supertonic") { | ||
| state.draft.supertonic.speed = clamp(speed, 0.7, 2.0); | ||
| if (supertonicSpeedInput) { | ||
| supertonicSpeedInput.value = state.draft.supertonic.speed.toFixed(2); | ||
| setRangeFill(supertonicSpeedInput); | ||
| } | ||
| if (supertonicSpeedLabel) { | ||
| supertonicSpeedLabel.textContent = `${state.draft.supertonic.speed.toFixed(2)}×`; | ||
| } | ||
| } | ||
| }; | ||
| speedInput.addEventListener("input", updatePreviewSpeedLabel); | ||
| updatePreviewSpeedLabel(); | ||
| } | ||
| if (genderFilterEl) { | ||
| genderFilterEl.addEventListener("click", (event) => { | ||
| const target = event.target; | ||
| if (!(target instanceof HTMLButtonElement)) return; | ||
| const value = (target.getAttribute("data-value") || "").toLowerCase(); | ||
| if (!value) return; | ||
| state.genderFilter = state.genderFilter === value ? "" : value; | ||
| renderAvailableVoices(); | ||
| }); | ||
| updateGenderFilterButtons(); | ||
| } | ||
| if (nameInput) { | ||
| nameInput.addEventListener("input", () => { | ||
| state.draft.name = nameInput.value; | ||
| markDirty(); | ||
| updateMixSummary(); | ||
| }); | ||
| } | ||
| if (importInput) { | ||
| importInput.addEventListener("change", () => { | ||
| const [file] = importInput.files || []; | ||
| if (file) { | ||
| runImport(file); | ||
| } | ||
| }); | ||
| } | ||
| if (headerActions) { | ||
| headerActions.addEventListener("click", (event) => { | ||
| const target = event.target; | ||
| if (!(target instanceof HTMLElement)) return; | ||
| const actionEl = target.closest("[data-action]"); | ||
| const action = actionEl instanceof HTMLElement ? actionEl.dataset.action : null; | ||
| if (!action) return; | ||
| if (action === "new-profile") { | ||
| requestNewProfile(); | ||
| } else if (action === "import-profiles") { | ||
| importInput?.click(); | ||
| } else if (action === "export-profiles") { | ||
| runExport(); | ||
| } | ||
| }); | ||
| } | ||
| if (dropzoneEl) { | ||
| const setHover = (hovered) => { | ||
| dropzoneEl.classList.toggle("is-hovered", hovered); | ||
| }; | ||
| [dropzoneEl, selectedListEl].forEach((target) => { | ||
| target.addEventListener("dragover", (event) => { | ||
| event.preventDefault(); | ||
| setHover(true); | ||
| }); | ||
| target.addEventListener("dragenter", (event) => { | ||
| event.preventDefault(); | ||
| setHover(true); | ||
| }); | ||
| target.addEventListener("dragleave", (event) => { | ||
| if (!event.currentTarget.contains(event.relatedTarget)) { | ||
| setHover(false); | ||
| } | ||
| }); | ||
| target.addEventListener("drop", (event) => { | ||
| event.preventDefault(); | ||
| const voiceId = event.dataTransfer?.getData("text/plain"); | ||
| if (voiceId) { | ||
| addVoiceToDraft(voiceId); | ||
| } | ||
| setHover(false); | ||
| }); | ||
| }); | ||
| dropzoneEl.addEventListener("click", (event) => { | ||
| if (!(event.target instanceof HTMLElement)) { | ||
| return; | ||
| } | ||
| if (event.target.closest(".mix-voice")) { | ||
| return; | ||
| } | ||
| if (event.target.closest(".mix-slider")) { | ||
| return; | ||
| } | ||
| const firstInactive = Array.from(availableCards.entries()).find( | ||
| ([voiceId]) => !state.draft.voices.has(voiceId), | ||
| ); | ||
| if (firstInactive) { | ||
| addVoiceToDraft(firstInactive[0]); | ||
| } | ||
| }); | ||
| } | ||
| renderAvailableVoices(); | ||
| renderProfileList(); | ||
| startNewProfile("kokoro"); | ||
| if (Object.keys(profiles).length) { | ||
| const first = Object.keys(profiles).sort((a, b) => a.localeCompare(b))[0]; | ||
| selectProfile(first); | ||
| } | ||
| loadSampleText(); | ||
| updateActionButtons(); | ||
| app.dataset.state = "ready"; | ||
| window.addEventListener("beforeunload", () => { | ||
| if (state.previewUrl) { | ||
| URL.revokeObjectURL(state.previewUrl); | ||
| } | ||
| }); | ||
| }; | ||
| if (document.readyState === "loading") { | ||
| document.addEventListener("DOMContentLoaded", setupVoiceMixer, { once: true }); | ||
| } else { | ||
| setupVoiceMixer(); | ||
| } |
| const STEP_ORDER = ["book", "chapters", "entities"]; | ||
| const STEP_META = { | ||
| book: { | ||
| index: 1, | ||
| title: "Book parameters", | ||
| hint: "Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.", | ||
| }, | ||
| chapters: { | ||
| index: 2, | ||
| title: "Select chapters", | ||
| hint: "Choose which chapters to convert. We'll analyse entities automatically when you continue.", | ||
| }, | ||
| entities: { | ||
| index: 3, | ||
| title: "Review entities", | ||
| hint: "Assign pronunciations, voices, and manual overrides before queueing the conversion.", | ||
| }, | ||
| }; | ||
| const wizardState = (window.AbogenWizardState = window.AbogenWizardState || { | ||
| initialized: false, | ||
| modal: null, | ||
| stage: null, | ||
| submitting: false, | ||
| initialStep: "book", | ||
| initialStageMarkup: "", | ||
| }); | ||
| const normalizeStep = (step) => { | ||
| let value = (step || "book").toLowerCase(); | ||
| if (value === "speakers") { | ||
| value = "entities"; | ||
| } | ||
| if (value === "settings" || value === "upload" || value === "") { | ||
| value = "book"; | ||
| } | ||
| if (!STEP_ORDER.includes(value)) { | ||
| return "book"; | ||
| } | ||
| return value; | ||
| }; | ||
| const setButtonLoading = (button, isLoading) => { | ||
| if (!button) { | ||
| return; | ||
| } | ||
| if (isLoading) { | ||
| if (!button.dataset.originalDisabled) { | ||
| button.dataset.originalDisabled = button.disabled ? "true" : "false"; | ||
| } | ||
| button.disabled = true; | ||
| button.dataset.loading = "true"; | ||
| button.setAttribute("aria-busy", "true"); | ||
| } else { | ||
| if (button.dataset.loading) { | ||
| delete button.dataset.loading; | ||
| } | ||
| button.removeAttribute("aria-busy"); | ||
| const original = button.dataset.originalDisabled; | ||
| if (original !== undefined) { | ||
| button.disabled = original === "true"; | ||
| delete button.dataset.originalDisabled; | ||
| } else { | ||
| button.disabled = false; | ||
| } | ||
| } | ||
| }; | ||
| const setSubmitting = (modal, isSubmitting, button) => { | ||
| if (!modal) return; | ||
| wizardState.submitting = isSubmitting; | ||
| if (isSubmitting) { | ||
| modal.dataset.submitting = "true"; | ||
| modal.setAttribute("aria-busy", "true"); | ||
| } else { | ||
| delete modal.dataset.submitting; | ||
| modal.removeAttribute("aria-busy"); | ||
| } | ||
| setButtonLoading(button, isSubmitting); | ||
| }; | ||
| const resetWizardToInitial = () => { | ||
| const modal = ensureModalRef(); | ||
| if (!modal) return; | ||
| wizardState.submitting = false; | ||
| delete modal.dataset.submitting; | ||
| modal.removeAttribute("aria-busy"); | ||
| modal.dataset.pendingId = ""; | ||
| const step = normalizeStep(wizardState.initialStep || modal.dataset.step || "book"); | ||
| modal.dataset.step = step; | ||
| updateHeaderCopy(modal, step); | ||
| updateFilenameLabel(modal, ""); | ||
| const stage = modal.querySelector('[data-role="wizard-stage"]'); | ||
| if (stage) { | ||
| wizardState.stage = stage; | ||
| destroyTransientAlerts(stage); | ||
| if (typeof wizardState.initialStageMarkup === "string" && wizardState.initialStageMarkup) { | ||
| stage.innerHTML = wizardState.initialStageMarkup; | ||
| reinitializeStageModules(stage); | ||
| } | ||
| } | ||
| }; | ||
| const findModal = () => document.querySelector('[data-role="new-job-modal"]'); | ||
| const ensureModalRef = () => { | ||
| if (wizardState.modal && wizardState.modal.isConnected) { | ||
| return wizardState.modal; | ||
| } | ||
| wizardState.modal = findModal(); | ||
| return wizardState.modal; | ||
| }; | ||
| const dispatchWizardEvent = (modal, type, detail = {}) => { | ||
| if (!modal) return; | ||
| const event = new CustomEvent(`wizard:${type}`, { bubbles: true, detail }); | ||
| modal.dispatchEvent(event); | ||
| }; | ||
| const destroyTransientAlerts = (stage) => { | ||
| if (!stage) { | ||
| return; | ||
| } | ||
| const alerts = stage.querySelectorAll('[data-role="wizard-error"]'); | ||
| alerts.forEach((alert) => alert.remove()); | ||
| }; | ||
| const displayTransientError = (modal, message) => { | ||
| if (!modal) return; | ||
| const stage = modal.querySelector('[data-role="wizard-stage"]'); | ||
| if (!stage) return; | ||
| const existing = stage.querySelector('[data-role="wizard-error"]'); | ||
| if (existing) { | ||
| existing.textContent = message; | ||
| return; | ||
| } | ||
| const alert = document.createElement("div"); | ||
| alert.className = "alert alert--error"; | ||
| alert.dataset.role = "wizard-error"; | ||
| alert.textContent = message; | ||
| stage.prepend(alert); | ||
| }; | ||
| const updateStepIndicators = (modal, activeStep, payload) => { | ||
| const indicators = modal.querySelectorAll('[data-role="wizard-step-indicator"]'); | ||
| const activeIndex = STEP_ORDER.indexOf(activeStep); | ||
| const completedList = Array.isArray(payload?.completed_steps) ? payload.completed_steps : []; | ||
| const completedSet = new Set(completedList.map((step) => normalizeStep(step))); | ||
| indicators.forEach((indicator) => { | ||
| const step = normalizeStep(indicator.dataset.step || "book"); | ||
| indicator.classList.remove("is-active", "is-complete"); | ||
| const index = STEP_ORDER.indexOf(step); | ||
| const isActive = index === activeIndex; | ||
| const visited = completedSet.has(step); | ||
| const isComplete = !isActive && (visited || (index > -1 && index < activeIndex)); | ||
| indicator.classList.toggle("is-complete", isComplete); | ||
| indicator.classList.toggle("is-active", isActive); | ||
| if (indicator instanceof HTMLButtonElement) { | ||
| const clickable = isComplete && !isActive; | ||
| indicator.disabled = !clickable; | ||
| indicator.setAttribute("aria-disabled", clickable ? "false" : "true"); | ||
| indicator.setAttribute("aria-current", isActive ? "step" : "false"); | ||
| if (clickable) { | ||
| indicator.dataset.state = "clickable"; | ||
| } else { | ||
| delete indicator.dataset.state; | ||
| } | ||
| } | ||
| }); | ||
| }; | ||
| const updateHeaderCopy = (modal, step, payload) => { | ||
| const meta = STEP_META[step]; | ||
| if (!meta) { | ||
| return; | ||
| } | ||
| const titleEl = modal.querySelector("#new-job-modal-title"); | ||
| const hintEl = modal.querySelector('[data-role="wizard-hint"]'); | ||
| if (titleEl) { | ||
| titleEl.textContent = payload?.title || meta.title; | ||
| } | ||
| if (hintEl) { | ||
| hintEl.textContent = payload?.hint || meta.hint; | ||
| } | ||
| updateStepIndicators(modal, step, payload); | ||
| }; | ||
| const updateFilenameLabel = (modal, filename) => { | ||
| const label = modal.querySelector(".wizard-card__filename"); | ||
| if (!label) return; | ||
| if (filename) { | ||
| label.hidden = false; | ||
| label.textContent = filename; | ||
| label.setAttribute("title", filename); | ||
| } else { | ||
| label.hidden = true; | ||
| label.textContent = ""; | ||
| label.removeAttribute("title"); | ||
| } | ||
| }; | ||
| const reinitializeStageModules = (stage) => { | ||
| if (!stage) return; | ||
| if (window.AbogenDashboard?.init) { | ||
| window.AbogenDashboard.init(); | ||
| } | ||
| if (window.AbogenPrepare?.init) { | ||
| window.AbogenPrepare.init(stage); | ||
| } | ||
| }; | ||
| const focusFirstInteractive = (stage) => { | ||
| if (!stage) return; | ||
| const focusable = stage.querySelector( | ||
| 'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled])' | ||
| ); | ||
| if (focusable instanceof HTMLElement) { | ||
| try { | ||
| focusable.focus({ preventScroll: true }); | ||
| } catch (error) { | ||
| // Ignore focus errors, browser may block programmatic focus | ||
| } | ||
| } | ||
| }; | ||
| const applyWizardPayload = (payload) => { | ||
| const modal = ensureModalRef(); | ||
| if (!modal) { | ||
| return; | ||
| } | ||
| if (payload.pending_id !== undefined) { | ||
| modal.dataset.pendingId = payload.pending_id || ""; | ||
| } | ||
| const step = normalizeStep(payload.step || modal.dataset.step || "book"); | ||
| modal.dataset.step = step; | ||
| modal.hidden = false; | ||
| modal.dataset.open = "true"; | ||
| document.body.classList.add("modal-open"); | ||
| updateHeaderCopy(modal, step, payload); | ||
| updateFilenameLabel(modal, payload.filename); | ||
| const stage = modal.querySelector('[data-role="wizard-stage"]'); | ||
| if (stage) { | ||
| destroyTransientAlerts(stage); | ||
| stage.innerHTML = payload.html || ""; | ||
| wizardState.stage = stage; | ||
| reinitializeStageModules(stage); | ||
| focusFirstInteractive(stage); | ||
| } | ||
| const stepDetail = { | ||
| step, | ||
| index: STEP_META[step]?.index || STEP_ORDER.indexOf(step) + 1, | ||
| total: STEP_ORDER.length, | ||
| pendingId: modal.dataset.pendingId || "", | ||
| notice: payload.notice || "", | ||
| error: payload.error || "", | ||
| }; | ||
| dispatchWizardEvent(modal, "step", stepDetail); | ||
| }; | ||
| const handleWizardRedirect = (payload) => { | ||
| const modal = ensureModalRef(); | ||
| if (!modal) return; | ||
| modal.hidden = true; | ||
| delete modal.dataset.open; | ||
| document.body.classList.remove("modal-open"); | ||
| resetWizardToInitial(); | ||
| dispatchWizardEvent(modal, "done", { redirectUrl: payload.redirect_url }); | ||
| if (payload.redirect_url) { | ||
| window.location.assign(payload.redirect_url); | ||
| } | ||
| }; | ||
| const processResponsePayload = (payload, responseOk) => { | ||
| if (!payload) { | ||
| return; | ||
| } | ||
| if (payload.redirect_url) { | ||
| handleWizardRedirect(payload); | ||
| return; | ||
| } | ||
| if (!payload.html && !responseOk) { | ||
| const modal = ensureModalRef(); | ||
| displayTransientError(modal, payload.error || "Something went wrong. Try again."); | ||
| return; | ||
| } | ||
| applyWizardPayload(payload); | ||
| }; | ||
| const requestWizardStep = async (url, { method = "GET", body = undefined } = {}) => { | ||
| const modal = ensureModalRef(); | ||
| if (!modal) return; | ||
| try { | ||
| const response = await fetch(url, { | ||
| method, | ||
| body, | ||
| headers: { Accept: "application/json" }, | ||
| credentials: "same-origin", | ||
| }); | ||
| const text = await response.text(); | ||
| const payload = text ? JSON.parse(text) : null; | ||
| processResponsePayload(payload, response.ok); | ||
| } catch (error) { | ||
| console.error("Wizard request failed", error); | ||
| displayTransientError(modal, error?.message || "Unable to update the wizard. Try again."); | ||
| } | ||
| }; | ||
| const submitWizardForm = async (form, submitter) => { | ||
| const modal = ensureModalRef(); | ||
| if (!modal) return; | ||
| if (wizardState.submitting) { | ||
| return; | ||
| } | ||
| const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || window.location.href; | ||
| const method = (submitter?.getAttribute("formmethod") || form.getAttribute("method") || "GET").toUpperCase(); | ||
| const stepTarget = submitter?.dataset?.stepTarget || ""; | ||
| const normalizedStepTarget = stepTarget ? stepTarget.toLowerCase() : ""; | ||
| if (normalizedStepTarget) { | ||
| const activeInput = form.querySelector('[data-role="active-step-input"]'); | ||
| if (activeInput) { | ||
| activeInput.value = normalizedStepTarget; | ||
| } | ||
| } | ||
| const formData = new FormData(form); | ||
| if (normalizedStepTarget) { | ||
| formData.set("active_step", normalizedStepTarget); | ||
| formData.set("next_step", normalizedStepTarget); | ||
| } | ||
| if (submitter && submitter.name && !formData.has(submitter.name)) { | ||
| formData.append(submitter.name, submitter.value ?? ""); | ||
| } | ||
| // Ensure pending_id is included if available in modal state but missing from form | ||
| if (!formData.get("pending_id") && modal && modal.dataset.pendingId) { | ||
| formData.set("pending_id", modal.dataset.pendingId); | ||
| } | ||
| const allowValidation = !submitter?.hasAttribute("formnovalidate") && !form.noValidate; | ||
| if (allowValidation && typeof form.reportValidity === "function" && !form.reportValidity()) { | ||
| return; | ||
| } | ||
| destroyTransientAlerts(modal.querySelector('[data-role="wizard-stage"]')); | ||
| setSubmitting(modal, true, submitter); | ||
| try { | ||
| const response = await fetch(action, { | ||
| method, | ||
| body: formData, | ||
| headers: { Accept: "application/json" }, | ||
| credentials: "same-origin", | ||
| }); | ||
| const text = await response.text(); | ||
| let payload = null; | ||
| try { | ||
| payload = text ? JSON.parse(text) : null; | ||
| } catch (parseError) { | ||
| console.error("Failed to parse wizard response", parseError); | ||
| displayTransientError(modal, "Received an invalid response. Try again."); | ||
| return; | ||
| } | ||
| processResponsePayload(payload, response.ok); | ||
| if (!response.ok && (!payload || !payload.html)) { | ||
| displayTransientError(modal, payload?.error || `Request failed (${response.status})`); | ||
| } | ||
| } catch (networkError) { | ||
| console.error("Wizard submission failed", networkError); | ||
| displayTransientError(modal, networkError?.message || "Unable to submit form. Check your connection and try again."); | ||
| } finally { | ||
| setSubmitting(modal, false, submitter); | ||
| } | ||
| }; | ||
| const handleCancel = async (button) => { | ||
| const modal = ensureModalRef(); | ||
| if (!modal) return; | ||
| const pendingId = button?.dataset.pendingId || modal.dataset.pendingId; | ||
| const template = modal.dataset.cancelUrlTemplate || ""; | ||
| if (!pendingId || !template) { | ||
| modal.hidden = true; | ||
| delete modal.dataset.open; | ||
| document.body.classList.remove("modal-open"); | ||
| dispatchWizardEvent(modal, "cancel", { pendingId }); | ||
| return; | ||
| } | ||
| const url = template.replace("__pending__", encodeURIComponent(pendingId)); | ||
| try { | ||
| const response = await fetch(url, { | ||
| method: "POST", | ||
| headers: { Accept: "application/json" }, | ||
| credentials: "same-origin", | ||
| }); | ||
| if (response.ok) { | ||
| const text = await response.text(); | ||
| const payload = text ? JSON.parse(text) : null; | ||
| if (payload?.redirect_url) { | ||
| handleWizardRedirect(payload); | ||
| return; | ||
| } | ||
| } | ||
| } catch (error) { | ||
| console.error("Cancel request failed", error); | ||
| } | ||
| modal.hidden = true; | ||
| delete modal.dataset.open; | ||
| document.body.classList.remove("modal-open"); | ||
| dispatchWizardEvent(modal, "cancel", { pendingId }); | ||
| resetWizardToInitial(); | ||
| }; | ||
| const navigateToWizardStep = (targetStep, pendingOverride) => { | ||
| const modal = ensureModalRef(); | ||
| if (!modal || wizardState.submitting) { | ||
| return; | ||
| } | ||
| const normalizedTarget = normalizeStep(targetStep || "book"); | ||
| const currentStep = modal.dataset.step ? normalizeStep(modal.dataset.step) : "book"; | ||
| if (normalizedTarget === currentStep) { | ||
| return; | ||
| } | ||
| const pendingId = pendingOverride || modal.dataset.pendingId || ""; | ||
| const template = modal.dataset.prepareUrlTemplate || ""; | ||
| if (!pendingId || !template) { | ||
| return; | ||
| } | ||
| const url = new URL(template.replace("__pending__", encodeURIComponent(pendingId)), window.location.origin); | ||
| url.searchParams.set("step", normalizedTarget); | ||
| url.searchParams.set("format", "json"); | ||
| requestWizardStep(url.toString(), { method: "GET" }); | ||
| }; | ||
| const handleBackToStep = (button) => { | ||
| const targetStep = normalizeStep(button.dataset.targetStep || "book"); | ||
| navigateToWizardStep(targetStep, button.dataset.pendingId); | ||
| }; | ||
| const handleWizardClick = (event) => { | ||
| const modal = ensureModalRef(); | ||
| if (!modal) return; | ||
| const closeTarget = event.target.closest('[data-role="new-job-modal-close"]'); | ||
| if (closeTarget) { | ||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
| handleCancel(closeTarget); | ||
| return; | ||
| } | ||
| const cancelButton = event.target.closest('[data-role="wizard-cancel"]'); | ||
| if (cancelButton) { | ||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
| handleCancel(cancelButton); | ||
| return; | ||
| } | ||
| const backButton = event.target.closest('[data-role="wizard-back"]'); | ||
| if (backButton) { | ||
| const targetStep = normalizeStep(backButton.dataset.targetStep || "book"); | ||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
| handleBackToStep(backButton); | ||
| return; | ||
| } | ||
| const indicator = event.target.closest('[data-role="wizard-step-indicator"]'); | ||
| if (indicator instanceof HTMLButtonElement) { | ||
| if (indicator.classList.contains("is-complete")) { | ||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
| navigateToWizardStep(indicator.dataset.step || "book"); | ||
| } | ||
| } | ||
| }; | ||
| const handleWizardSubmit = (event) => { | ||
| const form = event.target; | ||
| if (!(form instanceof HTMLFormElement)) { | ||
| return; | ||
| } | ||
| if (form.dataset.wizardForm !== "true") { | ||
| return; | ||
| } | ||
| const submitter = event.submitter || form.querySelector('button[type="submit"]'); | ||
| if (!submitter) { | ||
| return; | ||
| } | ||
| event.preventDefault(); | ||
| submitWizardForm(form, submitter); | ||
| }; | ||
| const initWizard = () => { | ||
| if (wizardState.initialized) { | ||
| return; | ||
| } | ||
| const modal = ensureModalRef(); | ||
| if (!modal) { | ||
| return; | ||
| } | ||
| wizardState.initialized = true; | ||
| wizardState.modal = modal; | ||
| wizardState.stage = modal.querySelector('[data-role="wizard-stage"]'); | ||
| const initialStep = normalizeStep(modal.dataset.step || "book"); | ||
| if (!wizardState.initialStageMarkup && wizardState.stage) { | ||
| wizardState.initialStageMarkup = wizardState.stage.innerHTML; | ||
| wizardState.initialStep = initialStep; | ||
| } | ||
| modal.addEventListener("submit", handleWizardSubmit, true); | ||
| modal.addEventListener("click", handleWizardClick); | ||
| }; | ||
| window.AbogenWizard = window.AbogenWizard || {}; | ||
| window.AbogenWizard.init = initWizard; | ||
| window.AbogenWizard.requestStep = requestWizardStep; | ||
| window.AbogenWizard.applyPayload = applyWizardPayload; | ||
| export { initWizard }; |
| <!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <title>{% block title %}abogen{% endblock %}</title> | ||
| <link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}"> | ||
| <script src="https://unpkg.com/htmx.org@2.0.3"></script> | ||
| <script src="https://unpkg.com/hyperscript.org@0.9.12"></script> | ||
| </head> | ||
| <body> | ||
| <header class="top-bar"> | ||
| <div class="brand"> | ||
| <span class="brand__mark">🔊</span> | ||
| <span class="brand__name">abogen</span> | ||
| <span class="brand__tagline">Verba audiuntur</span> | ||
| </div> | ||
| <nav class="top-actions"> | ||
| {% set endpoint = request.endpoint or '' %} | ||
| <a href="{{ url_for('main.index') }}" class="btn{% if endpoint == 'main.index' %} is-active{% endif %}">Dashboard</a> | ||
| <a href="{{ url_for('voices.voice_profiles') }}" class="btn{% if endpoint == 'voices.voice_profiles' %} is-active{% endif %}">Speaker Studio</a> | ||
| <a href="{{ url_for('entities.entities_page') }}" class="btn{% if endpoint == 'entities.entities_page' %} is-active{% endif %}">TTS Overrides</a> | ||
| <a href="{{ url_for('books.find_books_page') }}" class="btn{% if endpoint == 'books.find_books_page' %} is-active{% endif %}">Find Books</a> | ||
| <a href="{{ url_for('jobs.queue_page') }}" class="btn{% if endpoint in ['jobs.queue_page', 'jobs.job_detail'] %} is-active{% endif %}">Queue</a> | ||
| <a href="{{ url_for('settings.settings_page') }}" class="btn{% if endpoint == 'settings.settings_page' %} is-active{% endif %}">Settings</a> | ||
| </nav> | ||
| </header> | ||
| <main class="main"> | ||
| {% block content %}{% endblock %} | ||
| </main> | ||
| <footer class="footer"> | ||
| <span>Need help?</span> | ||
| <a href="https://github.com/denizsafak/abogen" target="_blank" rel="noopener">Read the docs</a> | ||
| </footer> | ||
| {% block scripts %}{% endblock %} | ||
| </body> | ||
| </html> |
| {% extends "base.html" %} | ||
| {% block title %}abogen · Debug WAVs{% endblock %} | ||
| {% block content %} | ||
| <section class="card"> | ||
| <h1 class="card__title">Debug WAVs</h1> | ||
| <p class="tag">Run ID: <code>{{ run_id }}</code></p> | ||
| <p class="hint">Each clip reads: ID, one second pause, then the reference text.</p> | ||
| {% if artifacts %} | ||
| <div class="field field--wide"> | ||
| <label>Samples</label> | ||
| <ul> | ||
| {% for item in artifacts %} | ||
| <li> | ||
| <div class="field field--stack" style="margin: 0;"> | ||
| <div> | ||
| <strong>{{ item.label }}</strong> | ||
| {% if item.text %} | ||
| — <span class="muted">{{ item.text }}</span> | ||
| {% endif %} | ||
| </div> | ||
| <audio controls preload="none" src="{{ item.url }}"></audio> | ||
| </div> | ||
| </li> | ||
| {% endfor %} | ||
| </ul> | ||
| </div> | ||
| {% endif %} | ||
| <div class="field field--inline"> | ||
| <a href="{{ url_for('settings.settings_page', _anchor='debug') }}" class="button button--ghost">Back to Settings</a> | ||
| </div> | ||
| </section> | ||
| {% endblock %} |
| {% extends "base.html" %} | ||
| {% block title %}abogen · TTS Overrides{% endblock %} | ||
| {% block content %} | ||
| <section | ||
| class="card card--panel" | ||
| data-override-root | ||
| data-preview-url="{{ url_for('api.api_entity_pronunciation_preview') }}" | ||
| data-language="{{ language }}" | ||
| > | ||
| <h1 class="card__title">TTS Overrides & Pronunciation</h1> | ||
| <p class="card__subtitle">Review and refine stored pronunciations so recurring names sound right in every project.</p> | ||
| <form class="entity-filter" method="get"> | ||
| <div class="entity-filter__row"> | ||
| <label class="field"> | ||
| <span>Language</span> | ||
| <select name="lang"> | ||
| {% for code, label in languages %} | ||
| <option value="{{ code }}" {% if code == language %}selected{% endif %}>{{ label }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </label> | ||
| <label class="field"> | ||
| <span>Voice filter</span> | ||
| <select name="voice"> | ||
| {% for option in voice_filter_options %} | ||
| <option value="{{ option.value }}" {% if option.value == voice_filter %}selected{% endif %}>{{ option.label }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </label> | ||
| <label class="field"> | ||
| <span>Pronunciations</span> | ||
| <select name="pronunciation"> | ||
| {% for option in pronunciation_filter_options %} | ||
| <option value="{{ option.value }}" {% if option.value == pronunciation_filter %}selected{% endif %}>{{ option.label }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </label> | ||
| <label class="field"> | ||
| <span>Limit</span> | ||
| <input type="number" name="limit" min="10" max="500" value="{{ limit }}"> | ||
| </label> | ||
| </div> | ||
| <div class="entity-filter__row entity-filter__row--actions"> | ||
| <label class="field field--grow"> | ||
| <span>Search overrides</span> | ||
| <input type="search" name="q" value="{{ query }}" placeholder="Search by token, pronunciation, or notes"> | ||
| </label> | ||
| <div class="entity-filter__actions"> | ||
| <button type="submit" class="button">Apply filters</button> | ||
| <a href="{{ url_for('entities.entities_page') }}" class="button button--ghost">Reset</a> | ||
| </div> | ||
| </div> | ||
| </form> | ||
| <p class="hint">Looking for saved speaker rosters instead? <a href="{{ url_for('voices.speaker_configs_page') }}">Manage speaker presets</a>.</p> | ||
| </section> | ||
| <section class="card card--panel"> | ||
| <header class="entity-summary"> | ||
| <h2 class="card__title">Overrides for {{ language_label }}</h2> | ||
| <p class="card__subtitle"> | ||
| Showing {{ stats.filtered }} of {{ stats.total }} stored overrides · | ||
| {{ stats.with_pronunciation }} with pronunciations · {{ stats.with_voice }} with assigned voices | ||
| </p> | ||
| </header> | ||
| {% if status_message or status_error %} | ||
| <div class="notice {% if status_error %}notice--error{% else %}notice--success{% endif %}"> | ||
| {{ status_error or status_message }} | ||
| </div> | ||
| {% endif %} | ||
| <div class="entity-table-tools"> | ||
| <label class="field entity-table-tools__filter"> | ||
| <span>Filter visible overrides</span> | ||
| <input | ||
| type="search" | ||
| data-role="override-filter" | ||
| placeholder="Type a name or voice to filter" | ||
| autocomplete="off" | ||
| > | ||
| </label> | ||
| <button type="button" class="button button--ghost" data-role="override-filter-clear">Clear</button> | ||
| </div> | ||
| <form | ||
| id="override-create-form" | ||
| class="form-section entity-create" | ||
| method="post" | ||
| action="{{ url_for('entities.upsert_global_override') }}" | ||
| > | ||
| <h3 class="form-section__title">Add manual override</h3> | ||
| <p class="entity-create__description">Create pronunciations or assign default voices without preparing a job.</p> | ||
| <input type="hidden" name="lang" value="{{ language }}"> | ||
| <input type="hidden" name="action" value="save"> | ||
| <input type="hidden" name="state_voice" value="{{ voice_filter }}"> | ||
| <input type="hidden" name="state_pronunciation" value="{{ pronunciation_filter }}"> | ||
| <input type="hidden" name="state_limit" value="{{ limit }}"> | ||
| <input type="hidden" name="state_query" value="{{ query }}"> | ||
| <div class="field-grid field-grid--compact"> | ||
| <label class="field"> | ||
| <span>Token</span> | ||
| <input type="text" name="token" placeholder="e.g. Arrakis" required> | ||
| </label> | ||
| <label class="field"> | ||
| <span>Pronunciation</span> | ||
| <input type="text" name="pronunciation" placeholder="Optional phonetic spelling"> | ||
| </label> | ||
| <label class="field"> | ||
| <span>Voice override</span> | ||
| <select name="voice"> | ||
| <option value="" selected>No override</option> | ||
| {% if options.voice_profile_options %} | ||
| <optgroup label="Saved mixes"> | ||
| {% for profile in options.voice_profile_options %} | ||
| {% set profile_value = 'profile:' ~ profile.name %} | ||
| <option value="{{ profile_value }}">{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option> | ||
| {% endfor %} | ||
| </optgroup> | ||
| {% endif %} | ||
| <optgroup label="Stock voices"> | ||
| {% for voice in options.voice_catalog %} | ||
| <option value="{{ voice.id }}">{{ voice.display_name }} - {{ voice.language_label }} - {{ voice.gender }}</option> | ||
| {% endfor %} | ||
| </optgroup> | ||
| </select> | ||
| </label> | ||
| </div> | ||
| <div class="entity-create__actions"> | ||
| <button | ||
| type="button" | ||
| class="button button--ghost" | ||
| data-role="preview-button" | ||
| data-form-id="override-create-form" | ||
| >Preview</button> | ||
| <button type="submit" class="button">Add override</button> | ||
| </div> | ||
| <div class="entity-preview" data-role="preview-container"> | ||
| <div class="entity-preview__status" data-role="preview-message"></div> | ||
| <audio | ||
| class="entity-preview__audio" | ||
| data-role="preview-audio" | ||
| controls | ||
| hidden | ||
| ></audio> | ||
| </div> | ||
| </form> | ||
| {% if overrides %} | ||
| <div class="table-wrapper"> | ||
| <table class="jobs-table overrides-table" data-role="override-table"> | ||
| <thead> | ||
| <tr> | ||
| <th scope="col">Token</th> | ||
| <th scope="col">Pronunciation</th> | ||
| <th scope="col">Voice</th> | ||
| <th scope="col">Usage</th> | ||
| <th scope="col">Last updated</th> | ||
| <th scope="col">Preview</th> | ||
| <th scope="col" class="overrides-table__actions-heading">Actions</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| {% for override in overrides %} | ||
| {% set form_id = "override-form-" ~ loop.index %} | ||
| <tr | ||
| data-role="override-row" | ||
| data-token="{{ override.token | lower }}" | ||
| > | ||
| <td data-label="Token"><span class="overrides-table__token">{{ override.token }}</span></td> | ||
| <td data-label="Pronunciation"> | ||
| <input | ||
| class="overrides-table__input" | ||
| type="text" | ||
| name="pronunciation" | ||
| form="{{ form_id }}" | ||
| value="{{ override.pronunciation or '' }}" | ||
| placeholder="Add pronunciation" | ||
| > | ||
| </td> | ||
| <td data-label="Voice"> | ||
| {% set current_voice = override.voice or '' %} | ||
| {% set known_voice = namespace(value=False) %} | ||
| {% if current_voice %} | ||
| {% if current_voice in options.voice_catalog_map %} | ||
| {% set known_voice.value = True %} | ||
| {% else %} | ||
| {% for profile in options.voice_profile_options %} | ||
| {% if current_voice == 'profile:' ~ profile.name %} | ||
| {% set known_voice.value = True %} | ||
| {% endif %} | ||
| {% endfor %} | ||
| {% endif %} | ||
| {% endif %} | ||
| <select | ||
| class="overrides-table__select" | ||
| name="voice" | ||
| form="{{ form_id }}" | ||
| > | ||
| <option value="" {% if not current_voice %}selected{% endif %}>No override</option> | ||
| {% if options.voice_profile_options %} | ||
| <optgroup label="Saved mixes"> | ||
| {% for profile in options.voice_profile_options %} | ||
| {% set profile_value = 'profile:' ~ profile.name %} | ||
| <option value="{{ profile_value }}" {% if current_voice == profile_value %}selected{% endif %}> | ||
| {{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %} | ||
| </option> | ||
| {% endfor %} | ||
| </optgroup> | ||
| {% endif %} | ||
| <optgroup label="Stock voices"> | ||
| {% for voice in options.voice_catalog %} | ||
| <option value="{{ voice.id }}" {% if current_voice == voice.id %}selected{% endif %}> | ||
| {{ voice.display_name }} - {{ voice.language_label }} - {{ voice.gender }} | ||
| </option> | ||
| {% endfor %} | ||
| </optgroup> | ||
| {% if current_voice and not known_voice.value %} | ||
| <option value="{{ current_voice }}" selected>{{ current_voice }}</option> | ||
| {% endif %} | ||
| </select> | ||
| </td> | ||
| <td data-label="Usage">{{ override.usage_count }}</td> | ||
| <td data-label="Last updated">{{ override.updated_at_label }}</td> | ||
| <td data-label="Preview"> | ||
| <div class="entity-preview" data-role="preview-container"> | ||
| <div class="entity-preview__controls"> | ||
| <button | ||
| type="button" | ||
| class="button button--ghost button--small" | ||
| data-role="preview-button" | ||
| data-form-id="{{ form_id }}" | ||
| >Preview</button> | ||
| <span class="entity-preview__status" data-role="preview-message"></span> | ||
| </div> | ||
| <audio | ||
| class="entity-preview__audio" | ||
| data-role="preview-audio" | ||
| controls | ||
| hidden | ||
| ></audio> | ||
| </div> | ||
| </td> | ||
| <td data-label="Actions"> | ||
| <form | ||
| id="{{ form_id }}" | ||
| class="overrides-table__form" | ||
| method="post" | ||
| action="{{ url_for('entities.upsert_global_override') }}" | ||
| > | ||
| <input type="hidden" name="lang" value="{{ language }}"> | ||
| <input type="hidden" name="token" value="{{ override.token }}"> | ||
| <input type="hidden" name="state_voice" value="{{ voice_filter }}"> | ||
| <input type="hidden" name="state_pronunciation" value="{{ pronunciation_filter }}"> | ||
| <input type="hidden" name="state_limit" value="{{ limit }}"> | ||
| <input type="hidden" name="state_query" value="{{ query }}"> | ||
| <div class="overrides-table__actions"> | ||
| <button type="submit" class="button button--small" name="action" value="save">Save</button> | ||
| <button type="submit" class="button button--ghost button--small button--danger" name="action" value="delete">Delete</button> | ||
| </div> | ||
| </form> | ||
| </td> | ||
| </tr> | ||
| {% endfor %} | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
| <p class="hint" data-role="filter-empty" hidden>No overrides match your current filter.</p> | ||
| {% else %} | ||
| <p class="hint">No overrides matched your filters. Try adjusting the search or create overrides from the Entities step while preparing a job.</p> | ||
| {% endif %} | ||
| </section> | ||
| {% endblock %} | ||
| {% block scripts %} | ||
| {{ super() }} | ||
| <script src="{{ url_for('static', filename='entities.js') }}" defer></script> | ||
| {% endblock %} |
| {% extends "base.html" %} | ||
| {% block title %}abogen · Find Books{% endblock %} | ||
| {% block content %} | ||
| <section class="card"> | ||
| <div class="card__title">Find Books</div> | ||
| <p class="card__subtitle">Browse trusted public-domain libraries or drill into your Calibre catalog without leaving abogen.</p> | ||
| <div class="card__body find-books__body"> | ||
| <div class="find-books__resources"> | ||
| <article class="resource-tile"> | ||
| <h2>Standard Ebooks</h2> | ||
| <p>Discover meticulously produced public-domain ebooks with consistent typography, metadata, and clean EPUB sources.</p> | ||
| <p> | ||
| <a class="button" href="https://standardebooks.org/ebooks" target="_blank" rel="noopener"> | ||
| Visit standardebooks.org | ||
| </a> | ||
| </p> | ||
| <p class="hint">Opens in a new tab so you can grab EPUB downloads and drag them into abogen.</p> | ||
| </article> | ||
| <article class="resource-tile"> | ||
| <h2>Project Gutenberg</h2> | ||
| <p>The world’s largest public-domain ebook library, offering thousands of EPUB and plain-text editions sourced from volunteer transcriptions.</p> | ||
| <p> | ||
| <a class="button" href="https://gutenberg.org/ebooks/" target="_blank" rel="noopener"> | ||
| Visit gutenberg.org | ||
| </a> | ||
| </p> | ||
| <p class="hint">Download the EPUB version for best results, then import it into abogen.</p> | ||
| </article> | ||
| </div> | ||
| <div class="find-books__opds"> | ||
| <h2>Calibre catalog</h2> | ||
| <p class="hint">Wire abogen to your Calibre OPDS server to browse, search, and import titles without leaving the app.</p> | ||
| {% if not opds_available %} | ||
| <div class="alert alert--warning">Enable the Calibre OPDS integration in settings to browse your library here.</div> | ||
| {% else %} | ||
| <button type="button" class="button" data-action="open-opds-modal">Browse Calibre catalog</button> | ||
| {% endif %} | ||
| </div> | ||
| </div> | ||
| </section> | ||
| {% if opds_available %} | ||
| <div class="modal" data-role="opds-modal" hidden> | ||
| <div class="modal__overlay" data-role="opds-modal-close" tabindex="-1"></div> | ||
| <div class="modal__content card card--modal opds-modal" role="dialog" aria-modal="true" aria-labelledby="opds-modal-title"> | ||
| <header class="modal__header opds-modal__header"> | ||
| <div class="modal__heading"> | ||
| <p class="modal__eyebrow">Calibre OPDS</p> | ||
| <h2 class="modal__title" id="opds-modal-title">Browse your catalog</h2> | ||
| </div> | ||
| <form class="opds-modal__search" data-role="opds-search"> | ||
| <label class="visually-hidden" for="opds-search-input">Search your Calibre catalog</label> | ||
| <div class="opds-search__field"> | ||
| <svg class="opds-search__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"> | ||
| <path d="M15.5 14h-.79l-.28-.27A6.5 6.5 0 0016 9.5 6.5 6.5 0 109.5 16a6.5 6.5 0 004.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1114 9.5 4.5 4.5 0 019.5 14z" /> | ||
| </svg> | ||
| <input type="search" id="opds-search-input" name="q" placeholder="Search by title, author, or keyword" autocomplete="off"> | ||
| </div> | ||
| <div class="opds-search__actions"> | ||
| <button type="submit" class="button">Search</button> | ||
| <button type="button" class="button button--ghost" data-action="opds-refresh">Reset</button> | ||
| </div> | ||
| </form> | ||
| <button type="button" class="button button--ghost opds-modal__close" data-role="opds-modal-close">Close</button> | ||
| </header> | ||
| <div class="opds-modal__tabs" data-role="opds-tabs"></div> | ||
| <div class="modal__body opds-modal__body"> | ||
| <div class="opds-browser" data-role="opds-browser"> | ||
| <div class="opds-modal__status" data-role="opds-status"></div> | ||
| <div class="opds-modal__nav" data-role="opds-nav"></div> | ||
| <div class="opds-modal__alpha" data-role="opds-alpha-picker"></div> | ||
| <div class="opds-modal__results-wrap"> | ||
| <ul class="opds-modal__results" data-role="opds-results"></ul> | ||
| </div> | ||
| <div class="opds-modal__nav opds-modal__nav--bottom" data-role="opds-nav-bottom"></div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| {% endif %} | ||
| {% with pending=None, readonly=False, active_step='settings' %} | ||
| {% include "partials/upload_modal.html" %} | ||
| {% endwith %} | ||
| {% include "partials/reader_modal.html" %} | ||
| {% endblock %} | ||
| {% block scripts %} | ||
| {{ super() }} | ||
| <script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script> | ||
| <script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script> | ||
| <script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script> | ||
| <script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script> | ||
| {% if opds_available %} | ||
| <script type="module" src="{{ url_for('static', filename='find_books.js') }}"></script> | ||
| {% endif %} | ||
| {% endblock %} |
| {% extends "base.html" %} | ||
| {% block title %}abogen · Dashboard{% endblock %} | ||
| {% block content %} | ||
| <style> | ||
| .stats-overview { | ||
| display: flex; | ||
| gap: 1rem; | ||
| margin-bottom: 2rem; | ||
| flex-wrap: wrap; | ||
| } | ||
| .stat-card { | ||
| background: var(--bg-surface, #fff); | ||
| border: 1px solid var(--border-subtle, #ddd); | ||
| border-radius: var(--radius-md, 8px); | ||
| padding: 1rem; | ||
| flex: 1; | ||
| min-width: 120px; | ||
| text-align: center; | ||
| box-shadow: var(--shadow-sm, 0 1px 2px rgba(0,0,0,0.05)); | ||
| } | ||
| .stat-card__value { | ||
| display: block; | ||
| font-size: 2rem; | ||
| font-weight: 700; | ||
| color: var(--text-main, #333); | ||
| line-height: 1.2; | ||
| } | ||
| .stat-card__label { | ||
| display: block; | ||
| font-size: 0.75rem; | ||
| color: var(--text-muted, #666); | ||
| text-transform: uppercase; | ||
| letter-spacing: 0.05em; | ||
| margin-top: 0.25rem; | ||
| } | ||
| </style> | ||
| <section class="stats-overview"> | ||
| <div class="stat-card"> | ||
| <span class="stat-card__value">{{ stats.total }}</span> | ||
| <span class="stat-card__label">Total Jobs</span> | ||
| </div> | ||
| <div class="stat-card"> | ||
| <span class="stat-card__value">{{ stats.completed }}</span> | ||
| <span class="stat-card__label">Converted</span> | ||
| </div> | ||
| <div class="stat-card"> | ||
| <span class="stat-card__value">{{ stats.running }}</span> | ||
| <span class="stat-card__label">Converting</span> | ||
| </div> | ||
| <div class="stat-card"> | ||
| <span class="stat-card__value">{{ stats.pending }}</span> | ||
| <span class="stat-card__label">Pending</span> | ||
| </div> | ||
| <div class="stat-card"> | ||
| <span class="stat-card__value">{{ stats.failed }}</span> | ||
| <span class="stat-card__label">Failed</span> | ||
| </div> | ||
| </section> | ||
| <section class="card card--workflow"> | ||
| <h1 class="card__title">Create a New Audiobook</h1> | ||
| <p class="card__subtitle">Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and entities in the next steps.</p> | ||
| <div class="upload-card__dropzone" data-role="upload-dropzone" tabindex="0" role="button" aria-label="Open upload settings or drop a manuscript file"> | ||
| <div class="upload-card__dropzone-content"> | ||
| <span class="upload-card__icon" aria-hidden="true">↑</span> | ||
| <p class="upload-card__headline">Drop your manuscript to begin</p> | ||
| <p class="upload-card__hint">Drag & drop a supported file here, or click to choose one in the next step.</p> | ||
| <p class="upload-card__filename" data-role="upload-dropzone-filename" hidden></p> | ||
| <button type="button" class="button" data-role="open-upload-modal">Open upload & settings</button> | ||
| {% if opds_available %} | ||
| <div class="wizard-card__body"> | ||
| <p>Upload an EPUB or text file to begin.</p> | ||
| <a class="button button--ghost" href="{{ url_for('books.find_books_page') }}">Browse Calibre library</a> | ||
| </div> | ||
| {% endif %} | ||
| </div> | ||
| </div> | ||
| </section> | ||
| {% with pending=None, readonly=False, active_step='settings' %} | ||
| {% include "partials/upload_modal.html" %} | ||
| {% endwith %} | ||
| {% include "partials/reader_modal.html" %} | ||
| {% endblock %} | ||
| {% block scripts %} | ||
| {{ super() }} | ||
| <script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script> | ||
| <script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script> | ||
| <script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script> | ||
| <script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script> | ||
| {% endblock %} |
| {% extends "base.html" %} | ||
| {% block title %}Job · {{ job.original_filename }}{% endblock %} | ||
| {% block content %} | ||
| <section class="card"> | ||
| <div class="card__title">{{ job.original_filename }}</div> | ||
| <div class="grid grid--two"> | ||
| <article> | ||
| <h2>Settings</h2> | ||
| <ul> | ||
| <li><strong>Voice:</strong> {{ job.voice }}</li> | ||
| <li><strong>Language:</strong> {{ options.languages.get(job.language, job.language) }}</li> | ||
| {% if job.voice_profile %} | ||
| <li><strong>Voice profile:</strong> {{ job.voice_profile }}</li> | ||
| {% endif %} | ||
| <li><strong>Speed:</strong> {{ '%.2f' | format(job.speed) }}×</li> | ||
| <li><strong>Subtitle mode:</strong> {{ job.subtitle_mode }}</li> | ||
| <li><strong>Audio format:</strong> {{ job.output_format }}</li> | ||
| <li><strong>Subtitle format:</strong> {{ job.subtitle_format }}</li> | ||
| <li><strong>GPU:</strong> {{ 'Yes' if job.use_gpu else 'No' }}</li> | ||
| <li><strong>Save chapters separately:</strong> {{ 'Yes' if job.save_chapters_separately else 'No' }}</li> | ||
| <li><strong>Merge at end:</strong> {{ 'Yes' if job.merge_chapters_at_end else 'No' }}</li> | ||
| <li><strong>Separate chapter format:</strong> {{ job.separate_chapters_format|upper }}</li> | ||
| <li><strong>Silence between chapters:</strong> {{ '%.1f'|format(job.silence_between_chapters) }}s</li> | ||
| <li><strong>Chapter intro delay:</strong> {{ '%.1f'|format(job.chapter_intro_delay) }}s</li> | ||
| <li><strong>Title intro:</strong> {{ 'Yes' if job.read_title_intro else 'No' }}</li> | ||
| <li><strong>Closing outro:</strong> {{ 'Yes' if job.read_closing_outro else 'No' }}</li> | ||
| <li><strong>Normalize chapter openings:</strong> {{ 'Yes' if job.normalize_chapter_opening_caps else 'No' }}</li> | ||
| <li><strong>Prefix chapter titles:</strong> {{ 'Yes' if job.auto_prefix_chapter_titles else 'No' }}</li> | ||
| <li><strong>Max words per subtitle:</strong> {{ job.max_subtitle_words }}</li> | ||
| <li><strong>Project folder:</strong> {{ 'Yes' if job.save_as_project else 'No' }}</li> | ||
| <li><strong>Chunk granularity:</strong> {{ job.chunk_level|replace('_', ' ')|title }}</li> | ||
| <li><strong>Speaker analysis threshold:</strong> {{ job.speaker_analysis_threshold }}</li> | ||
| <li><strong>Generate EPUB 3:</strong> {{ 'Yes' if job.generate_epub3 else 'No' }}</li> | ||
| </ul> | ||
| </article> | ||
| <article> | ||
| <h2>Status</h2> | ||
| <p><span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span></p> | ||
| <p>Created: {{ job.created_at|datetimeformat }}</p> | ||
| <p>Started: {{ job.started_at|datetimeformat }}</p> | ||
| <p>Finished: {{ job.finished_at|datetimeformat }}</p> | ||
| <p>Characters: {{ job.processed_characters }} / {{ job.total_characters or '—' }}</p> | ||
| {% set flags = downloads or {} %} | ||
| {% if flags.get('m4b') %} | ||
| <p><a class="button" href="{{ url_for('jobs.download_file', job_id=job.id, file_type='audio') }}">Download M4B</a></p> | ||
| {% elif flags.get('audio') %} | ||
| <p><a class="button" href="{{ url_for('jobs.download_file', job_id=job.id, file_type='audio') }}">Download audio</a></p> | ||
| {% endif %} | ||
| {% if flags.get('epub3') %} | ||
| <p><a class="button button--ghost" href="{{ url_for('jobs.job_epub', job_id=job.id) }}">Download EPUB 3</a></p> | ||
| {% endif %} | ||
| {% if job.status in [JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED] %} | ||
| <form action="{{ url_for('jobs.cancel_job', job_id=job.id) }}" method="post"> | ||
| <button type="submit" class="button button--ghost">Cancel job</button> | ||
| </form> | ||
| {% elif job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED] %} | ||
| <form action="{{ url_for('jobs.retry_job', job_id=job.id) }}" method="post"> | ||
| <button type="submit" class="button">Retry job</button> | ||
| </form> | ||
| {% endif %} | ||
| </article> | ||
| </div> | ||
| </section> | ||
| {% set analysis = job.speaker_analysis or {} %} | ||
| {% if analysis %} | ||
| {% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} | ||
| <section class="card"> | ||
| <div class="card__title">Speaker analysis</div> | ||
| <div class="grid grid--two"> | ||
| <article> | ||
| <h2>Summary</h2> | ||
| {% set stats = analysis.get('stats', {}) %} | ||
| <ul> | ||
| <li><strong>Total chunks:</strong> {{ stats.get('total_chunks', '—') }}</li> | ||
| <li><strong>Explicit dialogue chunks:</strong> {{ stats.get('explicit_chunks', '—') }}</li> | ||
| <li><strong>Active speakers:</strong> {{ stats.get('active_speakers', '—') }}</li> | ||
| <li><strong>Unique speakers observed:</strong> {{ stats.get('unique_speakers', '—') }}</li> | ||
| <li><strong>Suppressed speakers:</strong> {{ stats.get('suppressed', 0) }}</li> | ||
| </ul> | ||
| </article> | ||
| <article> | ||
| <h2>Detected speakers</h2> | ||
| {% set speakers = analysis.get('speakers', {}) %} | ||
| {% set narrator_id = analysis.get('narrator', 'narrator') %} | ||
| {% if speakers %} | ||
| <ul> | ||
| {% for speaker_id, payload in speakers.items() if speaker_id != narrator_id and not payload.get('suppressed') %} | ||
| {% set spoken_name = payload.get('pronunciation') or payload.get('label') or speaker_id|replace('_', ' ')|title %} | ||
| {% set preview_text = preview_template | replace("{{name}}", spoken_name) %} | ||
| <li> | ||
| <div class="speaker-line"> | ||
| <strong>{{ payload.get('label', speaker_id|replace('_', ' ')|title) }}</strong> | ||
| <button type="button" | ||
| class="icon-button speaker-list__preview" | ||
| data-role="speaker-preview" | ||
| data-job-id="{{ job.id }}" | ||
| data-speaker-id="{{ speaker_id }}" | ||
| data-preview-text="{{ preview_text|e }}" | ||
| data-language="{{ job.language }}" | ||
| data-voice="{{ payload.get('resolved_voice') or payload.get('voice_formula') or payload.get('voice') or job.voice }}" | ||
| data-speed="{{ '%.2f'|format(job.speed) }}" | ||
| data-use-gpu="{{ 'true' if job.use_gpu else 'false' }}" | ||
| aria-label="Preview pronunciation for {{ payload.get('label', speaker_id|replace('_', ' ')|title) }}" | ||
| title="Preview pronunciation"> | ||
| <span class="icon-button__glyph" aria-hidden="true">🔊</span> | ||
| <span class="spinner spinner--sm spinner--muted" aria-hidden="true"></span> | ||
| </button> | ||
| </div> | ||
| <div class="meta"> | ||
| <span>{{ payload.get('count', 0) }} chunks</span> | ||
| <span>Confidence: {{ payload.get('confidence', 'low')|title }}</span> | ||
| {% if payload.get('pronunciation') %} | ||
| <span>Pronunciation: {{ payload.get('pronunciation') }}</span> | ||
| {% endif %} | ||
| </div> | ||
| {% set quotes = payload.get('sample_quotes', []) %} | ||
| {% if quotes %} | ||
| <details> | ||
| <summary>Sample quotes</summary> | ||
| <ul> | ||
| {% for quote in quotes %} | ||
| <li>{{ quote }}</li> | ||
| {% endfor %} | ||
| </ul> | ||
| </details> | ||
| {% endif %} | ||
| </li> | ||
| {% endfor %} | ||
| </ul> | ||
| {% else %} | ||
| <p>No additional speakers detected.</p> | ||
| {% endif %} | ||
| {% set suppressed = analysis.get('suppressed_details') or analysis.get('suppressed', []) %} | ||
| {% if suppressed %} | ||
| <p class="muted"> | ||
| Suppressed speakers: | ||
| {% if suppressed[0] is string %} | ||
| {{ suppressed | join(', ') }} | ||
| {% else %} | ||
| {{ suppressed | map(attribute='label') | join(', ') }} | ||
| {% endif %} | ||
| </p> | ||
| {% endif %} | ||
| </article> | ||
| </div> | ||
| </section> | ||
| {% endif %} | ||
| {% include "partials/logs_section.html" %} | ||
| {% endblock %} | ||
| {% block scripts %} | ||
| {{ super() }} | ||
| <script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script> | ||
| {% endblock %} |
| {% extends "base.html" %} | ||
| {% block title %}Job Not Found{% endblock %} | ||
| {% block content %} | ||
| <section class="card logs-static"> | ||
| <div class="card__title-row"> | ||
| <div class="card__title">Job Not Found</div> | ||
| </div> | ||
| <p>This job no longer exists. It may have been deleted or the server was restarted.</p> | ||
| <p><a href="{{ url_for('main.index') }}" class="button button--primary">Return to Dashboard</a></p> | ||
| </section> | ||
| {% endblock %} |
| {% extends "base.html" %} | ||
| {% block title %}Logs · {{ job.original_filename }}{% endblock %} | ||
| {% block content %} | ||
| <section class="card logs-static"> | ||
| {% include "partials/logs.html" %} | ||
| {% if job.logs %} | ||
| <details class="log-copy"> | ||
| <summary>Copy raw log output</summary> | ||
| <textarea class="log-copy__textarea" readonly>{{- log_text -}}</textarea> | ||
| </details> | ||
| {% endif %} | ||
| </section> | ||
| {% endblock %} |
| {% extends "base.html" %} | ||
| {% block title %}Job Not Found{% endblock %} | ||
| {% block content %} | ||
| <section class="card"> | ||
| <div class="card__title-row"> | ||
| <div class="card__title">Job Not Found</div> | ||
| </div> | ||
| <p>This job no longer exists. It may have been deleted or the server was restarted.</p> | ||
| <p><a href="{{ url_for('main.index') }}" class="button button--primary">Return to Dashboard</a></p> | ||
| </section> | ||
| {% endblock %} |
| <div class="card__title">Queue</div> | ||
| <section class="queue-section"> | ||
| <header class="queue-section__header"> | ||
| <h3>Active jobs</h3> | ||
| </header> | ||
| {% if active_jobs %} | ||
| <ul class="job-cards"> | ||
| {% for job in active_jobs %} | ||
| {% set progress_value = ((job.progress or 0) * 100)|round(1) %} | ||
| <li class="job-card" data-status="{{ job.status.value }}"> | ||
| <div class="job-card__header"> | ||
| <div> | ||
| <a class="job-card__title" href="{{ url_for('jobs.job_detail', job_id=job.id) }}">{{ job.original_filename }}</a> | ||
| <div class="job-card__meta"> | ||
| {% if job.queue_position %}Position #{{ job.queue_position }} · {% endif %} | ||
| {% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }} | ||
| </div> | ||
| {% if job.pause_requested and job.status == JobStatus.RUNNING %} | ||
| <div class="job-card__meta job-card__meta--warning">Pause requested — waiting for a safe point…</div> | ||
| {% endif %} | ||
| </div> | ||
| <span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span> | ||
| </div> | ||
| <div class="job-card__progress"> | ||
| <div class="progress-bar" style="--progress: {{ progress_value }}%"> | ||
| <div class="progress-bar__fill"></div> | ||
| </div> | ||
| <div class="job-card__progress-meta"> | ||
| <small>{{ progress_value }}% · {{ job.processed_characters }} / {{ job.total_characters or '—' }}</small> | ||
| {% if job.estimated_time_remaining %} | ||
| <small class="job-card__eta">~{{ job.estimated_time_remaining | durationformat }} remaining</small> | ||
| {% endif %} | ||
| </div> | ||
| </div> | ||
| <div class="job-card__footer"> | ||
| <a class="button button--ghost" href="{{ url_for('jobs.job_detail', job_id=job.id) }}">Details</a> | ||
| {% if job.status == JobStatus.RUNNING %} | ||
| <button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.pause_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Pause</button> | ||
| {% elif job.status == JobStatus.PAUSED %} | ||
| <button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.resume_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Resume</button> | ||
| {% elif job.status == JobStatus.PENDING %} | ||
| <button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.pause_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Pause</button> | ||
| {% endif %} | ||
| {% if job.status in [JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED] %} | ||
| <button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.cancel_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Cancel this conversion?">Cancel</button> | ||
| {% endif %} | ||
| </div> | ||
| </li> | ||
| {% endfor %} | ||
| </ul> | ||
| {% else %} | ||
| <p class="queue-empty">No active jobs. Drop a file or paste text to get started.</p> | ||
| {% endif %} | ||
| </section> | ||
| <section class="queue-section"> | ||
| <header class="queue-section__header"> | ||
| <h3>Recent results</h3> | ||
| {% if total_finished > 0 %} | ||
| <button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.clear_finished_jobs') }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Remove completed jobs from this list?">Clear finished</button> | ||
| {% endif %} | ||
| </header> | ||
| {% if finished_jobs %} | ||
| <ul class="job-cards job-cards--compact"> | ||
| {% for job in finished_jobs %} | ||
| <li class="job-card"> | ||
| <div class="job-card__header"> | ||
| <div> | ||
| <a class="job-card__title" href="{{ url_for('jobs.job_detail', job_id=job.id) }}">{{ job.original_filename }}</a> | ||
| <div class="job-card__meta">Finished {{ job.finished_at|datetimeformat }}</div> | ||
| </div> | ||
| <span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span> | ||
| </div> | ||
| <div class="job-card__footer"> | ||
| <a class="button button--ghost" href="{{ url_for('jobs.job_detail', job_id=job.id) }}">Inspect</a> | ||
| {% if job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED] %} | ||
| <button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.retry_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Retry</button> | ||
| {% endif %} | ||
| {% set flags = download_flags.get(job.id, {}) %} | ||
| {% if job.status == JobStatus.COMPLETED %} | ||
| {% if flags.get('m4b') %} | ||
| <a class="button button--ghost" href="{{ url_for('jobs.download_file', job_id=job.id, file_type='audio') }}">Download M4B</a> | ||
| {% elif flags.get('audio') %} | ||
| <a class="button button--ghost" href="{{ url_for('jobs.download_file', job_id=job.id, file_type='audio') }}">Download Audio</a> | ||
| {% endif %} | ||
| {% if flags.get('epub3') %} | ||
| <a class="button button--ghost" href="{{ url_for('jobs.job_epub', job_id=job.id) }}">Download EPUB 3</a> | ||
| {% endif %} | ||
| {% if audiobookshelf_manual_available %} | ||
| <button type="button" | ||
| class="button button--ghost" | ||
| hx-post="{{ url_for('jobs.send_job_to_audiobookshelf', job_id=job.id) }}" | ||
| hx-target="#jobs-panel" | ||
| hx-swap="innerHTML"> | ||
| Send to Audiobookshelf | ||
| </button> | ||
| {% endif %} | ||
| {% endif %} | ||
| {% set reader_source = None %} | ||
| {% if job.status == JobStatus.COMPLETED and job.result %} | ||
| {% if job.result.epub_path %} | ||
| {% set reader_source = job.result.epub_path %} | ||
| {% elif job.result.artifacts and job.result.artifacts.get('epub3') %} | ||
| {% set reader_source = job.result.artifacts.get('epub3') %} | ||
| {% endif %} | ||
| {% endif %} | ||
| {% if reader_source %} | ||
| {% set metadata = job.metadata_tags if job.metadata_tags is mapping else {} %} | ||
| {% set job_display_title = metadata.get('title') or metadata.get('book_title') or metadata.get('name') or job.original_filename %} | ||
| <button type="button" | ||
| class="icon-button" | ||
| data-role="open-reader" | ||
| data-reader-url="{{ url_for('jobs.job_reader', job_id=job.id) }}" | ||
| data-book-title="{{ job_display_title }}" | ||
| title="Open reader" | ||
| aria-label="Open EPUB reader for {{ job_display_title }}"> | ||
| 📖 | ||
| </button> | ||
| {% endif %} | ||
| <button type="button" class="button button--ghost" hx-post="{{ url_for('jobs.delete_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Remove this job from the list?">Remove</button> | ||
| </div> | ||
| </li> | ||
| {% endfor %} | ||
| {% if total_finished > finished_jobs|length %} | ||
| <li class="job-card job-card--info"> | ||
| <div class="job-card__meta">{{ total_finished - finished_jobs|length }} more finished job{{ 's' if (total_finished - finished_jobs|length) != 1 else '' }} hidden. Clear finished to remove them.</div> | ||
| </li> | ||
| {% endif %} | ||
| </ul> | ||
| {% else %} | ||
| <p class="queue-empty">Completed jobs will appear here.</p> | ||
| {% endif %} | ||
| </section> |
| <section class="card" id="logs"> | ||
| <div class="card__title-row"> | ||
| <div class="card__title">Live log</div> | ||
| </div> | ||
| <p>Job not found (it may have completed, been removed, or the server restarted). Refresh the page to load an active job.</p> | ||
| </section> |
| <section class="card" id="logs" | ||
| hx-get="{{ url_for('jobs.job_logs_partial', job_id=job.id) }}" | ||
| hx-trigger="load, every 2s" | ||
| hx-target="#logs" | ||
| hx-swap="outerHTML"> | ||
| {% include "partials/logs.html" %} | ||
| </section> |
| {% set is_static = static_view if static_view is defined and static_view else False %} | ||
| <div class="card__title-row"> | ||
| <div class="card__title">Live log</div> | ||
| {% if not is_static %} | ||
| <a class="button button--ghost button--small" href="{{ url_for('jobs.job_logs', job_id=job.id) }}" target="_blank" rel="noopener">Open static view</a> | ||
| {% else %} | ||
| <a class="button button--ghost button--small" href="{{ url_for('jobs.job_detail', job_id=job.id) }}">Back to job</a> | ||
| {% endif %} | ||
| </div> | ||
| {% if job.logs %} | ||
| <ul class="log-list"> | ||
| {% for entry in job.logs|reverse %} | ||
| <li class="log-item log-item--{{ entry.level }}"> | ||
| <small>{{ entry.timestamp|datetimeformat('%H:%M:%S') }}</small> | ||
| <div>{{ entry.message }}</div> | ||
| </li> | ||
| {% endfor %} | ||
| </ul> | ||
| {% else %} | ||
| <p>No log entries yet. Check back soon!</p> | ||
| {% endif %} |
| {% set pending = pending if pending is defined else None %} | ||
| {% set metadata = pending.metadata_tags if pending else {} %} | ||
| {% set readonly = readonly if readonly is defined else False %} | ||
| {% set settings_dict = settings if settings is defined else {} %} | ||
| {% set options = options if options is defined else {} %} | ||
| {% set form_values = form_values if form_values is defined and form_values else {} %} | ||
| {% set language_value = form_values.get('language') if form_values else None %} | ||
| {% if not language_value %} | ||
| {% if pending and pending.language %} | ||
| {% set language_value = pending.language %} | ||
| {% else %} | ||
| {% set language_value = settings_dict.get('language', '') %} | ||
| {% endif %} | ||
| {% endif %} | ||
| {% if not language_value and options.languages %} | ||
| {% set sorted_languages = options.languages|dictsort %} | ||
| {% if sorted_languages %} | ||
| {% set language_value = sorted_languages[0][0] %} | ||
| {% endif %} | ||
| {% endif %} | ||
| {% set subtitle_value = form_values.get('subtitle_mode') if form_values else None %} | ||
| {% if not subtitle_value %} | ||
| {% if pending and pending.subtitle_mode %} | ||
| {% set subtitle_value = pending.subtitle_mode %} | ||
| {% else %} | ||
| {% set subtitle_value = settings_dict.get('subtitle_mode', 'Disabled') %} | ||
| {% endif %} | ||
| {% endif %} | ||
| {% set generate_flag = form_values.get('generate_epub3') if form_values else None %} | ||
| {% if generate_flag is not none %} | ||
| {% set generate_epub3 = True %} | ||
| {% else %} | ||
| {% set generate_epub3 = pending.generate_epub3 if pending else settings_dict.get('generate_epub3', False) %} | ||
| {% endif %} | ||
| {% set chunk_level_value = form_values.get('chunk_level') if form_values else None %} | ||
| {% if not chunk_level_value %} | ||
| {% if pending and pending.chunk_level %} | ||
| {% set chunk_level_value = pending.chunk_level %} | ||
| {% else %} | ||
| {% set chunk_level_value = settings_dict.get('chunk_level', 'paragraph') %} | ||
| {% endif %} | ||
| {% endif %} | ||
| {% set analysis_threshold_value = form_values.get('speaker_analysis_threshold') if form_values else None %} | ||
| {% if not analysis_threshold_value %} | ||
| {% if pending and pending.speaker_analysis_threshold %} | ||
| {% set analysis_threshold_value = pending.speaker_analysis_threshold %} | ||
| {% else %} | ||
| {% set analysis_threshold_value = settings_dict.get('speaker_analysis_threshold', 3) %} | ||
| {% endif %} | ||
| {% endif %} | ||
| {% set chapter_delay_value = form_values.get('chapter_intro_delay') if form_values else None %} | ||
| {% if not chapter_delay_value %} | ||
| {% if pending and pending.chapter_intro_delay is not none %} | ||
| {% set chapter_delay_value = pending.chapter_intro_delay %} | ||
| {% else %} | ||
| {% set chapter_delay_value = settings_dict.get('chapter_intro_delay', 0.5) %} | ||
| {% endif %} | ||
| {% endif %} | ||
| {% set read_intro_value = form_values.get('read_title_intro') if form_values else None %} | ||
| {% if read_intro_value is not none %} | ||
| {% set read_title_intro = ((read_intro_value|string)|lower) in ['true', '1', 'yes', 'on'] %} | ||
| {% else %} | ||
| {% if pending is not none %} | ||
| {% set read_title_intro = pending.read_title_intro %} | ||
| {% else %} | ||
| {% set read_title_intro = settings_dict.get('read_title_intro', False) %} | ||
| {% endif %} | ||
| {% endif %} | ||
| {% set read_outro_value = form_values.get('read_closing_outro') if form_values else None %} | ||
| {% if read_outro_value is not none %} | ||
| {% set read_closing_outro = ((read_outro_value|string)|lower) in ['true', '1', 'yes', 'on'] %} | ||
| {% else %} | ||
| {% if pending is not none %} | ||
| {% set read_closing_outro = pending.read_closing_outro %} | ||
| {% else %} | ||
| {% set read_closing_outro = settings_dict.get('read_closing_outro', True) %} | ||
| {% endif %} | ||
| {% endif %} | ||
| {% set normalize_caps_value = form_values.get('normalize_chapter_opening_caps') if form_values else None %} | ||
| {% if normalize_caps_value is not none %} | ||
| {% set normalize_chapter_opening_caps = ((normalize_caps_value|string)|lower) in ['true', '1', 'yes', 'on'] %} | ||
| {% else %} | ||
| {% if pending is not none %} | ||
| {% set normalize_chapter_opening_caps = pending.normalize_chapter_opening_caps %} | ||
| {% else %} | ||
| {% set normalize_chapter_opening_caps = settings_dict.get('normalize_chapter_opening_caps', True) %} | ||
| {% endif %} | ||
| {% endif %} | ||
| {% set selected_config = form_values.get('speaker_config') if form_values else None %} | ||
| {% if selected_config is none %} | ||
| {% if pending and pending.applied_speaker_config %} | ||
| {% set selected_config = pending.applied_speaker_config %} | ||
| {% else %} | ||
| {% set selected_config = '' %} | ||
| {% endif %} | ||
| {% endif %} | ||
| {% set narrator_speed_value = form_values.get('speed') if form_values else None %} | ||
| {% if narrator_speed_value is none %} | ||
| {% if pending and pending.speed %} | ||
| {% set narrator_speed_value = pending.speed %} | ||
| {% else %} | ||
| {% set narrator_speed_value = settings_dict.get('default_speed', 1.0) %} | ||
| {% endif %} | ||
| {% endif %} | ||
| {% set narrator_speed = narrator_speed_value|float if narrator_speed_value is not none else 1.0 %} | ||
| {% set speed_display = '%.2f'|format(narrator_speed if narrator_speed else 1.0) %} | ||
| {% set form_profile = form_values.get('voice_profile') if form_values else None %} | ||
| {% set form_voice = form_values.get('voice') if form_values else None %} | ||
| {% set form_formula = form_values.get('voice_formula') if form_values else None %} | ||
| {% set narrator_profile = None %} | ||
| {% if form_profile is not none and form_profile != '' %} | ||
| {% set narrator_profile = form_profile %} | ||
| {% elif pending and pending.voice_profile %} | ||
| {% set narrator_profile = pending.voice_profile %} | ||
| {% else %} | ||
| {% set narrator_profile = '' %} | ||
| {% endif %} | ||
| {% set narrator_voice = None %} | ||
| {% if form_voice %} | ||
| {% set narrator_voice = form_voice %} | ||
| {% elif pending and pending.voice %} | ||
| {% set narrator_voice = pending.voice %} | ||
| {% else %} | ||
| {% set narrator_voice = settings_dict.get('default_voice', options.voices[0] if options.voices else '') %} | ||
| {% endif %} | ||
| {% if (not narrator_profile) and narrator_voice and narrator_voice[:8]|lower == 'profile:' %} | ||
| {% set narrator_profile = narrator_voice[8:]|trim %} | ||
| {% set narrator_voice = '' %} | ||
| {% endif %} | ||
| {% set normalization_overrides = pending.normalization_overrides if pending and pending.normalization_overrides else {} %} | ||
| {% set voice_formula_value = '' %} | ||
| {% set profile_value = narrator_profile if narrator_profile else '__standard' %} | ||
| {% if profile_value == '__formula' %} | ||
| {% if form_formula %} | ||
| {% set voice_formula_value = form_formula %} | ||
| {% elif pending and pending.voice %} | ||
| {% set voice_formula_value = pending.voice %} | ||
| {% endif %} | ||
| {% elif profile_value not in ['__standard', '', None] %} | ||
| {% set voice_formula_value = '' %} | ||
| {% else %} | ||
| {% if form_formula %} | ||
| {% set profile_value = '__formula' %} | ||
| {% set voice_formula_value = form_formula %} | ||
| {% elif narrator_voice and ('+' in narrator_voice or '*' in narrator_voice) %} | ||
| {% set profile_value = '__formula' %} | ||
| {% set voice_formula_value = narrator_voice %} | ||
| {% else %} | ||
| {% set profile_value = '__standard' %} | ||
| {% set voice_formula_value = '' %} | ||
| {% endif %} | ||
| {% endif %} | ||
| {% if profile_value == '__formula' and not voice_formula_value %} | ||
| {% if pending and pending.voice %} | ||
| {% set voice_formula_value = pending.voice %} | ||
| {% endif %} | ||
| {% endif %} | ||
| {% if profile_value != '__standard' and profile_value != '__formula' %} | ||
| {% set narrator_voice = '' %} | ||
| {% endif %} | ||
| {% if not narrator_voice and options.voices %} | ||
| {% set narrator_voice = options.voices[0] %} | ||
| {% endif %} | ||
| {% if error %} | ||
| <div class="alert alert--error">{{ error }}</div> | ||
| {% endif %} | ||
| {% if notice %} | ||
| <div class="alert alert--info">{{ notice }}</div> | ||
| {% endif %} | ||
| <form action="{{ url_for('main.wizard_upload') if not readonly else '#' }}" | ||
| method="post" | ||
| id="new-job-book-form" | ||
| class="upload-form" | ||
| data-role="wizard-form" | ||
| data-wizard-form="true" | ||
| data-step="book" | ||
| data-pending-id="{{ pending.id if pending else '' }}" | ||
| {% if not readonly %}enctype="multipart/form-data"{% endif %}> | ||
| <input type="hidden" name="pending_id" value="{{ pending.id if pending else '' }}"> | ||
| <div class="modal__body wizard-card__body upload-form__sections"> | ||
| <section class="form-section"> | ||
| <h3 class="form-section__title">Manuscript & subtitles</h3> | ||
| <div class="field-grid field-grid--compact"> | ||
| <div class="field field--file field--span-2"> | ||
| <label for="source_file">Source file</label> | ||
| <input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown" {{ 'disabled' if readonly else '' }}> | ||
| {% if pending %} | ||
| <p class="hint">Current file: {{ pending.original_filename }}</p> | ||
| {% endif %} | ||
| </div> | ||
| <div class="field"> | ||
| <label for="language">Language</label> | ||
| <select id="language" name="language" {{ 'disabled' if readonly else '' }}> | ||
| {% for key, label in options.languages.items() %} | ||
| <option value="{{ key }}" {% if key == language_value %}selected{% endif %}>{{ label }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="subtitle_mode">Subtitle mode</label> | ||
| <select id="subtitle_mode" name="subtitle_mode" {{ 'disabled' if readonly else '' }}> | ||
| {% set subtitle_options = ['Disabled', 'Sentence', 'Sentence + Comma', 'Sentence + Highlighting'] %} | ||
| {% for option in subtitle_options %} | ||
| <option value="{{ option }}" {% if subtitle_value == option %}selected{% endif %}>{{ option }}</option> | ||
| {% endfor %} | ||
| {% for i in range(1, 11) %} | ||
| {% set label = i ~ ' ' ~ ('word' if i == 1 else 'words') %} | ||
| <option value="{{ label }}" {% if subtitle_value == label %}selected{% endif %}>{{ label }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </div> | ||
| <div class="field field--stack field--span-2"> | ||
| <span class="field__caption">Additional outputs</span> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="generate_epub3" value="true" {% if generate_epub3 %}checked{% endif %} {{ 'disabled' if readonly else '' }}> | ||
| <span>Generate EPUB 3 (experimental)</span> | ||
| </label> | ||
| <p class="hint">Creates a synchronized EPUB alongside audio output.</p> | ||
| </div> | ||
| </div> | ||
| </section> | ||
| <section class="form-section"> | ||
| <h3 class="form-section__title">Book Metadata</h3> | ||
| <div class="field-grid field-grid--compact"> | ||
| <div class="field field--span-2"> | ||
| <label for="meta_title">Title</label> | ||
| <input type="text" id="meta_title" name="meta_title" value="{{ metadata.get('title', '') }}" {{ 'disabled' if readonly else '' }}> | ||
| </div> | ||
| <div class="field field--span-2"> | ||
| <label for="meta_subtitle">Subtitle</label> | ||
| <input type="text" id="meta_subtitle" name="meta_subtitle" value="{{ metadata.get('subtitle', '') }}" {{ 'disabled' if readonly else '' }}> | ||
| </div> | ||
| <div class="field field--span-2"> | ||
| <label for="meta_author">Author(s)</label> | ||
| <input type="text" id="meta_author" name="meta_author" value="{{ metadata.get('authors', '') or metadata.get('author', '') }}" {{ 'disabled' if readonly else '' }}> | ||
| <p class="hint">Comma separated for multiple authors</p> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="meta_series">Series</label> | ||
| <input type="text" id="meta_series" name="meta_series" value="{{ metadata.get('series', '') or metadata.get('series_name', '') }}" {{ 'disabled' if readonly else '' }}> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="meta_series_index">Series Index</label> | ||
| <input type="text" id="meta_series_index" name="meta_series_index" value="{{ metadata.get('series_index', '') or metadata.get('series_sequence', '') }}" {{ 'disabled' if readonly else '' }}> | ||
| </div> | ||
| <div class="field field--span-2"> | ||
| <label for="meta_publisher">Publisher</label> | ||
| <input type="text" id="meta_publisher" name="meta_publisher" value="{{ metadata.get('publisher', '') }}" {{ 'disabled' if readonly else '' }}> | ||
| </div> | ||
| <div class="field field--span-2"> | ||
| <label for="meta_description">Description</label> | ||
| <textarea id="meta_description" name="meta_description" rows="3" {{ 'disabled' if readonly else '' }}>{{ metadata.get('description', '') or metadata.get('summary', '') }}</textarea> | ||
| </div> | ||
| <div class="field field--stack field--span-2"> | ||
| <label class="toggle-pill"> | ||
| <input type="hidden" name="remove_cover" value="false"> | ||
| <input type="checkbox" name="remove_cover" value="true" {{ 'disabled' if readonly else '' }}> | ||
| <span>Remove Cover Image</span> | ||
| </label> | ||
| {% if pending and pending.cover_image_path %} | ||
| <p class="hint">Cover image currently exists.</p> | ||
| {% else %} | ||
| <p class="hint">No cover image found.</p> | ||
| {% endif %} | ||
| </div> | ||
| </div> | ||
| </section> | ||
| <section class="form-section"> | ||
| <h3 class="form-section__title">Narrator defaults</h3> | ||
| <div class="form-section__layout form-section__layout--split"> | ||
| <div class="form-section__group"> | ||
| <div class="field"> | ||
| <label for="voice_profile">Voice profile</label> | ||
| <select id="voice_profile" name="voice_profile" data-role="voice-profile" {{ 'disabled' if readonly else '' }}> | ||
| <option value="__standard" {% if profile_value == '__standard' %}selected{% endif %}>Standard voice</option> | ||
| <option value="__formula" {% if profile_value == '__formula' %}selected{% endif %}>Custom voice formula</option> | ||
| {% if options.voice_profile_options %} | ||
| <optgroup label="Saved mixes"> | ||
| {% for profile in options.voice_profile_options %} | ||
| <option value="{{ profile.name }}" data-language="{{ profile.language }}" data-formula="{{ profile.formula|e }}" {% if profile_value == profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} ({{ profile.language|upper }}){% endif %}</option> | ||
| {% endfor %} | ||
| </optgroup> | ||
| {% endif %} | ||
| </select> | ||
| </div> | ||
| <div class="field" data-role="voice-field" {% if profile_value != '__standard' %}hidden aria-hidden="true"{% endif %}> | ||
| <label for="voice">Voice</label> | ||
| <select id="voice" name="voice" data-role="voice-select" data-default="{{ narrator_voice or settings_dict.get('default_voice', '') }}" {{ 'disabled' if readonly else '' }}> | ||
| {% for voice in options.voices %} | ||
| <option value="{{ voice }}" {% if narrator_voice == voice and profile_value == '__standard' %}selected{% endif %}>{{ voice }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </div> | ||
| <div class="field" data-conditional="formula" data-role="formula-field" {% if profile_value != '__formula' %}hidden aria-hidden="true"{% endif %}> | ||
| <label for="voice_formula">Custom voice formula</label> | ||
| <input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula" value="{{ voice_formula_value }}" {{ 'disabled' if readonly else '' }}> | ||
| </div> | ||
| </div> | ||
| <div class="form-section__group"> | ||
| <div class="field field--slider"> | ||
| <label for="speed">Speed <span class="tag" id="speed_value">{{ speed_display }}×</span></label> | ||
| <input type="range" id="speed" name="speed" min="0.6" max="1.4" step="0.05" value="{{ speed_display }}" {{ 'disabled' if readonly else '' }} oninput="document.getElementById('speed_value').textContent = Number.parseFloat(this.value).toFixed(2) + '×';"> | ||
| </div> | ||
| <div class="field field--with-action field--preview" data-role="voice-preview"> | ||
| <div class="field__label-row"> | ||
| <span class="field__label">Preview</span> | ||
| <button type="button" class="button button--ghost button--small" data-role="voice-preview-button" {{ 'disabled' if readonly else '' }}>Preview voice</button> | ||
| </div> | ||
| <p class="hint field__status" data-role="voice-preview-status" aria-live="polite" hidden></p> | ||
| <audio class="voice-preview__audio" data-role="voice-preview-audio" controls preload="none" hidden></audio> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </section> | ||
| <section class="form-section"> | ||
| <h3 class="form-section__title">Text normalization</h3> | ||
| <div class="field-grid field-grid--compact"> | ||
| <div class="field field--stack field--span-2"> | ||
| <span class="field__label">Apostrophe strategy</span> | ||
| <div class="choices choices--inline"> | ||
| {% for option in options.apostrophe_modes %} | ||
| <label class="radio-pill"> | ||
| <input type="radio" name="normalization_apostrophe_mode" value="{{ option.value }}" {% if normalization_overrides.get('normalization_apostrophe_mode', settings_dict.get('normalization_apostrophe_mode', 'spacy')) == option.value %}checked{% endif %} {{ 'disabled' if readonly else '' }}> | ||
| <span>{{ option.label }}</span> | ||
| </label> | ||
| {% endfor %} | ||
| </div> | ||
| </div> | ||
| <div class="field field--stack field--span-2"> | ||
| <label for="normalization_numbers_year_style">Year pronunciation style</label> | ||
| <select id="normalization_numbers_year_style" name="normalization_numbers_year_style" {{ 'disabled' if readonly else '' }}> | ||
| <option value="american" {% if normalization_overrides.get('normalization_numbers_year_style', settings_dict.get('normalization_numbers_year_style', 'american')) == 'american' %}selected{% endif %}>American (1990 -> nineteen ninety)</option> | ||
| <option value="off" {% if normalization_overrides.get('normalization_numbers_year_style', settings_dict.get('normalization_numbers_year_style', 'american')) == 'off' %}selected{% endif %}>Off (read as number)</option> | ||
| </select> | ||
| </div> | ||
| {% for group in options.normalization_groups %} | ||
| {% for option in group.options %} | ||
| <div class="field field--stack"> | ||
| <label class="toggle-pill"> | ||
| <input type="hidden" name="{{ option.key }}" value="false"> | ||
| <input type="checkbox" name="{{ option.key }}" value="true" {% if normalization_overrides.get(option.key, settings_dict.get(option.key, True)) %}checked{% endif %} {{ 'disabled' if readonly else '' }}> | ||
| <span>{{ option.label }}</span> | ||
| </label> | ||
| </div> | ||
| {% endfor %} | ||
| {% endfor %} | ||
| </div> | ||
| </section> | ||
| <section class="form-section"> | ||
| <h3 class="form-section__title">Entities & casting</h3> | ||
| <div class="field-grid field-grid--compact"> | ||
| <div class="field field--stack field--span-2"> | ||
| <label for="speaker_config">Speaker preset</label> | ||
| <select id="speaker_config" name="speaker_config" {{ 'disabled' if readonly else '' }}> | ||
| <option value="">None</option> | ||
| {% for config in options.speaker_configs %} | ||
| <option value="{{ config.name }}" {% if selected_config == config.name %}selected{% endif %}>{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</option> | ||
| {% endfor %} | ||
| </select> | ||
| <p class="hint">Reuse a saved roster to keep character voices consistent.</p> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="chunk_level">Chunk granularity</label> | ||
| <select id="chunk_level" name="chunk_level" {{ 'disabled' if readonly else '' }}> | ||
| {% for option in options.chunk_levels %} | ||
| <option value="{{ option.value }}" {% if chunk_level_value == option.value %}selected{% endif %}>{{ option.label }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| <p class="hint">Paragraphs work well for long-form narration; sentences give finer subtitle sync.</p> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="speaker_analysis_threshold">Minimum mentions</label> | ||
| <input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ analysis_threshold_value }}" {{ 'disabled' if readonly else '' }}> | ||
| <p class="hint">Entities appearing less often fall back to the narrator voice.</p> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="chapter_intro_delay">Pause after chapter titles (seconds)</label> | ||
| <input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(chapter_delay_value|float if chapter_delay_value is not none else 0.5) }}" {{ 'disabled' if readonly else '' }}> | ||
| <p class="hint">Set to 0 to disable the pause after speaking each chapter title.</p> | ||
| </div> | ||
| <div class="field field--stack"> | ||
| <label class="toggle-pill"> | ||
| <input type="hidden" name="read_title_intro" value="false"> | ||
| <input type="checkbox" name="read_title_intro" value="true" {% if read_title_intro %}checked{% endif %} {{ 'disabled' if readonly else '' }}> | ||
| <span>Read title and authors before narration</span> | ||
| </label> | ||
| <p class="hint">When enabled, the narrator speaks the book title and author list before chapter one begins.</p> | ||
| </div> | ||
| <div class="field field--stack"> | ||
| <label class="toggle-pill"> | ||
| <input type="hidden" name="read_closing_outro" value="false"> | ||
| <input type="checkbox" name="read_closing_outro" value="true" {% if read_closing_outro %}checked{% endif %} {{ 'disabled' if readonly else '' }}> | ||
| <span>Read closing outro after narration</span> | ||
| </label> | ||
| <p class="hint">Adds a short "The end" statement after the final chapter, including series details when available.</p> | ||
| </div> | ||
| <div class="field field--stack"> | ||
| <label class="toggle-pill"> | ||
| <input type="hidden" name="normalize_chapter_opening_caps" value="false"> | ||
| <input type="checkbox" name="normalize_chapter_opening_caps" value="true" {% if normalize_chapter_opening_caps %}checked{% endif %} {{ 'disabled' if readonly else '' }}> | ||
| <span>Normalize ALL CAPS chapter openings</span> | ||
| </label> | ||
| <p class="hint">Converts opening sentences written in uppercase to sentence case while keeping acronyms like AI intact.</p> | ||
| </div> | ||
| </div> | ||
| </section> | ||
| </div> | ||
| <footer class="modal__footer wizard-card__footer"> | ||
| <div class="wizard-card__footer-actions"> | ||
| <button type="button" class="button button--ghost" data-role="wizard-cancel" data-pending-id="{{ pending.id if pending else '' }}">Cancel</button> | ||
| </div> | ||
| <div class="wizard-card__footer-actions"> | ||
| {% if readonly %} | ||
| <button type="button" class="button" data-role="wizard-back" data-target-step="chapters" data-pending-id="{{ pending.id if pending else '' }}">Return to chapters</button> | ||
| {% else %} | ||
| <button type="submit" class="button" data-step-target="chapters">Chapter selection</button> | ||
| {% endif %} | ||
| </div> | ||
| </footer> | ||
| </form> |
| <form method="post" | ||
| action="{{ url_for('main.wizard_update', pending_id=pending.id) }}" | ||
| class="prepare-form" | ||
| id="prepare-form" | ||
| data-role="prepare-form" | ||
| data-wizard-form="true" | ||
| data-step="chapters" | ||
| data-pending-id="{{ pending.id }}" | ||
| data-analyze-url="{{ url_for('main.wizard_update', pending_id=pending.id) }}"> | ||
| <input type="hidden" name="step" value="chapters"> | ||
| <input type="hidden" name="active_step" value="chapters" data-role="active-step-input"> | ||
| <div class="wizard-hidden-inputs" aria-hidden="true"> | ||
| <input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}"> | ||
| <input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}"> | ||
| <input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}"> | ||
| <input type="hidden" name="read_title_intro" value="{{ 'true' if pending.read_title_intro else 'false' }}"> | ||
| <input type="hidden" name="read_closing_outro" value="{{ 'true' if pending.read_closing_outro else 'false' }}"> | ||
| <input type="hidden" name="normalize_chapter_opening_caps" value="{{ 'true' if pending.normalize_chapter_opening_caps else 'false' }}"> | ||
| {% if pending.generate_epub3 %} | ||
| <input type="hidden" name="generate_epub3" value="true"> | ||
| {% endif %} | ||
| </div> | ||
| <div class="modal__body wizard-card__body"> | ||
| {% if error %} | ||
| <div class="alert alert--error">{{ error }}</div> | ||
| {% endif %} | ||
| {% if notice %} | ||
| <div class="alert alert--info">{{ notice }}</div> | ||
| {% endif %} | ||
| <section class="form-section"> | ||
| <div class="form-section__title-row"> | ||
| <h3 class="form-section__title">Detected chapters</h3> | ||
| <p class="hint">Toggle chapters on or off, rename them, and override the voice per chapter if needed.</p> | ||
| </div> | ||
| <div class="chapter-grid"> | ||
| {% for chapter in pending.chapters %} | ||
| {% set is_enabled = chapter.enabled is not defined or chapter.enabled %} | ||
| {% set selected_option = '__default' %} | ||
| {% if chapter.voice_profile %} | ||
| {% set selected_option = 'profile:' ~ chapter.voice_profile %} | ||
| {% elif chapter.voice %} | ||
| {% set selected_option = 'voice:' ~ chapter.voice %} | ||
| {% elif chapter.voice_formula %} | ||
| {% set selected_option = 'formula' %} | ||
| {% endif %} | ||
| <article class="chapter-card" | ||
| data-role="chapter-row" | ||
| data-disabled="{{ 'false' if is_enabled else 'true' }}" | ||
| data-expanded="false"> | ||
| <header class="chapter-card__summary" data-role="chapter-summary"> | ||
| <label class="chapter-card__checkbox"> | ||
| <input type="checkbox" | ||
| name="chapter-{{ loop.index0 }}-enabled" | ||
| data-role="chapter-enabled" | ||
| {% if is_enabled %}checked{% endif %}> | ||
| <span>Chapter {{ loop.index }} · {{ chapter.title }}</span> | ||
| </label> | ||
| <button type="button" | ||
| class="chapter-card__toggle" | ||
| data-role="chapter-toggle" | ||
| aria-expanded="false" | ||
| aria-label="Toggle chapter details"> | ||
| <span class="chapter-card__toggle-icon" aria-hidden="true">▾</span> | ||
| </button> | ||
| </header> | ||
| <div class="chapter-card__details" | ||
| data-role="chapter-details"> | ||
| <div class="chapter-card__field"> | ||
| <label for="chapter-{{ loop.index0 }}-title">Title</label> | ||
| <input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}"> | ||
| </div> | ||
| <div class="chapter-card__preview"> | ||
| <details> | ||
| <summary>Preview full text</summary> | ||
| <pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre> | ||
| </details> | ||
| </div> | ||
| <div class="chapter-card__field"> | ||
| <label for="chapter-{{ loop.index0 }}-voice">Voice override</label> | ||
| <select id="chapter-{{ loop.index0 }}-voice" name="chapter-{{ loop.index0 }}-voice" data-role="voice-select"> | ||
| <option value="__default" {% if selected_option == '__default' %}selected{% endif %}>Use job default</option> | ||
| <optgroup label="Voices"> | ||
| {% for voice in options.voices %} | ||
| <option value="voice:{{ voice }}" {% if selected_option == 'voice:' ~ voice %}selected{% endif %}>{{ voice }}</option> | ||
| {% endfor %} | ||
| </optgroup> | ||
| {% if options.voice_profile_options %} | ||
| <optgroup label="Profiles"> | ||
| {% for profile in options.voice_profile_options %} | ||
| <option value="profile:{{ profile.name }}" {% if selected_option == 'profile:' ~ profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option> | ||
| {% endfor %} | ||
| </optgroup> | ||
| {% endif %} | ||
| <option value="formula" {% if selected_option == 'formula' %}selected{% endif %}>Custom formula…</option> | ||
| </select> | ||
| <input type="text" | ||
| name="chapter-{{ loop.index0 }}-formula" | ||
| class="chapter-card__formula" | ||
| data-role="formula-input" | ||
| placeholder="af_nova*0.4+am_liam*0.6" | ||
| value="{{ chapter.voice_formula or '' }}" | ||
| {% if selected_option != 'formula' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}> | ||
| </div> | ||
| </div> | ||
| </article> | ||
| {% endfor %} | ||
| </div> | ||
| </section> | ||
| </div> | ||
| <footer class="modal__footer wizard-card__footer"> | ||
| <div class="wizard-card__footer-actions"> | ||
| <button type="button" class="button button--ghost" data-role="wizard-back" data-target-step="book" data-pending-id="{{ pending.id }}">Previous</button> | ||
| <button type="button" class="button button--ghost" data-role="wizard-cancel" data-pending-id="{{ pending.id }}">Cancel</button> | ||
| </div> | ||
| <div class="wizard-card__footer-actions"> | ||
| <button type="submit" | ||
| class="button" | ||
| data-role="submit-speaker-analysis" | ||
| data-step-target="entities"> | ||
| Continue to entities | ||
| </button> | ||
| </div> | ||
| </footer> | ||
| </form> |
| {% set embed_scripts = embed_scripts if embed_scripts is defined else True %} | ||
| {% set recognition_enabled = settings.enable_entity_recognition if settings is defined and settings.enable_entity_recognition is not none else True %} | ||
| <form method="post" | ||
| action="{{ url_for('main.wizard_finish', pending_id=pending.id) }}" | ||
| class="prepare-form" | ||
| id="prepare-form" | ||
| data-role="prepare-form" | ||
| data-wizard-form="true" | ||
| data-step="entities" | ||
| data-pending-id="{{ pending.id }}" | ||
| data-analyze-url="{{ url_for('entities.analyze_entities', pending_id=pending.id) }}" | ||
| data-entities-url="{{ url_for('entities.get_entities', pending_id=pending.id) }}" | ||
| data-manual-list-url="{{ url_for('entities.list_manual_overrides', pending_id=pending.id) }}" | ||
| data-manual-upsert-url="{{ url_for('entities.upsert_override', pending_id=pending.id) }}" | ||
| data-manual-delete-url-template="{{ url_for('entities.delete_override', pending_id=pending.id, override_id='__OVERRIDE_ID__') }}" | ||
| data-manual-search-url="{{ url_for('entities.search_candidates', pending_id=pending.id) }}" | ||
| data-language="{{ pending.language }}" | ||
| data-base-voice="{{ pending.voice }}" | ||
| data-speed="{{ '%.2f'|format(pending.speed) }}" | ||
| data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}" | ||
| data-entities-enabled="{{ 'true' if recognition_enabled else 'false' }}"> | ||
| <input type="hidden" name="active_step" value="entities" data-role="active-step-input"> | ||
| <div class="wizard-hidden-inputs" aria-hidden="true"> | ||
| <input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}"> | ||
| <input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}"> | ||
| <input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}"> | ||
| <input type="hidden" name="read_title_intro" value="{{ 'true' if pending.read_title_intro else 'false' }}"> | ||
| <input type="hidden" name="normalize_chapter_opening_caps" value="{{ 'true' if pending.normalize_chapter_opening_caps else 'false' }}"> | ||
| {% if pending.generate_epub3 %} | ||
| <input type="hidden" name="generate_epub3" value="true"> | ||
| {% endif %} | ||
| {% for chapter in pending.chapters %} | ||
| {% set idx = loop.index0 %} | ||
| {% set is_enabled = chapter.enabled is not defined or chapter.enabled %} | ||
| {% set selected_option = '__default' %} | ||
| {% if chapter.voice_profile %} | ||
| {% set selected_option = 'profile:' ~ chapter.voice_profile %} | ||
| {% elif chapter.voice %} | ||
| {% set selected_option = 'voice:' ~ chapter.voice %} | ||
| {% elif chapter.voice_formula %} | ||
| {% set selected_option = 'formula' %} | ||
| {% endif %} | ||
| {% if is_enabled %} | ||
| <input type="hidden" name="chapter-{{ idx }}-enabled" value="on"> | ||
| {% endif %} | ||
| <input type="hidden" name="chapter-{{ idx }}-title" value="{{ chapter.title }}"> | ||
| <input type="hidden" name="chapter-{{ idx }}-voice" value="{{ selected_option }}"> | ||
| <input type="hidden" name="chapter-{{ idx }}-formula" value="{{ chapter.voice_formula or '' }}"> | ||
| {% endfor %} | ||
| </div> | ||
| <div class="modal__body wizard-card__body"> | ||
| {% if error %} | ||
| <div class="alert alert--error">{{ error }}</div> | ||
| {% endif %} | ||
| {% if notice %} | ||
| <div class="alert alert--info">{{ notice }}</div> | ||
| {% endif %} | ||
| <div class="entity-tabs" data-role="entity-tabs"> | ||
| {% if not recognition_enabled %} | ||
| <div class="alert alert--info entity-tabs__notice"> | ||
| Entity recognition is disabled in Settings. Enable it to populate detected people and entities automatically. | ||
| </div> | ||
| {% endif %} | ||
| <div class="entity-tabs__nav" role="tablist" aria-label="Entity categories"> | ||
| <button type="button" | ||
| class="entity-tabs__tab is-active" | ||
| data-role="entity-tab" | ||
| data-panel="people" | ||
| id="entity-tab-people" | ||
| aria-controls="entity-panel-people" | ||
| aria-selected="true" | ||
| role="tab"> | ||
| People | ||
| </button> | ||
| <button type="button" | ||
| class="entity-tabs__tab" | ||
| data-role="entity-tab" | ||
| data-panel="entities" | ||
| id="entity-tab-entities" | ||
| aria-controls="entity-panel-entities" | ||
| aria-selected="false" | ||
| role="tab"> | ||
| Entities | ||
| </button> | ||
| <button type="button" | ||
| class="entity-tabs__tab" | ||
| data-role="entity-tab" | ||
| data-panel="manual" | ||
| id="entity-tab-manual" | ||
| aria-controls="entity-panel-manual" | ||
| aria-selected="false" | ||
| role="tab"> | ||
| Manual Overrides | ||
| </button> | ||
| <div class="entity-tabs__status" data-role="global-entity-spinner" hidden> | ||
| <span class="spinner spinner--sm"></span> | ||
| <span class="entity-tabs__status-text">Scanning text...</span> | ||
| </div> | ||
| </div> | ||
| <div class="entity-tabs__panels"> | ||
| <section class="entity-tabs__panel is-active" | ||
| data-role="entity-panel" | ||
| data-panel="people" | ||
| id="entity-panel-people" | ||
| role="tabpanel" | ||
| aria-labelledby="entity-tab-people"> | ||
| <section class="prepare-speaker-config"> | ||
| <div class="prepare-speaker-config__header"> | ||
| <h2>Speaker configuration</h2> | ||
| <p class="hint">Reuse saved presets to keep character voices consistent between projects.</p> | ||
| </div> | ||
| <div class="prepare-speaker-config__grid"> | ||
| <label class="field prepare-speaker-config__field" for="applied_speaker_config"> | ||
| <span>Saved preset</span> | ||
| <select id="applied_speaker_config" name="applied_speaker_config"> | ||
| <option value="">None</option> | ||
| {% for config in options.speaker_configs %} | ||
| <option value="{{ config.name }}" {% if pending.applied_speaker_config == config.name %}selected{% endif %}> | ||
| {{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %} | ||
| </option> | ||
| {% endfor %} | ||
| </select> | ||
| <p class="hint">Presets are saved from previous jobs.</p> | ||
| </label> | ||
| <div class="prepare-speaker-config__actions"> | ||
| <button type="submit" | ||
| class="button button--ghost" | ||
| name="apply_speaker_config" | ||
| data-role="submit-speaker-analysis" | ||
| value="1" | ||
| formaction="{{ url_for('entities.analyze_entities', pending_id=pending.id) }}" | ||
| formmethod="post" | ||
| formnovalidate | ||
| {% if not options.speaker_configs %}disabled{% endif %}> | ||
| Apply preset | ||
| </button> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="save_speaker_config" value="1"> | ||
| <span>Save roster updates back to preset</span> | ||
| </label> | ||
| </div> | ||
| </div> | ||
| </section> | ||
| {% if pending.speakers %} | ||
| <div class="prepare-speakers"> | ||
| <h2>Speaker settings</h2> | ||
| <p class="hint">Set pronunciations, lock specific voices, and audition sample paragraphs to hear casting choices.</p> | ||
| <ul class="speaker-list"> | ||
| {% for speaker_id, speaker in pending.speakers.items() %} | ||
| {% set pronunciation_text = speaker.pronunciation or speaker.label %} | ||
| {% set selected_voice = speaker.resolved_voice or speaker.voice %} | ||
| {% set seen = namespace(values=[]) %} | ||
| {% set sample_quotes = speaker.sample_quotes or [] %} | ||
| {% set detected_gender = speaker.detected_gender or speaker.gender or 'unknown' %} | ||
| {% set current_gender = speaker.gender or detected_gender %} | ||
| {% set gender_label = 'Either' if current_gender == 'either' else (current_gender|title if current_gender != 'unknown' else 'Unknown') %} | ||
| {% set detected_label = 'Either' if detected_gender == 'either' else (detected_gender|title if detected_gender != 'unknown' else 'Unknown') %} | ||
| <li class="speaker-list__item" | ||
| data-speaker-id="{{ speaker_id }}" | ||
| data-default-pronunciation="{{ pronunciation_text }}"> | ||
| <div class="speaker-line speaker-list__header"> | ||
| <span class="speaker-list__name">{{ speaker.label }}</span> | ||
| <button type="button" | ||
| class="icon-button speaker-list__preview" | ||
| data-role="speaker-preview" | ||
| data-speaker-id="{{ speaker_id }}" | ||
| data-preview-source="pronunciation" | ||
| data-preview-text="{{ pronunciation_text|e }}" | ||
| data-language="{{ pending.language }}" | ||
| data-voice="{{ selected_voice or pending.voice }}" | ||
| data-speed="{{ '%.2f'|format(pending.speed) }}" | ||
| data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}" | ||
| aria-label="Preview pronunciation for {{ speaker.label }}" | ||
| title="Preview pronunciation"> | ||
| 🔊 | ||
| </button> | ||
| </div> | ||
| <template data-role="speaker-samples">{{ sample_quotes | tojson }}</template> | ||
| <div class="speaker-list__meta"> | ||
| <div class="speaker-gender" data-role="speaker-gender" data-speaker-id="{{ speaker_id }}"> | ||
| <button type="button" | ||
| class="chip speaker-gender__pill" | ||
| data-role="gender-pill" | ||
| data-current="{{ current_gender }}"> | ||
| {{ gender_label }} voice | ||
| </button> | ||
| <div class="speaker-gender__menu" data-role="gender-menu" hidden> | ||
| <p class="hint">Detected: {{ detected_label }}</p> | ||
| <div class="speaker-gender__options"> | ||
| <button type="button" class="chip" data-role="gender-option" data-value="female">Female</button> | ||
| <button type="button" class="chip" data-role="gender-option" data-value="male">Male</button> | ||
| <button type="button" class="chip" data-role="gender-option" data-value="either">Either</button> | ||
| <button type="button" class="chip" data-role="gender-option" data-value="{{ detected_gender }}" {% if detected_gender == current_gender %}data-state="active"{% endif %}> | ||
| Use detected ({{ detected_label }}) | ||
| </button> | ||
| </div> | ||
| </div> | ||
| <input type="hidden" name="speaker-{{ speaker_id }}-gender" value="{{ current_gender }}" data-role="gender-input"> | ||
| <input type="hidden" name="speaker-{{ speaker_id }}-detected-gender" value="{{ detected_gender }}"> | ||
| </div> | ||
| {% if speaker.get('analysis_count') %} | ||
| <span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span> | ||
| {% endif %} | ||
| </div> | ||
| <label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation"> | ||
| <span>Pronunciation</span> | ||
| <input type="text" | ||
| id="speaker-{{ speaker_id }}-pronunciation" | ||
| name="speaker-{{ speaker_id }}-pronunciation" | ||
| value="{{ pronunciation_text }}" | ||
| data-role="speaker-pronunciation" | ||
| placeholder="{{ speaker.label }}"> | ||
| </label> | ||
| <div class="speaker-list__controls"> | ||
| <div class="speaker-list__selection"> | ||
| <label class="speaker-list__field" for="speaker-{{ speaker_id }}-voice"> | ||
| <span>Assigned voice</span> | ||
| <select id="speaker-{{ speaker_id }}-voice" | ||
| name="speaker-{{ speaker_id }}-voice" | ||
| data-role="speaker-voice" | ||
| data-default-voice="{{ pending.voice }}"> | ||
| <option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option> | ||
| <option value="__custom_mix" data-role="custom-mix-option" {% if speaker.voice_formula %}selected{% else %}hidden disabled{% endif %}> | ||
| Custom mix | ||
| </option> | ||
| {% if options.voice_profile_options %} | ||
| <optgroup label="Saved speakers"> | ||
| {% for profile in options.voice_profile_options %} | ||
| {% set profile_value = 'speaker:' ~ profile.name %} | ||
| {% set provider_label = 'Supertonic' if profile.provider == 'supertonic' else 'Kokoro' %} | ||
| <option value="{{ profile_value }}" {% if selected_voice == profile_value %}selected{% endif %}> | ||
| {{ profile.name }} · {{ provider_label }}{% if profile.language %} · {{ profile.language|upper }}{% endif %} | ||
| </option> | ||
| {% endfor %} | ||
| </optgroup> | ||
| {% endif %} | ||
| {% if speaker.recommended_voices %} | ||
| <optgroup label="Recommended"> | ||
| {% for voice_id in speaker.recommended_voices[:6] %} | ||
| {% if voice_id not in seen.values %} | ||
| {% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %} | ||
| <option value="{{ voice_id }}" {% if selected_voice == voice_id %}selected{% endif %}> | ||
| {{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }} | ||
| </option> | ||
| {% set _ = seen.values.append(voice_id) %} | ||
| {% endif %} | ||
| {% endfor %} | ||
| </optgroup> | ||
| {% endif %} | ||
| <optgroup label="All voices"> | ||
| {% for voice in options.voice_catalog %} | ||
| {% if voice.id not in seen.values %} | ||
| <option value="{{ voice.id }}" | ||
| {% if selected_voice == voice.id %}selected{% endif %}> | ||
| {{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }} | ||
| </option> | ||
| {% endif %} | ||
| {% endfor %} | ||
| </optgroup> | ||
| </select> | ||
| </label> | ||
| <button type="button" | ||
| class="button button--ghost button--small" | ||
| data-role="open-voice-browser" | ||
| data-speaker-id="{{ speaker_id }}"> | ||
| Browse voices | ||
| </button> | ||
| <button type="button" | ||
| class="button button--ghost button--small" | ||
| data-role="generate-voice" | ||
| data-speaker-id="{{ speaker_id }}"> | ||
| Generate voice | ||
| </button> | ||
| <button type="button" | ||
| class="button button--ghost button--small" | ||
| data-role="speaker-preview" | ||
| data-preview-kind="generated" | ||
| data-speaker-id="{{ speaker_id }}" | ||
| data-preview-source="generated" | ||
| data-preview-text="{{ pronunciation_text|e }}" | ||
| data-language="{{ pending.language }}" | ||
| data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}" | ||
| data-speed="{{ '%.2f'|format(pending.speed) }}" | ||
| data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}" | ||
| {% if not speaker.voice_formula %}hidden{% endif %}> | ||
| Preview generated | ||
| </button> | ||
| </div> | ||
| <div class="speaker-list__mix" data-role="speaker-mix" {% if not speaker.voice_formula %}hidden{% endif %}> | ||
| <span class="tag">Custom mix</span> | ||
| <span data-role="speaker-mix-label">{{ speaker.voice_formula or '' }}</span> | ||
| <div class="speaker-list__mix-actions"> | ||
| <button type="button" | ||
| class="button button--ghost button--small" | ||
| data-role="speaker-preview" | ||
| data-preview-source="mix" | ||
| data-speaker-id="{{ speaker_id }}" | ||
| data-preview-text="{{ pronunciation_text|e }}" | ||
| data-language="{{ pending.language }}" | ||
| data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}" | ||
| data-speed="{{ '%.2f'|format(pending.speed) }}" | ||
| data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"> | ||
| Preview mix | ||
| </button> | ||
| <button type="button" class="button button--ghost button--small" data-role="clear-mix">Clear</button> | ||
| </div> | ||
| </div> | ||
| <input type="hidden" name="speaker-{{ speaker_id }}-formula" value="{{ speaker.voice_formula or '' }}" data-role="speaker-formula"> | ||
| </div> | ||
| <details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}> | ||
| <summary>Sample paragraphs</summary> | ||
| {% if sample_quotes %} | ||
| {% set first_sample = sample_quotes[0] if sample_quotes|length > 0 else None %} | ||
| {% set first_excerpt = first_sample.excerpt if first_sample is mapping else first_sample %} | ||
| {% set first_hint = first_sample.gender_hint if first_sample is mapping else '' %} | ||
| <article class="speaker-sample" data-role="speaker-sample"> | ||
| <p data-role="sample-text">{{ first_excerpt }}</p> | ||
| <p class="hint" data-role="sample-hint" {% if not first_hint %}hidden{% endif %}>{{ first_hint }}</p> | ||
| <div class="speaker-sample__actions"> | ||
| <button type="button" | ||
| class="button button--ghost button--small" | ||
| data-role="speaker-preview" | ||
| data-preview-source="sample" | ||
| data-speaker-id="{{ speaker_id }}" | ||
| data-preview-text="{{ first_excerpt }}" | ||
| data-language="{{ pending.language }}" | ||
| data-voice="{{ selected_voice or pending.voice }}" | ||
| data-speed="{{ '%.2f'|format(pending.speed) }}" | ||
| data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"> | ||
| Preview with assigned voice | ||
| </button> | ||
| <button type="button" | ||
| class="button button--ghost button--small" | ||
| data-role="open-voice-browser" | ||
| data-speaker-id="{{ speaker_id }}" | ||
| data-sample-index="0"> | ||
| Preview in voice browser | ||
| </button> | ||
| {% if sample_quotes|length > 1 %} | ||
| <button type="button" | ||
| class="button button--ghost button--small" | ||
| data-role="speaker-next-sample"> | ||
| Show another example | ||
| </button> | ||
| {% endif %} | ||
| </div> | ||
| </article> | ||
| {% else %} | ||
| <p class="hint">No paragraphs captured yet. Continue from Step 2 to gather dialogue samples automatically.</p> | ||
| {% endif %} | ||
| </details> | ||
| {% if speaker.recommended_voices %} | ||
| <div class="speaker-list__recommendations"> | ||
| <span class="hint">Suggested:</span> | ||
| {% for voice_id in speaker.recommended_voices[:6] %} | ||
| {% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %} | ||
| <button type="button" | ||
| class="chip" | ||
| data-role="recommended-voice" | ||
| data-voice="{{ voice_id }}" | ||
| title="{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}"> | ||
| {{ voice_meta.display_name or voice_id }} | ||
| </button> | ||
| {% endfor %} | ||
| </div> | ||
| {% endif %} | ||
| </li> | ||
| {% endfor %} | ||
| </ul> | ||
| </div> | ||
| {% else %} | ||
| <p class="hint">No additional speakers detected yet. The narrator voice will be used for all dialogue.</p> | ||
| {% endif %} | ||
| <section class="entity-summary entity-summary--people" data-role="people-summary"> | ||
| <header class="entity-summary__header"> | ||
| <div class="entity-summary__titles"> | ||
| <h2>Detected people</h2> | ||
| <p class="hint">Characters surfaced by entity recognition. Filter by mention count to focus on recurring names.</p> | ||
| </div> | ||
| <div class="entity-summary__filters"> | ||
| <label class="field field--inline" for="people-mention-filter"> | ||
| <span>Minimum mentions</span> | ||
| <select id="people-mention-filter" data-role="entity-filter-people"> | ||
| <option value="0">All</option> | ||
| <option value="1">1+</option> | ||
| <option value="2">2+</option> | ||
| <option value="5">5+</option> | ||
| <option value="10">10+</option> | ||
| </select> | ||
| </label> | ||
| </div> | ||
| </header> | ||
| <dl class="entity-summary__stats" data-role="people-stats"></dl> | ||
| <ol class="entity-summary__list" data-role="entity-list-people"></ol> | ||
| </section> | ||
| </section> | ||
| <section class="entity-tabs__panel" | ||
| data-role="entity-panel" | ||
| data-panel="entities" | ||
| id="entity-panel-entities" | ||
| role="tabpanel" | ||
| aria-labelledby="entity-tab-entities" | ||
| hidden> | ||
| <div class="entity-summary" data-role="entities-summary"> | ||
| <header class="entity-summary__header"> | ||
| <div class="entity-summary__titles"> | ||
| <h2>Detected entities</h2> | ||
| <p class="hint">Assign pronunciations and voices for recurring names and terminology across the manuscript.</p> | ||
| </div> | ||
| <div class="entity-summary__filters"> | ||
| <label class="field field--inline" for="entities-mention-filter"> | ||
| <span>Minimum mentions</span> | ||
| <select id="entities-mention-filter" data-role="entity-filter-entities"> | ||
| <option value="0">All</option> | ||
| <option value="1">1+</option> | ||
| <option value="2">2+</option> | ||
| <option value="5">5+</option> | ||
| <option value="10">10+</option> | ||
| </select> | ||
| </label> | ||
| <label class="field field--inline" for="entities-kind-filter"> | ||
| <span>Entity type</span> | ||
| <select id="entities-kind-filter" data-role="entity-filter-kind"> | ||
| <option value="all">All</option> | ||
| </select> | ||
| </label> | ||
| <div class="entity-summary__refresh-group"> | ||
| <span class="spinner spinner--sm spinner--muted" data-role="entities-spinner" aria-hidden="true" hidden></span> | ||
| <button type="button" class="button button--ghost button--small" data-role="entities-refresh" {% if not recognition_enabled %}disabled aria-disabled="true"{% endif %}>Refresh</button> | ||
| </div> | ||
| </div> | ||
| </header> | ||
| <dl class="entity-summary__stats" data-role="entity-stats"></dl> | ||
| <ol class="entity-summary__list" data-role="entity-list-entities"></ol> | ||
| <template data-role="entity-row-template"> | ||
| <li class="entity-summary__item" data-role="entity-row"> | ||
| <header class="entity-summary__item-header"> | ||
| <div> | ||
| <h3 class="entity-summary__label" data-role="entity-label"></h3> | ||
| <p class="entity-summary__kind" data-role="entity-kind"></p> | ||
| </div> | ||
| <div class="entity-summary__actions"> | ||
| <span class="badge badge--muted" data-role="entity-count"></span> | ||
| <button type="button" class="button button--ghost button--small" data-role="speaker-preview" data-entity-preview="true" hidden>Preview</button> | ||
| <button type="button" class="button button--ghost button--small" data-role="entity-add-override">Add manual override</button> | ||
| </div> | ||
| </header> | ||
| <div class="entity-inline-override" data-role="inline-override" hidden> | ||
| <div class="entity-inline-override__fields"> | ||
| <label class="field" data-role="inline-override-pronunciation-label"> | ||
| <span>Pronunciation</span> | ||
| <input type="text" data-role="manual-override-pronunciation" value=""> | ||
| </label> | ||
| <label class="field"> | ||
| <span>Assigned voice</span> | ||
| <select data-role="manual-override-voice" data-default-voice="{{ pending.voice }}"> | ||
| <option value="">Use narrator voice ({{ pending.voice }})</option> | ||
| </select> | ||
| </label> | ||
| </div> | ||
| <div class="entity-inline-override__actions"> | ||
| <div class="entity-inline-override__buttons"> | ||
| <button type="button" class="button button--ghost button--small" data-role="speaker-preview" data-preview-source="manual">Preview</button> | ||
| <button type="button" class="button button--ghost button--small" data-role="inline-override-save">Save</button> | ||
| </div> | ||
| <button type="button" class="button button--ghost button--small" data-role="inline-override-remove">Remove override</button> | ||
| </div> | ||
| </div> | ||
| <div class="entity-summary__samples" data-role="entity-samples"></div> | ||
| </li> | ||
| </template> | ||
| </div> | ||
| </section> | ||
| <section class="entity-tabs__panel" | ||
| data-role="entity-panel" | ||
| data-panel="manual" | ||
| id="entity-panel-manual" | ||
| role="tabpanel" | ||
| aria-labelledby="entity-tab-manual" | ||
| hidden> | ||
| <div class="manual-overrides" data-role="manual-overrides"> | ||
| <header class="manual-overrides__header"> | ||
| <div class="manual-overrides__copy"> | ||
| <h2>Manual overrides</h2> | ||
| <p class="hint">Search tokens from the book or add custom entries. Set pronunciations and assign voices to ensure previews and conversions use your preferred delivery.</p> | ||
| </div> | ||
| <div class="manual-overrides__header-actions"> | ||
| <span class="manual-overrides__status" data-role="manual-override-status" aria-live="polite"></span> | ||
| <button type="button" class="button button--ghost button--small" data-role="manual-override-save-all">Save overrides</button> | ||
| </div> | ||
| </header> | ||
| <div class="manual-overrides__search"> | ||
| <label class="field" for="manual-override-query"> | ||
| <span>Search manuscript tokens</span> | ||
| <input type="search" id="manual-override-query" data-role="manual-override-query" placeholder="Search by name or phrase"> | ||
| </label> | ||
| <div class="manual-overrides__search-actions"> | ||
| <button type="button" class="button button--ghost" data-role="manual-override-search">Search</button> | ||
| <button type="button" class="button button--ghost" data-role="manual-override-add-custom">Add custom token</button> | ||
| </div> | ||
| <ul class="manual-overrides__results" data-role="manual-override-results"></ul> | ||
| </div> | ||
| <div class="manual-overrides__list" data-role="manual-override-list"></div> | ||
| <template data-role="manual-override-template"> | ||
| <article class="manual-override" data-override-id=""> | ||
| <header class="manual-override__header"> | ||
| <div> | ||
| <h3 class="manual-override__label" data-role="override-label"></h3> | ||
| <p class="manual-override__notes" data-role="override-notes"></p> | ||
| </div> | ||
| <div class="manual-override__actions"> | ||
| <button type="button" class="button button--ghost button--small" data-role="speaker-preview" data-preview-source="manual">Preview</button> | ||
| <button type="button" class="button button--ghost button--small" data-role="manual-override-delete">Remove</button> | ||
| </div> | ||
| </header> | ||
| <div class="manual-override__body"> | ||
| <label class="field" data-role="manual-override-pronunciation-label"> | ||
| <span>Pronunciation</span> | ||
| <input type="text" data-role="manual-override-pronunciation" value=""> | ||
| </label> | ||
| <label class="field"> | ||
| <span>Assigned voice</span> | ||
| <select data-role="manual-override-voice" data-default-voice="{{ pending.voice }}"> | ||
| <option value="">Use narrator voice ({{ pending.voice }})</option> | ||
| </select> | ||
| </label> | ||
| <div class="manual-override__meta" data-role="manual-override-meta"></div> | ||
| </div> | ||
| </article> | ||
| </template> | ||
| <p class="manual-overrides__empty" data-role="manual-overrides-empty" hidden>No overrides yet. Use search or add a custom entry to begin.</p> | ||
| <section class="manual-overrides__section" data-role="heteronym-overrides"> | ||
| <header class="manual-overrides__header"> | ||
| <div class="manual-overrides__copy"> | ||
| <h2>Heteronyms</h2> | ||
| <p class="hint">Review sentences that contain a word with multiple pronunciations. The suggested option is selected by default. Hover the highlighted word for examples and use Preview to audition each pronunciation.</p> | ||
| </div> | ||
| </header> | ||
| <div class="manual-overrides__list" data-role="heteronym-override-list"></div> | ||
| <template data-role="heteronym-override-template"> | ||
| <article class="manual-override" data-entry-id=""> | ||
| <header class="manual-override__header"> | ||
| <div> | ||
| <h3 class="manual-override__label" data-role="heteronym-sentence"></h3> | ||
| <p class="manual-override__notes" data-role="heteronym-notes"></p> | ||
| </div> | ||
| </header> | ||
| <div class="manual-override__body" data-role="heteronym-options"></div> | ||
| </article> | ||
| </template> | ||
| <p class="manual-overrides__empty" data-role="heteronym-overrides-empty" hidden>No heteronyms found in the current selection.</p> | ||
| </section> | ||
| </div> | ||
| </section> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <footer class="modal__footer wizard-card__footer"> | ||
| <div class="wizard-card__footer-actions"> | ||
| <button type="button" class="button button--ghost" data-role="wizard-back" data-target-step="chapters" data-pending-id="{{ pending.id }}">Previous</button> | ||
| <button type="button" class="button button--ghost" data-role="wizard-cancel" data-pending-id="{{ pending.id }}">Cancel</button> | ||
| </div> | ||
| <div class="wizard-card__footer-actions"> | ||
| <button type="submit" class="button" data-step-target="finalize">Queue conversion</button> | ||
| </div> | ||
| </footer> | ||
| </form> | ||
| <div class="modal" data-role="voice-modal" hidden> | ||
| <div class="modal__overlay" data-role="voice-modal-close" tabindex="-1"></div> | ||
| <div class="modal__content voice-browser" role="dialog" aria-modal="true" aria-labelledby="voice-modal-title"> | ||
| <header class="voice-browser__header"> | ||
| <h2 id="voice-modal-title">Choose a voice</h2> | ||
| <button type="button" class="icon-button" data-role="voice-modal-close" aria-label="Close voice browser">✕</button> | ||
| </header> | ||
| <div class="voice-browser__body"> | ||
| <aside class="voice-browser__filters"> | ||
| <label class="field"> | ||
| <span>Search</span> | ||
| <input type="search" data-role="voice-filter" placeholder="Search by name, language, or tag"> | ||
| </label> | ||
| <div class="voice-browser__chips" data-role="voice-filter-chips"></div> | ||
| </aside> | ||
| <section class="voice-browser__results" data-role="voice-results"></section> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <script id="entity-summary-data" type="application/json">{{ pending.entity_summary or {} | tojson }}</script> | ||
| <script id="entity-cache-key" type="application/json">{{ pending.entity_cache_key or '' | tojson }}</script> | ||
| <script id="manual-overrides-data" type="application/json">{{ pending.manual_overrides or [] | tojson }}</script> | ||
| <script id="pronunciation-overrides-data" type="application/json">{{ pending.pronunciation_overrides or [] | tojson }}</script> | ||
| <script id="heteronym-overrides-data" type="application/json">{{ pending.heteronym_overrides or [] | tojson }}</script> | ||
| <script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script> | ||
| <script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script> | ||
| {% if embed_scripts %} | ||
| <script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script> | ||
| <script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script> | ||
| <script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script> | ||
| {% endif %} |
| <div class="modal" data-role="reader-modal" hidden> | ||
| <div class="modal__overlay" data-role="reader-modal-close" tabindex="-1"></div> | ||
| <div class="modal__content card card--modal reader-modal" role="dialog" aria-modal="true" aria-labelledby="reader-modal-title"> | ||
| <header class="modal__header"> | ||
| <div class="modal__heading"> | ||
| <h2 class="modal__title" id="reader-modal-title">Read & listen</h2> | ||
| <p class="hint" data-role="reader-modal-hint">Preview synchronized narration directly in your browser.</p> | ||
| </div> | ||
| <button type="button" class="icon-button" data-role="reader-modal-close" aria-label="Close reader">✕</button> | ||
| </header> | ||
| <div class="modal__body reader-modal__body"> | ||
| <iframe title="EPUB reader" | ||
| class="reader-modal__frame" | ||
| data-role="reader-frame" | ||
| allow="autoplay" | ||
| loading="lazy"></iframe> | ||
| </div> | ||
| </div> | ||
| </div> |
| {% set pending = pending if pending is defined else None %} | ||
| {% set readonly = readonly if readonly is defined else False %} | ||
| {% set settings_dict = settings if settings is defined else {} %} | ||
| {% set provided_step = step if step is defined else (active_step if active_step is defined else 'book') %} | ||
| {% set current_step = provided_step %} | ||
| {% if current_step in ['settings', 'upload', ''] %}{% set current_step = 'book' %}{% endif %} | ||
| {% if current_step not in ['book', 'chapters', 'entities'] %}{% set current_step = 'book' %}{% endif %} | ||
| {% set step_number = {'book': 1, 'chapters': 2, 'entities': 3} %} | ||
| {% set step_titles = { | ||
| 'book': 'Book parameters', | ||
| 'chapters': 'Select chapters', | ||
| 'entities': 'Review entities' | ||
| } %} | ||
| {% set step_hints = { | ||
| 'book': 'Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.', | ||
| 'chapters': "Choose which chapters to convert. We'll analyse entities automatically when you continue.", | ||
| 'entities': 'Assign pronunciations, voices, and manual overrides before queueing the conversion.' | ||
| } %} | ||
| {% set navigation_labels = { | ||
| 'book': 'Book parameters', | ||
| 'chapters': 'Chapters', | ||
| 'entities': 'Entities' | ||
| } %} | ||
| {% set total_steps = 3 %} | ||
| {% set current_index = step_number[current_step] %} | ||
| {% set is_open = open if open is defined else False %} | ||
| {% set prepare_url_template = url_for('main.wizard_start', pending_id='__pending__') %} | ||
| {% set cancel_url_template = url_for('main.wizard_cancel', pending_id='__pending__') %} | ||
| {% set analyze_url_template = url_for('main.wizard_update', pending_id='__pending__') %} | ||
| <div class="modal" | ||
| data-role="new-job-modal" | ||
| data-step="{{ current_step }}" | ||
| data-pending-id="{{ pending.id if pending else '' }}" | ||
| data-prepare-url-template="{{ prepare_url_template }}" | ||
| data-cancel-url-template="{{ cancel_url_template }}" | ||
| data-analyze-url-template="{{ analyze_url_template }}" | ||
| {% if is_open %}data-open="true"{% else %}hidden{% endif %}> | ||
| <div class="modal__overlay" data-role="new-job-modal-close" tabindex="-1"></div> | ||
| <div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="new-job-modal-title"> | ||
| <header class="modal__header wizard-card__header"> | ||
| <div class="wizard-card__headline"> | ||
| <nav class="step-indicator" aria-label="New job workflow"> | ||
| {% for slug, label in navigation_labels.items() %} | ||
| {% set item_index = step_number[slug] %} | ||
| {% set state = 'is-active' if slug == current_step else ('is-complete' if item_index < current_index else '') %} | ||
| <button type="button" | ||
| class="step-indicator__item {{ state }}" | ||
| data-role="wizard-step-indicator" | ||
| data-step="{{ slug }}" | ||
| aria-current="{{ 'step' if slug == current_step else 'false' }}"> | ||
| <span class="step-indicator__index">{{ item_index }}</span> | ||
| <span class="step-indicator__label">{{ label }}</span> | ||
| </button> | ||
| {% endfor %} | ||
| </nav> | ||
| <h2 class="modal__title" id="new-job-modal-title">{{ step_titles[current_step] }}</h2> | ||
| <p class="hint" data-role="wizard-hint">{{ step_hints[current_step] }}</p> | ||
| </div> | ||
| <div class="wizard-card__aside"> | ||
| <button type="button" class="icon-button wizard-card__close" data-role="new-job-modal-close" aria-label="Close new job wizard">✕</button> | ||
| {% if pending %} | ||
| <p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p> | ||
| {% endif %} | ||
| </div> | ||
| </header> | ||
| <div class="wizard-card__stage" data-role="wizard-stage"> | ||
| {% if current_step == 'book' %} | ||
| {% include 'partials/new_job_step_book.html' %} | ||
| {% elif current_step == 'chapters' %} | ||
| {% include 'partials/new_job_step_chapters.html' %} | ||
| {% else %} | ||
| {% include 'partials/new_job_step_entities.html' %} | ||
| {% endif %} | ||
| </div> | ||
| </div> | ||
| </div> |
| {% extends "base.html" %} | ||
| {% block title %}Prepare · {{ pending.original_filename }}{% endblock %} | ||
| {% set is_multi_speaker = pending.speaker_mode == 'multi' %} | ||
| {% set total_steps = 3 if is_multi_speaker else 2 %} | ||
| {% block content %} | ||
| <section class="wizard-page wizard-page--modal" data-step="chapters"> | ||
| <div class="modal modal--wizard" data-role="wizard-modal" data-open="true"> | ||
| <div class="modal__overlay" aria-hidden="true"></div> | ||
| <div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="prepare-chapters-title"> | ||
| <header class="modal__header wizard-card__header"> | ||
| <div class="wizard-card__headline"> | ||
| <nav class="step-indicator" aria-label="Audiobook workflow"> | ||
| <button type="button" | ||
| class="step-indicator__item is-complete" | ||
| data-role="open-upload-modal"> | ||
| <span class="step-indicator__index">1</span> | ||
| <span class="step-indicator__label">Upload & settings</span> | ||
| </button> | ||
| <a class="step-indicator__item is-active" | ||
| aria-current="step" | ||
| href="{{ url_for('main.wizard_step', pending_id=pending.id, step='chapters') }}"> | ||
| <span class="step-indicator__index">2</span> | ||
| <span class="step-indicator__label">Chapters</span> | ||
| </a> | ||
| {% if is_multi_speaker %} | ||
| <a class="step-indicator__item" | ||
| href="{{ url_for('main.wizard_step', pending_id=pending.id, step='entities') }}"> | ||
| <span class="step-indicator__index">3</span> | ||
| <span class="step-indicator__label">Entities</span> | ||
| </a> | ||
| {% endif %} | ||
| </nav> | ||
| <h2 class="modal__title" id="prepare-chapters-title">Select chapters</h2> | ||
| <p class="hint">Choose which chapters to convert. We'll analyse entities automatically when you continue.</p> | ||
| </div> | ||
| <div class="wizard-card__aside"> | ||
| <p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p> | ||
| </div> | ||
| </header> | ||
| <form method="post" | ||
| action="{{ url_for('main.wizard_finish', pending_id=pending.id) }}" | ||
| class="prepare-form" | ||
| id="prepare-form" | ||
| data-speaker-mode="{{ pending.speaker_mode }}" | ||
| data-analyze-url="{{ url_for('main.wizard_update', pending_id=pending.id) }}"> | ||
| <input type="hidden" name="active_step" value="chapters" data-role="active-step-input"> | ||
| <div class="wizard-hidden-inputs" aria-hidden="true"> | ||
| <input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}"> | ||
| <input type="hidden" name="speaker_mode" value="{{ pending.speaker_mode }}"> | ||
| <input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}"> | ||
| <input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}"> | ||
| <input type="hidden" name="read_title_intro" value="{{ 'true' if pending.read_title_intro else 'false' }}"> | ||
| <input type="hidden" name="read_closing_outro" value="{{ 'true' if pending.read_closing_outro else 'false' }}"> | ||
| <input type="hidden" name="normalize_chapter_opening_caps" value="{{ 'true' if pending.normalize_chapter_opening_caps else 'false' }}"> | ||
| {% if pending.generate_epub3 %} | ||
| <input type="hidden" name="generate_epub3" value="true"> | ||
| {% endif %} | ||
| </div> | ||
| <div class="modal__body wizard-card__body"> | ||
| {% if error %} | ||
| <div class="alert alert--error">{{ error }}</div> | ||
| {% endif %} | ||
| {% if notice %} | ||
| <div class="alert alert--info">{{ notice }}</div> | ||
| {% endif %} | ||
| <section class="form-section"> | ||
| <div class="form-section__title-row"> | ||
| <h3 class="form-section__title">Detected chapters</h3> | ||
| <p class="hint">Toggle chapters on or off, rename them, and override the voice per chapter if needed.</p> | ||
| </div> | ||
| <div class="chapter-grid"> | ||
| {% for chapter in pending.chapters %} | ||
| {% set is_enabled = chapter.enabled is not defined or chapter.enabled %} | ||
| {% set selected_option = '__default' %} | ||
| {% if chapter.voice_profile %} | ||
| {% set selected_option = 'profile:' ~ chapter.voice_profile %} | ||
| {% elif chapter.voice %} | ||
| {% set selected_option = 'voice:' ~ chapter.voice %} | ||
| {% elif chapter.voice_formula %} | ||
| {% set selected_option = 'formula' %} | ||
| {% endif %} | ||
| <article class="chapter-card" | ||
| data-role="chapter-row" | ||
| data-disabled="{{ 'false' if is_enabled else 'true' }}" | ||
| data-expanded="false"> | ||
| <header class="chapter-card__summary" data-role="chapter-summary"> | ||
| <label class="chapter-card__checkbox"> | ||
| <input type="checkbox" | ||
| name="chapter-{{ loop.index0 }}-enabled" | ||
| data-role="chapter-enabled" | ||
| {% if is_enabled %}checked{% endif %}> | ||
| <span>Chapter {{ loop.index }} · {{ chapter.title }}</span> | ||
| </label> | ||
| <button type="button" | ||
| class="chapter-card__toggle" | ||
| data-role="chapter-toggle" | ||
| aria-expanded="false" | ||
| aria-label="Toggle chapter details"> | ||
| <span class="chapter-card__toggle-icon" aria-hidden="true">▾</span> | ||
| </button> | ||
| </header> | ||
| <div class="chapter-card__details" | ||
| data-role="chapter-details"> | ||
| <div class="chapter-card__field"> | ||
| <label for="chapter-{{ loop.index0 }}-title">Title</label> | ||
| <input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}"> | ||
| </div> | ||
| <div class="chapter-card__preview"> | ||
| <details> | ||
| <summary>Preview full text</summary> | ||
| <pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre> | ||
| </details> | ||
| </div> | ||
| <div class="chapter-card__field"> | ||
| <label for="chapter-{{ loop.index0 }}-voice">Voice override</label> | ||
| <select id="chapter-{{ loop.index0 }}-voice" name="chapter-{{ loop.index0 }}-voice" data-role="voice-select"> | ||
| <option value="__default" {% if selected_option == '__default' %}selected{% endif %}>Use job default</option> | ||
| <optgroup label="Voices"> | ||
| {% for voice in options.voices %} | ||
| <option value="voice:{{ voice }}" {% if selected_option == 'voice:' ~ voice %}selected{% endif %}>{{ voice }}</option> | ||
| {% endfor %} | ||
| </optgroup> | ||
| {% if options.voice_profile_options %} | ||
| <optgroup label="Profiles"> | ||
| {% for profile in options.voice_profile_options %} | ||
| <option value="profile:{{ profile.name }}" {% if selected_option == 'profile:' ~ profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option> | ||
| {% endfor %} | ||
| </optgroup> | ||
| {% endif %} | ||
| <option value="formula" {% if selected_option == 'formula' %}selected{% endif %}>Custom formula…</option> | ||
| </select> | ||
| <input type="text" | ||
| name="chapter-{{ loop.index0 }}-formula" | ||
| class="chapter-card__formula" | ||
| data-role="formula-input" | ||
| placeholder="af_nova*0.4+am_liam*0.6" | ||
| value="{{ chapter.voice_formula or '' }}" | ||
| {% if selected_option != 'formula' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}> | ||
| </div> | ||
| </div> | ||
| </article> | ||
| {% endfor %} | ||
| </div> | ||
| </section> | ||
| </div> | ||
| <footer class="modal__footer wizard-card__footer"> | ||
| <div class="wizard-card__footer-actions"> | ||
| <button type="button" class="button button--ghost" data-role="wizard-previous">Previous</button> | ||
| <button type="submit" class="button button--ghost" form="cancel-form">Cancel</button> | ||
| </div> | ||
| <div class="wizard-card__footer-actions"> | ||
| {% if is_multi_speaker %} | ||
| <button type="submit" | ||
| class="button" | ||
| data-role="submit-speaker-analysis" | ||
| data-step-toggle="analysis" | ||
| formaction="{{ url_for('main.wizard_update', pending_id=pending.id) }}" | ||
| formmethod="post"> | ||
| Continue to entities | ||
| </button> | ||
| {% endif %} | ||
| <button type="submit" class="button" data-step-toggle="finalize"{% if is_multi_speaker %} hidden aria-hidden="true"{% endif %}> | ||
| Queue conversion | ||
| </button> | ||
| </div> | ||
| </footer> | ||
| </form> | ||
| <form method="post" action="{{ url_for('main.wizard_cancel', pending_id=pending.id) }}" id="cancel-form"></form> | ||
| </div> | ||
| </div> | ||
| </section> | ||
| {% with pending=pending, readonly=True, active_step='chapters' %} | ||
| {% include "partials/upload_modal.html" %} | ||
| {% endwith %} | ||
| {% endblock %} | ||
| {% block scripts %} | ||
| {{ super() }} | ||
| <script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script> | ||
| <script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script> | ||
| <script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script> | ||
| <script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script> | ||
| <script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script> | ||
| <script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script> | ||
| {% endblock %} |
| {# This template is intentionally left empty. | ||
| Step-specific templates now live in `prepare_chapters.html` and `prepare_entities.html`. | ||
| The file is kept as a placeholder to avoid breaking documentation references. #} |
| data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}" | ||
| data-speed="{{ '%.2f'|format(pending.speed) }}" | ||
| data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}" | ||
| {% if not speaker.voice_formula %}hidden{% endif %}> | ||
| Preview generated | ||
| </button> | ||
| </div> | ||
| <div class="speaker-list__mix" data-role="speaker-mix" {% if not speaker.voice_formula %}hidden{% endif %}> | ||
| <span class="tag">Custom mix</span> | ||
| <span data-role="speaker-mix-label">{{ speaker.voice_formula or '' }}</span> | ||
| <div class="speaker-list__mix-actions"> | ||
| <button type="button" | ||
| class="button button--ghost button--small" | ||
| data-role="speaker-preview" | ||
| data-preview-source="mix" | ||
| data-speaker-id="{{ speaker_id }}" | ||
| data-preview-text="{{ pronunciation_text|e }}" | ||
| data-language="{{ pending.language }}" | ||
| data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}" | ||
| data-speed="{{ '%.2f'|format(pending.speed) }}" | ||
| data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"> | ||
| Preview mix | ||
| </button> | ||
| <button type="button" class="button button--ghost button--small" data-role="clear-mix">Clear</button> | ||
| </div> | ||
| </div> | ||
| <input type="hidden" name="speaker-{{ speaker_id }}-formula" value="{{ speaker.voice_formula or '' }}" data-role="speaker-formula"> | ||
| </div> | ||
| <details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}> | ||
| <summary>Sample paragraphs</summary> | ||
| {% if sample_quotes %} | ||
| {% set first_sample = sample_quotes[0] if sample_quotes|length > 0 else None %} | ||
| {% set first_excerpt = first_sample.excerpt if first_sample is mapping else first_sample %} | ||
| {% set first_hint = first_sample.gender_hint if first_sample is mapping else '' %} | ||
| <article class="speaker-sample" data-role="speaker-sample"> | ||
| <p data-role="sample-text">{{ first_excerpt }}</p> | ||
| <p class="hint" data-role="sample-hint" {% if not first_hint %}hidden{% endif %}>{{ first_hint }}</p> | ||
| <div class="speaker-sample__actions"> | ||
| <button type="button" | ||
| class="button button--ghost button--small" | ||
| data-role="speaker-preview" | ||
| data-preview-source="sample" | ||
| data-speaker-id="{{ speaker_id }}" | ||
| data-preview-text="{{ first_excerpt }}" | ||
| data-language="{{ pending.language }}" | ||
| data-voice="{{ selected_voice or pending.voice }}" | ||
| data-speed="{{ '%.2f'|format(pending.speed) }}" | ||
| data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"> | ||
| Preview with assigned voice | ||
| </button> | ||
| <button type="button" | ||
| class="button button--ghost button--small" | ||
| data-role="open-voice-browser" | ||
| data-speaker-id="{{ speaker_id }}" | ||
| data-sample-index="0"> | ||
| Preview in voice browser | ||
| </button> | ||
| {% if sample_quotes|length > 1 %} | ||
| <button type="button" | ||
| class="button button--ghost button--small" | ||
| data-role="speaker-next-sample"> | ||
| Show another example | ||
| </button> | ||
| {% endif %} | ||
| </div> | ||
| </article> | ||
| {% else %} | ||
| <p class="hint">No paragraphs captured yet. Continue from Step 2 to gather dialogue samples automatically.</p> | ||
| {% endif %} | ||
| </details> | ||
| {% if speaker.recommended_voices %} | ||
| <div class="speaker-list__recommendations"> | ||
| <span class="hint">Suggested:</span> | ||
| {% for voice_id in speaker.recommended_voices[:6] %} | ||
| {% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %} | ||
| <button type="button" | ||
| class="chip" | ||
| data-role="recommended-voice" | ||
| data-voice="{{ voice_id }}" | ||
| title="{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}"> | ||
| {{ voice_meta.display_name or voice_id }} | ||
| </button> | ||
| {% endfor %} | ||
| </div> | ||
| {% endif %} | ||
| </li> | ||
| {% endfor %} | ||
| </ul> | ||
| </div> | ||
| {% else %} | ||
| <p class="hint">No additional speakers detected yet. The narrator voice will be used for all dialogue.</p> | ||
| {% endif %} | ||
| </div> | ||
| <footer class="modal__footer wizard-card__footer"> | ||
| <div class="wizard-card__footer-actions"> | ||
| <a class="button button--ghost" href="{{ url_for('main.wizard_step', pending_id=pending.id, step='chapters') }}">Previous</a> | ||
| <button type="submit" class="button button--ghost" form="cancel-form">Cancel</button> | ||
| </div> | ||
| <div class="wizard-card__footer-actions"> | ||
| <button type="submit" class="button" form="prepare-form">Next: Review Entities</button> | ||
| </div> | ||
| </footer> | ||
| </div> | ||
| <form method="post" action="{{ url_for('main.wizard_cancel', pending_id=pending.id) }}" id="cancel-form"></form> | ||
| <div class="modal" data-role="voice-modal" hidden> | ||
| <div class="modal__overlay" data-role="voice-modal-close" tabindex="-1"></div> | ||
| <div class="modal__content voice-browser" role="dialog" aria-modal="true" aria-labelledby="voice-modal-title"> | ||
| <header class="voice-browser__header"> | ||
| <h2 id="voice-modal-title">Choose a voice</h2> | ||
| <button type="button" class="icon-button" data-role="voice-modal-close" aria-label="Close voice browser">✕</button> | ||
| </header> | ||
| <div class="voice-browser__body"> | ||
| <aside class="voice-browser__filters"> | ||
| <label class="field"> | ||
| <span>Search</span> | ||
| <input type="search" data-role="voice-modal-search" placeholder="Search by name or language"> | ||
| </label> | ||
| <label class="field"> | ||
| <span>Language</span> | ||
| <select data-role="voice-modal-language"> | ||
| <option value="">All languages</option> | ||
| {% for code, label in options.languages.items() %} | ||
| <option value="{{ code }}">{{ label }} ({{ code|upper }})</option> | ||
| {% endfor %} | ||
| </select> | ||
| </label> | ||
| <div class="voice-browser__gender" role="group" aria-label="Filter by gender"> | ||
| <button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="">All</button> | ||
| <button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="f">Female</button> | ||
| <button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="m">Male</button> | ||
| </div> | ||
| <button type="button" class="button button--ghost" data-role="voice-modal-clear">Clear selection</button> | ||
| </aside> | ||
| <section class="voice-browser__catalog" aria-label="Voice catalog"> | ||
| <ul class="voice-browser__list" data-role="voice-modal-list"></ul> | ||
| </section> | ||
| <section class="voice-browser__mix" data-role="voice-modal-mix"> | ||
| <header class="voice-browser__mix-header"> | ||
| <h3>Selected voices</h3> | ||
| <p class="tag" data-role="voice-modal-mix-total">Total weight: 0.00</p> | ||
| </header> | ||
| <div class="voice-browser__mix-list" data-role="voice-modal-mix-list"></div> | ||
| <section class="voice-browser__preview" data-role="voice-modal-preview"> | ||
| <h3 data-role="voice-modal-selected-name">Select a voice to preview</h3> | ||
| <p class="tag" data-role="voice-modal-selected-meta"></p> | ||
| <div class="voice-browser__samples" data-role="voice-modal-samples"></div> | ||
| <div class="voice-browser__actions"> | ||
| <button type="button" class="button" data-role="voice-modal-apply" disabled>Apply mix</button> | ||
| </div> | ||
| </section> | ||
| </section> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </section> | ||
| {% with pending=pending, readonly=True, active_step='speakers' %} | ||
| {% include "partials/upload_modal.html" %} | ||
| {% endwith %} | ||
| {% endblock %} | ||
| {% block scripts %} | ||
| {{ super() }} | ||
| <script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script> | ||
| <script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script> | ||
| <script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script> | ||
| <script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script> | ||
| <script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script> | ||
| <script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script> | ||
| {% endblock %} |
| {% extends "base.html" %} | ||
| {% block title %}abogen · Queue{% endblock %} | ||
| {% block content %} | ||
| <section class="card"> | ||
| <div id="jobs-panel" | ||
| hx-get="{{ url_for('jobs.jobs_partial') }}" | ||
| hx-trigger="load, every 3s" | ||
| hx-target="#jobs-panel" | ||
| hx-swap="innerHTML"> | ||
| {{ jobs_panel|safe }} | ||
| </div> | ||
| </section> | ||
| {% include "partials/reader_modal.html" %} | ||
| {% endblock %} | ||
| {% block scripts %} | ||
| {{ super() }} | ||
| <script type="module" src="{{ url_for('static', filename='queue.js') }}"></script> | ||
| {% endblock %} |
| <!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <title>{{ display_title or job.original_filename }} · Reader</title> | ||
| <style> | ||
| :root { | ||
| color-scheme: dark; | ||
| --bg: #020617; | ||
| --panel: rgba(15, 23, 42, 0.92); | ||
| --border: rgba(148, 163, 184, 0.25); | ||
| --text: #e2e8f0; | ||
| --accent: #38bdf8; | ||
| } | ||
| * { | ||
| box-sizing: border-box; | ||
| } | ||
| html, | ||
| body { | ||
| margin: 0; | ||
| height: 100%; | ||
| background: var(--bg); | ||
| color: var(--text); | ||
| font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; | ||
| } | ||
| body { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.75rem; | ||
| padding: 0.75rem; | ||
| } | ||
| .reader-shell { | ||
| display: flex; | ||
| flex-direction: column; | ||
| flex: 1 1 auto; | ||
| min-height: 0; | ||
| gap: 0.75rem; | ||
| } | ||
| .reader-toolbar { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| gap: 1rem; | ||
| padding: 0.75rem 1rem; | ||
| border-radius: 14px; | ||
| background: var(--panel); | ||
| border: 1px solid var(--border); | ||
| } | ||
| .reader-toolbar__group { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| flex: 0 0 auto; | ||
| } | ||
| .reader-toolbar__group--end { | ||
| justify-content: flex-end; | ||
| } | ||
| .reader-toolbar__title { | ||
| flex: 1 1 auto; | ||
| text-align: center; | ||
| font-weight: 600; | ||
| font-size: 1rem; | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
| white-space: nowrap; | ||
| } | ||
| .reader-toolbar button, | ||
| .reader-chapter-panel button { | ||
| flex: 0 0 auto; | ||
| border: 1px solid var(--border); | ||
| background: rgba(30, 41, 59, 0.8); | ||
| color: var(--text); | ||
| border-radius: 12px; | ||
| padding: 0.45rem 0.9rem; | ||
| font-weight: 500; | ||
| font-size: 0.95rem; | ||
| cursor: pointer; | ||
| transition: background 0.2s ease, border-color 0.2s ease, opacity 0.2s ease; | ||
| } | ||
| .reader-toolbar button:hover, | ||
| .reader-toolbar button:focus-visible, | ||
| .reader-chapter-panel button:hover, | ||
| .reader-chapter-panel button:focus-visible { | ||
| background: rgba(56, 189, 248, 0.2); | ||
| border-color: rgba(56, 189, 248, 0.4); | ||
| outline: none; | ||
| } | ||
| .reader-toolbar button:disabled, | ||
| .reader-chapter-panel button:disabled { | ||
| opacity: 0.4; | ||
| cursor: not-allowed; | ||
| } | ||
| .reader-toolbar__icon { | ||
| display: inline-flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| padding: 0.4rem; | ||
| width: 2.5rem; | ||
| height: 2.5rem; | ||
| border-radius: 999px; | ||
| } | ||
| .reader-toolbar__icon svg { | ||
| width: 1rem; | ||
| height: 1rem; | ||
| fill: currentColor; | ||
| } | ||
| .reader-toolbar select { | ||
| flex: 1 1 30%; | ||
| min-width: 140px; | ||
| max-width: 320px; | ||
| border-radius: 12px; | ||
| border: 1px solid var(--border); | ||
| background: rgba(30, 41, 59, 0.8); | ||
| color: var(--text); | ||
| padding: 0.4rem 0.75rem; | ||
| font-size: 0.95rem; | ||
| } | ||
| .reader-toolbar select:focus-visible { | ||
| outline: 2px solid var(--accent); | ||
| outline-offset: 2px; | ||
| } | ||
| #reader-container { | ||
| flex: 1 1 auto; | ||
| min-height: 0; | ||
| border-radius: 16px; | ||
| background: var(--panel); | ||
| border: 1px solid var(--border); | ||
| overflow: hidden; | ||
| display: flex; | ||
| position: relative; | ||
| } | ||
| #reader-view { | ||
| flex: 1 1 auto; | ||
| width: 100%; | ||
| height: 100%; | ||
| min-height: 0; | ||
| outline: none; | ||
| } | ||
| #reader-view iframe { | ||
| width: 100%; | ||
| height: 100%; | ||
| border: 0; | ||
| } | ||
| .reader-footer { | ||
| border-radius: 14px; | ||
| background: var(--panel); | ||
| border: 1px solid var(--border); | ||
| padding: 0.75rem 1rem; | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.5rem; | ||
| } | ||
| .reader-footer audio { | ||
| width: 100%; | ||
| } | ||
| .reader-player { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.5rem; | ||
| } | ||
| .reader-player__controls { | ||
| display: flex; | ||
| flex-wrap: wrap; | ||
| gap: 0.5rem; | ||
| align-items: center; | ||
| } | ||
| .reader-player__controls button { | ||
| border: 1px solid var(--border); | ||
| background: rgba(30, 41, 59, 0.8); | ||
| color: var(--text); | ||
| border-radius: 12px; | ||
| padding: 0.4rem 0.8rem; | ||
| font-size: 0.9rem; | ||
| cursor: pointer; | ||
| transition: background 0.2s ease, border-color 0.2s ease, opacity 0.2s ease; | ||
| } | ||
| .reader-player__controls button:hover, | ||
| .reader-player__controls button:focus-visible { | ||
| background: rgba(56, 189, 248, 0.2); | ||
| border-color: rgba(56, 189, 248, 0.4); | ||
| outline: none; | ||
| } | ||
| .reader-player__controls button:disabled { | ||
| opacity: 0.4; | ||
| cursor: not-allowed; | ||
| } | ||
| .reader-player__secondary { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| flex-wrap: wrap; | ||
| font-size: 0.85rem; | ||
| color: rgba(226, 232, 240, 0.75); | ||
| } | ||
| .reader-player__secondary label { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| } | ||
| .reader-player__secondary select { | ||
| border-radius: 10px; | ||
| border: 1px solid var(--border); | ||
| background: rgba(30, 41, 59, 0.8); | ||
| color: var(--text); | ||
| padding: 0.25rem 0.5rem; | ||
| font-size: 0.9rem; | ||
| } | ||
| .reader-player__secondary select:focus-visible { | ||
| outline: 2px solid var(--accent); | ||
| outline-offset: 2px; | ||
| } | ||
| .reader-chapter-links { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.5rem; | ||
| margin: 0; | ||
| max-height: 100%; | ||
| overflow-y: auto; | ||
| padding: 0.5rem 0; | ||
| } | ||
| .reader-chapter-links button { | ||
| border: 1px solid var(--border); | ||
| background: rgba(30, 41, 59, 0.7); | ||
| color: var(--text); | ||
| border-radius: 10px; | ||
| padding: 0.45rem 0.75rem; | ||
| font-size: 0.9rem; | ||
| cursor: pointer; | ||
| white-space: nowrap; | ||
| text-align: left; | ||
| transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, opacity 0.2s ease; | ||
| width: 100%; | ||
| } | ||
| .reader-chapter-links button:hover, | ||
| .reader-chapter-links button:focus-visible { | ||
| background: rgba(56, 189, 248, 0.2); | ||
| border-color: rgba(56, 189, 248, 0.4); | ||
| outline: none; | ||
| } | ||
| .reader-chapter-links button[aria-current="true"] { | ||
| background: rgba(56, 189, 248, 0.35); | ||
| border-color: rgba(56, 189, 248, 0.6); | ||
| color: var(--text); | ||
| } | ||
| .reader-chapter-links button:disabled { | ||
| opacity: 0.4; | ||
| cursor: not-allowed; | ||
| } | ||
| .reader-chapter-links__empty { | ||
| font-size: 0.85rem; | ||
| color: rgba(226, 232, 240, 0.6); | ||
| } | ||
| .visually-hidden { | ||
| border: 0; | ||
| clip: rect(1px, 1px, 1px, 1px); | ||
| clip-path: inset(50%); | ||
| height: 1px; | ||
| margin: -1px; | ||
| overflow: hidden; | ||
| padding: 0; | ||
| position: absolute; | ||
| width: 1px; | ||
| white-space: nowrap; | ||
| } | ||
| .reader-chapter-panel { | ||
| position: fixed; | ||
| top: 0.75rem; | ||
| bottom: 0.75rem; | ||
| right: 0.75rem; | ||
| width: min(320px, 85vw); | ||
| background: var(--panel); | ||
| border: 1px solid var(--border); | ||
| border-radius: 16px 0 0 16px; | ||
| box-shadow: -6px 0 24px rgba(15, 23, 42, 0.45); | ||
| display: flex; | ||
| flex-direction: column; | ||
| transform: translateX(110%); | ||
| transition: transform 0.24s ease; | ||
| z-index: 20; | ||
| pointer-events: none; | ||
| } | ||
| .reader-chapter-panel.is-open { | ||
| transform: translateX(0); | ||
| pointer-events: auto; | ||
| } | ||
| .reader-chapter-panel__header { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: space-between; | ||
| padding: 0.75rem 1rem; | ||
| border-bottom: 1px solid var(--border); | ||
| } | ||
| .reader-chapter-panel__title { | ||
| font-weight: 600; | ||
| font-size: 0.95rem; | ||
| } | ||
| .reader-chapter-panel__body { | ||
| flex: 1 1 auto; | ||
| min-height: 0; | ||
| padding: 0 1rem 1rem; | ||
| overflow: hidden; | ||
| display: flex; | ||
| flex-direction: column; | ||
| } | ||
| .reader-chapter-panel__body > .reader-chapter-links { | ||
| flex: 1 1 auto; | ||
| } | ||
| .reader-status { | ||
| font-size: 0.85rem; | ||
| color: rgba(226, 232, 240, 0.75); | ||
| } | ||
| .chunk--active { | ||
| background: rgba(56, 189, 248, 0.35); | ||
| box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.5); | ||
| border-radius: 6px; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body | ||
| data-asset-base="{{ asset_base }}" | ||
| data-epub-url="{{ epub_url }}" | ||
| > | ||
| <div class="reader-shell" data-role="reader-shell"> | ||
| <div class="reader-toolbar"> | ||
| <div class="reader-toolbar__group"> | ||
| <button type="button" data-action="prev">Previous</button> | ||
| <select data-role="reader-chapter" aria-label="Select chapter"></select> | ||
| </div> | ||
| <div class="reader-toolbar__title" data-role="reader-title">{{ display_title or job.original_filename }}</div> | ||
| <div class="reader-toolbar__group reader-toolbar__group--end"> | ||
| <button type="button" data-action="next">Next</button> | ||
| <button | ||
| type="button" | ||
| class="reader-toolbar__icon" | ||
| data-action="toggle-chapters" | ||
| aria-label="Toggle chapter list" | ||
| aria-expanded="false" | ||
| aria-controls="reader-chapter-panel" | ||
| > | ||
| <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"> | ||
| <path d="M4 7h16v2H4zm0 5h16v2H4zm0 5h16v2H4z" /> | ||
| </svg> | ||
| <span class="visually-hidden">Toggle chapter list</span> | ||
| </button> | ||
| </div> | ||
| </div> | ||
| <div id="reader-container"> | ||
| <div id="reader-view" class="reader-view" tabindex="0" aria-label="Book reader"></div> | ||
| </div> | ||
| <div class="reader-footer"> | ||
| {% if audio_url %} | ||
| <div class="reader-player" data-role="reader-player"> | ||
| <audio controls preload="metadata" src="{{ audio_url }}"></audio> | ||
| <div class="reader-player__controls"> | ||
| <button type="button" data-action="player-prev" aria-label="Go to previous chapter">Prev chapter</button> | ||
| <button type="button" data-action="player-rewind" aria-label="Rewind 15 seconds">-15s</button> | ||
| <button type="button" data-action="player-toggle" aria-label="Play or pause audio">Play</button> | ||
| <button type="button" data-action="player-forward" aria-label="Skip forward 15 seconds">+15s</button> | ||
| <button type="button" data-action="player-next" aria-label="Go to next chapter">Next chapter</button> | ||
| </div> | ||
| <div class="reader-player__secondary"> | ||
| <label for="reader-playback-rate">Speed</label> | ||
| <select id="reader-playback-rate" data-role="playback-rate"> | ||
| <option value="0.75">0.75x</option> | ||
| <option value="0.9">0.90x</option> | ||
| <option value="1" selected>1.00x</option> | ||
| <option value="1.25">1.25x</option> | ||
| <option value="1.5">1.50x</option> | ||
| <option value="1.75">1.75x</option> | ||
| <option value="2">2.00x</option> | ||
| </select> | ||
| </div> | ||
| </div> | ||
| {% endif %} | ||
| <div class="reader-status" data-role="reader-status" aria-live="polite">Loading EPUB…</div> | ||
| </div> | ||
| </div> | ||
| <aside class="reader-chapter-panel" data-role="chapter-panel" id="reader-chapter-panel" aria-hidden="true"> | ||
| <div class="reader-chapter-panel__header"> | ||
| <span class="reader-chapter-panel__title">Chapters</span> | ||
| <button type="button" class="reader-toolbar__icon" data-action="close-chapter-panel" aria-label="Close chapter list"> | ||
| <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"> | ||
| <path d="M6.2 5.2 5.2 6.2 11 12l-5.8 5.8 1 1L12 13l5.8 5.8 1-1L13 12l5.8-5.8-1-1L12 11 6.2 5.2z" /> | ||
| </svg> | ||
| <span class="visually-hidden">Close chapter list</span> | ||
| </button> | ||
| </div> | ||
| <div class="reader-chapter-panel__body"> | ||
| <div class="reader-chapter-links" data-role="chapter-links" aria-label="Chapters"></div> | ||
| </div> | ||
| </aside> | ||
| <script type="application/json" id="reader-data">{{ {'chapters': chapters, 'title': display_title or job.original_filename, 'chapterTimings': chapter_timings}|tojson }}</script> | ||
| <script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script> | ||
| <script src="https://cdn.jsdelivr.net/npm/epubjs@0.3.93/dist/epub.min.js"></script> | ||
| <script type="module"> | ||
| const statusEl = document.querySelector('[data-role="reader-status"]'); | ||
| const prevBtn = document.querySelector('[data-action="prev"]'); | ||
| const nextBtn = document.querySelector('[data-action="next"]'); | ||
| const chapterSelect = document.querySelector('[data-role="reader-chapter"]'); | ||
| const titleEl = document.querySelector('[data-role="reader-title"]'); | ||
| const audioEl = document.querySelector('audio'); | ||
| const playerPrevBtn = document.querySelector('[data-action="player-prev"]'); | ||
| const playerNextBtn = document.querySelector('[data-action="player-next"]'); | ||
| const playerRewindBtn = document.querySelector('[data-action="player-rewind"]'); | ||
| const playerForwardBtn = document.querySelector('[data-action="player-forward"]'); | ||
| const playerToggleBtn = document.querySelector('[data-action="player-toggle"]'); | ||
| const playbackRateSelect = document.querySelector('[data-role="playback-rate"]'); | ||
| const chapterLinksEl = document.querySelector('[data-role="chapter-links"]'); | ||
| const chapterPanel = document.querySelector('[data-role="chapter-panel"]'); | ||
| const chapterPanelToggleBtn = document.querySelector('[data-action="toggle-chapters"]'); | ||
| const chapterPanelCloseBtn = document.querySelector('[data-action="close-chapter-panel"]'); | ||
| const bodyDataset = document.body.dataset; | ||
| const assetBase = bodyDataset.assetBase || ''; | ||
| const epubUrl = bodyDataset.epubUrl || ''; | ||
| const reduceMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); | ||
| let preferSmoothScroll = !reduceMotionQuery.matches; | ||
| if (typeof reduceMotionQuery.addEventListener === 'function') { | ||
| reduceMotionQuery.addEventListener('change', (event) => { | ||
| preferSmoothScroll = !event.matches; | ||
| }); | ||
| } else if (typeof reduceMotionQuery.addListener === 'function') { | ||
| reduceMotionQuery.addListener((event) => { | ||
| preferSmoothScroll = !event.matches; | ||
| }); | ||
| } | ||
| const ACTIVE_CLASS = 'chunk--active'; | ||
| const PLAYER_SKIP_SECONDS = 15; | ||
| const syncCache = new Map(); | ||
| const setStatus = (message) => { | ||
| if (statusEl) { | ||
| statusEl.textContent = message; | ||
| } | ||
| }; | ||
| const navigateToChapter = (index, options = {}) => { | ||
| if (!chapters.length) { | ||
| return Promise.resolve(null); | ||
| } | ||
| const normalizedIndex = Math.min(Math.max(index, 0), chapters.length - 1); | ||
| const preservePlayback = options.preservePlayback !== false; | ||
| const shouldScroll = options.scroll !== false; | ||
| const forcePlay = Boolean(options.forcePlay); | ||
| const wasPlaying = audioEl ? !audioEl.paused : false; | ||
| const targetShouldPlay = forcePlay || (preservePlayback && wasPlaying); | ||
| return displayChapter(normalizedIndex, { scroll: shouldScroll }).then((startTime) => { | ||
| if (!audioEl) { | ||
| return startTime; | ||
| } | ||
| const timelineEntry = getChapterTiming(normalizedIndex); | ||
| const fallbackStart = timelineEntry && Number.isFinite(timelineEntry.start) ? timelineEntry.start : null; | ||
| const resolvedStart = Number.isFinite(startTime) ? startTime : fallbackStart; | ||
| if (resolvedStart !== null && Number.isFinite(resolvedStart)) { | ||
| audioEl.currentTime = resolvedStart; | ||
| } | ||
| if (targetShouldPlay) { | ||
| audioEl.play().catch(() => {}); | ||
| } | ||
| return resolvedStart; | ||
| }).catch((error) => { | ||
| console.warn('Failed to navigate to chapter', error); | ||
| return null; | ||
| }); | ||
| }; | ||
| const removeLeadingSlash = (value) => { | ||
| if (typeof value !== 'string') { | ||
| return value; | ||
| } | ||
| const normalized = value.replace(/\\/g, '/').replace(/^[\/]+/, ''); | ||
| if (!normalized) { | ||
| return ''; | ||
| } | ||
| const segments = normalized.split('/').filter((segment) => segment && segment !== '.'); | ||
| if (!segments.length) { | ||
| return ''; | ||
| } | ||
| const deduped = []; | ||
| segments.forEach((segment) => { | ||
| if (!deduped.length || deduped[deduped.length - 1].toLowerCase() !== segment.toLowerCase()) { | ||
| deduped.push(segment); | ||
| } | ||
| }); | ||
| return deduped.join('/'); | ||
| }; | ||
| const normalizeHref = (value) => { | ||
| if (!value) { | ||
| return ''; | ||
| } | ||
| const raw = String(value); | ||
| const pathPart = raw.split('#', 1)[0]; | ||
| const cleanPart = pathPart.split('?', 1)[0]; | ||
| if (!cleanPart) { | ||
| return ''; | ||
| } | ||
| try { | ||
| const decoded = decodeURIComponent(cleanPart); | ||
| return removeLeadingSlash(decoded); | ||
| } catch (error) { | ||
| return removeLeadingSlash(cleanPart); | ||
| } | ||
| }; | ||
| let spineHrefList = []; | ||
| const spineHrefLookup = new Map(); | ||
| const refreshSpineLookup = (items) => { | ||
| spineHrefList = []; | ||
| spineHrefLookup.clear(); | ||
| if (!Array.isArray(items)) { | ||
| return; | ||
| } | ||
| items.forEach((entry) => { | ||
| const href = typeof entry === 'string' ? entry : entry?.href; | ||
| const normalized = removeLeadingSlash(href || ''); | ||
| if (!normalized) { | ||
| return; | ||
| } | ||
| spineHrefList.push(normalized); | ||
| const key = normalized.toLowerCase(); | ||
| if (!spineHrefLookup.has(key)) { | ||
| spineHrefLookup.set(key, normalized); | ||
| } | ||
| }); | ||
| }; | ||
| const resolveSpineHref = (value) => { | ||
| if (!value) { | ||
| return ''; | ||
| } | ||
| const normalized = removeLeadingSlash(value); | ||
| if (!normalized) { | ||
| return ''; | ||
| } | ||
| const direct = spineHrefLookup.get(normalized.toLowerCase()); | ||
| if (direct) { | ||
| return direct; | ||
| } | ||
| const segments = normalized.split('/'); | ||
| for (let index = 1; index < segments.length; index += 1) { | ||
| const candidate = segments.slice(index).join('/'); | ||
| if (!candidate) { | ||
| continue; | ||
| } | ||
| const match = spineHrefLookup.get(candidate.toLowerCase()); | ||
| if (match) { | ||
| return match; | ||
| } | ||
| } | ||
| return normalized; | ||
| }; | ||
| const formatClock = (value) => { | ||
| if (!Number.isFinite(value) || value < 0) { | ||
| return '--:--'; | ||
| } | ||
| const totalSeconds = Math.floor(value); | ||
| const hours = Math.floor(totalSeconds / 3600); | ||
| const minutes = Math.floor((totalSeconds % 3600) / 60); | ||
| const seconds = totalSeconds % 60; | ||
| if (hours > 0) { | ||
| return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; | ||
| } | ||
| return `${minutes}:${seconds.toString().padStart(2, '0')}`; | ||
| }; | ||
| const parseSmilTime = (value) => { | ||
| if (!value) { | ||
| return null; | ||
| } | ||
| const raw = String(value).trim(); | ||
| if (!raw) { | ||
| return null; | ||
| } | ||
| if (/^\d+(?:\.\d+)?$/.test(raw)) { | ||
| return Number.parseFloat(raw); | ||
| } | ||
| let match = raw.match(/^(\d+):(\d{2}):(\d{2}(?:\.\d+)?)$/); | ||
| if (match) { | ||
| const hours = Number.parseInt(match[1], 10); | ||
| const minutes = Number.parseInt(match[2], 10); | ||
| const seconds = Number.parseFloat(match[3]); | ||
| return hours * 3600 + minutes * 60 + seconds; | ||
| } | ||
| match = raw.match(/^(\d+):(\d{2}(?:\.\d+)?)$/); | ||
| if (match) { | ||
| const minutes = Number.parseInt(match[1], 10); | ||
| const seconds = Number.parseFloat(match[2]); | ||
| return minutes * 60 + seconds; | ||
| } | ||
| match = raw.match(/^([\d.]+)\s*(h|hours?|hr|m|min|minutes?|s|sec|seconds?|ms|millisecond|milliseconds)$/i); | ||
| if (match) { | ||
| const valueNum = Number.parseFloat(match[1]); | ||
| const unit = match[2].toLowerCase(); | ||
| if (Number.isNaN(valueNum)) { | ||
| return null; | ||
| } | ||
| if (unit.startsWith('h')) { | ||
| return valueNum * 3600; | ||
| } | ||
| if (unit.startsWith('m') && unit !== 'ms' && unit !== 'millisecond' && unit !== 'milliseconds') { | ||
| return valueNum * 60; | ||
| } | ||
| if (unit === 'ms' || unit === 'millisecond' || unit === 'milliseconds') { | ||
| return valueNum / 1000; | ||
| } | ||
| return valueNum; | ||
| } | ||
| return null; | ||
| }; | ||
| const coerceTime = (value) => { | ||
| if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { | ||
| return value; | ||
| } | ||
| if (typeof value === 'string') { | ||
| const trimmed = value.trim(); | ||
| if (trimmed) { | ||
| const parsed = Number.parseFloat(trimmed); | ||
| if (Number.isFinite(parsed) && parsed >= 0) { | ||
| return parsed; | ||
| } | ||
| } | ||
| } | ||
| return null; | ||
| }; | ||
| const fetchEpubAsset = async (url) => { | ||
| if (!url) { | ||
| throw new Error('Missing EPUB URL'); | ||
| } | ||
| const response = await fetch(url, { | ||
| credentials: 'same-origin', | ||
| headers: { | ||
| Accept: 'application/epub+zip,application/octet-stream;q=0.8,*/*;q=0.5', | ||
| }, | ||
| }); | ||
| if (!response.ok) { | ||
| const error = new Error(`HTTP ${response.status}`); | ||
| error.status = response.status; | ||
| throw error; | ||
| } | ||
| const buffer = await response.arrayBuffer(); | ||
| return buffer; | ||
| }; | ||
| let ensureJsZipPromise = null; | ||
| const ensureJsZip = async () => { | ||
| if (typeof window !== 'undefined' && window.JSZip) { | ||
| return window.JSZip; | ||
| } | ||
| if (ensureJsZipPromise) { | ||
| return ensureJsZipPromise; | ||
| } | ||
| ensureJsZipPromise = (async () => { | ||
| try { | ||
| const module = await import('https://cdn.jsdelivr.net/npm/jszip@3.10.1/+esm'); | ||
| const lib = module?.default || module?.JSZip || module; | ||
| if (lib && typeof window !== 'undefined') { | ||
| window.JSZip = lib; | ||
| } | ||
| return lib || null; | ||
| } catch (error) { | ||
| console.error('Failed to dynamically import JSZip', error); | ||
| return null; | ||
| } | ||
| })(); | ||
| return ensureJsZipPromise; | ||
| }; | ||
| const buildAssetUrl = (path) => { | ||
| if (!path) { | ||
| return ''; | ||
| } | ||
| const trimmed = String(path).replace(/^\.\//, ''); | ||
| const base = `${window.location.origin}${assetBase}`; | ||
| try { | ||
| const root = base.endsWith('/') ? base : `${base}/`; | ||
| const url = new URL(trimmed, root); | ||
| return url.toString(); | ||
| } catch (error) { | ||
| const normalized = trimmed.replace(/^\/+/, ''); | ||
| return `${assetBase}${normalized}`; | ||
| } | ||
| }; | ||
| const flattenToc = (entries) => { | ||
| const output = []; | ||
| const walk = (items) => { | ||
| if (!Array.isArray(items)) { | ||
| return; | ||
| } | ||
| items.forEach((item) => { | ||
| if (!item) { | ||
| return; | ||
| } | ||
| output.push(item); | ||
| if (item.subitems) { | ||
| walk(item.subitems); | ||
| } | ||
| if (item.children) { | ||
| walk(item.children); | ||
| } | ||
| if (item.subnav) { | ||
| walk(item.subnav); | ||
| } | ||
| }); | ||
| }; | ||
| walk(entries); | ||
| return output; | ||
| }; | ||
| const normalizeChapterList = (items) => { | ||
| const normalized = []; | ||
| if (!Array.isArray(items)) { | ||
| return normalized; | ||
| } | ||
| items.forEach((entry) => { | ||
| if (!entry) { | ||
| return; | ||
| } | ||
| const hrefValue = entry.href || entry.src || ''; | ||
| const href = typeof hrefValue === 'string' ? hrefValue.trim() : ''; | ||
| if (!href) { | ||
| return; | ||
| } | ||
| const labelSource = entry.title ?? entry.label ?? entry.name ?? entry.text ?? ''; | ||
| const title = typeof labelSource === 'string' && labelSource.trim() ? labelSource.trim() : `Chapter ${normalized.length + 1}`; | ||
| let sourceIndex = null; | ||
| const rawIndex = entry.index ?? entry.order ?? entry.position; | ||
| if (typeof rawIndex === 'number' && Number.isFinite(rawIndex)) { | ||
| sourceIndex = Math.max(0, Math.floor(rawIndex)); | ||
| } else if (typeof rawIndex === 'string') { | ||
| const parsed = Number.parseInt(rawIndex.trim(), 10); | ||
| if (Number.isInteger(parsed) && parsed >= 0) { | ||
| sourceIndex = parsed; | ||
| } | ||
| } | ||
| if (!Number.isInteger(sourceIndex)) { | ||
| sourceIndex = normalized.length; | ||
| } | ||
| normalized.push({ | ||
| href, | ||
| normalizedHref: normalizeHref(href), | ||
| title, | ||
| sourceIndex, | ||
| }); | ||
| }); | ||
| return normalized; | ||
| }; | ||
| const dedupeChapters = (items) => { | ||
| const seen = new Set(); | ||
| const output = []; | ||
| items.forEach((item) => { | ||
| if (!item || !item.normalizedHref) { | ||
| return; | ||
| } | ||
| if (seen.has(item.normalizedHref)) { | ||
| return; | ||
| } | ||
| seen.add(item.normalizedHref); | ||
| output.push(item); | ||
| }); | ||
| return output; | ||
| }; | ||
| const rawDataNode = document.getElementById('reader-data'); | ||
| let payload = {}; | ||
| try { | ||
| payload = JSON.parse(rawDataNode?.textContent || '{}'); | ||
| } catch (error) { | ||
| payload = {}; | ||
| } | ||
| const baseTitle = typeof payload.title === 'string' && payload.title ? payload.title : 'Book'; | ||
| document.title = `${baseTitle} · Reader`; | ||
| const rawTimingEntries = Array.isArray(payload.chapterTimings) ? payload.chapterTimings : []; | ||
| const timingBySourceIndex = new Map(); | ||
| rawTimingEntries.forEach((entry) => { | ||
| if (!entry || typeof entry !== 'object') { | ||
| return; | ||
| } | ||
| const rawIndex = entry.index; | ||
| let sourceIndex = null; | ||
| if (typeof rawIndex === 'number' && Number.isFinite(rawIndex)) { | ||
| sourceIndex = Math.max(0, Math.floor(rawIndex)); | ||
| } else if (typeof rawIndex === 'string') { | ||
| const parsed = Number.parseInt(rawIndex.trim(), 10); | ||
| if (Number.isInteger(parsed) && parsed >= 0) { | ||
| sourceIndex = parsed; | ||
| } | ||
| } | ||
| if (sourceIndex === null) { | ||
| return; | ||
| } | ||
| const timingEntry = { | ||
| start: coerceTime(entry.start), | ||
| end: coerceTime(entry.end), | ||
| title: typeof entry.title === 'string' && entry.title.trim() ? entry.title.trim() : null, | ||
| }; | ||
| timingBySourceIndex.set(sourceIndex, timingEntry); | ||
| }); | ||
| let chapters = dedupeChapters(normalizeChapterList(Array.isArray(payload.chapters) ? payload.chapters : [])); | ||
| chapters = chapters.map((chapter, idx) => { | ||
| const cleanedHref = removeLeadingSlash(chapter.href); | ||
| const normalized = chapter.normalizedHref ? removeLeadingSlash(chapter.normalizedHref) : normalizeHref(cleanedHref); | ||
| const sourceIndex = Number.isInteger(chapter.sourceIndex) ? Number(chapter.sourceIndex) : idx; | ||
| return { | ||
| ...chapter, | ||
| href: cleanedHref, | ||
| normalizedHref: normalized, | ||
| title: chapter.title || `Chapter ${idx + 1}`, | ||
| spineHref: chapter.spineHref ? removeLeadingSlash(chapter.spineHref) : null, | ||
| sourceIndex, | ||
| }; | ||
| }); | ||
| chapters = dedupeChapters(chapters); | ||
| console.info('[reader] Chapters payload normalized', chapters.map((chapter) => chapter.href)); | ||
| let chapterTimeline = []; | ||
| const rebuildChapterTimings = () => { | ||
| chapterTimeline = chapters.map((chapter, idx) => { | ||
| const sourceIndex = Number.isInteger(chapter.sourceIndex) ? Number(chapter.sourceIndex) : idx; | ||
| const timing = timingBySourceIndex.get(sourceIndex) || null; | ||
| return { | ||
| sourceIndex, | ||
| start: timing ? coerceTime(timing.start) : null, | ||
| end: timing ? coerceTime(timing.end) : null, | ||
| title: timing && typeof timing.title === 'string' ? timing.title : null, | ||
| }; | ||
| }); | ||
| }; | ||
| rebuildChapterTimings(); | ||
| const getChapterTiming = (index) => { | ||
| if (index < 0 || index >= chapterTimeline.length) { | ||
| return null; | ||
| } | ||
| return chapterTimeline[index] || null; | ||
| }; | ||
| let book = null; | ||
| let rendition = null; | ||
| let currentSync = []; | ||
| let activeOverlay = null; | ||
| let currentChapterIndex = 0; | ||
| let navigationLock = false; | ||
| let suppressRelocate = false; | ||
| let scheduledSync = 0; | ||
| let chapterLinkButtons = []; | ||
| let chapterPanelOpen = false; | ||
| let chapterPanelRestoreFocus = null; | ||
| const focusFirstChapterButton = () => { | ||
| if (!chapterPanelOpen || !chapterLinkButtons.length) { | ||
| return; | ||
| } | ||
| const firstInteractive = chapterLinkButtons.find((button) => button instanceof HTMLButtonElement && !button.disabled); | ||
| if (firstInteractive) { | ||
| try { | ||
| firstInteractive.focus({ preventScroll: true }); | ||
| } catch (error) { | ||
| firstInteractive.focus(); | ||
| } | ||
| } | ||
| }; | ||
| const setChapterPanelOpen = (open) => { | ||
| if (!chapterPanel) { | ||
| chapterPanelOpen = false; | ||
| return; | ||
| } | ||
| chapterPanelOpen = Boolean(open); | ||
| chapterPanel.classList.toggle('is-open', chapterPanelOpen); | ||
| chapterPanel.setAttribute('aria-hidden', chapterPanelOpen ? 'false' : 'true'); | ||
| if (chapterPanelToggleBtn) { | ||
| chapterPanelToggleBtn.setAttribute('aria-expanded', chapterPanelOpen ? 'true' : 'false'); | ||
| } | ||
| if (!chapterPanelOpen && chapterPanelRestoreFocus && typeof chapterPanelRestoreFocus.focus === 'function') { | ||
| try { | ||
| chapterPanelRestoreFocus.focus({ preventScroll: true }); | ||
| } catch (error) { | ||
| // ignore focus restoration failure | ||
| } | ||
| } | ||
| if (!chapterPanelOpen) { | ||
| chapterPanelRestoreFocus = null; | ||
| } | ||
| }; | ||
| const openChapterPanel = () => { | ||
| if (!chapterPanel) { | ||
| return; | ||
| } | ||
| if (!chapterPanelOpen) { | ||
| const activeElement = document.activeElement; | ||
| if (activeElement instanceof HTMLElement && activeElement !== document.body) { | ||
| chapterPanelRestoreFocus = activeElement; | ||
| } else { | ||
| chapterPanelRestoreFocus = chapterPanelToggleBtn instanceof HTMLElement ? chapterPanelToggleBtn : null; | ||
| } | ||
| } | ||
| setChapterPanelOpen(true); | ||
| requestAnimationFrame(() => { | ||
| focusFirstChapterButton(); | ||
| }); | ||
| }; | ||
| const closeChapterPanel = () => { | ||
| setChapterPanelOpen(false); | ||
| }; | ||
| const toggleChapterPanel = () => { | ||
| if (chapterPanelOpen) { | ||
| closeChapterPanel(); | ||
| } else { | ||
| openChapterPanel(); | ||
| } | ||
| }; | ||
| const updateChapterLinkState = () => { | ||
| if (!chapterLinkButtons.length) { | ||
| return; | ||
| } | ||
| chapterLinkButtons.forEach((button, idx) => { | ||
| if (!(button instanceof HTMLButtonElement)) { | ||
| return; | ||
| } | ||
| if (idx === currentChapterIndex) { | ||
| button.setAttribute('aria-current', 'true'); | ||
| } else { | ||
| button.removeAttribute('aria-current'); | ||
| } | ||
| }); | ||
| }; | ||
| const updatePlaybackControls = () => { | ||
| const hasChapters = chapters.length > 0; | ||
| const canPrev = hasChapters && currentChapterIndex > 0; | ||
| const canNext = hasChapters && currentChapterIndex < chapters.length - 1; | ||
| if (playerPrevBtn) { | ||
| playerPrevBtn.disabled = !canPrev; | ||
| } | ||
| if (playerNextBtn) { | ||
| playerNextBtn.disabled = !canNext; | ||
| } | ||
| if (playerRewindBtn) { | ||
| playerRewindBtn.disabled = !audioEl; | ||
| } | ||
| if (playerForwardBtn) { | ||
| playerForwardBtn.disabled = !audioEl; | ||
| } | ||
| if (playerToggleBtn) { | ||
| if (!audioEl) { | ||
| playerToggleBtn.disabled = true; | ||
| playerToggleBtn.textContent = 'Play'; | ||
| } else { | ||
| playerToggleBtn.disabled = false; | ||
| playerToggleBtn.textContent = audioEl.paused ? 'Play' : 'Pause'; | ||
| } | ||
| } | ||
| if (playbackRateSelect) { | ||
| playbackRateSelect.disabled = !audioEl; | ||
| if (audioEl) { | ||
| const currentRate = Math.max(0.5, Math.min(3, audioEl.playbackRate || 1)); | ||
| const formattedRate = currentRate.toFixed(2); | ||
| let matchFound = false; | ||
| Array.from(playbackRateSelect.options).forEach((option) => { | ||
| const optionRate = Number.parseFloat(option.value); | ||
| if (Number.isFinite(optionRate) && Math.abs(optionRate - currentRate) < 0.01) { | ||
| matchFound = true; | ||
| } | ||
| }); | ||
| if (!matchFound) { | ||
| const customOption = new Option(`${formattedRate}x`, currentRate.toString(), true, true); | ||
| playbackRateSelect.append(customOption); | ||
| } | ||
| playbackRateSelect.value = currentRate.toString(); | ||
| } else { | ||
| playbackRateSelect.value = '1'; | ||
| } | ||
| } | ||
| }; | ||
| const buildChapterLinks = () => { | ||
| if (!chapterLinksEl) { | ||
| return; | ||
| } | ||
| chapterLinksEl.innerHTML = ''; | ||
| chapterLinkButtons = []; | ||
| if (!chapters.length) { | ||
| const placeholder = document.createElement('span'); | ||
| placeholder.className = 'reader-chapter-links__empty'; | ||
| placeholder.textContent = 'No chapters available.'; | ||
| chapterLinksEl.append(placeholder); | ||
| return; | ||
| } | ||
| chapters.forEach((chapter, idx) => { | ||
| const button = document.createElement('button'); | ||
| button.type = 'button'; | ||
| button.dataset.chapterIndex = String(idx); | ||
| const label = chapter.title || `Chapter ${idx + 1}`; | ||
| button.textContent = label; | ||
| button.title = label; | ||
| chapterLinksEl.append(button); | ||
| chapterLinkButtons.push(button); | ||
| }); | ||
| updateChapterLinkState(); | ||
| if (chapterPanelOpen) { | ||
| focusFirstChapterButton(); | ||
| } | ||
| }; | ||
| const updateStatus = () => { | ||
| if (!statusEl) { | ||
| return; | ||
| } | ||
| const parts = []; | ||
| if (chapters.length) { | ||
| const position = Math.min(currentChapterIndex + 1, chapters.length); | ||
| parts.push(`Chapter ${position} of ${chapters.length}`); | ||
| } else { | ||
| parts.push('No chapters loaded'); | ||
| } | ||
| if (audioEl) { | ||
| const currentTime = formatClock(audioEl.currentTime || 0); | ||
| const hasDuration = Number.isFinite(audioEl.duration); | ||
| if (hasDuration) { | ||
| parts.push(`Audio ${currentTime} / ${formatClock(audioEl.duration)}`); | ||
| } else { | ||
| parts.push(`Audio ${currentTime}`); | ||
| } | ||
| if (currentSync.length) { | ||
| if (activeOverlay) { | ||
| parts.push(`Chunk ${activeOverlay.index + 1}/${currentSync.length}`); | ||
| } else { | ||
| parts.push('Sync ready'); | ||
| } | ||
| } else { | ||
| parts.push('Sync unavailable'); | ||
| } | ||
| } | ||
| statusEl.textContent = parts.join(' • '); | ||
| }; | ||
| const updateControls = () => { | ||
| const hasChapters = chapters.length > 0; | ||
| if (prevBtn) { | ||
| prevBtn.disabled = !hasChapters || currentChapterIndex <= 0; | ||
| } | ||
| if (nextBtn) { | ||
| nextBtn.disabled = !hasChapters || currentChapterIndex >= chapters.length - 1; | ||
| } | ||
| if (chapterSelect) { | ||
| if (hasChapters) { | ||
| chapterSelect.disabled = false; | ||
| chapterSelect.value = String(currentChapterIndex); | ||
| } else { | ||
| chapterSelect.disabled = true; | ||
| chapterSelect.value = ''; | ||
| } | ||
| } | ||
| if (titleEl) { | ||
| titleEl.textContent = baseTitle; | ||
| } | ||
| if (chapterPanelToggleBtn) { | ||
| chapterPanelToggleBtn.disabled = !hasChapters; | ||
| if (!hasChapters && chapterPanelOpen) { | ||
| closeChapterPanel(); | ||
| } | ||
| } | ||
| updatePlaybackControls(); | ||
| updateChapterLinkState(); | ||
| }; | ||
| const highlightChunk = (chunkId, { scroll = false } = {}) => { | ||
| if (!rendition) { | ||
| return; | ||
| } | ||
| const contents = rendition.getContents(); | ||
| contents.forEach((content) => { | ||
| const doc = content.document; | ||
| if (!doc) { | ||
| return; | ||
| } | ||
| doc.querySelectorAll(`.${ACTIVE_CLASS}`).forEach((node) => { | ||
| node.classList.remove(ACTIVE_CLASS); | ||
| }); | ||
| if (!chunkId) { | ||
| return; | ||
| } | ||
| const target = doc.getElementById(chunkId); | ||
| if (!target) { | ||
| return; | ||
| } | ||
| target.classList.add(ACTIVE_CLASS); | ||
| if (scroll) { | ||
| try { | ||
| const hostWindow = content.window; | ||
| const rect = target.getBoundingClientRect(); | ||
| const viewportHeight = hostWindow?.innerHeight || doc.documentElement.clientHeight || 0; | ||
| if (rect.top < 48 || rect.bottom > viewportHeight - 48) { | ||
| target.scrollIntoView({ | ||
| block: 'center', | ||
| behavior: preferSmoothScroll ? 'smooth' : 'auto', | ||
| }); | ||
| } | ||
| } catch (error) { | ||
| // ignore scrolling errors from detached iframes | ||
| } | ||
| } | ||
| }); | ||
| }; | ||
| const findOverlayAt = (time) => { | ||
| if (!currentSync.length) { | ||
| return null; | ||
| } | ||
| let low = 0; | ||
| let high = currentSync.length - 1; | ||
| let candidate = null; | ||
| while (low <= high) { | ||
| const mid = Math.floor((low + high) / 2); | ||
| const overlay = currentSync[mid]; | ||
| if (!overlay) { | ||
| break; | ||
| } | ||
| if (time < overlay.begin) { | ||
| high = mid - 1; | ||
| continue; | ||
| } | ||
| if (time > overlay.end) { | ||
| low = mid + 1; | ||
| continue; | ||
| } | ||
| candidate = overlay; | ||
| break; | ||
| } | ||
| if (!candidate && low >= 0 && low < currentSync.length) { | ||
| const overlay = currentSync[low]; | ||
| if (overlay && time >= overlay.begin && time <= overlay.end) { | ||
| candidate = overlay; | ||
| } | ||
| } | ||
| if (!candidate && high >= 0 && high < currentSync.length) { | ||
| const overlay = currentSync[high]; | ||
| if (overlay && time >= overlay.begin && time <= overlay.end) { | ||
| candidate = overlay; | ||
| } | ||
| } | ||
| return candidate; | ||
| }; | ||
| const syncToTime = (time, { force = false, scroll = false } = {}) => { | ||
| if (!currentSync.length || !Number.isFinite(time)) { | ||
| if (force) { | ||
| activeOverlay = null; | ||
| highlightChunk(null); | ||
| updateStatus(); | ||
| } | ||
| return; | ||
| } | ||
| const overlay = findOverlayAt(time); | ||
| if (!overlay) { | ||
| if (force) { | ||
| activeOverlay = null; | ||
| highlightChunk(null); | ||
| updateStatus(); | ||
| } | ||
| return; | ||
| } | ||
| if (!force && activeOverlay && overlay.fragment === activeOverlay.fragment) { | ||
| return; | ||
| } | ||
| activeOverlay = overlay; | ||
| highlightChunk(overlay.fragment, { scroll }); | ||
| updateStatus(); | ||
| }; | ||
| const loadChapterSync = async (href) => { | ||
| const key = normalizeHref(href); | ||
| if (!key) { | ||
| currentSync = []; | ||
| activeOverlay = null; | ||
| return currentSync; | ||
| } | ||
| if (syncCache.has(key)) { | ||
| currentSync = syncCache.get(key) || []; | ||
| activeOverlay = null; | ||
| return currentSync; | ||
| } | ||
| const candidates = []; | ||
| const textSwap = key.replace(/\/text\//i, '/smil/'); | ||
| if (textSwap !== key) { | ||
| const maybe = textSwap.replace(/\.xhtml$/i, '.smil'); | ||
| if (!candidates.includes(maybe)) { | ||
| candidates.push(maybe); | ||
| } | ||
| } | ||
| const directSmil = key.replace(/\.xhtml$/i, '.smil'); | ||
| if (!candidates.includes(directSmil)) { | ||
| candidates.push(directSmil); | ||
| } | ||
| if (!candidates.includes(key)) { | ||
| candidates.push(key); | ||
| } | ||
| let overlays = []; | ||
| for (const candidate of candidates) { | ||
| const url = buildAssetUrl(candidate); | ||
| if (!url) { | ||
| continue; | ||
| } | ||
| try { | ||
| const response = await fetch(url, { credentials: 'same-origin' }); | ||
| if (!response.ok) { | ||
| continue; | ||
| } | ||
| const xmlText = await response.text(); | ||
| const parser = new DOMParser(); | ||
| const doc = parser.parseFromString(xmlText, 'application/xml'); | ||
| if (doc.querySelector('parsererror')) { | ||
| continue; | ||
| } | ||
| const parsed = []; | ||
| doc.querySelectorAll('par').forEach((par) => { | ||
| const textEl = par.querySelector('text'); | ||
| const audioNode = par.querySelector('audio'); | ||
| if (!textEl || !audioNode) { | ||
| return; | ||
| } | ||
| const textSrc = textEl.getAttribute('src') || ''; | ||
| const fragment = textSrc.split('#')[1] || ''; | ||
| const begin = parseSmilTime(audioNode.getAttribute('clipBegin')); | ||
| const endRaw = parseSmilTime(audioNode.getAttribute('clipEnd')); | ||
| if (!fragment || begin === null) { | ||
| return; | ||
| } | ||
| const end = endRaw !== null && endRaw >= begin ? endRaw : begin + 0.05; | ||
| parsed.push({ | ||
| fragment, | ||
| begin, | ||
| end, | ||
| id: par.getAttribute('id') || fragment, | ||
| index: parsed.length, | ||
| }); | ||
| }); | ||
| if (parsed.length) { | ||
| parsed.sort((a, b) => a.begin - b.begin); | ||
| parsed.forEach((item, index) => { | ||
| item.index = index; | ||
| }); | ||
| overlays = parsed; | ||
| break; | ||
| } | ||
| } catch (error) { | ||
| console.warn('Failed to load SMIL asset', candidate, error); | ||
| } | ||
| } | ||
| currentSync = overlays; | ||
| activeOverlay = null; | ||
| syncCache.set(key, overlays); | ||
| return overlays; | ||
| }; | ||
| const initializeChapters = () => { | ||
| if (!chapterSelect) { | ||
| return; | ||
| } | ||
| chapterSelect.innerHTML = ''; | ||
| if (!chapters.length) { | ||
| const option = document.createElement('option'); | ||
| option.value = ''; | ||
| option.textContent = 'No chapters'; | ||
| chapterSelect.append(option); | ||
| chapterSelect.disabled = true; | ||
| buildChapterLinks(); | ||
| return; | ||
| } | ||
| chapterSelect.disabled = false; | ||
| chapters.forEach((chapter, idx) => { | ||
| const option = document.createElement('option'); | ||
| option.value = String(idx); | ||
| option.textContent = chapter.title || `Chapter ${idx + 1}`; | ||
| chapterSelect.append(option); | ||
| }); | ||
| chapterSelect.value = String(currentChapterIndex); | ||
| buildChapterLinks(); | ||
| }; | ||
| const registerRenditionHooks = () => { | ||
| if (!rendition) { | ||
| return; | ||
| } | ||
| const injectedStyles = `:root { color-scheme: dark; } | ||
| body { background: transparent !important; color: rgba(226, 232, 240, 0.96) !important; font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif !important; line-height: 1.6; } | ||
| a { color: #38bdf8; } | ||
| .chunk { transition: background-color 0.25s ease, box-shadow 0.25s ease; } | ||
| .chunk.${ACTIVE_CLASS} { background-color: rgba(56, 189, 248, 0.25); box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.5); border-radius: 6px; } | ||
| .chunk-group { margin: 0 0 1.1rem 0; }`; | ||
| // Inject styling into each EPUB iframe so highlights render consistently. | ||
| rendition.hooks.content.register((contents) => { | ||
| try { | ||
| const doc = contents.document; | ||
| if (!doc) { | ||
| return; | ||
| } | ||
| const style = doc.createElement('style'); | ||
| style.textContent = injectedStyles; | ||
| doc.head.appendChild(style); | ||
| if (doc.documentElement) { | ||
| doc.documentElement.style.background = 'transparent'; | ||
| } | ||
| if (doc.body) { | ||
| doc.body.style.background = 'transparent'; | ||
| } | ||
| const handleClick = (event) => { | ||
| const target = event.target instanceof Element ? event.target.closest('.chunk') : null; | ||
| if (!target) { | ||
| return; | ||
| } | ||
| const chunkId = target.getAttribute('id'); | ||
| if (!chunkId) { | ||
| return; | ||
| } | ||
| const overlay = currentSync.find((entry) => entry.fragment === chunkId) || null; | ||
| if (overlay) { | ||
| activeOverlay = overlay; | ||
| highlightChunk(chunkId, { scroll: true }); | ||
| updateStatus(); | ||
| if (audioEl) { | ||
| audioEl.currentTime = overlay.begin; | ||
| audioEl.play().catch(() => {}); | ||
| } | ||
| } | ||
| }; | ||
| doc.addEventListener('click', handleClick); | ||
| } catch (error) { | ||
| console.warn('Failed to decorate EPUB content', error); | ||
| } | ||
| }); | ||
| }; | ||
| const displayChapter = async (index, options = {}) => { | ||
| if (!chapters.length || !book || !rendition) { | ||
| return; | ||
| } | ||
| const normalizedIndex = Math.min(Math.max(index, 0), chapters.length - 1); | ||
| const target = chapters[normalizedIndex]; | ||
| if (!target || navigationLock) { | ||
| return; | ||
| } | ||
| navigationLock = true; | ||
| let failed = false; | ||
| let resolvedStart = null; | ||
| setStatus('Loading chapter…'); | ||
| highlightChunk(null); | ||
| activeOverlay = null; | ||
| updateStatus(); | ||
| try { | ||
| await book.ready; | ||
| const fragment = typeof options.fragment === 'string' ? options.fragment : ''; | ||
| const baseHref = target.href.split('#')[0]; | ||
| const displayResolved = resolveSpineHref(target.spineHref || target.href); | ||
| const fallbackDisplay = removeLeadingSlash(target.href); | ||
| const displayBase = displayResolved || fallbackDisplay; | ||
| const displayHref = fragment ? `${displayBase}#${fragment}` : displayBase; | ||
| const canonicalSource = fragment ? displayBase : displayHref; | ||
| const canonicalCandidate = typeof book.canonical === 'function' ? book.canonical(canonicalSource) : canonicalSource; | ||
| const isBlobCanonical = typeof canonicalCandidate === 'string' && canonicalCandidate.startsWith('blob:'); | ||
| const canonicalHref = isBlobCanonical ? null : removeLeadingSlash(canonicalCandidate); | ||
| const syncHref = removeLeadingSlash((canonicalHref || baseHref).split('#')[0]); | ||
| console.info('[reader] Displaying chapter', { | ||
| requestedIndex: normalizedIndex, | ||
| href: target.href, | ||
| spineHref: target.spineHref, | ||
| displayBase, | ||
| displayHref, | ||
| canonicalHref, | ||
| syncHref, | ||
| }); | ||
| suppressRelocate = true; | ||
| await rendition.display(displayHref); | ||
| suppressRelocate = false; | ||
| currentChapterIndex = normalizedIndex; | ||
| updateControls(); | ||
| const syncTarget = syncHref || target.normalizedHref || removeLeadingSlash(target.href); | ||
| await loadChapterSync(syncTarget); | ||
| const syncStartCandidate = currentSync.length ? currentSync[0].begin : null; | ||
| if (Number.isFinite(syncStartCandidate)) { | ||
| const timelineEntry = getChapterTiming(normalizedIndex); | ||
| if (timelineEntry) { | ||
| timelineEntry.start = syncStartCandidate; | ||
| } | ||
| const sourceIndex = chapters[normalizedIndex] && Number.isInteger(chapters[normalizedIndex].sourceIndex) | ||
| ? Number(chapters[normalizedIndex].sourceIndex) | ||
| : null; | ||
| if (sourceIndex !== null) { | ||
| const existingTiming = timingBySourceIndex.get(sourceIndex) || {}; | ||
| existingTiming.start = syncStartCandidate; | ||
| if (chapters[normalizedIndex] && typeof chapters[normalizedIndex].title === 'string') { | ||
| existingTiming.title = existingTiming.title || chapters[normalizedIndex].title; | ||
| } | ||
| timingBySourceIndex.set(sourceIndex, existingTiming); | ||
| } | ||
| } | ||
| const fallbackTiming = getChapterTiming(normalizedIndex); | ||
| resolvedStart = Number.isFinite(syncStartCandidate) | ||
| ? syncStartCandidate | ||
| : fallbackTiming && Number.isFinite(fallbackTiming.start) | ||
| ? fallbackTiming.start | ||
| : null; | ||
| const shouldScroll = options.scroll ?? Boolean(audioEl && !audioEl.paused); | ||
| if (fragment) { | ||
| highlightChunk(fragment, { scroll: true }); | ||
| const overlayMatch = currentSync.find((entry) => entry.fragment === fragment); | ||
| if (overlayMatch) { | ||
| activeOverlay = overlayMatch; | ||
| if (Number.isFinite(overlayMatch.begin)) { | ||
| resolvedStart = overlayMatch.begin; | ||
| } | ||
| } | ||
| } else if (audioEl) { | ||
| syncToTime(audioEl.currentTime || 0, { force: true, scroll: shouldScroll }); | ||
| } else { | ||
| updateStatus(); | ||
| } | ||
| } catch (error) { | ||
| failed = true; | ||
| console.error('Failed to display chapter', error); | ||
| setStatus('Unable to display this chapter.'); | ||
| } finally { | ||
| suppressRelocate = false; | ||
| navigationLock = false; | ||
| if (!failed) { | ||
| updateStatus(); | ||
| } | ||
| } | ||
| return failed ? null : resolvedStart; | ||
| }; | ||
| const scheduleSync = () => { | ||
| if (!audioEl) { | ||
| return; | ||
| } | ||
| if (scheduledSync) { | ||
| return; | ||
| } | ||
| scheduledSync = window.requestAnimationFrame(() => { | ||
| scheduledSync = 0; | ||
| syncToTime(audioEl.currentTime || 0, { scroll: false }); | ||
| }); | ||
| }; | ||
| if (audioEl) { | ||
| audioEl.addEventListener('timeupdate', scheduleSync); | ||
| audioEl.addEventListener('seeked', () => { | ||
| syncToTime(audioEl.currentTime || 0, { force: true, scroll: true }); | ||
| }); | ||
| audioEl.addEventListener('loadedmetadata', () => { | ||
| updateStatus(); | ||
| updatePlaybackControls(); | ||
| }); | ||
| audioEl.addEventListener('play', () => { | ||
| syncToTime(audioEl.currentTime || 0, { force: true, scroll: true }); | ||
| updateStatus(); | ||
| updatePlaybackControls(); | ||
| }); | ||
| audioEl.addEventListener('pause', () => { | ||
| updateStatus(); | ||
| updatePlaybackControls(); | ||
| }); | ||
| audioEl.addEventListener('ended', () => { | ||
| activeOverlay = null; | ||
| highlightChunk(null); | ||
| updateStatus(); | ||
| updatePlaybackControls(); | ||
| }); | ||
| } | ||
| const init = async () => { | ||
| if (!epubUrl) { | ||
| initializeChapters(); | ||
| updateControls(); | ||
| setStatus('EPUB asset unavailable for this job.'); | ||
| return; | ||
| } | ||
| if (typeof window.ePub !== 'function') { | ||
| initializeChapters(); | ||
| updateControls(); | ||
| setStatus('EPUB.js failed to load.'); | ||
| return; | ||
| } | ||
| const jszipLib = await ensureJsZip(); | ||
| if (!jszipLib) { | ||
| setStatus('JSZip could not be loaded. Please check your network connection.'); | ||
| return; | ||
| } | ||
| setStatus('Fetching EPUB…'); | ||
| let epubPayload = null; | ||
| const fetchTimeout = setTimeout(() => { | ||
| setStatus('Still fetching EPUB… this may take a moment for large books.'); | ||
| }, 4000); | ||
| try { | ||
| epubPayload = await fetchEpubAsset(epubUrl); | ||
| console.info('[reader] EPUB fetched', { | ||
| byteLength: epubPayload instanceof ArrayBuffer ? epubPayload.byteLength : null, | ||
| type: epubPayload ? epubPayload.constructor?.name : null, | ||
| }); | ||
| } catch (error) { | ||
| clearTimeout(fetchTimeout); | ||
| console.error('Failed to fetch EPUB payload', error); | ||
| if (error?.status === 404) { | ||
| setStatus('The EPUB package could not be found for this job.'); | ||
| } else if (error?.status === 403) { | ||
| setStatus('Access to the EPUB package was denied. Check your session.'); | ||
| } else { | ||
| setStatus('Unable to fetch the EPUB asset for this job.'); | ||
| } | ||
| return; | ||
| } | ||
| clearTimeout(fetchTimeout); | ||
| if (!epubPayload || !(epubPayload instanceof ArrayBuffer) || epubPayload.byteLength === 0) { | ||
| console.error('Fetched EPUB payload is empty or invalid', { | ||
| type: typeof epubPayload, | ||
| byteLength: epubPayload && typeof epubPayload === 'object' && 'byteLength' in epubPayload ? epubPayload.byteLength : null, | ||
| }); | ||
| setStatus('The EPUB package retrieved from the server was empty.'); | ||
| return; | ||
| } | ||
| let epubBlob = null; | ||
| try { | ||
| epubBlob = new Blob([epubPayload], { type: 'application/epub+zip' }); | ||
| console.info('[reader] Created EPUB blob', { | ||
| size: epubBlob.size, | ||
| type: epubBlob.type, | ||
| }); | ||
| } catch (error) { | ||
| console.error('Failed to construct Blob from EPUB payload', error); | ||
| setStatus('The EPUB data could not be prepared for viewing.'); | ||
| return; | ||
| } | ||
| book = window.ePub({ | ||
| replacements: 'view', | ||
| requestCredentials: 'same-origin', | ||
| }); | ||
| setStatus('Loading book…'); | ||
| let openError = null; | ||
| try { | ||
| console.info('[reader] Opening book from blob'); | ||
| await book.open(epubBlob); | ||
| console.info('[reader] Book open resolved via blob'); | ||
| } catch (error) { | ||
| openError = error; | ||
| console.warn('Primary EPUB open attempt failed, will retry via object URL', error); | ||
| } | ||
| if (openError) { | ||
| let objectUrl = null; | ||
| try { | ||
| objectUrl = URL.createObjectURL(epubBlob); | ||
| console.info('[reader] Opening book from object URL'); | ||
| await book.open(objectUrl, 'epub'); | ||
| console.info('[reader] Book open resolved via object URL'); | ||
| } catch (fallbackError) { | ||
| console.error('Fallback EPUB open attempt failed', fallbackError); | ||
| if (objectUrl) { | ||
| URL.revokeObjectURL(objectUrl); | ||
| } | ||
| const errorMessage = fallbackError && fallbackError.message ? fallbackError.message : openError && openError.message; | ||
| if (errorMessage) { | ||
| setStatus(`Unable to open this EPUB. (${errorMessage})`); | ||
| } else { | ||
| setStatus('Unable to open this EPUB.'); | ||
| } | ||
| return; | ||
| } finally { | ||
| if (objectUrl) { | ||
| // Delay revocation slightly so the renderer can finish fetching. | ||
| window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); | ||
| } | ||
| } | ||
| } | ||
| rendition = book.renderTo('reader-view', { | ||
| flow: 'scrolled-doc', | ||
| width: '100%', | ||
| height: '100%', | ||
| spread: 'none', | ||
| allowScriptedContent: false, | ||
| }); | ||
| book.on('book:loadFailed', (error) => { | ||
| console.error('EPUB engine reported a load failure', error); | ||
| setStatus('EPUB content could not be loaded.'); | ||
| }); | ||
| registerRenditionHooks(); | ||
| try { | ||
| await book.ready; | ||
| console.info('[reader] Book ready resolved'); | ||
| try { | ||
| const spineItems = Array.isArray(book.spine?.items) ? book.spine.items : []; | ||
| refreshSpineLookup(spineItems); | ||
| console.info('[reader] Spine hrefs', spineHrefList); | ||
| } catch (spineError) { | ||
| console.warn('[reader] Unable to log spine hrefs', spineError); | ||
| } | ||
| } catch (error) { | ||
| console.error('Book failed to initialize', error); | ||
| if (error && error.message) { | ||
| setStatus(`Unable to open this EPUB. (${error.message})`); | ||
| } else { | ||
| setStatus('Unable to open this EPUB.'); | ||
| } | ||
| return; | ||
| } | ||
| let navigation = null; | ||
| try { | ||
| navigation = await book.loaded.navigation; | ||
| } catch (error) { | ||
| console.warn('Navigation unavailable for this EPUB', error); | ||
| } | ||
| if (!chapters.length && navigation && Array.isArray(navigation.toc)) { | ||
| chapters = normalizeChapterList(flattenToc(navigation.toc)); | ||
| } | ||
| chapters = dedupeChapters(chapters); | ||
| rebuildChapterTimings(); | ||
| if (typeof book.canonical === 'function') { | ||
| chapters = chapters.map((chapter) => { | ||
| const canonicalCandidate = book.canonical(chapter.href) || chapter.href; | ||
| const canonicalNormalized = normalizeHref(canonicalCandidate); | ||
| const looksBlob = typeof canonicalCandidate === 'string' && canonicalCandidate.startsWith('blob:'); | ||
| const effectiveHref = removeLeadingSlash(looksBlob ? chapter.href : canonicalCandidate); | ||
| const fallbackNormalized = chapter.normalizedHref || normalizeHref(chapter.href); | ||
| const effectiveNormalized = !looksBlob && canonicalNormalized ? canonicalNormalized : fallbackNormalized; | ||
| return { | ||
| ...chapter, | ||
| href: effectiveHref, | ||
| normalizedHref: effectiveNormalized, | ||
| spineHref: resolveSpineHref(effectiveHref), | ||
| }; | ||
| }); | ||
| chapters = dedupeChapters(chapters); | ||
| rebuildChapterTimings(); | ||
| } | ||
| chapters = chapters.map((chapter) => ({ | ||
| ...chapter, | ||
| spineHref: resolveSpineHref(chapter.spineHref || chapter.href), | ||
| })); | ||
| currentChapterIndex = Math.min(currentChapterIndex, Math.max(chapters.length - 1, 0)); | ||
| initializeChapters(); | ||
| updateControls(); | ||
| if (!chapters.length) { | ||
| setStatus('No readable chapters found in this EPUB.'); | ||
| return; | ||
| } | ||
| rendition.on('relocated', (location) => { | ||
| if (suppressRelocate || !location || !location.start) { | ||
| return; | ||
| } | ||
| const normalized = normalizeHref(location.start.href); | ||
| if (!normalized) { | ||
| return; | ||
| } | ||
| const index = chapters.findIndex((chapter) => chapter.normalizedHref === normalized); | ||
| if (index >= 0 && index !== currentChapterIndex) { | ||
| currentChapterIndex = index; | ||
| updateControls(); | ||
| const relocateTarget = chapters[index].normalizedHref || removeLeadingSlash(chapters[index].href); | ||
| loadChapterSync(relocateTarget).then(() => { | ||
| if (audioEl && !audioEl.paused) { | ||
| syncToTime(audioEl.currentTime || 0, { force: true, scroll: false }); | ||
| } | ||
| updateStatus(); | ||
| }); | ||
| } | ||
| }); | ||
| rendition.on('rendered', () => { | ||
| if (audioEl && !audioEl.paused) { | ||
| syncToTime(audioEl.currentTime || 0, { force: true, scroll: false }); | ||
| } | ||
| }); | ||
| window.addEventListener('resize', () => { | ||
| if (rendition && typeof rendition.resize === 'function') { | ||
| rendition.resize(); | ||
| } | ||
| }); | ||
| setStatus('Preparing viewer…'); | ||
| await displayChapter(currentChapterIndex, { scroll: false, force: true }); | ||
| updateStatus(); | ||
| }; | ||
| document.addEventListener('keydown', (event) => { | ||
| if (event.defaultPrevented || event.altKey || event.metaKey || event.ctrlKey) { | ||
| return; | ||
| } | ||
| const target = event.target; | ||
| if (target instanceof HTMLElement) { | ||
| const tag = target.tagName.toLowerCase(); | ||
| if (tag === 'input' || tag === 'textarea' || tag === 'select' || target.isContentEditable) { | ||
| return; | ||
| } | ||
| } | ||
| if (event.key === 'ArrowLeft' && currentChapterIndex > 0) { | ||
| event.preventDefault(); | ||
| navigateToChapter(currentChapterIndex - 1, { preservePlayback: true }); | ||
| } else if (event.key === 'ArrowRight' && currentChapterIndex < chapters.length - 1) { | ||
| event.preventDefault(); | ||
| navigateToChapter(currentChapterIndex + 1, { preservePlayback: true }); | ||
| } | ||
| }); | ||
| prevBtn?.addEventListener('click', () => { | ||
| if (currentChapterIndex > 0) { | ||
| navigateToChapter(currentChapterIndex - 1, { preservePlayback: true }); | ||
| } | ||
| }); | ||
| nextBtn?.addEventListener('click', () => { | ||
| if (currentChapterIndex < chapters.length - 1) { | ||
| navigateToChapter(currentChapterIndex + 1, { preservePlayback: true }); | ||
| } | ||
| }); | ||
| chapterSelect?.addEventListener('change', (event) => { | ||
| const value = Number.parseInt(event.target.value, 10); | ||
| if (!Number.isNaN(value)) { | ||
| navigateToChapter(value, { preservePlayback: true }); | ||
| } | ||
| }); | ||
| window.addEventListener('message', (event) => { | ||
| if (!audioEl) { | ||
| return; | ||
| } | ||
| if (event.origin && event.origin !== window.location.origin) { | ||
| return; | ||
| } | ||
| const data = event.data; | ||
| if (!data || typeof data !== 'object') { | ||
| return; | ||
| } | ||
| if (data.type === 'abogen:reader:pause') { | ||
| audioEl.pause(); | ||
| if (Number.isFinite(data.currentTime) && data.currentTime >= 0) { | ||
| audioEl.currentTime = data.currentTime; | ||
| } | ||
| updateStatus(); | ||
| updatePlaybackControls(); | ||
| } | ||
| }); | ||
| playerPrevBtn?.addEventListener('click', () => { | ||
| if (currentChapterIndex > 0) { | ||
| navigateToChapter(currentChapterIndex - 1, { preservePlayback: true }); | ||
| } | ||
| }); | ||
| playerNextBtn?.addEventListener('click', () => { | ||
| if (currentChapterIndex < chapters.length - 1) { | ||
| navigateToChapter(currentChapterIndex + 1, { preservePlayback: true }); | ||
| } | ||
| }); | ||
| playerRewindBtn?.addEventListener('click', () => { | ||
| if (!audioEl) { | ||
| return; | ||
| } | ||
| const current = Number.isFinite(audioEl.currentTime) ? audioEl.currentTime : 0; | ||
| audioEl.currentTime = Math.max(0, current - PLAYER_SKIP_SECONDS); | ||
| syncToTime(audioEl.currentTime || 0, { force: true, scroll: false }); | ||
| updateStatus(); | ||
| }); | ||
| playerForwardBtn?.addEventListener('click', () => { | ||
| if (!audioEl || !Number.isFinite(audioEl.currentTime)) { | ||
| return; | ||
| } | ||
| const duration = Number.isFinite(audioEl.duration) ? audioEl.duration : undefined; | ||
| const target = audioEl.currentTime + PLAYER_SKIP_SECONDS; | ||
| audioEl.currentTime = duration ? Math.min(duration, target) : target; | ||
| syncToTime(audioEl.currentTime || 0, { force: true, scroll: false }); | ||
| updateStatus(); | ||
| }); | ||
| playerToggleBtn?.addEventListener('click', () => { | ||
| if (!audioEl) { | ||
| return; | ||
| } | ||
| if (audioEl.paused) { | ||
| audioEl.play().catch(() => {}); | ||
| } else { | ||
| audioEl.pause(); | ||
| } | ||
| }); | ||
| playbackRateSelect?.addEventListener('change', (event) => { | ||
| if (!audioEl) { | ||
| return; | ||
| } | ||
| const value = Number.parseFloat(event.target.value); | ||
| if (Number.isFinite(value) && value > 0) { | ||
| const clamped = Math.max(0.5, Math.min(3, value)); | ||
| audioEl.playbackRate = clamped; | ||
| } | ||
| updatePlaybackControls(); | ||
| }); | ||
| chapterPanelToggleBtn?.addEventListener('click', (event) => { | ||
| event.preventDefault(); | ||
| toggleChapterPanel(); | ||
| }); | ||
| chapterPanelCloseBtn?.addEventListener('click', (event) => { | ||
| event.preventDefault(); | ||
| closeChapterPanel(); | ||
| if (chapterPanelToggleBtn) { | ||
| try { | ||
| chapterPanelToggleBtn.focus({ preventScroll: true }); | ||
| } catch (error) { | ||
| chapterPanelToggleBtn.focus(); | ||
| } | ||
| } | ||
| }); | ||
| const handleChapterPanelDismiss = (event) => { | ||
| if (!chapterPanelOpen || !chapterPanel) { | ||
| return; | ||
| } | ||
| const target = event.target; | ||
| if (!(target instanceof Node)) { | ||
| return; | ||
| } | ||
| if (chapterPanel.contains(target)) { | ||
| return; | ||
| } | ||
| if (chapterPanelToggleBtn && chapterPanelToggleBtn.contains(target)) { | ||
| return; | ||
| } | ||
| closeChapterPanel(); | ||
| }; | ||
| document.addEventListener('mousedown', handleChapterPanelDismiss); | ||
| document.addEventListener('touchstart', handleChapterPanelDismiss); | ||
| document.addEventListener('keydown', (event) => { | ||
| if (event.key === 'Escape' && chapterPanelOpen) { | ||
| event.preventDefault(); | ||
| closeChapterPanel(); | ||
| } | ||
| }); | ||
| chapterLinksEl?.addEventListener('click', (event) => { | ||
| const target = event.target instanceof Element ? event.target.closest('button[data-chapter-index]') : null; | ||
| if (!(target instanceof HTMLButtonElement)) { | ||
| return; | ||
| } | ||
| event.preventDefault(); | ||
| const idx = Number.parseInt(target.dataset.chapterIndex || '', 10); | ||
| if (Number.isNaN(idx)) { | ||
| return; | ||
| } | ||
| const shouldResume = audioEl ? !audioEl.paused : false; | ||
| navigateToChapter(idx, { preservePlayback: true, forcePlay: shouldResume }).finally(() => { | ||
| if (chapterPanelOpen) { | ||
| closeChapterPanel(); | ||
| } | ||
| }); | ||
| }); | ||
| updatePlaybackControls(); | ||
| init().catch((error) => { | ||
| console.error('Failed to initialize reader', error); | ||
| setStatus('Reader failed to initialize.'); | ||
| }); | ||
| </script> | ||
| </body> | ||
| </html> |
| {% extends "base.html" %} | ||
| {% block title %}abogen · Settings{% endblock %} | ||
| {% block content %} | ||
| <section class="card settings"> | ||
| <h1 class="card__title">Application Settings</h1> | ||
| <p class="tag">Settings apply to new jobs you queue from the dashboard.</p> | ||
| {% if saved %} | ||
| <div class="alert alert--success">Settings saved successfully.</div> | ||
| {% endif %} | ||
| {% with messages = get_flashed_messages(with_categories=True) %} | ||
| {% if messages %} | ||
| {% for category, message in messages %} | ||
| <div class="alert {% if category == 'error' %}alert--error{% else %}alert--success{% endif %}">{{ message }}</div> | ||
| {% endfor %} | ||
| {% endif %} | ||
| {% endwith %} | ||
| <div class="settings-layout"> | ||
| <nav class="settings-nav" aria-label="Settings sections"> | ||
| <button type="button" class="settings-nav__item is-active" data-section="narration">Narration</button> | ||
| <button type="button" class="settings-nav__item" data-section="audio">Audio & Delivery</button> | ||
| <button type="button" class="settings-nav__item" data-section="subtitles">Subtitles & Text</button> | ||
| <button type="button" class="settings-nav__item" data-section="performance">Performance</button> | ||
| <button type="button" class="settings-nav__item{% if not llm_ready %} is-disabled{% endif %}" data-section="llm">LLM</button> | ||
| <button type="button" class="settings-nav__item" data-section="normalization">Text Normalization</button> | ||
| <button type="button" class="settings-nav__item" data-section="integrations">Integrations</button> | ||
| <button type="button" class="settings-nav__item" data-section="debug">Debug</button> | ||
| </nav> | ||
| <form action="{{ url_for('settings.update_settings') }}" method="post" class="settings__form"> | ||
| <div class="settings-panels"> | ||
| <section class="settings-panel is-active" data-section="narration"> | ||
| <fieldset class="settings__section"> | ||
| <legend>Narration Defaults</legend> | ||
| <div class="field"> | ||
| <label for="default_speaker">Default Speaker</label> | ||
| <select id="default_speaker" name="default_speaker"> | ||
| <option value="" {% if not settings.default_speaker %}selected{% endif %}>Use fallback voice</option> | ||
| {% if options.voice_profile_options %} | ||
| {% for profile in options.voice_profile_options %} | ||
| {% set profile_value = 'speaker:' ~ profile.name %} | ||
| <option value="{{ profile_value }}" {% if settings.default_speaker == profile_value %}selected{% endif %}>{{ profile.name }}{% if profile.provider %} · {{ profile.provider|capitalize }}{% endif %}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option> | ||
| {% endfor %} | ||
| {% endif %} | ||
| {% set current_default = settings.default_speaker %} | ||
| {% if current_default %} | ||
| {% set known_default = namespace(value=False) %} | ||
| {% for profile in options.voice_profile_options %} | ||
| {% if current_default == 'speaker:' ~ profile.name or current_default == 'profile:' ~ profile.name %} | ||
| {% set known_default.value = True %} | ||
| {% endif %} | ||
| {% endfor %} | ||
| {% if not known_default.value %} | ||
| <option value="{{ current_default }}" selected>{{ current_default }}</option> | ||
| {% endif %} | ||
| {% endif %} | ||
| </select> | ||
| <p class="hint">Pick a saved speaker from Speaker Studio to use by default for new jobs.</p> | ||
| </div> | ||
| <div class="field field--wide"> | ||
| <p class="tag">Kokoro settings</p> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="default_voice">Fallback Kokoro Voice</label> | ||
| <select id="default_voice" name="default_voice"> | ||
| <optgroup label="Standard voices"> | ||
| {% for voice in options.voices %} | ||
| <option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option> | ||
| {% endfor %} | ||
| </optgroup> | ||
| {% if options.voice_profile_options %} | ||
| <optgroup label="Saved speakers"> | ||
| {% for profile in options.voice_profile_options %} | ||
| {% set profile_value = 'speaker:' ~ profile.name %} | ||
| <option value="{{ profile_value }}" {% if settings.default_voice == profile_value %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option> | ||
| {% endfor %} | ||
| </optgroup> | ||
| {% endif %} | ||
| {% set current_default = settings.default_voice %} | ||
| {% if current_default %} | ||
| {% set known_default = namespace(value=False) %} | ||
| {% if current_default in options.voices %} | ||
| {% set known_default.value = True %} | ||
| {% else %} | ||
| {% for profile in options.voice_profile_options %} | ||
| {% if current_default == 'profile:' ~ profile.name or current_default == 'speaker:' ~ profile.name %} | ||
| {% set known_default.value = True %} | ||
| {% endif %} | ||
| {% endfor %} | ||
| {% endif %} | ||
| {% if not known_default.value %} | ||
| <option value="{{ current_default }}" selected>{{ current_default }}</option> | ||
| {% endif %} | ||
| {% endif %} | ||
| </select> | ||
| <p class="hint">Used when no default speaker is selected, and as a fallback when speaker analysis cannot resolve a speaker.</p> | ||
| </div> | ||
| <div class="field field--wide"> | ||
| <p class="tag">Supertonic settings</p> | ||
| <p class="hint">These defaults apply when a Supertonic speaker does not override them.</p> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="supertonic_total_steps">Supertonic Quality (total steps)</label> | ||
| <input type="number" id="supertonic_total_steps" name="supertonic_total_steps" min="2" max="15" value="{{ settings.supertonic_total_steps }}"> | ||
| <p class="hint">2 = fastest/lowest quality, 15 = slowest/highest quality.</p> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="supertonic_speed">Supertonic Speed</label> | ||
| <input type="number" id="supertonic_speed" name="supertonic_speed" min="0.7" max="2.0" step="0.05" value="{{ '%.2f'|format(settings.supertonic_speed) }}"> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="chunk_level_default">Chunk Granularity</label> | ||
| <select id="chunk_level_default" name="chunk_level"> | ||
| {% for option in options.chunk_levels %} | ||
| <option value="{{ option.value }}" {% if settings.chunk_level == option.value %}selected{% endif %}>{{ option.label }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="speaker_analysis_threshold">Speaker Analysis Minimum Mentions</label> | ||
| <input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ settings.speaker_analysis_threshold }}"> | ||
| <p class="hint">Speakers detected fewer times fall back to the narrator voice.</p> | ||
| </div> | ||
| <div class="field field--wide"> | ||
| <label for="speaker_pronunciation_sentence">Speaker Pronunciation Preview</label> | ||
| <input type="text" id="speaker_pronunciation_sentence" name="speaker_pronunciation_sentence" value="{{ settings.speaker_pronunciation_sentence }}" placeholder="This is {{ '{{name}}' }} speaking."> | ||
| <p class="hint">Include <code>{{ '{{name}}' }}</code> where the speaker name should be inserted.</p> | ||
| </div> | ||
| <div class="field field--stack"> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="enable_entity_recognition" value="true" {% if settings.enable_entity_recognition %}checked{% endif %}> | ||
| <span>Automatically detect entities for new jobs</span> | ||
| </label> | ||
| <p class="hint">Disable if you prefer to skip entity extraction in the job wizard.</p> | ||
| </div> | ||
| <div class="field field--wide"> | ||
| <label for="speaker_random_languages">Randomizer Languages</label> | ||
| {% set selected_languages = settings.speaker_random_languages or [] %} | ||
| <select id="speaker_random_languages" name="speaker_random_languages" multiple size="6"> | ||
| {% for code, label in options.languages.items() %} | ||
| <option value="{{ code }}" {% if code in selected_languages %}selected{% endif %}>{{ label }} ({{ code|upper }})</option> | ||
| {% endfor %} | ||
| </select> | ||
| <p class="hint">Limits random voice selection for speakers marked as random. Leave empty to allow any language.</p> | ||
| </div> | ||
| </fieldset> | ||
| </section> | ||
| <section class="settings-panel" data-section="audio"> | ||
| <fieldset class="settings__section"> | ||
| <legend>Audio & Delivery</legend> | ||
| <div class="field"> | ||
| <label for="output_format">Audio Format</label> | ||
| <select id="output_format" name="output_format"> | ||
| {% for fmt in options.output_formats %} | ||
| <option value="{{ fmt }}" {% if settings.output_format == fmt %}selected{% endif %}>{{ fmt }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="save_mode">Save Location</label> | ||
| <select id="save_mode" name="save_mode"> | ||
| {% for location in save_locations %} | ||
| <option value="{{ location.value }}" {% if settings.save_mode == location.value %}selected{% endif %}>{{ location.label }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| <p class="tag">Default output: <code>{{ default_output_dir }}</code></p> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="separate_chapters_format">Separate Chapter Format</label> | ||
| <select id="separate_chapters_format" name="separate_chapters_format"> | ||
| {% for fmt in options.separate_formats %} | ||
| <option value="{{ fmt }}" {% if settings.separate_chapters_format == fmt %}selected{% endif %}>{{ fmt|upper }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </div> | ||
| <div class="field field--choices"> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="save_chapters_separately" value="true" {% if settings.save_chapters_separately %}checked{% endif %}> | ||
| <span>Save Each Chapter Separately</span> | ||
| </label> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="merge_chapters_at_end" value="true" {% if settings.merge_chapters_at_end %}checked{% endif %}> | ||
| <span>Also Create Merged Audiobook</span> | ||
| </label> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="save_as_project" value="true" {% if settings.save_as_project %}checked{% endif %}> | ||
| <span>Save as Project With Metadata</span> | ||
| </label> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="silence_between_chapters">Silence Between Chapters (Seconds)</label> | ||
| <input type="number" step="0.5" min="0" id="silence_between_chapters" name="silence_between_chapters" value="{{ settings.silence_between_chapters }}"> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="chapter_intro_delay">Pause After Chapter Titles (Seconds)</label> | ||
| <input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(settings.chapter_intro_delay) }}"> | ||
| <p class="hint">Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.</p> | ||
| </div> | ||
| <div class="field field--stack"> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="read_title_intro" value="true" {% if settings.read_title_intro %}checked{% endif %}> | ||
| <span>Read Book Title Before Narration</span> | ||
| </label> | ||
| <p class="hint">When enabled, the narrator speaks the title, optional subtitle, and author names before chapter one.</p> | ||
| </div> | ||
| <div class="field field--stack"> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="read_closing_outro" value="true" {% if settings.read_closing_outro %}checked{% endif %}> | ||
| <span>Read Closing Outro After Narration</span> | ||
| </label> | ||
| <p class="hint">Adds a brief "The end" line after the final chapter, optionally including series information.</p> | ||
| </div> | ||
| <div class="field field--stack"> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="normalize_chapter_opening_caps" value="true" {% if settings.normalize_chapter_opening_caps %}checked{% endif %}> | ||
| <span>Normalize ALL CAPS Chapter Openings</span> | ||
| </label> | ||
| <p class="hint">Converts screaming uppercase openings to sentence case while preserving acronyms.</p> | ||
| </div> | ||
| <div class="field field--stack"> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="auto_prefix_chapter_titles" value="true" {% if settings.auto_prefix_chapter_titles %}checked{% endif %}> | ||
| <span>Add "Chapter" before numeric chapter titles</span> | ||
| </label> | ||
| <p class="hint">Ensures the spoken chapter heading starts with "Chapter" when source titles begin with only a number or numeral.</p> | ||
| </div> | ||
| </fieldset> | ||
| </section> | ||
| <section class="settings-panel" data-section="subtitles"> | ||
| <fieldset class="settings__section"> | ||
| <legend>Subtitles & Text</legend> | ||
| <div class="field"> | ||
| <label for="subtitle_format">Subtitle File Format</label> | ||
| <select id="subtitle_format" name="subtitle_format"> | ||
| {% for value, text in options.subtitle_formats %} | ||
| <option value="{{ value }}" {% if settings.subtitle_format == value %}selected{% endif %}>{{ text }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="max_subtitle_words">Max Words Per Subtitle Entry</label> | ||
| <input type="number" min="1" max="200" id="max_subtitle_words" name="max_subtitle_words" value="{{ settings.max_subtitle_words }}"> | ||
| </div> | ||
| <div class="field field--choices"> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="replace_single_newlines" value="true" {% if settings.replace_single_newlines %}checked{% endif %}> | ||
| <span>Replace Single Newlines</span> | ||
| </label> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="generate_epub3" value="true" {% if settings.generate_epub3 %}checked{% endif %}> | ||
| <span>Generate EPUB 3 (experimental)</span> | ||
| </label> | ||
| </div> | ||
| </fieldset> | ||
| </section> | ||
| <section class="settings-panel" data-section="performance"> | ||
| <fieldset class="settings__section"> | ||
| <legend>Performance</legend> | ||
| <div class="field field--choices"> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="use_gpu" value="true" {% if settings.use_gpu %}checked{% endif %}> | ||
| <span>Use GPU (when available)</span> | ||
| </label> | ||
| </div> | ||
| </fieldset> | ||
| </section> | ||
| <section class="settings-panel" data-section="llm"> | ||
| <fieldset class="settings__section"> | ||
| <legend>Endpoint</legend> | ||
| <div class="field"> | ||
| <label for="llm_base_url">Base URL</label> | ||
| <input type="url" id="llm_base_url" name="llm_base_url" value="{{ settings.llm_base_url }}" placeholder="https://localhost:11434/v1"> | ||
| <p class="hint">Point to an OpenAI-compatible endpoint such as Ollama or a proxy.</p> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="llm_api_key">API Key</label> | ||
| <input type="text" id="llm_api_key" name="llm_api_key" value="{{ settings.llm_api_key }}" autocomplete="off" placeholder="ollama"> | ||
| <p class="hint">Leave blank or use <code>ollama</code> for local servers that do not require keys.</p> | ||
| </div> | ||
| <div class="field field--inline"> | ||
| <div class="field__group"> | ||
| <label for="llm_model">Default Model</label> | ||
| <select id="llm_model" name="llm_model" data-current-model="{{ settings.llm_model }}"> | ||
| {% if settings.llm_model %} | ||
| <option value="{{ settings.llm_model }}" selected>{{ settings.llm_model }}</option> | ||
| {% else %} | ||
| <option value="" selected disabled>Select a model</option> | ||
| {% endif %} | ||
| </select> | ||
| </div> | ||
| <div class="field__group"> | ||
| <label for="llm_timeout">Timeout (seconds)</label> | ||
| <input type="number" step="1" min="1" id="llm_timeout" name="llm_timeout" value="{{ settings.llm_timeout }}"> | ||
| </div> | ||
| <button type="button" class="button button--ghost" data-action="llm-refresh-models">Refresh models</button> | ||
| </div> | ||
| </fieldset> | ||
| <fieldset class="settings__section"> | ||
| <legend>Normalization Prompt</legend> | ||
| <div class="field"> | ||
| <label for="llm_prompt">Prompt Template</label> | ||
| <textarea id="llm_prompt" name="llm_prompt" rows="6">{{ settings.llm_prompt }}</textarea> | ||
| <p class="hint">Use <code>{{ '{{sentence}}' }}</code> for the active sentence. <code>{{ '{{paragraph}}' }}</code> remains available for legacy prompts.</p> | ||
| </div> | ||
| <div class="field field--choices"> | ||
| <span class="field__label">Context Mode</span> | ||
| <div class="choices choices--inline"> | ||
| {% for option in llm_context_options %} | ||
| <label class="radio-pill"> | ||
| <input type="radio" name="llm_context_mode" value="{{ option.value }}" {% if settings.llm_context_mode == option.value %}checked{% endif %}> | ||
| <span>{{ option.label }}</span> | ||
| </label> | ||
| {% endfor %} | ||
| </div> | ||
| </div> | ||
| <div class="preview-card" data-preview="llm"> | ||
| <label for="llm_preview_text">Try the prompt</label> | ||
| <textarea id="llm_preview_text" rows="3">I've been waiting all day.</textarea> | ||
| <div class="preview-card__actions"> | ||
| <button type="button" class="button" data-action="llm-preview">Preview</button> | ||
| <span class="preview-card__status" data-role="llm-preview-status"></span> | ||
| </div> | ||
| <div class="preview-card__output" data-role="llm-preview-output"></div> | ||
| </div> | ||
| </fieldset> | ||
| </section> | ||
| <section class="settings-panel" data-section="normalization"> | ||
| {% for group in options.normalization_groups %} | ||
| <fieldset class="settings__section"> | ||
| <legend>{{ group.label }}</legend> | ||
| {% if group.label == "Apostrophes & Contractions" %} | ||
| <div class="field"> | ||
| <span class="field__label">Strategy</span> | ||
| <div class="choices choices--inline"> | ||
| {% for option in apostrophe_modes %} | ||
| <label class="radio-pill"> | ||
| <input type="radio" name="normalization_apostrophe_mode" value="{{ option.value }}" {% if settings.normalization_apostrophe_mode == option.value %}checked{% endif %}> | ||
| <span>{{ option.label }}</span> | ||
| </label> | ||
| {% endfor %} | ||
| </div> | ||
| {% if settings.normalization_apostrophe_mode == 'llm' and not llm_ready %} | ||
| <p class="hint hint--warning">Configure the LLM connection before using it for audiobook runs.</p> | ||
| {% endif %} | ||
| </div> | ||
| {% endif %} | ||
| <div class="field field--choices"> | ||
| {% for option in group.options %} | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="{{ option.key }}" value="true" {% if settings[option.key] %}checked{% endif %}> | ||
| <span>{{ option.label }}</span> | ||
| </label> | ||
| {% endfor %} | ||
| </div> | ||
| {% if group.label == "Apostrophes & Contractions" %} | ||
| <div class="field field--inline field--actions"> | ||
| <button type="button" class="button button--ghost button--small" data-action="contraction-modal-open">Advanced Contraction Settings…</button> | ||
| <p class="hint">Choose which contraction families are expanded.</p> | ||
| </div> | ||
| {% endif %} | ||
| </fieldset> | ||
| {% endfor %} | ||
| <fieldset class="settings__section"> | ||
| <legend>Sample & Preview</legend> | ||
| <div class="field field--inline"> | ||
| <div class="field__group"> | ||
| <label for="normalization_sample_select">Sample</label> | ||
| <select id="normalization_sample_select"> | ||
| {% for key, text in normalization_samples.items() %} | ||
| <option value="{{ text }}" {% if loop.first %}selected{% endif %}>{{ key|capitalize }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </div> | ||
| <div class="field__group"> | ||
| <label for="normalization_sample_voice">Voice</label> | ||
| <select id="normalization_sample_voice"> | ||
| {% for voice in options.voices %} | ||
| <option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </div> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="normalization_sample_text">Sample text</label> | ||
| <textarea id="normalization_sample_text" rows="4">{{ normalization_samples['apostrophes'] }}</textarea> | ||
| </div> | ||
| <div class="preview-card" data-preview="normalization"> | ||
| <div class="preview-card__actions"> | ||
| <button type="button" class="button" data-action="normalization-preview">Preview with current settings</button> | ||
| <span class="preview-card__status" data-role="normalization-preview-status"></span> | ||
| </div> | ||
| <pre class="preview-card__output" data-role="normalization-preview-output"></pre> | ||
| <audio controls class="preview-card__audio" data-role="normalization-preview-audio" hidden></audio> | ||
| </div> | ||
| </fieldset> | ||
| </section> | ||
| <section class="settings-panel" data-section="integrations"> | ||
| <fieldset class="settings__section"> | ||
| <legend>Calibre OPDS</legend> | ||
| <div class="field field--choices"> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="calibre_opds_enabled" value="true" {% if integrations.calibre_opds.enabled %}checked{% endif %}> | ||
| <span>Enable Calibre integration</span> | ||
| </label> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="calibre_opds_verify_ssl" value="true" {% if integrations.calibre_opds.verify_ssl %}checked{% endif %}> | ||
| <span>Verify TLS certificates</span> | ||
| </label> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="calibre_opds_base_url">Base URL</label> | ||
| <input type="url" id="calibre_opds_base_url" name="calibre_opds_base_url" value="{{ integrations.calibre_opds.base_url }}" placeholder="https://calibre.example.com/opds"> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="calibre_opds_username">Username</label> | ||
| <input type="text" id="calibre_opds_username" name="calibre_opds_username" value="{{ integrations.calibre_opds.username }}"> | ||
| </div> | ||
| <div class="field field--inline"> | ||
| <div class="field__group"> | ||
| <label for="calibre_opds_password">Password</label> | ||
| <input type="password" id="calibre_opds_password" name="calibre_opds_password" value="" placeholder="{% if integrations.calibre_opds.has_password %}••••••••{% endif %}" data-has-secret="{{ 'true' if integrations.calibre_opds.has_password else 'false' }}"> | ||
| <p class="hint">Leave blank to keep the stored password.</p> | ||
| </div> | ||
| <div class="field__group field__group--checkbox"> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="calibre_opds_password_clear" value="true"> | ||
| <span>Clear saved password</span> | ||
| </label> | ||
| </div> | ||
| </div> | ||
| <div class="field field--inline"> | ||
| <button type="button" class="button button--ghost" data-action="calibre-test">Test connection</button> | ||
| <span class="field__status" data-role="calibre-test-status" aria-live="polite"></span> | ||
| </div> | ||
| </fieldset> | ||
| <fieldset class="settings__section"> | ||
| <legend>Audiobookshelf</legend> | ||
| <div class="field field--choices"> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="audiobookshelf_enabled" value="true" {% if integrations.audiobookshelf.enabled %}checked{% endif %}> | ||
| <span>Enable Audiobookshelf uploads</span> | ||
| </label> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="audiobookshelf_auto_send" value="true" {% if integrations.audiobookshelf.auto_send %}checked{% endif %}> | ||
| <span>Upload finished jobs automatically</span> | ||
| </label> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="audiobookshelf_verify_ssl" value="true" {% if integrations.audiobookshelf.verify_ssl %}checked{% endif %}> | ||
| <span>Verify TLS certificates</span> | ||
| </label> | ||
| </div> | ||
| <div class="field"> | ||
| <label for="audiobookshelf_base_url">Base URL</label> | ||
| <input type="url" id="audiobookshelf_base_url" name="audiobookshelf_base_url" value="{{ integrations.audiobookshelf.base_url }}" placeholder="https://abs.local:13378"> | ||
| <p class="hint">Use the server root (no trailing <code>/api</code>); the upload requests add it automatically.</p> | ||
| </div> | ||
| <div class="field field--inline"> | ||
| <div class="field__group"> | ||
| <label for="audiobookshelf_library_id">Library ID</label> | ||
| <input type="text" id="audiobookshelf_library_id" name="audiobookshelf_library_id" value="{{ integrations.audiobookshelf.library_id }}"> | ||
| </div> | ||
| <div class="field__group"> | ||
| <label for="audiobookshelf_collection_id">Collection ID (optional)</label> | ||
| <input type="text" id="audiobookshelf_collection_id" name="audiobookshelf_collection_id" value="{{ integrations.audiobookshelf.collection_id }}"> | ||
| </div> | ||
| <div class="field__group"> | ||
| <label for="audiobookshelf_folder_id">Folder (name or ID)</label> | ||
| <div class="field__input-with-button"> | ||
| <input type="text" id="audiobookshelf_folder_id" name="audiobookshelf_folder_id" value="{{ integrations.audiobookshelf.folder_id }}"> | ||
| <button type="button" class="button button--ghost button--small" data-action="audiobookshelf-list-folders">Browse folders</button> | ||
| </div> | ||
| <p class="hint">Enter the folder exactly as it appears in Audiobookshelf, paste the folder ID, or browse the available folders.</p> | ||
| </div> | ||
| </div> | ||
| <div class="field field--inline"> | ||
| <div class="field__group"> | ||
| <label for="audiobookshelf_api_token">API token</label> | ||
| <input type="password" id="audiobookshelf_api_token" name="audiobookshelf_api_token" value="" placeholder="{% if integrations.audiobookshelf.has_api_token %}••••••••{% endif %}" data-has-secret="{{ 'true' if integrations.audiobookshelf.has_api_token else 'false' }}"> | ||
| <p class="hint">Leave blank to keep the stored token.</p> | ||
| </div> | ||
| <div class="field__group field__group--checkbox"> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="audiobookshelf_api_token_clear" value="true"> | ||
| <span>Clear saved token</span> | ||
| </label> | ||
| </div> | ||
| </div> | ||
| <div class="field field--inline"> | ||
| <div class="field__group"> | ||
| <label for="audiobookshelf_timeout">Request timeout (seconds)</label> | ||
| <input type="number" step="1" min="5" id="audiobookshelf_timeout" name="audiobookshelf_timeout" value="{{ integrations.audiobookshelf.timeout }}"> | ||
| </div> | ||
| </div> | ||
| <div class="field field--choices"> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="audiobookshelf_send_cover" value="true" {% if integrations.audiobookshelf.send_cover %}checked{% endif %}> | ||
| <span>Include cover artwork</span> | ||
| </label> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="audiobookshelf_send_chapters" value="true" {% if integrations.audiobookshelf.send_chapters %}checked{% endif %}> | ||
| <span>Send chapter markers</span> | ||
| </label> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="audiobookshelf_send_subtitles" value="true" {% if integrations.audiobookshelf.send_subtitles %}checked{% endif %}> | ||
| <span>Send subtitle files</span> | ||
| </label> | ||
| </div> | ||
| <div class="field field--inline"> | ||
| <button type="button" class="button button--ghost" data-action="audiobookshelf-test">Test connection</button> | ||
| <span class="field__status" data-role="audiobookshelf-test-status" aria-live="polite"></span> | ||
| </div> | ||
| </fieldset> | ||
| </section> | ||
| <section class="settings-panel" data-section="debug"> | ||
| <fieldset class="settings__section"> | ||
| <legend>Debug · TTS transformations</legend> | ||
| <p class="hint">Generate a set of WAV files from a purpose-built EPUB containing code-tagged examples. When something sounds wrong, report the code (e.g. <code>NUM_001</code>) to pinpoint the failing transformation.</p> | ||
| <div class="field field--stack"> | ||
| <button type="submit" class="button" form="debug-tts-form">Generate debug WAVs</button> | ||
| <p class="hint">Uses your current Settings defaults (voice, language, speed, GPU). If generation fails, an error will appear at the top of this page.</p> | ||
| </div> | ||
| {% if debug_manifest and debug_manifest.artifacts %} | ||
| <div class="field field--wide"> | ||
| <label>Latest debug WAVs</label> | ||
| <ul> | ||
| {% for item in debug_manifest.artifacts %} | ||
| <li> | ||
| <a href="{{ url_for('settings.download_debug_wav', run_id=debug_manifest.run_id, filename=item.filename) }}">{{ item.label }} · {{ item.filename }}</a> | ||
| </li> | ||
| {% endfor %} | ||
| </ul> | ||
| </div> | ||
| {% endif %} | ||
| {% if debug_samples %} | ||
| <div class="field field--wide"> | ||
| <label>Included examples</label> | ||
| <ul> | ||
| {% for sample in debug_samples %} | ||
| <li><strong>{{ sample.code }}</strong> — {{ sample.label }}: <span class="muted">{{ sample.text }}</span></li> | ||
| {% endfor %} | ||
| </ul> | ||
| </div> | ||
| {% endif %} | ||
| </fieldset> | ||
| </section> | ||
| </div> | ||
| <div class="modal" data-role="contraction-modal" hidden> | ||
| <div class="modal__overlay" data-role="contraction-modal-overlay" tabindex="-1"></div> | ||
| <div class="modal__content card card--modal" role="dialog" aria-modal="true" aria-labelledby="contraction-modal-title"> | ||
| <header class="modal__header"> | ||
| <p class="modal__eyebrow">Normalization</p> | ||
| <h2 class="modal__title" id="contraction-modal-title">Contraction Options</h2> | ||
| <button type="button" class="button button--ghost button--small" data-action="contraction-modal-close" aria-label="Close contraction options">Close</button> | ||
| </header> | ||
| <div class="modal__body"> | ||
| <p class="hint">Enable or disable specific contraction families. These controls apply when contraction expansion is enabled.</p> | ||
| <div class="field field--choices"> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="normalization_contraction_aux_be" value="true" {% if settings.normalization_contraction_aux_be %}checked{% endif %}> | ||
| <span>Be verbs ('m, 're, 's -> am/is/are)</span> | ||
| </label> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="normalization_contraction_aux_have" value="true" {% if settings.normalization_contraction_aux_have %}checked{% endif %}> | ||
| <span>Have auxiliaries ('ve, 'd -> have/had)</span> | ||
| </label> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="normalization_contraction_modal_will" value="true" {% if settings.normalization_contraction_modal_will %}checked{% endif %}> | ||
| <span>Will modals ('ll -> will)</span> | ||
| </label> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="normalization_contraction_modal_would" value="true" {% if settings.normalization_contraction_modal_would %}checked{% endif %}> | ||
| <span>Would conditionals ('d -> would)</span> | ||
| </label> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="normalization_contraction_negation_not" value="true" {% if settings.normalization_contraction_negation_not %}checked{% endif %}> | ||
| <span>Negative forms (can't -> can not)</span> | ||
| </label> | ||
| <label class="toggle-pill"> | ||
| <input type="checkbox" name="normalization_contraction_let_us" value="true" {% if settings.normalization_contraction_let_us %}checked{% endif %}> | ||
| <span>Let us (let's -> let us)</span> | ||
| </label> | ||
| </div> | ||
| </div> | ||
| <footer class="modal__footer"> | ||
| <button type="button" class="button button--ghost" data-action="contraction-modal-close">Done</button> | ||
| </footer> | ||
| </div> | ||
| </div> | ||
| <div class="modal" data-role="audiobookshelf-folder-modal" hidden> | ||
| <div class="modal__overlay" data-role="audiobookshelf-folder-overlay" tabindex="-1"></div> | ||
| <div class="modal__content card card--modal folder-picker-modal" role="dialog" aria-modal="true" aria-labelledby="audiobookshelf-folder-picker-title"> | ||
| <header class="modal__header"> | ||
| <p class="modal__eyebrow">Audiobookshelf</p> | ||
| <h2 class="modal__title" id="audiobookshelf-folder-picker-title">Select folder</h2> | ||
| <button type="button" class="button button--ghost button--small" data-action="audiobookshelf-folder-close" aria-label="Close folder picker">Close</button> | ||
| </header> | ||
| <div class="modal__body"> | ||
| <div class="folder-picker" data-role="audiobookshelf-folder-picker"> | ||
| <div class="folder-picker__controls"> | ||
| <input type="search" data-role="audiobookshelf-folder-filter" placeholder="Filter folders" autocomplete="off" spellcheck="false"> | ||
| </div> | ||
| <p class="folder-picker__status" data-role="audiobookshelf-folder-status"></p> | ||
| <div class="folder-picker__list" data-role="audiobookshelf-folder-list" role="listbox"></div> | ||
| <p class="folder-picker__empty" data-role="audiobookshelf-folder-empty" hidden>No folders match your filter.</p> | ||
| </div> | ||
| </div> | ||
| <footer class="modal__footer"> | ||
| <button type="button" class="button button--ghost" data-action="audiobookshelf-folder-close">Cancel</button> | ||
| </footer> | ||
| </div> | ||
| </div> | ||
| <div class="settings__actions"> | ||
| <button type="submit" class="button">Save Settings</button> | ||
| </div> | ||
| </form> | ||
| <form id="debug-tts-form" action="{{ url_for('settings.run_debug_wavs') }}" method="post"></form> | ||
| </div> | ||
| </section> | ||
| {% endblock %} | ||
| {% block scripts %} | ||
| {{ super() }} | ||
| <script type="module" src="{{ url_for('static', filename='settings.js') }}"></script> | ||
| {% endblock %} | ||
| {% extends "base.html" %} | ||
| {% macro speaker_row(row_key, speaker, options) %} | ||
| {% set gender = (speaker.gender or 'unknown') %} | ||
| <div class="speaker-config-row" data-role="speaker-row" data-row-id="{{ row_key }}"> | ||
| <input type="hidden" name="speaker_rows" value="{{ row_key }}"> | ||
| <input type="hidden" name="speaker-{{ row_key }}-id" value="{{ speaker.id or row_key }}"> | ||
| <div class="speaker-config-row__grid"> | ||
| <label class="field" for="speaker-{{ row_key }}-label"> | ||
| <span>Label</span> | ||
| <input type="text" id="speaker-{{ row_key }}-label" name="speaker-{{ row_key }}-label" value="{{ speaker.label or '' }}" placeholder="Character name" required> | ||
| </label> | ||
| <label class="field" for="speaker-{{ row_key }}-gender"> | ||
| <span>Gender</span> | ||
| <select id="speaker-{{ row_key }}-gender" name="speaker-{{ row_key }}-gender"> | ||
| <option value="unknown" {% if gender == 'unknown' %}selected{% endif %}>Unknown</option> | ||
| <option value="female" {% if gender == 'female' %}selected{% endif %}>Female</option> | ||
| <option value="male" {% if gender == 'male' %}selected{% endif %}>Male</option> | ||
| </select> | ||
| </label> | ||
| <label class="field" for="speaker-{{ row_key }}-voice"> | ||
| <span>Preferred voice</span> | ||
| <select id="speaker-{{ row_key }}-voice" name="speaker-{{ row_key }}-voice"> | ||
| <option value="" {% if not speaker.voice %}selected{% endif %}>Random compatible voice</option> | ||
| {% for voice in options.voice_catalog %} | ||
| <option value="{{ voice.id }}" {% if speaker.voice == voice.id %}selected{% endif %}>{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </label> | ||
| </div> | ||
| <div class="speaker-config-row__footer"> | ||
| <button type="button" class="button button--ghost button--small" data-action="remove-speaker"> | ||
| Remove | ||
| </button> | ||
| </div> | ||
| </div> | ||
| {% endmacro %} | ||
| {% block title %}Speaker presets{% endblock %} | ||
| {% block content %} | ||
| <section class="card speaker-configs"> | ||
| <div class="step-indicator" aria-label="Audiobook workflow"> | ||
| <span class="step-indicator__item is-complete"> | ||
| <span class="step-indicator__index">1</span> | ||
| <span class="step-indicator__label">Upload</span> | ||
| </span> | ||
| <span class="step-indicator__item is-active"> | ||
| <span class="step-indicator__index">2</span> | ||
| <span class="step-indicator__label">Speakers</span> | ||
| </span> | ||
| <span class="step-indicator__item"> | ||
| <span class="step-indicator__index">3</span> | ||
| <span class="step-indicator__label">Queue</span> | ||
| </span> | ||
| </div> | ||
| <div class="card__title">Speaker presets</div> | ||
| <p class="card__subtitle">Store recurring casts and keep voices consistent between books.</p> | ||
| {% if message %} | ||
| <div class="alert alert--success">{{ message }}</div> | ||
| {% endif %} | ||
| {% if error %} | ||
| <div class="alert alert--error">{{ error }}</div> | ||
| {% endif %} | ||
| <div class="speaker-configs__layout"> | ||
| <aside class="speaker-configs__sidebar"> | ||
| <div class="speaker-configs__sidebar-header"> | ||
| <h2>Saved presets</h2> | ||
| <a class="button button--ghost button--small" href="{{ url_for('voices.speaker_configs_page') }}">New preset</a> | ||
| </div> | ||
| <ul class="speaker-configs__list"> | ||
| {% for config in configs %} | ||
| <li class="speaker-configs__entry {% if editing_name == config.name %}is-active{% endif %}"> | ||
| <a href="{{ url_for('voices.speaker_configs_page', config=config.name) }}">{{ config.name }}</a> | ||
| <span class="speaker-configs__meta">{{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</span> | ||
| <form method="post" action="{{ url_for('voices.delete_speaker_config_named', name=config.name) }}" class="speaker-configs__delete" onsubmit="return confirm('Delete preset {{ config.name }}?');"> | ||
| <button type="submit" class="link-button">Delete</button> | ||
| </form> | ||
| </li> | ||
| {% else %} | ||
| <li class="speaker-configs__empty">No presets yet. Create your first configuration on the right.</li> | ||
| {% endfor %} | ||
| </ul> | ||
| </aside> | ||
| <div class="speaker-configs__editor"> | ||
| <form method="post" class="speaker-config-form" id="speaker-config-form"> | ||
| <input type="hidden" name="config_version" value="{{ editing.version or 1 }}"> | ||
| <div class="speaker-config-form__grid"> | ||
| <label class="field" for="config_name"> | ||
| <span>Preset name</span> | ||
| <input type="text" id="config_name" name="config_name" value="{{ editing_name }}" placeholder="Mystery duo" required> | ||
| </label> | ||
| <label class="field" for="config_language"> | ||
| <span>Primary language</span> | ||
| <select id="config_language" name="config_language"> | ||
| {% for code, label in options.languages.items() %} | ||
| <option value="{{ code }}" {% if editing.language == code %}selected{% endif %}>{{ label }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </label> | ||
| <label class="field" for="config_default_voice"> | ||
| <span>Fallback voice</span> | ||
| <select id="config_default_voice" name="config_default_voice"> | ||
| <option value="" {% if not editing.default_voice %}selected{% endif %}>Use narrator voice</option> | ||
| {% for voice in options.voice_catalog %} | ||
| <option value="{{ voice.id }}" {% if editing.default_voice == voice.id %}selected{% endif %}>{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </label> | ||
| <label class="field field--full" for="config_notes"> | ||
| <span>Notes</span> | ||
| <textarea id="config_notes" name="config_notes" rows="3" placeholder="Pronunciation reminders, references, or character context...">{{ editing.notes or '' }}</textarea> | ||
| </label> | ||
| </div> | ||
| <section class="speaker-config-rows"> | ||
| <div class="speaker-config-rows__header"> | ||
| <h3>Speakers</h3> | ||
| <button type="button" class="button button--ghost button--small" data-action="add-speaker">Add speaker</button> | ||
| </div> | ||
| <p class="hint">Add each recurring character, set their gender, and choose a preferred voice.</p> | ||
| <div class="speaker-config-rows__list" data-role="speaker-rows"> | ||
| {% set speakers_map = editing.speakers or {} %} | ||
| {% if speakers_map %} | ||
| {% for speaker_id, speaker in speakers_map|dictsort(attribute='1.label') %} | ||
| {{ speaker_row(speaker_id, speaker, options) }} | ||
| {% endfor %} | ||
| {% else %} | ||
| <div class="speaker-config-rows__empty" data-role="empty-state">No speakers yet. Add your first character.</div> | ||
| {% endif %} | ||
| </div> | ||
| </section> | ||
| <div class="speaker-configs__actions"> | ||
| <button type="submit" class="button">Save configuration</button> | ||
| <div class="speaker-configs__actions-right"> | ||
| <a class="button button--ghost" href="{{ url_for('main.index') }}">Back to dashboard</a> | ||
| </div> | ||
| </div> | ||
| </form> | ||
| </div> | ||
| </div> | ||
| <template id="speaker-row-template"> | ||
| {{ speaker_row('__ROW__', {'id': '', 'label': '', 'gender': 'unknown', 'voice': ''}, options) | safe }} | ||
| </template> | ||
| </section> | ||
| {% endblock %} | ||
| {% block scripts %} | ||
| {{ super() }} | ||
| <script type="module" src="{{ url_for('static', filename='speaker-configs.js') }}"></script> | ||
| {% endblock %} |
| {% extends "base.html" %} | ||
| {% block title %}abogen · Speaker Studio{% endblock %} | ||
| {% block content %} | ||
| <section class="card voice-mixer"> | ||
| <div class="voice-mixer__header"> | ||
| <div> | ||
| <h1 class="card__title">Speaker Studio</h1> | ||
| <p class="tag">Create and manage speakers for Kokoro and Supertonic.</p> | ||
| </div> | ||
| <div class="voice-mixer__header-actions"> | ||
| <button type="button" class="button button--ghost" data-action="new-profile">New speaker</button> | ||
| <button type="button" class="button button--ghost" data-action="import-profiles">Import</button> | ||
| <button type="button" class="button button--ghost" data-action="export-profiles">Export</button> | ||
| </div> | ||
| </div> | ||
| <div id="voice-mixer-app" class="voice-mixer__layout" data-state="loading"> | ||
| <aside class="voice-mixer__profiles" data-role="profile-list"> | ||
| <p class="tag">Loading profiles…</p> | ||
| </aside> | ||
| <section class="voice-mixer__editor" data-role="editor"> | ||
| <noscript> | ||
| <p class="tag">JavaScript is required for the studio. Please enable it to edit speakers.</p> | ||
| </noscript> | ||
| <form id="voice-profile-form" class="voice-editor" autocomplete="off"> | ||
| <div class="voice-status" data-role="status"></div> | ||
| <div class="voice-editor__meta"> | ||
| <div class="voice-editor__identity"> | ||
| <div class="field voice-editor__name-field"> | ||
| <label for="profile-name">Speaker name</label> | ||
| <input id="profile-name" name="name" type="text" placeholder="Narrator" required> | ||
| </div> | ||
| <div class="voice-editor__toolbar" role="group" aria-label="Profile actions"> | ||
| <button type="submit" class="icon-button icon-button--primary" data-role="save-profile" disabled aria-label="Save profile" title="Save profile"> | ||
| <svg aria-hidden="true" viewBox="0 0 24 24" focusable="false"> | ||
| <path d="M5 3h11l5 5v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7 4a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm-5 11h10v-5H7v5z" fill="currentColor" /> | ||
| </svg> | ||
| </button> | ||
| <button type="button" class="icon-button" data-role="duplicate-profile" aria-label="Duplicate profile" title="Duplicate profile" disabled> | ||
| <svg aria-hidden="true" viewBox="0 0 24 24" focusable="false"> | ||
| <path d="M8 4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2V4zm-3 6a2 2 0 0 1 2-2h1v8a3 3 0 0 0 3 3h8v1a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2z" fill="currentColor" /> | ||
| </svg> | ||
| </button> | ||
| <button type="button" class="icon-button icon-button--danger" data-role="delete-profile" aria-label="Delete profile" title="Delete profile" disabled> | ||
| <svg aria-hidden="true" viewBox="0 0 24 24" focusable="false"> | ||
| <path d="M9 3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1h5v2H4V3h5zm1 5h2v10h-2V8zm4 0h2v10h-2V8zM6 8h2v10H6V8z" fill="currentColor" /> | ||
| </svg> | ||
| </button> | ||
| </div> | ||
| </div> | ||
| <div class="field voice-editor__provider"> | ||
| <label for="profile-provider">TTS provider</label> | ||
| <select id="profile-provider" name="provider" data-role="provider"> | ||
| <option value="kokoro">Kokoro</option> | ||
| <option value="supertonic">Supertonic</option> | ||
| </select> | ||
| </div> | ||
| <div class="field voice-editor__language"> | ||
| <label for="profile-language">Language</label> | ||
| <select id="profile-language" name="language"> | ||
| {% for key, label in options.languages.items() %} | ||
| <option value="{{ key }}">{{ label }} ({{ key|upper }})</option> | ||
| {% endfor %} | ||
| </select> | ||
| </div> | ||
| <div class="voice-editor__provider-panel" data-role="supertonic-panel" hidden> | ||
| <div class="field"> | ||
| <label for="supertonic-voice">Supertonic voice</label> | ||
| <select id="supertonic-voice" data-role="supertonic-voice"> | ||
| {% for voice in ['M1','M2','M3','M4','M5','F1','F2','F3','F4','F5'] %} | ||
| <option value="{{ voice }}">{{ voice }}</option> | ||
| {% endfor %} | ||
| </select> | ||
| </div> | ||
| <div class="field field--slider"> | ||
| <label for="supertonic-quality">Supertonic quality (total steps) <span class="tag" data-role="supertonic-steps-display">5</span></label> | ||
| <input type="range" id="supertonic-quality" data-role="supertonic-steps" min="2" max="15" step="1" value="5"> | ||
| <p class="hint">2 = fastest/lowest quality, 15 = slowest/highest quality.</p> | ||
| </div> | ||
| <div class="field field--slider"> | ||
| <label for="supertonic-speaker-speed">Supertonic speed <span class="tag" data-role="supertonic-speed-display">1.00×</span></label> | ||
| <input type="range" id="supertonic-speaker-speed" data-role="supertonic-speed" min="0.7" max="2.0" step="0.05" value="1.0"> | ||
| </div> | ||
| <p class="hint">Supertonic voice mixing is not implemented yet. Stub target: <a href="https://github.com/Topping1/Supertonic-Voice-Mixer" target="_blank" rel="noreferrer">Supertonic-Voice-Mixer</a>.</p> | ||
| </div> | ||
| </div> | ||
| <div class="voice-editor__summary"> | ||
| <span data-role="profile-summary">Select or create a profile to begin.</span> | ||
| <span class="tag" data-role="mix-total">Total weight: 0.00</span> | ||
| </div> | ||
| <div class="voice-editor__canvas" data-role="kokoro-mixer"> | ||
| <section class="voice-available"> | ||
| <header class="voice-available__header"> | ||
| <div class="voice-available__title"> | ||
| <h2>Available voices</h2> | ||
| <p class="tag">Drag into the mixer or click Add.</p> | ||
| </div> | ||
| <div class="voice-filter"> | ||
| <label class="voice-filter__label" for="voice-language-filter">Language</label> | ||
| <div class="voice-filter__controls"> | ||
| <select id="voice-language-filter" data-role="voice-filter"> | ||
| <option value="">All languages</option> | ||
| {% for key, label in options.languages.items() %} | ||
| <option value="{{ key }}">{{ label }} ({{ key|upper }})</option> | ||
| {% endfor %} | ||
| </select> | ||
| <div class="voice-gender-filter" data-role="gender-filter" aria-label="Filter by gender"> | ||
| <button type="button" class="voice-gender-filter__button" data-value="f" aria-pressed="false" title="Show female voices">♀</button> | ||
| <button type="button" class="voice-gender-filter__button" data-value="m" aria-pressed="false" title="Show male voices">♂</button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </header> | ||
| <div class="voice-available__list" data-role="available-voices"></div> | ||
| </section> | ||
| <section class="voice-mix"> | ||
| <header class="voice-mix__header"> | ||
| <h2>Your mix</h2> | ||
| <p class="tag">Drop voices below to set levels.</p> | ||
| </header> | ||
| <div class="voice-mix__dropzone" data-role="dropzone"> | ||
| <p class="voice-mix__empty" data-role="mix-empty">Drag voices here or tap “Add”.</p> | ||
| <div class="voice-mix__list" data-role="selected-voices"></div> | ||
| </div> | ||
| </section> | ||
| </div> | ||
| <div class="voice-editor__actions"> | ||
| <div class="voice-preview"> | ||
| <div class="field"> | ||
| <label for="preview-text">Preview text</label> | ||
| <textarea id="preview-text" rows="3" data-role="preview-text" placeholder="Paste a short line to audition your mix."></textarea> | ||
| </div> | ||
| <div class="field field--slider"> | ||
| <label for="preview-speed">Preview speed <span class="tag" data-role="preview-speed-display">1.00×</span></label> | ||
| <input id="preview-speed" type="range" min="0.7" max="2.0" step="0.05" value="1.0" data-role="preview-speed"> | ||
| </div> | ||
| <div class="voice-preview__controls"> | ||
| <button type="button" class="button" data-role="preview-button">Preview speaker</button> | ||
| <button type="button" class="button button--ghost" data-role="load-sample">Use sample text</button> | ||
| </div> | ||
| <audio data-role="preview-audio" controls preload="none"></audio> | ||
| </div> | ||
| </div> | ||
| </form> | ||
| </section> | ||
| </div> | ||
| <input id="voice-import-input" type="file" accept="application/json" hidden> | ||
| <div class="modal" data-role="provider-picker-modal" hidden> | ||
| <div class="modal__overlay" data-role="provider-picker-overlay" tabindex="-1"></div> | ||
| <div class="modal__content card card--modal" role="dialog" aria-modal="true" aria-labelledby="provider-picker-title"> | ||
| <header class="modal__header"> | ||
| <p class="modal__eyebrow">Speaker Studio</p> | ||
| <h2 class="modal__title" id="provider-picker-title">Choose a TTS provider</h2> | ||
| <button type="button" class="button button--ghost button--small" data-role="provider-picker-close" aria-label="Close provider picker">Close</button> | ||
| </header> | ||
| <div class="modal__body"> | ||
| <p class="hint">Select which text-to-speech provider this speaker will use. You can create separate speakers per provider.</p> | ||
| <div class="field field--choices" data-role="provider-picker-options"></div> | ||
| </div> | ||
| <footer class="modal__footer"> | ||
| <button type="button" class="button button--ghost" data-role="provider-picker-cancel">Cancel</button> | ||
| <button type="button" class="button" data-role="provider-picker-confirm">Continue</button> | ||
| </footer> | ||
| </div> | ||
| </div> | ||
| </section> | ||
| {% endblock %} | ||
| {% block scripts %} | ||
| <script id="voice-mixer-options" type="application/json">{{ options | tojson | safe }}</script> | ||
| <script> | ||
| window.ABOGEN_VOICE_MIXER_DATA = JSON.parse(document.getElementById('voice-mixer-options').textContent); | ||
| </script> | ||
| <script type="module" src="{{ url_for('static', filename='voices.js') }}"></script> | ||
| {% endblock %} |
+5
-0
@@ -22,2 +22,3 @@ # System files | ||
| .env/ | ||
| .env | ||
| .venv/ | ||
@@ -34,4 +35,8 @@ test/ | ||
| *config.json | ||
| config/ | ||
| storage/ | ||
| build/ | ||
| dist/ | ||
| .old/ | ||
| test_assets/ | ||
| dev_notes/ |
+241
-71
| Metadata-Version: 2.4 | ||
| Name: abogen | ||
| Version: 1.2.5 | ||
| Version: 1.3.0 | ||
| Summary: Generate audiobooks from EPUBs, PDFs and text with synchronized captions. | ||
@@ -27,5 +27,11 @@ Project-URL: Homepage, https://github.com/denizsafak/abogen | ||
| Requires-Dist: ebooklib>=0.19 | ||
| Requires-Dist: flask>=3.0.3 | ||
| Requires-Dist: gpustat>=1.1.1 | ||
| Requires-Dist: httpx>=0.27.0 | ||
| Requires-Dist: kokoro>=0.9.4 | ||
| Requires-Dist: markdown>=3.9 | ||
| Requires-Dist: misaki[zh]>=0.9.4 | ||
| Requires-Dist: mutagen>=1.47.0 | ||
| Requires-Dist: num2words>=0.5.13 | ||
| Requires-Dist: numpy>=1.24.0 | ||
| Requires-Dist: pip | ||
@@ -35,6 +41,8 @@ Requires-Dist: platformdirs>=4.3.7 | ||
| Requires-Dist: pymupdf>=1.25.5 | ||
| Requires-Dist: pyqt6>=6.10.0 | ||
| Requires-Dist: pyqt6>=6.5.0 | ||
| Requires-Dist: python-dotenv>=1.0.1 | ||
| Requires-Dist: soundfile>=0.13.1 | ||
| Requires-Dist: spacy>=3.8.7 | ||
| Requires-Dist: spacy<4.0,>=3.8.7 | ||
| Requires-Dist: static-ffmpeg>=2.13 | ||
| Requires-Dist: supertonic>=0.1.0 | ||
| Provides-Extra: cuda | ||
@@ -46,2 +54,5 @@ Requires-Dist: torch; extra == 'cuda' | ||
| Requires-Dist: torch; extra == 'cuda130' | ||
| Provides-Extra: dev | ||
| Requires-Dist: build; extra == 'dev' | ||
| Requires-Dist: pytest; extra == 'dev' | ||
| Provides-Extra: rocm | ||
@@ -58,2 +69,3 @@ Requires-Dist: pytorch-triton-rocm; extra == 'rocm' | ||
| [](https://github.com/denizsafak/abogen/releases/latest) | ||
| [&color=blue)](https://pypi.org/project/abogen/) | ||
| [](https://github.com/psf/black) | ||
@@ -72,3 +84,3 @@ [](https://opensource.org/licenses/MIT) | ||
| > This demo was generated in just 5 seconds, producing ∼1 minute of audio with perfectly synced subtitles. To create a similar video, see [the demo guide](https://github.com/denizsafak/abogen/tree/main/demo). | ||
| > This demo was generated in just 5 seconds, producing ∼1 minute of audio with perfectly synced subtitles. To create a similar video, see [the demo guide](https://github.com/denizsafak/abogen/tree/main/demo). | ||
@@ -95,11 +107,11 @@ ## `How to install?` <a href="https://pypi.org/project/abogen/" target="_blank"><img src="https://img.shields.io/pypi/pyversions/abogen" alt="Abogen Compatible PyPi Python Versions" align="right" style="margin-top:6px;"></a> | ||
| # For NVIDIA GPUs (CUDA 12.8) - Recommended | ||
| uv tool install --python 3.12 abogen[cuda] | ||
| uv tool install --python 3.12 abogen[cuda] --extra-index-url https://download.pytorch.org/whl/cu128 --index-strategy unsafe-best-match | ||
| # For NVIDIA GPUs (CUDA 12.6) - Older drivers | ||
| uv tool install --python 3.12 abogen[cuda126] | ||
| uv tool install --python 3.12 abogen[cuda126] --extra-index-url https://download.pytorch.org/whl/cu126 --index-strategy unsafe-best-match | ||
| # For NVIDIA GPUs (CUDA 13.0) - Newer drivers | ||
| uv tool install --python 3.12 abogen[cuda130] | ||
| uv tool install --python 3.12 abogen[cuda130] --extra-index-url https://download.pytorch.org/whl/cu130 --index-strategy unsafe-best-match | ||
| # For AMD GPUs or without GPU (CPU) - ROCm is not available on Windows. Use Linux if you have AMD GPU | ||
| # For AMD GPUs or without GPU - If you have AMD GPU, you need to use Linux for GPU acceleration, because ROCm is not available on Windows. | ||
| uv tool install --python 3.12 abogen | ||
@@ -138,4 +150,7 @@ ``` | ||
| # Install abogen (Automatically handles Silicon Mac/MPS support) | ||
| uv tool install --python 3.12 abogen | ||
| # For Silicon Mac (M1, M2 etc.) | ||
| uv tool install --python 3.13 abogen --with "kokoro @ git+https://github.com/hexgrad/kokoro.git,numpy<2" | ||
| # For Intel Mac | ||
| uv tool install --python 3.12 abogen --with "kokoro @ git+https://github.com/hexgrad/kokoro.git,numpy<2" | ||
| ``` | ||
@@ -175,7 +190,7 @@ | ||
| # For NVIDIA GPUs or without GPU (CPU) - No need to include [CUDA] in here. | ||
| # For NVIDIA GPUs or without GPU - No need to include [cuda] in here. | ||
| uv tool install --python 3.12 abogen | ||
| # For AMD GPUs (ROCm 6.4) | ||
| uv tool install --python 3.12 abogen[rocm] | ||
| uv tool install --python 3.12 abogen[rocm] --extra-index-url https://download.pytorch.org/whl/nightly/rocm6.4 --index-strategy unsafe-best-match | ||
| ``` | ||
@@ -221,5 +236,21 @@ | ||
| ## Interfaces | ||
| Abogen offers **two interfaces**, but currently they have different feature sets. The **Web UI** contains newer features that are still being integrated into the desktop application. | ||
| | Command | Interface | Features | | ||
| |---------|-----------|----------| | ||
| | `abogen` | PyQt6 Desktop GUI | Stable core features | | ||
| | `abogen-web` | Flask Web UI | Core features + **Supertonic TTS**, **LLM Normalization**, **Audiobookshelf Integration** and more! | | ||
| > **Note:** The Web UI is under active development. We are working to integrate these new features into the PyQt desktop app. until then, the Web UI provides the most feature-rich experience. | ||
| > Special thanks to [@jeremiahsb](https://github.com/jeremiahsb) for making this possible! I was honestly surprised by his [massive contribution](https://github.com/denizsafak/abogen/pull/120) (>55,000 lines!) that brought the entire Web UI to life. | ||
| # 🖥️ Desktop Application (PyQt) | ||
| ## `How to run?` | ||
| You can simply run this command to start Abogen: | ||
| You can simply run this command to start Abogen Desktop GUI: | ||
@@ -229,2 +260,3 @@ ```bash | ||
| ``` | ||
| > [!TIP] | ||
@@ -246,3 +278,3 @@ > If you installed Abogen using the Windows installer `(WINDOWS_INSTALL.bat)`, It should have created a shortcut in the same folder, or your desktop. You can run it from there. If you lost the shortcut, Abogen is located in `python_embedded/Scripts/abogen.exe`. You can run it from there directly. | ||
| Here’s Abogen in action: in this demo, it processes ∼3,000 characters of text in just 11 seconds and turns it into 3 minutes and 28 seconds of audio, and I have a low-end **RTX 2060 Mobile laptop GPU**. Your results may vary depending on your hardware. | ||
| Here’s Abogen in action: in this demo, it processes ∼3,000 characters of text in just 11 seconds and turns it into 3 minutes and 28 seconds of audio, and I have a low-end **RTX 2060 Mobile laptop GPU**. Your results may vary depending on your hardware. | ||
@@ -320,2 +352,168 @@ ## `Configuration` | ||
| --- | ||
| # 🌐 Web Application (WebUI) | ||
| ## `How to run?` | ||
| Run this command to start the Web UI: | ||
| ```bash | ||
| abogen-web | ||
| ``` | ||
| Then open http://localhost:8808 and drag in your documents. Jobs run in the background worker and the browser updates automatically. | ||
| <img title="Abogen in action" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen-webui.png'> | ||
| ## `Using the web UI` | ||
| 1. Upload a document (drag & drop or use the upload button). | ||
| 2. Choose voice, language, speed, subtitle style, and output format. | ||
| 3. Click **Create job**. The job immediately appears in the queue. | ||
| 4. Watch progress and logs update live. Download audio/subtitle assets when complete. | ||
| 5. Cancel or delete jobs any time. Download logs for troubleshooting. | ||
| Multiple jobs can run sequentially; the worker processes them in order. | ||
| ## `Container image` | ||
| You can build a lightweight container image directly from the repository root: | ||
| ```bash | ||
| docker build -t abogen . | ||
| mkdir -p ~/abogen-data/uploads ~/abogen-data/outputs | ||
| docker run --rm \ | ||
| -p 8808:8808 \ | ||
| -v ~/abogen-data:/data \ | ||
| --name abogen \ | ||
| abogen | ||
| ``` | ||
| Browse to http://localhost:8808. Uploaded source files are stored in `/data/uploads` and rendered audio/subtitles appear in `/data/outputs`. | ||
| ### Container environment variables | ||
| | Variable | Default | Purpose | | ||
| |----------|---------|---------| | ||
| | `ABOGEN_HOST` | `0.0.0.0` | Bind address for the Flask server | | ||
| | `ABOGEN_PORT` | `8808` | HTTP port | | ||
| | `ABOGEN_DEBUG` | `false` | Enable Flask debug mode | | ||
| | `ABOGEN_UPLOAD_ROOT` | `/data/uploads` | Directory where uploaded files are stored | | ||
| | `ABOGEN_OUTPUT_ROOT` | `/data/outputs` | Directory for generated audio and subtitles (legacy alias of `ABOGEN_OUTPUT_DIR`) | | ||
| | `ABOGEN_OUTPUT_DIR` | `/data/outputs` | Container path for rendered audio/subtitles | | ||
| | `ABOGEN_SETTINGS_DIR` | `/config` | Container path for JSON settings/configuration | | ||
| | `ABOGEN_TEMP_DIR` | `/data/cache` (Docker) or platform cache dir | Container path for temporary audio working files | | ||
| | `ABOGEN_UID` | `1000` | UID that the container should run as (matches host user) | | ||
| | `ABOGEN_GID` | `1000` | GID that the container should run as (matches host group) | | ||
| | `ABOGEN_LLM_BASE_URL` | `""` | OpenAI-compatible endpoint used to seed the Settings → LLM panel | | ||
| | `ABOGEN_LLM_API_KEY` | `""` | API key passed to the endpoint above | | ||
| | `ABOGEN_LLM_MODEL` | `""` | Default model selected when you refresh the model list | | ||
| | `ABOGEN_LLM_TIMEOUT` | `30` | Timeout (seconds) for server-side LLM requests | | ||
| | `ABOGEN_LLM_CONTEXT_MODE` | `sentence` | Default prompt context window (`sentence`, `paragraph`, `document`) | | ||
| | `ABOGEN_LLM_PROMPT` | `""` | Custom normalization prompt template seeded into the UI | | ||
| Set any of these with `-e VAR=value` when starting the container. | ||
| To discover your local UID/GID for matching file permissions inside the container, run: | ||
| ```bash | ||
| id -u | ||
| id -g | ||
| ``` | ||
| Use those values to populate `ABOGEN_UID` / `ABOGEN_GID` in your `.env` file. | ||
| When running via Docker Compose, set `ABOGEN_SETTINGS_DIR`, | ||
| `ABOGEN_OUTPUT_DIR`, and `ABOGEN_TEMP_DIR` in your `.env` file to the host | ||
| directories you want mounted into the container. Compose maps them to | ||
| `/config`, `/data/outputs`, and `/data/cache` respectively while exporting | ||
| those in-container paths to the application. Non-audio caches (e.g., Hugging | ||
| Face downloads) stick to the container's internal cache under `/tmp/abogen-home/.cache` | ||
| by default, so only conversion scratch data touches the mounted `ABOGEN_TEMP_DIR`. | ||
| Ensure each host directory exists and is writable by the UID/GID you configure | ||
| before starting the stack. | ||
| ### Docker Compose (GPU by default) | ||
| The repo includes `docker-compose.yaml`, which targets GPU hosts out of the box. Install the NVIDIA Container Toolkit and run: | ||
| ```bash | ||
| docker compose up -d --build | ||
| ``` | ||
| Key build/runtime knobs: | ||
| - `TORCH_VERSION` – pin a specific PyTorch release that matches your driver (leave blank for the latest on the configured index). | ||
| - `TORCH_INDEX_URL` – swap out the PyTorch download index when targeting a different CUDA build. | ||
| - `ABOGEN_DATA` – host path that stores uploads/outputs (defaults to `./data`). | ||
| CPU-only deployment: comment out the `deploy.resources.reservations.devices` block (and the optional `runtime: nvidia` line) inside the compose file. Compose will then run without requesting a GPU. If you prefer the classic CLI: | ||
| ```bash | ||
| docker build -f abogen/Dockerfile -t abogen-gpu . | ||
| docker run --rm \ | ||
| --gpus all \ | ||
| -p 8808:8808 \ | ||
| -v ~/abogen-data:/data \ | ||
| abogen-gpu | ||
| ``` | ||
| ## `LLM-assisted text normalization` | ||
| Abogen can hand tricky apostrophes and contractions to an OpenAI-compatible large language model. Configure it from **Settings → LLM**: | ||
| 1. Enter the base URL for your endpoint (Ollama, OpenAI proxy, etc.) and an API key if required. Use the server root (for Ollama: `http://localhost:11434`)—Abogen appends `/v1/...` automatically, but it also accepts inputs that already end in `/v1`. | ||
| 2. Click **Refresh models** to load the catalog, pick a default model, and adjust the timeout or prompt template. | ||
| 3. Use the preview box to test the prompt, then save the settings. The Normalization panel can synthesize a short audio preview with the current configuration. | ||
| When you are running inside Docker or a CI pipeline, seed the form automatically with `ABOGEN_LLM_*` variables in your `.env` file. The `.env.example` file includes sample values for a local Ollama server. | ||
| ## `Audiobookshelf integration` | ||
| Abogen can push finished audiobooks directly into Audiobookshelf. Configure this under **Settings → Integrations → Audiobookshelf** by providing: | ||
| - **Base URL** – the HTTPS origin (and optional path prefix) where your Audiobookshelf server is reachable, for example `https://abs.example.com` or `https://media.example.com/abs`. Do **not** append `/api`. | ||
| - **Library ID** – the identifier of the target Audiobookshelf library (copy it from the library’s settings page in ABS). | ||
| - **Folder (name or ID)** – the destination folder inside that library. Enter the folder name exactly as it appears in Audiobookshelf (Abogen resolves it to the correct ID automatically), paste the raw `folderId`, or click **Browse folders** to fetch the available folders and populate the field. | ||
| - **API token** – a personal access token generated in Audiobookshelf under *Account → API tokens*. | ||
| You can enable automatic uploads for future jobs or trigger individual uploads from the queue once the connection succeeds. | ||
| ### Reverse proxy checklist (Nginx Proxy Manager) | ||
| When Audiobookshelf sits behind Nginx Proxy Manager (NPM), make sure the API paths and headers reach the backend untouched: | ||
| 1. Create a **Proxy Host** that points to your ABS container or host (default forward port `13378`). | ||
| 2. Under the **SSL** tab, enable your certificate and tick **Force SSL** if you want HTTPS only. | ||
| 3. In the **Advanced** tab, append the snippet below so bearer tokens, client IPs, and large uploads survive the proxy hop: | ||
| ```nginx | ||
| proxy_set_header Host $host; | ||
| proxy_set_header X-Real-IP $remote_addr; | ||
| proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | ||
| proxy_set_header X-Forwarded-Proto $scheme; | ||
| proxy_set_header X-Forwarded-Host $host; | ||
| proxy_set_header X-Forwarded-Port $server_port; | ||
| proxy_set_header Authorization $http_authorization; | ||
| client_max_body_size 5g; | ||
| proxy_read_timeout 300s; | ||
| proxy_connect_timeout 300s; | ||
| ``` | ||
| 4. Disable **Block Common Exploits** (it strips Authorization headers in some NPM builds). | ||
| 5. Enable **Websockets Support** on the main proxy screen (Audiobookshelf uses it for the web UI, and it keeps the reverse proxy configuration consistent). | ||
| 6. If you publish Audiobookshelf under a path prefix (for example `/abs`), add a **Custom Location** with `Location: /abs/` and set the **Forward Path** to `/`. That rewrite strips the `/abs` prefix before traffic reaches Audiobookshelf so `/abs/api/...` on the internet becomes `/api/...` on the backend. Use the same prefixed URL in Abogen’s “Base URL” field. | ||
| After saving the proxy host, test the API from the machine running Abogen: | ||
| ```bash | ||
| curl -i "https://abs.example.com/api/libraries" \ | ||
| -H "Authorization: Bearer YOUR_API_TOKEN" | ||
| ``` | ||
| If you still receive `Cannot GET /api/...`, the proxy is rewriting paths. Double-check the **Custom Locations** table (the `Forward Path` column should be empty for `/abs/`) and review the NPM access/error logs while issuing the curl request to confirm the backend sees the full `/api/libraries` URL. | ||
| A JSON response confirming the libraries list means the proxy is routing API calls correctly. You can then use **Browse folders** to confirm the library contents, run **Test connection** in Abogen’s settings (it verifies the library and resolves the folder), and use the “Send to Audiobookshelf” button on completed jobs. | ||
| ## `JSON endpoints` | ||
| Need machine-readable status updates? The dashboard calls a small set of helper endpoints you can reuse: | ||
| - `GET /api/jobs/<id>` returns job metadata, progress, and log lines in JSON. | ||
| - `GET /partials/jobs` renders the live job list as HTML (htmx uses this for polling). | ||
| - `GET /partials/jobs/<id>/logs` renders just the log window. | ||
| More automation hooks are planned; contributions are very welcome if you need additional routes. | ||
| --- | ||
| # Core Features (Available in Both) | ||
| ## `About Chapter Markers` | ||
@@ -394,2 +592,5 @@ When you process ePUB, PDF or markdown files, Abogen converts them into text files stored in your cache directory. When you click "Edit," you're actually modifying these converted text files. In these text files, you'll notice tags that look like this: | ||
| --- | ||
| # Guides & Troubleshooting | ||
| ## `MPV Config` | ||
@@ -413,50 +614,2 @@ I highly recommend using [MPV](https://mpv.io/installation/) to play your audio files, as it supports displaying subtitles even without a video track. Here's my `mpv.conf`: | ||
| ## `Docker Guide` | ||
| If you want to run Abogen in a Docker container: | ||
| 1) [Download the repository](https://github.com/denizsafak/abogen/archive/refs/heads/main.zip) and extract, or clone it using git. | ||
| 2) Go to `abogen` folder. You should see `Dockerfile` there. | ||
| 3) Open your termminal in that directory and run the following commands: | ||
| ```bash | ||
| # Build the Docker image: | ||
| docker build --progress plain -t abogen . | ||
| # Note that building the image may take a while. | ||
| # After building is complete, run the Docker container: | ||
| # Windows | ||
| docker run --name abogen -v %cd%:/shared -p 5800:5800 -p 5900:5900 --gpus all abogen | ||
| # Linux | ||
| docker run --name abogen -v $(pwd):/shared -p 5800:5800 -p 5900:5900 --gpus all abogen | ||
| # MacOS | ||
| docker run --name abogen -v $(pwd):/shared -p 5800:5800 -p 5900:5900 abogen | ||
| # We expose port 5800 for use by a web browser, 5900 if you want to connect with a VNC client. | ||
| ``` | ||
| Abogen launches automatically inside the container. | ||
| - You can access it via a web browser at [http://localhost:5800](http://localhost:5800) or connect to it using a VNC client at `localhost:5900`. | ||
| - You can use `/shared` directory to share files between your host and the container. | ||
| - For later use, start it with `docker start abogen` and stop it with `docker stop abogen`. | ||
| - Pass in `-e WEB_AUDIO="1"` for `docker run` to enable audio. | ||
| Known issues: | ||
| - Audio preview is not working inside container (ALSA error) if using a VNC client. | ||
| - `Open cache directory` and `Open configuration directory` options in settings not working. (Tried pcmanfm, did not work with Abogen). | ||
| > Special thanks to [@geo38](https://www.reddit.com/user/geo38/) from Reddit, who provided the Dockerfile and instructions in [this comment](https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/). | ||
| ## `🌐 Web Application` | ||
| A web-based version of Abogen has been developed by [@jeremiahsb](https://github.com/jeremiahsb). | ||
| **Access the repository here:** [jeremiahsb/abogen](https://github.com/jeremiahsb/abogen) | ||
| > [!NOTE] | ||
| > I intend to merge this implementation into the main repository in the future once existing conflicts are resolved. Until then, please be aware that the web version is maintained independently and may not always be in sync with the latest updates in this repository. | ||
| > Special thanks to [@jeremiahsb](https://github.com/jeremiahsb) for implementing the web app! | ||
| ## `Similar Projects` | ||
@@ -526,7 +679,8 @@ Abogen is a standalone project, but it is inspired by and shares some similarities with other projects. Here are a few: | ||
| > ```bash | ||
| > # First uninstall Abogen | ||
| > uv tool uninstall abogen | ||
| > # First, try CUDA 13.0 for newer drivers | ||
| > uv tool install --python 3.12 abogen[cuda130] | ||
| > # If that doesn't work, try CUDA 12.6 for older drivers | ||
| > uv tool install --python 3.12 abogen[cuda126] | ||
| > # Try CUDA 12.6 for older drivers | ||
| > uv tool install --python 3.12 abogen[cuda126] --extra-index-url https://download.pytorch.org/whl/cu126 --index-strategy unsafe-best-match | ||
| > # If that doesn't work, try CUDA 13.0 for newer drivers | ||
| > uv tool install --python 3.12 abogen[cuda130] --extra-index-url https://download.pytorch.org/whl/cu130 --index-strategy unsafe-best-match | ||
| > ``` | ||
@@ -566,3 +720,3 @@ | ||
| > ```bash | ||
| > pip install torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0 | ||
| > pip install torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0 --index-url https://download.pytorch.org/whl/cu128 | ||
| > ``` | ||
@@ -609,10 +763,26 @@ | ||
| # Go to the directory where you extracted the repository and run: | ||
| pip install -e . # Installs the package in editable mode | ||
| pip install build # Install the build package | ||
| python -m build # Builds the package in dist folder (optional) | ||
| abogen # Opens the GUI | ||
| pip install -e .[dev] # Installs the package in editable mode with build dependencies | ||
| python -m build # Builds the package in dist folder (optional) | ||
| abogen # Opens the GUI | ||
| ``` | ||
| > Make sure you are using Python 3.10 to 3.12. You need to create a virtual environment if needed. | ||
| <details> | ||
| <summary><b>Alternative: Using uv (click to expand)</b></summary> | ||
| ```bash | ||
| # Go to the directory where you extracted the repository and run: | ||
| uv venv --python 3.12 # Creates a virtual environment with Python 3.12 | ||
| # After activating the virtual environment, run: | ||
| uv pip install -e . # Installs the package in editable mode | ||
| uv build # Builds the package in dist folder (optional) | ||
| abogen # Opens the GUI | ||
| ``` | ||
| </details> | ||
| Feel free to explore the code and make any changes you like. | ||
| ## `Credits` | ||
| - Web UI implementation by [@jeremiahsb](https://github.com/jeremiahsb) | ||
| - Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible. | ||
@@ -619,0 +789,0 @@ - Thanks to the [spaCy](https://spacy.io/) project for its sentence-segmentation tools, which help Abogen produce cleaner, more natural sentence segmentation. |
+77
-38
@@ -8,37 +8,59 @@ [build-system] | ||
| description = "Generate audiobooks from EPUBs, PDFs and text with synchronized captions." | ||
| authors = [ | ||
| { name="Deniz Şafak", email="denizsafak98@gmail.com" } | ||
| ] | ||
| authors = [{ name = "Deniz Şafak", email = "denizsafak98@gmail.com" }] | ||
| readme = "README.md" | ||
| license = "MIT" | ||
| requires-python = ">=3.10, <3.13" | ||
| keywords = ["audiobook", "epub", "pdf", "text-to-speech", "subtitle", "tts", "kokoro", "accessibility", "book-converter", "voice-synthesis", "multilingual", "chapter-management", "subtitles", "content-creation", "media-generation"] | ||
| keywords = [ | ||
| "audiobook", | ||
| "epub", | ||
| "pdf", | ||
| "text-to-speech", | ||
| "subtitle", | ||
| "tts", | ||
| "kokoro", | ||
| "accessibility", | ||
| "book-converter", | ||
| "voice-synthesis", | ||
| "multilingual", | ||
| "chapter-management", | ||
| "subtitles", | ||
| "content-creation", | ||
| "media-generation", | ||
| ] | ||
| dependencies = [ | ||
| "pip", | ||
| "PyQt6>=6.10.0", | ||
| "kokoro>=0.9.4", | ||
| "misaki[zh]>=0.9.4", | ||
| "ebooklib>=0.19", | ||
| "beautifulsoup4>=4.13.4", | ||
| "PyMuPDF>=1.25.5", | ||
| "platformdirs>=4.3.7", | ||
| "soundfile>=0.13.1", | ||
| "pygame>=2.6.1", | ||
| "charset_normalizer>=3.4.1", | ||
| "chardet>=5.2.0", | ||
| "static_ffmpeg>=2.13", | ||
| "Markdown>=3.9", | ||
| "spacy>=3.8.7" | ||
| "pip", | ||
| "kokoro>=0.9.4", | ||
| "misaki[zh]>=0.9.4", | ||
| "supertonic>=0.1.0", | ||
| "ebooklib>=0.19", | ||
| "beautifulsoup4>=4.13.4", | ||
| "spacy>=3.8.7,<4.0", | ||
| "PyMuPDF>=1.25.5", | ||
| "platformdirs>=4.3.7", | ||
| "soundfile>=0.13.1", | ||
| "mutagen>=1.47.0", | ||
| "pygame>=2.6.1", | ||
| "charset_normalizer>=3.4.1", | ||
| "chardet>=5.2.0", | ||
| "python-dotenv>=1.0.1", | ||
| "static_ffmpeg>=2.13", | ||
| "Markdown>=3.9", | ||
| "Flask>=3.0.3", | ||
| "numpy>=1.24.0", | ||
| "gpustat>=1.1.1", | ||
| "num2words>=0.5.13", | ||
| "httpx>=0.27.0", | ||
| "PyQt6>=6.5.0", | ||
| ] | ||
| classifiers = [ | ||
| "Intended Audience :: End Users/Desktop", | ||
| "Topic :: Multimedia :: Sound/Audio :: Conversion", | ||
| "Topic :: Multimedia :: Sound/Audio :: Sound Synthesis", | ||
| "Topic :: Multimedia :: Sound/Audio :: Speech", | ||
| "Topic :: Text Processing", | ||
| "Programming Language :: Python :: 3.10", | ||
| "Programming Language :: Python :: 3.11", | ||
| "Programming Language :: Python :: 3.12", | ||
| "Operating System :: OS Independent" | ||
| "Intended Audience :: End Users/Desktop", | ||
| "Topic :: Multimedia :: Sound/Audio :: Conversion", | ||
| "Topic :: Multimedia :: Sound/Audio :: Sound Synthesis", | ||
| "Topic :: Multimedia :: Sound/Audio :: Speech", | ||
| "Topic :: Text Processing", | ||
| "Programming Language :: Python :: 3.10", | ||
| "Programming Language :: Python :: 3.11", | ||
| "Programming Language :: Python :: 3.12", | ||
| "Operating System :: OS Independent", | ||
| ] | ||
@@ -54,15 +76,21 @@ | ||
| [tool.hatch.metadata] | ||
| allow-direct-references = true | ||
| [project.gui-scripts] | ||
| abogen = "abogen.main:main" | ||
| abogen = "abogen.pyqt.main:main" | ||
| [project.scripts] | ||
| abogen-cli = "abogen.main:main" | ||
| abogen-cli = "abogen.webui.app:main" | ||
| abogen-web = "abogen.webui.app:main" | ||
| abogen-pyqt = "abogen.pyqt.main:main" | ||
| [tool.hatch.build.targets.sdist] | ||
| exclude = [ | ||
| "/.github", | ||
| "/demo", | ||
| "/abogen/resources", | ||
| "/abogen/assets/create_shortcuts.bat", | ||
| "WINDOWS_INSTALL.bat", | ||
| "/.github", | ||
| "/demo", | ||
| "/abogen/resources", | ||
| "/abogen/assets/create_shortcuts.bat", | ||
| "WINDOWS_INSTALL.bat", | ||
| ] | ||
@@ -73,2 +101,5 @@ | ||
| [tool.hatch.build] | ||
| include = ["abogen/webui/templates/**", "abogen/webui/static/**"] | ||
| [tool.hatch.version] | ||
@@ -78,2 +109,8 @@ path = "abogen/VERSION" | ||
| [tool.pytest.ini_options] | ||
| filterwarnings = [ | ||
| "ignore:builtin type .* has no __module__ attribute:DeprecationWarning", | ||
| "ignore:Importing 'parser.split_arg_string' is deprecated:DeprecationWarning", | ||
| ] | ||
| # --- OPTIONAL DEPENDENCIES --- | ||
@@ -90,2 +127,4 @@ | ||
| rocm = ["torch", "pytorch-triton-rocm"] | ||
| # Development dependencies # uv tool install abogen[dev] | ||
| dev = ["build", "pytest"] | ||
@@ -97,3 +136,3 @@ # --- KOKORO CONFIGURATION (for macOS) --- | ||
| { git = "https://github.com/hexgrad/kokoro.git", marker = "sys_platform == 'darwin'" }, | ||
| { index = "pypi", marker = "sys_platform != 'darwin'" } | ||
| { index = "pypi", marker = "sys_platform != 'darwin'" }, | ||
| ] | ||
@@ -114,3 +153,3 @@ | ||
| # CUDA 12.8 (NVIDIA) | ||
| { index = "pytorch-cuda-128", marker = "extra == 'cuda' and extra != 'rocm' and extra != 'cuda130' and extra != 'cuda126'" } | ||
| { index = "pytorch-cuda-128", marker = "extra == 'cuda' and extra != 'rocm' and extra != 'cuda130' and extra != 'cuda126'" }, | ||
| ] | ||
@@ -121,3 +160,3 @@ | ||
| pytorch-triton-rocm = [ | ||
| { index = "pytorch-rocm-64-nightly", marker = "extra == 'rocm'" } | ||
| { index = "pytorch-rocm-64-nightly", marker = "extra == 'rocm'" }, | ||
| ] | ||
@@ -124,0 +163,0 @@ |
+227
-68
@@ -7,2 +7,3 @@ # abogen <img width="40px" title="abogen icon" src="https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/abogen/assets/icon.ico" align="right" style="padding-left: 10px; padding-top:5px;"> | ||
| [](https://github.com/denizsafak/abogen/releases/latest) | ||
| [&color=blue)](https://pypi.org/project/abogen/) | ||
| [](https://github.com/psf/black) | ||
@@ -21,3 +22,3 @@ [](https://opensource.org/licenses/MIT) | ||
| > This demo was generated in just 5 seconds, producing ∼1 minute of audio with perfectly synced subtitles. To create a similar video, see [the demo guide](https://github.com/denizsafak/abogen/tree/main/demo). | ||
| > This demo was generated in just 5 seconds, producing ∼1 minute of audio with perfectly synced subtitles. To create a similar video, see [the demo guide](https://github.com/denizsafak/abogen/tree/main/demo). | ||
@@ -44,11 +45,11 @@ ## `How to install?` <a href="https://pypi.org/project/abogen/" target="_blank"><img src="https://img.shields.io/pypi/pyversions/abogen" alt="Abogen Compatible PyPi Python Versions" align="right" style="margin-top:6px;"></a> | ||
| # For NVIDIA GPUs (CUDA 12.8) - Recommended | ||
| uv tool install --python 3.12 abogen[cuda] | ||
| uv tool install --python 3.12 abogen[cuda] --extra-index-url https://download.pytorch.org/whl/cu128 --index-strategy unsafe-best-match | ||
| # For NVIDIA GPUs (CUDA 12.6) - Older drivers | ||
| uv tool install --python 3.12 abogen[cuda126] | ||
| uv tool install --python 3.12 abogen[cuda126] --extra-index-url https://download.pytorch.org/whl/cu126 --index-strategy unsafe-best-match | ||
| # For NVIDIA GPUs (CUDA 13.0) - Newer drivers | ||
| uv tool install --python 3.12 abogen[cuda130] | ||
| uv tool install --python 3.12 abogen[cuda130] --extra-index-url https://download.pytorch.org/whl/cu130 --index-strategy unsafe-best-match | ||
| # For AMD GPUs or without GPU (CPU) - ROCm is not available on Windows. Use Linux if you have AMD GPU | ||
| # For AMD GPUs or without GPU - If you have AMD GPU, you need to use Linux for GPU acceleration, because ROCm is not available on Windows. | ||
| uv tool install --python 3.12 abogen | ||
@@ -87,4 +88,7 @@ ``` | ||
| # Install abogen (Automatically handles Silicon Mac/MPS support) | ||
| uv tool install --python 3.12 abogen | ||
| # For Silicon Mac (M1, M2 etc.) | ||
| uv tool install --python 3.13 abogen --with "kokoro @ git+https://github.com/hexgrad/kokoro.git,numpy<2" | ||
| # For Intel Mac | ||
| uv tool install --python 3.12 abogen --with "kokoro @ git+https://github.com/hexgrad/kokoro.git,numpy<2" | ||
| ``` | ||
@@ -124,7 +128,7 @@ | ||
| # For NVIDIA GPUs or without GPU (CPU) - No need to include [CUDA] in here. | ||
| # For NVIDIA GPUs or without GPU - No need to include [cuda] in here. | ||
| uv tool install --python 3.12 abogen | ||
| # For AMD GPUs (ROCm 6.4) | ||
| uv tool install --python 3.12 abogen[rocm] | ||
| uv tool install --python 3.12 abogen[rocm] --extra-index-url https://download.pytorch.org/whl/nightly/rocm6.4 --index-strategy unsafe-best-match | ||
| ``` | ||
@@ -170,5 +174,21 @@ | ||
| ## Interfaces | ||
| Abogen offers **two interfaces**, but currently they have different feature sets. The **Web UI** contains newer features that are still being integrated into the desktop application. | ||
| | Command | Interface | Features | | ||
| |---------|-----------|----------| | ||
| | `abogen` | PyQt6 Desktop GUI | Stable core features | | ||
| | `abogen-web` | Flask Web UI | Core features + **Supertonic TTS**, **LLM Normalization**, **Audiobookshelf Integration** and more! | | ||
| > **Note:** The Web UI is under active development. We are working to integrate these new features into the PyQt desktop app. until then, the Web UI provides the most feature-rich experience. | ||
| > Special thanks to [@jeremiahsb](https://github.com/jeremiahsb) for making this possible! I was honestly surprised by his [massive contribution](https://github.com/denizsafak/abogen/pull/120) (>55,000 lines!) that brought the entire Web UI to life. | ||
| # 🖥️ Desktop Application (PyQt) | ||
| ## `How to run?` | ||
| You can simply run this command to start Abogen: | ||
| You can simply run this command to start Abogen Desktop GUI: | ||
@@ -178,2 +198,3 @@ ```bash | ||
| ``` | ||
| > [!TIP] | ||
@@ -195,3 +216,3 @@ > If you installed Abogen using the Windows installer `(WINDOWS_INSTALL.bat)`, It should have created a shortcut in the same folder, or your desktop. You can run it from there. If you lost the shortcut, Abogen is located in `python_embedded/Scripts/abogen.exe`. You can run it from there directly. | ||
| Here’s Abogen in action: in this demo, it processes ∼3,000 characters of text in just 11 seconds and turns it into 3 minutes and 28 seconds of audio, and I have a low-end **RTX 2060 Mobile laptop GPU**. Your results may vary depending on your hardware. | ||
| Here’s Abogen in action: in this demo, it processes ∼3,000 characters of text in just 11 seconds and turns it into 3 minutes and 28 seconds of audio, and I have a low-end **RTX 2060 Mobile laptop GPU**. Your results may vary depending on your hardware. | ||
@@ -269,2 +290,168 @@ ## `Configuration` | ||
| --- | ||
| # 🌐 Web Application (WebUI) | ||
| ## `How to run?` | ||
| Run this command to start the Web UI: | ||
| ```bash | ||
| abogen-web | ||
| ``` | ||
| Then open http://localhost:8808 and drag in your documents. Jobs run in the background worker and the browser updates automatically. | ||
| <img title="Abogen in action" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen-webui.png'> | ||
| ## `Using the web UI` | ||
| 1. Upload a document (drag & drop or use the upload button). | ||
| 2. Choose voice, language, speed, subtitle style, and output format. | ||
| 3. Click **Create job**. The job immediately appears in the queue. | ||
| 4. Watch progress and logs update live. Download audio/subtitle assets when complete. | ||
| 5. Cancel or delete jobs any time. Download logs for troubleshooting. | ||
| Multiple jobs can run sequentially; the worker processes them in order. | ||
| ## `Container image` | ||
| You can build a lightweight container image directly from the repository root: | ||
| ```bash | ||
| docker build -t abogen . | ||
| mkdir -p ~/abogen-data/uploads ~/abogen-data/outputs | ||
| docker run --rm \ | ||
| -p 8808:8808 \ | ||
| -v ~/abogen-data:/data \ | ||
| --name abogen \ | ||
| abogen | ||
| ``` | ||
| Browse to http://localhost:8808. Uploaded source files are stored in `/data/uploads` and rendered audio/subtitles appear in `/data/outputs`. | ||
| ### Container environment variables | ||
| | Variable | Default | Purpose | | ||
| |----------|---------|---------| | ||
| | `ABOGEN_HOST` | `0.0.0.0` | Bind address for the Flask server | | ||
| | `ABOGEN_PORT` | `8808` | HTTP port | | ||
| | `ABOGEN_DEBUG` | `false` | Enable Flask debug mode | | ||
| | `ABOGEN_UPLOAD_ROOT` | `/data/uploads` | Directory where uploaded files are stored | | ||
| | `ABOGEN_OUTPUT_ROOT` | `/data/outputs` | Directory for generated audio and subtitles (legacy alias of `ABOGEN_OUTPUT_DIR`) | | ||
| | `ABOGEN_OUTPUT_DIR` | `/data/outputs` | Container path for rendered audio/subtitles | | ||
| | `ABOGEN_SETTINGS_DIR` | `/config` | Container path for JSON settings/configuration | | ||
| | `ABOGEN_TEMP_DIR` | `/data/cache` (Docker) or platform cache dir | Container path for temporary audio working files | | ||
| | `ABOGEN_UID` | `1000` | UID that the container should run as (matches host user) | | ||
| | `ABOGEN_GID` | `1000` | GID that the container should run as (matches host group) | | ||
| | `ABOGEN_LLM_BASE_URL` | `""` | OpenAI-compatible endpoint used to seed the Settings → LLM panel | | ||
| | `ABOGEN_LLM_API_KEY` | `""` | API key passed to the endpoint above | | ||
| | `ABOGEN_LLM_MODEL` | `""` | Default model selected when you refresh the model list | | ||
| | `ABOGEN_LLM_TIMEOUT` | `30` | Timeout (seconds) for server-side LLM requests | | ||
| | `ABOGEN_LLM_CONTEXT_MODE` | `sentence` | Default prompt context window (`sentence`, `paragraph`, `document`) | | ||
| | `ABOGEN_LLM_PROMPT` | `""` | Custom normalization prompt template seeded into the UI | | ||
| Set any of these with `-e VAR=value` when starting the container. | ||
| To discover your local UID/GID for matching file permissions inside the container, run: | ||
| ```bash | ||
| id -u | ||
| id -g | ||
| ``` | ||
| Use those values to populate `ABOGEN_UID` / `ABOGEN_GID` in your `.env` file. | ||
| When running via Docker Compose, set `ABOGEN_SETTINGS_DIR`, | ||
| `ABOGEN_OUTPUT_DIR`, and `ABOGEN_TEMP_DIR` in your `.env` file to the host | ||
| directories you want mounted into the container. Compose maps them to | ||
| `/config`, `/data/outputs`, and `/data/cache` respectively while exporting | ||
| those in-container paths to the application. Non-audio caches (e.g., Hugging | ||
| Face downloads) stick to the container's internal cache under `/tmp/abogen-home/.cache` | ||
| by default, so only conversion scratch data touches the mounted `ABOGEN_TEMP_DIR`. | ||
| Ensure each host directory exists and is writable by the UID/GID you configure | ||
| before starting the stack. | ||
| ### Docker Compose (GPU by default) | ||
| The repo includes `docker-compose.yaml`, which targets GPU hosts out of the box. Install the NVIDIA Container Toolkit and run: | ||
| ```bash | ||
| docker compose up -d --build | ||
| ``` | ||
| Key build/runtime knobs: | ||
| - `TORCH_VERSION` – pin a specific PyTorch release that matches your driver (leave blank for the latest on the configured index). | ||
| - `TORCH_INDEX_URL` – swap out the PyTorch download index when targeting a different CUDA build. | ||
| - `ABOGEN_DATA` – host path that stores uploads/outputs (defaults to `./data`). | ||
| CPU-only deployment: comment out the `deploy.resources.reservations.devices` block (and the optional `runtime: nvidia` line) inside the compose file. Compose will then run without requesting a GPU. If you prefer the classic CLI: | ||
| ```bash | ||
| docker build -f abogen/Dockerfile -t abogen-gpu . | ||
| docker run --rm \ | ||
| --gpus all \ | ||
| -p 8808:8808 \ | ||
| -v ~/abogen-data:/data \ | ||
| abogen-gpu | ||
| ``` | ||
| ## `LLM-assisted text normalization` | ||
| Abogen can hand tricky apostrophes and contractions to an OpenAI-compatible large language model. Configure it from **Settings → LLM**: | ||
| 1. Enter the base URL for your endpoint (Ollama, OpenAI proxy, etc.) and an API key if required. Use the server root (for Ollama: `http://localhost:11434`)—Abogen appends `/v1/...` automatically, but it also accepts inputs that already end in `/v1`. | ||
| 2. Click **Refresh models** to load the catalog, pick a default model, and adjust the timeout or prompt template. | ||
| 3. Use the preview box to test the prompt, then save the settings. The Normalization panel can synthesize a short audio preview with the current configuration. | ||
| When you are running inside Docker or a CI pipeline, seed the form automatically with `ABOGEN_LLM_*` variables in your `.env` file. The `.env.example` file includes sample values for a local Ollama server. | ||
| ## `Audiobookshelf integration` | ||
| Abogen can push finished audiobooks directly into Audiobookshelf. Configure this under **Settings → Integrations → Audiobookshelf** by providing: | ||
| - **Base URL** – the HTTPS origin (and optional path prefix) where your Audiobookshelf server is reachable, for example `https://abs.example.com` or `https://media.example.com/abs`. Do **not** append `/api`. | ||
| - **Library ID** – the identifier of the target Audiobookshelf library (copy it from the library’s settings page in ABS). | ||
| - **Folder (name or ID)** – the destination folder inside that library. Enter the folder name exactly as it appears in Audiobookshelf (Abogen resolves it to the correct ID automatically), paste the raw `folderId`, or click **Browse folders** to fetch the available folders and populate the field. | ||
| - **API token** – a personal access token generated in Audiobookshelf under *Account → API tokens*. | ||
| You can enable automatic uploads for future jobs or trigger individual uploads from the queue once the connection succeeds. | ||
| ### Reverse proxy checklist (Nginx Proxy Manager) | ||
| When Audiobookshelf sits behind Nginx Proxy Manager (NPM), make sure the API paths and headers reach the backend untouched: | ||
| 1. Create a **Proxy Host** that points to your ABS container or host (default forward port `13378`). | ||
| 2. Under the **SSL** tab, enable your certificate and tick **Force SSL** if you want HTTPS only. | ||
| 3. In the **Advanced** tab, append the snippet below so bearer tokens, client IPs, and large uploads survive the proxy hop: | ||
| ```nginx | ||
| proxy_set_header Host $host; | ||
| proxy_set_header X-Real-IP $remote_addr; | ||
| proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | ||
| proxy_set_header X-Forwarded-Proto $scheme; | ||
| proxy_set_header X-Forwarded-Host $host; | ||
| proxy_set_header X-Forwarded-Port $server_port; | ||
| proxy_set_header Authorization $http_authorization; | ||
| client_max_body_size 5g; | ||
| proxy_read_timeout 300s; | ||
| proxy_connect_timeout 300s; | ||
| ``` | ||
| 4. Disable **Block Common Exploits** (it strips Authorization headers in some NPM builds). | ||
| 5. Enable **Websockets Support** on the main proxy screen (Audiobookshelf uses it for the web UI, and it keeps the reverse proxy configuration consistent). | ||
| 6. If you publish Audiobookshelf under a path prefix (for example `/abs`), add a **Custom Location** with `Location: /abs/` and set the **Forward Path** to `/`. That rewrite strips the `/abs` prefix before traffic reaches Audiobookshelf so `/abs/api/...` on the internet becomes `/api/...` on the backend. Use the same prefixed URL in Abogen’s “Base URL” field. | ||
| After saving the proxy host, test the API from the machine running Abogen: | ||
| ```bash | ||
| curl -i "https://abs.example.com/api/libraries" \ | ||
| -H "Authorization: Bearer YOUR_API_TOKEN" | ||
| ``` | ||
| If you still receive `Cannot GET /api/...`, the proxy is rewriting paths. Double-check the **Custom Locations** table (the `Forward Path` column should be empty for `/abs/`) and review the NPM access/error logs while issuing the curl request to confirm the backend sees the full `/api/libraries` URL. | ||
| A JSON response confirming the libraries list means the proxy is routing API calls correctly. You can then use **Browse folders** to confirm the library contents, run **Test connection** in Abogen’s settings (it verifies the library and resolves the folder), and use the “Send to Audiobookshelf” button on completed jobs. | ||
| ## `JSON endpoints` | ||
| Need machine-readable status updates? The dashboard calls a small set of helper endpoints you can reuse: | ||
| - `GET /api/jobs/<id>` returns job metadata, progress, and log lines in JSON. | ||
| - `GET /partials/jobs` renders the live job list as HTML (htmx uses this for polling). | ||
| - `GET /partials/jobs/<id>/logs` renders just the log window. | ||
| More automation hooks are planned; contributions are very welcome if you need additional routes. | ||
| --- | ||
| # Core Features (Available in Both) | ||
| ## `About Chapter Markers` | ||
@@ -343,2 +530,5 @@ When you process ePUB, PDF or markdown files, Abogen converts them into text files stored in your cache directory. When you click "Edit," you're actually modifying these converted text files. In these text files, you'll notice tags that look like this: | ||
| --- | ||
| # Guides & Troubleshooting | ||
| ## `MPV Config` | ||
@@ -362,50 +552,2 @@ I highly recommend using [MPV](https://mpv.io/installation/) to play your audio files, as it supports displaying subtitles even without a video track. Here's my `mpv.conf`: | ||
| ## `Docker Guide` | ||
| If you want to run Abogen in a Docker container: | ||
| 1) [Download the repository](https://github.com/denizsafak/abogen/archive/refs/heads/main.zip) and extract, or clone it using git. | ||
| 2) Go to `abogen` folder. You should see `Dockerfile` there. | ||
| 3) Open your termminal in that directory and run the following commands: | ||
| ```bash | ||
| # Build the Docker image: | ||
| docker build --progress plain -t abogen . | ||
| # Note that building the image may take a while. | ||
| # After building is complete, run the Docker container: | ||
| # Windows | ||
| docker run --name abogen -v %cd%:/shared -p 5800:5800 -p 5900:5900 --gpus all abogen | ||
| # Linux | ||
| docker run --name abogen -v $(pwd):/shared -p 5800:5800 -p 5900:5900 --gpus all abogen | ||
| # MacOS | ||
| docker run --name abogen -v $(pwd):/shared -p 5800:5800 -p 5900:5900 abogen | ||
| # We expose port 5800 for use by a web browser, 5900 if you want to connect with a VNC client. | ||
| ``` | ||
| Abogen launches automatically inside the container. | ||
| - You can access it via a web browser at [http://localhost:5800](http://localhost:5800) or connect to it using a VNC client at `localhost:5900`. | ||
| - You can use `/shared` directory to share files between your host and the container. | ||
| - For later use, start it with `docker start abogen` and stop it with `docker stop abogen`. | ||
| - Pass in `-e WEB_AUDIO="1"` for `docker run` to enable audio. | ||
| Known issues: | ||
| - Audio preview is not working inside container (ALSA error) if using a VNC client. | ||
| - `Open cache directory` and `Open configuration directory` options in settings not working. (Tried pcmanfm, did not work with Abogen). | ||
| > Special thanks to [@geo38](https://www.reddit.com/user/geo38/) from Reddit, who provided the Dockerfile and instructions in [this comment](https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/). | ||
| ## `🌐 Web Application` | ||
| A web-based version of Abogen has been developed by [@jeremiahsb](https://github.com/jeremiahsb). | ||
| **Access the repository here:** [jeremiahsb/abogen](https://github.com/jeremiahsb/abogen) | ||
| > [!NOTE] | ||
| > I intend to merge this implementation into the main repository in the future once existing conflicts are resolved. Until then, please be aware that the web version is maintained independently and may not always be in sync with the latest updates in this repository. | ||
| > Special thanks to [@jeremiahsb](https://github.com/jeremiahsb) for implementing the web app! | ||
| ## `Similar Projects` | ||
@@ -475,7 +617,8 @@ Abogen is a standalone project, but it is inspired by and shares some similarities with other projects. Here are a few: | ||
| > ```bash | ||
| > # First uninstall Abogen | ||
| > uv tool uninstall abogen | ||
| > # First, try CUDA 13.0 for newer drivers | ||
| > uv tool install --python 3.12 abogen[cuda130] | ||
| > # If that doesn't work, try CUDA 12.6 for older drivers | ||
| > uv tool install --python 3.12 abogen[cuda126] | ||
| > # Try CUDA 12.6 for older drivers | ||
| > uv tool install --python 3.12 abogen[cuda126] --extra-index-url https://download.pytorch.org/whl/cu126 --index-strategy unsafe-best-match | ||
| > # If that doesn't work, try CUDA 13.0 for newer drivers | ||
| > uv tool install --python 3.12 abogen[cuda130] --extra-index-url https://download.pytorch.org/whl/cu130 --index-strategy unsafe-best-match | ||
| > ``` | ||
@@ -515,3 +658,3 @@ | ||
| > ```bash | ||
| > pip install torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0 | ||
| > pip install torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0 --index-url https://download.pytorch.org/whl/cu128 | ||
| > ``` | ||
@@ -558,10 +701,26 @@ | ||
| # Go to the directory where you extracted the repository and run: | ||
| pip install -e . # Installs the package in editable mode | ||
| pip install build # Install the build package | ||
| python -m build # Builds the package in dist folder (optional) | ||
| abogen # Opens the GUI | ||
| pip install -e .[dev] # Installs the package in editable mode with build dependencies | ||
| python -m build # Builds the package in dist folder (optional) | ||
| abogen # Opens the GUI | ||
| ``` | ||
| > Make sure you are using Python 3.10 to 3.12. You need to create a virtual environment if needed. | ||
| <details> | ||
| <summary><b>Alternative: Using uv (click to expand)</b></summary> | ||
| ```bash | ||
| # Go to the directory where you extracted the repository and run: | ||
| uv venv --python 3.12 # Creates a virtual environment with Python 3.12 | ||
| # After activating the virtual environment, run: | ||
| uv pip install -e . # Installs the package in editable mode | ||
| uv build # Builds the package in dist folder (optional) | ||
| abogen # Opens the GUI | ||
| ``` | ||
| </details> | ||
| Feel free to explore the code and make any changes you like. | ||
| ## `Credits` | ||
| - Web UI implementation by [@jeremiahsb](https://github.com/jeremiahsb) | ||
| - Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible. | ||
@@ -568,0 +727,0 @@ - Thanks to the [spaCy](https://spacy.io/) project for its sentence-segmentation tools, which help Abogen produce cleaner, more natural sentence segmentation. |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
| <?xml version="1.0" encoding="utf-8"?> | ||
| <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> | ||
| <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> | ||
| <svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" | ||
| viewBox="0 0 512 512" xml:space="preserve"> | ||
| <style type="text/css"> | ||
| .st0{fill:#808080;} | ||
| </style> | ||
| <g> | ||
| <path class="st0" d="M502.325,307.303l-39.006-30.805c-6.215-4.908-9.665-12.429-9.668-20.348c0-0.084,0-0.168,0-0.252 | ||
| c-0.014-7.936,3.44-15.478,9.667-20.396l39.007-30.806c8.933-7.055,12.093-19.185,7.737-29.701l-17.134-41.366 | ||
| c-4.356-10.516-15.167-16.86-26.472-15.532l-49.366,5.8c-7.881,0.926-15.656-1.966-21.258-7.586 | ||
| c-0.059-0.06-0.118-0.119-0.177-0.178c-5.597-5.602-8.476-13.36-7.552-21.225l5.799-49.363 | ||
| c1.328-11.305-5.015-22.116-15.531-26.472L337.004,1.939c-10.516-4.356-22.646-1.196-29.701,7.736l-30.805,39.005 | ||
| c-4.908,6.215-12.43,9.665-20.349,9.668c-0.084,0-0.168,0-0.252,0c-7.935,0.014-15.477-3.44-20.395-9.667L204.697,9.675 | ||
| c-7.055-8.933-19.185-12.092-29.702-7.736L133.63,19.072c-10.516,4.356-16.86,15.167-15.532,26.473l5.799,49.366 | ||
| c0.926,7.881-1.964,15.656-7.585,21.257c-0.059,0.059-0.118,0.118-0.178,0.178c-5.602,5.598-13.36,8.477-21.226,7.552 | ||
| l-49.363-5.799c-11.305-1.328-22.116,5.015-26.472,15.531L1.939,174.996c-4.356,10.516-1.196,22.646,7.736,29.701l39.006,30.805 | ||
| c6.215,4.908,9.665,12.429,9.668,20.348c0,0.084,0,0.167,0,0.251c0.014,7.935-3.44,15.477-9.667,20.395L9.675,307.303 | ||
| c-8.933,7.055-12.092,19.185-7.736,29.701l17.134,41.365c4.356,10.516,15.168,16.86,26.472,15.532l49.366-5.799 | ||
| c7.882-0.926,15.656,1.965,21.258,7.586c0.059,0.059,0.118,0.119,0.178,0.178c5.597,5.603,8.476,13.36,7.552,21.226l-5.799,49.364 | ||
| c-1.328,11.305,5.015,22.116,15.532,26.472l41.366,17.134c10.516,4.356,22.646,1.196,29.701-7.736l30.804-39.005 | ||
| c4.908-6.215,12.43-9.665,20.348-9.669c0.084,0,0.168,0,0.251,0c7.936-0.014,15.478,3.44,20.396,9.667l30.806,39.007 | ||
| c7.055,8.933,19.185,12.093,29.701,7.736l41.366-17.134c10.516-4.356,16.86-15.168,15.532-26.472l-5.8-49.366 | ||
| c-0.926-7.881,1.965-15.656,7.586-21.257c0.059-0.059,0.119-0.119,0.178-0.178c5.602-5.597,13.36-8.476,21.225-7.552l49.364,5.799 | ||
| c11.305,1.328,22.117-5.015,26.472-15.531l17.134-41.365C514.418,326.488,511.258,314.358,502.325,307.303z M281.292,329.698 | ||
| c-39.68,16.436-85.172-2.407-101.607-42.087c-16.436-39.68,2.407-85.171,42.087-101.608c39.68-16.436,85.172,2.407,101.608,42.088 | ||
| C339.815,267.771,320.972,313.262,281.292,329.698z"/> | ||
| </g> | ||
| </svg> |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
| from abogen.utils import get_version | ||
| # Program Information | ||
| PROGRAM_NAME = "abogen" | ||
| PROGRAM_DESCRIPTION = "Generate audiobooks from EPUBs, PDFs, text and subtitles with synchronized captions." | ||
| GITHUB_URL = "https://github.com/denizsafak/abogen" | ||
| VERSION = get_version() | ||
| # Settings | ||
| CHAPTER_OPTIONS_COUNTDOWN = 30 # Countdown seconds for chapter options | ||
| SUBTITLE_FORMATS = [ | ||
| ("srt", "SRT (standard)"), | ||
| ("ass_wide", "ASS (wide)"), | ||
| ("ass_narrow", "ASS (narrow)"), | ||
| ("ass_centered_wide", "ASS (centered wide)"), | ||
| ("ass_centered_narrow", "ASS (centered narrow)"), | ||
| ] | ||
| # Language description mapping | ||
| LANGUAGE_DESCRIPTIONS = { | ||
| "a": "American English", | ||
| "b": "British English", | ||
| "e": "Spanish", | ||
| "f": "French", | ||
| "h": "Hindi", | ||
| "i": "Italian", | ||
| "j": "Japanese", | ||
| "p": "Brazilian Portuguese", | ||
| "z": "Mandarin Chinese", | ||
| } | ||
| # Supported sound formats | ||
| SUPPORTED_SOUND_FORMATS = [ | ||
| "wav", | ||
| "mp3", | ||
| "opus", | ||
| "m4b", | ||
| "flac", | ||
| ] | ||
| # Supported subtitle formats | ||
| SUPPORTED_SUBTITLE_FORMATS = [ | ||
| "srt", | ||
| "ass", | ||
| "vtt", | ||
| ] | ||
| # Supported input formats | ||
| SUPPORTED_INPUT_FORMATS = [ | ||
| "epub", | ||
| "pdf", | ||
| "txt", | ||
| "srt", | ||
| "ass", | ||
| "vtt", | ||
| ] | ||
| # Supported languages for subtitle generation | ||
| # Currently, only 'a (American English)' and 'b (British English)' are supported for subtitle generation. | ||
| # This is because tokens that contain timestamps are not generated for other languages in the Kokoro pipeline. | ||
| # Please refer to: https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py | ||
| # 383 English processing (unchanged) | ||
| # 384 if self.lang_code in 'ab': | ||
| SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys()) | ||
| # Voice and sample text constants | ||
| VOICES_INTERNAL = [ | ||
| "af_alloy", | ||
| "af_aoede", | ||
| "af_bella", | ||
| "af_heart", | ||
| "af_jessica", | ||
| "af_kore", | ||
| "af_nicole", | ||
| "af_nova", | ||
| "af_river", | ||
| "af_sarah", | ||
| "af_sky", | ||
| "am_adam", | ||
| "am_echo", | ||
| "am_eric", | ||
| "am_fenrir", | ||
| "am_liam", | ||
| "am_michael", | ||
| "am_onyx", | ||
| "am_puck", | ||
| "am_santa", | ||
| "bf_alice", | ||
| "bf_emma", | ||
| "bf_isabella", | ||
| "bf_lily", | ||
| "bm_daniel", | ||
| "bm_fable", | ||
| "bm_george", | ||
| "bm_lewis", | ||
| "ef_dora", | ||
| "em_alex", | ||
| "em_santa", | ||
| "ff_siwis", | ||
| "hf_alpha", | ||
| "hf_beta", | ||
| "hm_omega", | ||
| "hm_psi", | ||
| "if_sara", | ||
| "im_nicola", | ||
| "jf_alpha", | ||
| "jf_gongitsune", | ||
| "jf_nezumi", | ||
| "jf_tebukuro", | ||
| "jm_kumo", | ||
| "pf_dora", | ||
| "pm_alex", | ||
| "pm_santa", | ||
| "zf_xiaobei", | ||
| "zf_xiaoni", | ||
| "zf_xiaoxiao", | ||
| "zf_xiaoyi", | ||
| "zm_yunjian", | ||
| "zm_yunxi", | ||
| "zm_yunxia", | ||
| "zm_yunyang", | ||
| ] | ||
| # Voice and sample text mapping | ||
| SAMPLE_VOICE_TEXTS = { | ||
| "a": "This is a sample of the selected voice.", | ||
| "b": "This is a sample of the selected voice.", | ||
| "e": "Este es una muestra de la voz seleccionada.", | ||
| "f": "Ceci est un exemple de la voix sélectionnée.", | ||
| "h": "यह चयनित आवाज़ का एक नमूना है।", | ||
| "i": "Questo è un esempio della voce selezionata.", | ||
| "j": "これは選択した声のサンプルです。", | ||
| "p": "Este é um exemplo da voz selecionada.", | ||
| "z": "这是所选语音的示例。", | ||
| } | ||
| COLORS = { | ||
| "BLUE": "#007dff", | ||
| "RED": "#c0392b", | ||
| "ORANGE": "#FFA500", | ||
| "GREEN": "#42ad4a", | ||
| "GREEN_BG": "rgba(66, 173, 73, 0.1)", | ||
| "GREEN_BG_HOVER": "rgba(66, 173, 73, 0.15)", | ||
| "GREEN_BORDER": "#42ad4a", | ||
| "BLUE_BG": "rgba(0, 102, 255, 0.05)", | ||
| "BLUE_BG_HOVER": "rgba(0, 102, 255, 0.1)", | ||
| "BLUE_BORDER_HOVER": "#6ab0de", | ||
| "YELLOW_BACKGROUND": "rgba(255, 221, 51, 0.40)", | ||
| "GREY_BACKGROUND": "rgba(128, 128, 128, 0.15)", | ||
| "GREY_BORDER": "#808080", | ||
| "RED_BACKGROUND": "rgba(232, 78, 60, 0.15)", | ||
| "RED_BG": "rgba(232, 78, 60, 0.10)", | ||
| "RED_BG_HOVER": "rgba(232, 78, 60, 0.15)", | ||
| # Theme palette colors | ||
| "DARK_BG": "#202326", | ||
| "DARK_BASE": "#141618", | ||
| "DARK_ALT": "#2c2f31", | ||
| "DARK_BUTTON": "#292c30", | ||
| "DARK_DISABLED": "#535353", | ||
| "LIGHT_BG": "#eff0f1", | ||
| "LIGHT_DISABLED": "#9a9999", | ||
| } |
Sorry, the diff of this file is too big to display
| # Special thanks to @geo38 from Reddit, who provided this Dockerfile: | ||
| # https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/ | ||
| # Use a docker base image that runs a window manager that can be viewed | ||
| # outside the image with a web browser or VNC client. | ||
| # https://github.com/jlesage/docker-baseimage-gui | ||
| FROM jlesage/baseimage-gui:debian-12-v4 | ||
| # Load stuff needed by abogen | ||
| RUN apt-get update \ | ||
| && apt-get install -y \ | ||
| python3 \ | ||
| python3-venv \ | ||
| python3-pip \ | ||
| python3-pyqt6 \ | ||
| espeak-ng \ | ||
| libxcb-cursor0 \ | ||
| libgl1 \ | ||
| && apt-get clean \ | ||
| && rm -rf /var/lib/apt/lists/* | ||
| # The base image will run /startapp.sh on launch. | ||
| # | ||
| # The base image runs that script as user 'app' uid=1000. That user | ||
| # does not exist in the base image but is created at run time. | ||
| # | ||
| # We need to install abogen in python venv (requirement of newer python3). | ||
| # | ||
| # The python venv has to be writable by the 'app' user as abogen dynamically | ||
| # installs python packages, so create the venv as that user | ||
| # | ||
| # We intend to share the /shared directory with the host using a bind volume | ||
| # in order to access any source files and the created files. | ||
| RUN echo '#!/bin/bash\nsource /app/venv/bin/activate\nexec abogen' > /startapp.sh \ | ||
| && chmod 555 /startapp.sh \ | ||
| && mkdir /app /shared \ | ||
| && chown 1000:1000 /app /shared \ | ||
| && chmod 755 /app /shared | ||
| USER 1000:1000 | ||
| RUN python3 -m venv /app/venv | ||
| RUN /bin/bash -c "source /app/venv/bin/activate && pip install abogen" | ||
| # Change back to user ROOT as the startup scripts inside base image needs it | ||
| USER root |
Sorry, the diff of this file is too big to display
| log_callback = None | ||
| show_warning_signal_emitter = None # Renamed for clarity | ||
| def set_log_callback(cb): | ||
| global log_callback | ||
| log_callback = cb | ||
| def set_show_warning_signal_emitter(emitter): # Renamed for clarity | ||
| global show_warning_signal_emitter | ||
| show_warning_signal_emitter = emitter | ||
| from huggingface_hub import hf_hub_download | ||
| def tracked_hf_hub_download(*args, **kwargs): | ||
| try: | ||
| local_kwargs = dict(kwargs) | ||
| local_kwargs["local_files_only"] = True | ||
| hf_hub_download(*args, **local_kwargs) | ||
| except Exception: | ||
| repo_id = kwargs.get("repo_id", "<unknown repo>") | ||
| filename = kwargs.get("filename", "<unknown file>") | ||
| if filename.endswith(".pth"): | ||
| msg = f"\nDownloading model '{filename}' from Hugging Face ({repo_id}). This may take a while. Please wait..." | ||
| if show_warning_signal_emitter: # Check if the emitter is set | ||
| show_warning_signal_emitter.emit( | ||
| "Downloading Model", | ||
| f"Downloading model '{filename}' from Hugging Face repository '{repo_id}'. This may take a while, please wait.", | ||
| ) | ||
| else: | ||
| msg = f"\nDownloading '{filename}' from Hugging Face ({repo_id}). Please wait..." | ||
| if log_callback: | ||
| print(msg, flush=True) | ||
| log_callback(msg) | ||
| else: | ||
| print(msg, flush=True) | ||
| return hf_hub_download(*args, **kwargs) | ||
| import huggingface_hub | ||
| huggingface_hub.hf_hub_download = tracked_hf_hub_download |
| import gpustat | ||
| def check(): | ||
| try: | ||
| stats = gpustat.new_query() | ||
| except Exception: | ||
| return False | ||
| nvidia_keywords = ["nvidia", "rtx", "gtx", "quadro", "tesla", "titan", "mx"] | ||
| for gpu in stats.gpus: | ||
| name = gpu.name.lower() | ||
| if any(keyword in name for keyword in nvidia_keywords): | ||
| return True | ||
| return False | ||
| if __name__ == "__main__": | ||
| stats = gpustat.new_query() | ||
| for gpu in stats.gpus: | ||
| print(gpu.name) | ||
| print(check()) |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
-187
| import os | ||
| import sys | ||
| import platform | ||
| import atexit | ||
| import signal | ||
| from abogen.utils import get_resource_path, load_config, prevent_sleep_end | ||
| # Fix PyTorch DLL loading issue ([WinError 1114]) on Windows before importing PyQt6 | ||
| if platform.system() == "Windows": | ||
| import ctypes | ||
| from importlib.util import find_spec | ||
| try: | ||
| if ( | ||
| (spec := find_spec("torch")) | ||
| and spec.origin | ||
| and os.path.exists( | ||
| dll_path := os.path.join(os.path.dirname(spec.origin), "lib", "c10.dll") | ||
| ) | ||
| ): | ||
| ctypes.CDLL(os.path.normpath(dll_path)) | ||
| except Exception: | ||
| pass | ||
| # Qt platform plugin detection (fixes #59) | ||
| try: | ||
| from PyQt6.QtCore import QLibraryInfo | ||
| # Get the path to the plugins directory | ||
| plugins = QLibraryInfo.path(QLibraryInfo.LibraryPath.PluginsPath) | ||
| # Normalize path to use the OS-native separators and absolute path | ||
| platform_dir = os.path.normpath(os.path.join(plugins, "platforms")) | ||
| # Ensure we work with an absolute path for clarity | ||
| platform_dir = os.path.abspath(platform_dir) | ||
| if os.path.isdir(platform_dir): | ||
| os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = platform_dir | ||
| print("QT_QPA_PLATFORM_PLUGIN_PATH set to:", platform_dir) | ||
| else: | ||
| print("PyQt6 platform plugins not found at", platform_dir) | ||
| except ImportError: | ||
| print("PyQt6 not installed.") | ||
| # Pre-load "libxcb-cursor" on Linux (fixes #101) | ||
| if platform.system() == "Linux": | ||
| arch = platform.machine().lower() | ||
| lib_filename = {"x86_64": "libxcb-cursor-amd64.so.0", "amd64": "libxcb-cursor-amd64.so.0", "aarch64": "libxcb-cursor-arm64.so.0", "arm64": "libxcb-cursor-arm64.so.0"}.get(arch) | ||
| if lib_filename: | ||
| import ctypes | ||
| try: | ||
| # Try to load the system libxcb-cursor.so.0 first | ||
| ctypes.CDLL('libxcb-cursor.so.0', mode=ctypes.RTLD_GLOBAL) | ||
| except OSError: | ||
| # System lib not available, load the bundled version | ||
| lib_path = get_resource_path('abogen.libs', lib_filename) | ||
| if lib_path: | ||
| try: | ||
| ctypes.CDLL(lib_path, mode=ctypes.RTLD_GLOBAL) | ||
| except OSError: | ||
| # If it fails (e.g. wrong glibc version on very old systems), | ||
| # we simply ignore it and hope the system has the library. | ||
| pass | ||
| # Set application ID for Windows taskbar icon | ||
| if platform.system() == "Windows": | ||
| try: | ||
| from abogen.constants import PROGRAM_NAME, VERSION | ||
| import ctypes | ||
| app_id = f"{PROGRAM_NAME}.{VERSION}" | ||
| ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) | ||
| except Exception as e: | ||
| print("Warning: failed to set AppUserModelID:", e) | ||
| from PyQt6.QtWidgets import QApplication | ||
| from PyQt6.QtGui import QIcon | ||
| from PyQt6.QtCore import ( | ||
| QLibraryInfo, | ||
| qInstallMessageHandler, | ||
| QtMsgType, | ||
| ) | ||
| # Add the directory to Python path | ||
| sys.path.insert(0, os.path.join(os.path.dirname(__file__))) | ||
| # Set Hugging Face Hub environment variables | ||
| os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry | ||
| os.environ["HF_HUB_ETAG_TIMEOUT"] = "10" # Metadata request timeout (seconds) | ||
| os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "10" # File download timeout (seconds) | ||
| os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # Disable symlinks warning | ||
| if load_config().get("disable_kokoro_internet", False): | ||
| print("INFO: Kokoro's internet access is disabled.") | ||
| os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access | ||
| from abogen.gui import abogen | ||
| from abogen.constants import PROGRAM_NAME, VERSION | ||
| # Set environment variables for AMD ROCm | ||
| os.environ["MIOPEN_FIND_MODE"] = "FAST" | ||
| os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0" | ||
| # Reset sleep states | ||
| atexit.register(prevent_sleep_end) | ||
| # Also handle signals (Ctrl+C, kill, etc.) | ||
| def _cleanup_sleep(signum, frame): | ||
| prevent_sleep_end() | ||
| sys.exit(0) | ||
| signal.signal(signal.SIGINT, _cleanup_sleep) | ||
| signal.signal(signal.SIGTERM, _cleanup_sleep) | ||
| # Ensure sys.stdout and sys.stderr are valid in GUI mode | ||
| if sys.stdout is None: | ||
| sys.stdout = open(os.devnull, "w") | ||
| if sys.stderr is None: | ||
| sys.stderr = open(os.devnull, "w") | ||
| # Enable MPS GPU acceleration on Mac Apple Silicon | ||
| if platform.system() == "Darwin" and platform.processor() == "arm": | ||
| os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1" | ||
| # Custom message handler to filter out specific Qt warnings | ||
| def qt_message_handler(mode, context, message): | ||
| # In PyQt6, the mode is an enum, so we compare with the enum members | ||
| if "Wayland does not support QWindow::requestActivate()" in message: | ||
| return # Suppress this specific message | ||
| if "setGrabPopup called with a parent, QtWaylandClient" in message: | ||
| return | ||
| if mode == QtMsgType.QtWarningMsg: | ||
| print(f"Qt Warning: {message}") | ||
| elif mode == QtMsgType.QtCriticalMsg: | ||
| print(f"Qt Critical: {message}") | ||
| elif mode == QtMsgType.QtFatalMsg: | ||
| print(f"Qt Fatal: {message}") | ||
| elif mode == QtMsgType.QtInfoMsg: | ||
| print(f"Qt Info: {message}") | ||
| # Install the custom message handler | ||
| qInstallMessageHandler(qt_message_handler) | ||
| # Handle Wayland on Linux GNOME | ||
| if platform.system() == "Linux": | ||
| xdg_session = os.environ.get("XDG_SESSION_TYPE", "").lower() | ||
| desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() | ||
| if ( | ||
| "gnome" in desktop | ||
| and xdg_session == "wayland" | ||
| and "QT_QPA_PLATFORM" not in os.environ | ||
| ): | ||
| os.environ["QT_QPA_PLATFORM"] = "wayland" | ||
| def main(): | ||
| """Main entry point for console usage.""" | ||
| app = QApplication(sys.argv) | ||
| # Set application icon using get_resource_path from utils | ||
| icon_path = get_resource_path("abogen.assets", "icon.ico") | ||
| if icon_path: | ||
| app.setWindowIcon(QIcon(icon_path)) | ||
| # Set the .desktop name on Linux | ||
| if platform.system() == "Linux": | ||
| try: | ||
| app.setDesktopFileName("abogen") | ||
| except AttributeError: | ||
| pass | ||
| ex = abogen() | ||
| ex.show() | ||
| sys.exit(app.exec()) | ||
| if __name__ == "__main__": | ||
| main() |
| """ | ||
| Pre-download dialog and worker for Abogen | ||
| This module consolidates pre-download logic for Kokoro voices and model | ||
| and spaCy language models. The code favors clarity, avoids duplication, | ||
| and handles optional dependencies gracefully. | ||
| """ | ||
| from typing import List, Optional, Tuple | ||
| import importlib | ||
| import importlib.util | ||
| from PyQt6.QtWidgets import ( | ||
| QDialog, | ||
| QVBoxLayout, | ||
| QHBoxLayout, | ||
| QLabel, | ||
| QPushButton, | ||
| QSpacerItem, | ||
| QSizePolicy, | ||
| ) | ||
| from PyQt6.QtCore import QThread, pyqtSignal | ||
| from abogen.constants import COLORS, VOICES_INTERNAL | ||
| from abogen.spacy_utils import SPACY_MODELS | ||
| import abogen.hf_tracker | ||
| # Helpers | ||
| def _unique_sorted_models() -> List[str]: | ||
| """Return a sorted list of unique spaCy model package names.""" | ||
| return sorted(set(SPACY_MODELS.values())) | ||
| def _is_package_installed(pkg_name: str) -> bool: | ||
| """Return True if a package with the given name can be imported (site-packages).""" | ||
| try: | ||
| return importlib.util.find_spec(pkg_name) is not None | ||
| except Exception: | ||
| return False | ||
| # NOTE: explicit HF cache helper removed; we use try_to_load_from_cache in-scope where needed | ||
| class PreDownloadWorker(QThread): | ||
| """Worker thread to download required models/voices. | ||
| Emits human-readable messages via `progress`. Uses `category_done` to indicate | ||
| a category (voices/model/spacy) finished successfully. Emits `error` on exception | ||
| and `finished` after all work completes. | ||
| """ | ||
| # Emit (category, status, message) | ||
| progress = pyqtSignal(str, str, str) | ||
| category_done = pyqtSignal(str) | ||
| finished = pyqtSignal() | ||
| error = pyqtSignal(str) | ||
| def __init__(self, parent=None): | ||
| super().__init__(parent) | ||
| self._cancelled = False | ||
| # repo and filenames used for Kokoro model | ||
| self._repo_id = "hexgrad/Kokoro-82M" | ||
| self._model_files = ["kokoro-v1_0.pth", "config.json"] | ||
| # Track download success per category | ||
| self._voices_success = False | ||
| self._model_success = False | ||
| self._spacy_success = False | ||
| # Suppress HF tracker warnings during downloads | ||
| self._original_emitter = abogen.hf_tracker.show_warning_signal_emitter | ||
| def cancel(self) -> None: | ||
| self._cancelled = True | ||
| def run(self) -> None: | ||
| # Suppress HF tracker warnings during downloads | ||
| abogen.hf_tracker.show_warning_signal_emitter = None | ||
| try: | ||
| self._download_kokoro_voices() | ||
| if self._cancelled: | ||
| return | ||
| if self._voices_success: | ||
| self.category_done.emit("voices") | ||
| self._download_kokoro_model() | ||
| if self._cancelled: | ||
| return | ||
| if self._model_success: | ||
| self.category_done.emit("model") | ||
| self._download_spacy_models() | ||
| if self._cancelled: | ||
| return | ||
| if self._spacy_success: | ||
| self.category_done.emit("spacy") | ||
| self.finished.emit() | ||
| except Exception as exc: # pragma: no cover - best-effort reporting | ||
| self.error.emit(str(exc)) | ||
| finally: | ||
| # Restore original emitter | ||
| abogen.hf_tracker.show_warning_signal_emitter = self._original_emitter | ||
| # Kokoro voices | ||
| def _download_kokoro_voices(self) -> None: | ||
| self._voices_success = True | ||
| try: | ||
| from huggingface_hub import hf_hub_download, try_to_load_from_cache | ||
| except Exception: | ||
| self.progress.emit( | ||
| "voice", "warning", "huggingface_hub not installed, skipping voices..." | ||
| ) | ||
| self._voices_success = False | ||
| return | ||
| voice_list = VOICES_INTERNAL | ||
| for idx, voice in enumerate(voice_list, start=1): | ||
| if self._cancelled: | ||
| self._voices_success = False | ||
| return | ||
| filename = f"voices/{voice}.pt" | ||
| if try_to_load_from_cache(repo_id=self._repo_id, filename=filename): | ||
| self.progress.emit( | ||
| "voice", | ||
| "installed", | ||
| f"{idx}/{len(voice_list)}: {voice} already present", | ||
| ) | ||
| continue | ||
| self.progress.emit( | ||
| "voice", "downloading", f"{idx}/{len(voice_list)}: {voice}..." | ||
| ) | ||
| try: | ||
| hf_hub_download(repo_id=self._repo_id, filename=filename) | ||
| self.progress.emit("voice", "downloaded", f"{voice} downloaded") | ||
| except Exception as exc: | ||
| self.progress.emit( | ||
| "voice", "warning", f"could not download {voice}: {exc}" | ||
| ) | ||
| self._voices_success = False | ||
| # Kokoro model | ||
| def _download_kokoro_model(self) -> None: | ||
| self._model_success = True | ||
| try: | ||
| from huggingface_hub import hf_hub_download, try_to_load_from_cache | ||
| except Exception: | ||
| self.progress.emit( | ||
| "model", "warning", "huggingface_hub not installed, skipping model..." | ||
| ) | ||
| self._model_success = False | ||
| return | ||
| for fname in self._model_files: | ||
| if self._cancelled: | ||
| self._model_success = False | ||
| return | ||
| category = "config" if fname == "config.json" else "model" | ||
| if try_to_load_from_cache(repo_id=self._repo_id, filename=fname): | ||
| self.progress.emit( | ||
| category, "installed", f"file {fname} already present" | ||
| ) | ||
| continue | ||
| self.progress.emit(category, "downloading", f"file {fname}...") | ||
| try: | ||
| hf_hub_download(repo_id=self._repo_id, filename=fname) | ||
| self.progress.emit(category, "downloaded", f"file {fname} downloaded") | ||
| except Exception as exc: | ||
| self.progress.emit( | ||
| category, "warning", f"could not download file {fname}: {exc}" | ||
| ) | ||
| self._model_success = False | ||
| # spaCy models | ||
| def _download_spacy_models(self) -> None: | ||
| """Download spaCy models. Prefer missing models provided by parent. | ||
| Parent dialog will populate _spacy_models_missing during checking. | ||
| """ | ||
| self._spacy_success = True | ||
| # Determine which models to process: prefer parent-provided missing list to avoid | ||
| # re-checking everything; otherwise use the full unique list. | ||
| parent = self.parent() | ||
| models_to_process: List[str] = _unique_sorted_models() | ||
| try: | ||
| if ( | ||
| parent is not None | ||
| and hasattr(parent, "_spacy_models_missing") | ||
| and parent._spacy_models_missing | ||
| ): | ||
| models_to_process = list(dict.fromkeys(parent._spacy_models_missing)) | ||
| except Exception: | ||
| pass | ||
| # If spaCy is not available to run the CLI, skip gracefully | ||
| try: | ||
| import spacy.cli as _spacy_cli | ||
| except Exception: | ||
| self.progress.emit( | ||
| "spacy", "warning", "spaCy not available, skipping spaCy models..." | ||
| ) | ||
| self._spacy_success = False | ||
| return | ||
| for idx, model_name in enumerate(models_to_process, start=1): | ||
| if self._cancelled: | ||
| self._spacy_success = False | ||
| return | ||
| if _is_package_installed(model_name): | ||
| self.progress.emit( | ||
| "spacy", | ||
| "installed", | ||
| f"{idx}/{len(models_to_process)}: {model_name} already installed", | ||
| ) | ||
| continue | ||
| self.progress.emit( | ||
| "spacy", | ||
| "downloading", | ||
| f"{idx}/{len(models_to_process)}: {model_name}...", | ||
| ) | ||
| try: | ||
| _spacy_cli.download(model_name) | ||
| self.progress.emit("spacy", "downloaded", f"{model_name} downloaded") | ||
| except Exception as exc: | ||
| self.progress.emit( | ||
| "spacy", "warning", f"could not download {model_name}: {exc}" | ||
| ) | ||
| self._spacy_success = False | ||
| class PreDownloadDialog(QDialog): | ||
| """Dialog to show and control pre-download process.""" | ||
| VOICE_PREFIX = "Kokoro voices: " | ||
| MODEL_PREFIX = "Kokoro model: " | ||
| CONFIG_PREFIX = "Kokoro config: " | ||
| SPACY_PREFIX = "spaCy models: " | ||
| def __init__(self, parent=None): | ||
| super().__init__(parent) | ||
| self.setWindowTitle("Pre-download Models and Voices") | ||
| self.setMinimumWidth(500) | ||
| self.worker: Optional[PreDownloadWorker] = None | ||
| self.has_missing = False | ||
| self._spacy_models_checked: List[tuple] = [] | ||
| self._spacy_models_missing: List[str] = [] | ||
| self._status_worker = None | ||
| # Map keywords to (label, prefix) - labels filled after UI creation | ||
| self.status_map = { | ||
| "voice": (None, self.VOICE_PREFIX), | ||
| "spacy": (None, self.SPACY_PREFIX), | ||
| "model": (None, self.MODEL_PREFIX), | ||
| "config": (None, self.CONFIG_PREFIX), | ||
| } | ||
| self.category_map = { | ||
| "voices": ["voice"], | ||
| "model": ["model", "config"], | ||
| "spacy": ["spacy"], | ||
| } | ||
| self._setup_ui() | ||
| self._start_status_check() | ||
| def _setup_ui(self) -> None: | ||
| layout = QVBoxLayout(self) | ||
| layout.setSpacing(0) | ||
| layout.setContentsMargins(15, 0, 15, 15) | ||
| desc = QLabel( | ||
| "You can pre-download all required models and voices for offline use.\n" | ||
| "This includes Kokoro voices, Kokoro model (and config), and spaCy models." | ||
| ) | ||
| desc.setWordWrap(True) | ||
| layout.addWidget(desc) | ||
| # Status rows | ||
| status_layout = QVBoxLayout() | ||
| status_title = QLabel("<b>Current Status:</b>") | ||
| status_layout.addWidget(status_title) | ||
| self.voices_status = QLabel(self.VOICE_PREFIX + "⏳ Checking...") | ||
| row = QHBoxLayout() | ||
| row.addWidget(self.voices_status) | ||
| row.addStretch() | ||
| status_layout.addLayout(row) | ||
| self.model_status = QLabel(self.MODEL_PREFIX + "⏳ Checking...") | ||
| row = QHBoxLayout() | ||
| row.addWidget(self.model_status) | ||
| row.addStretch() | ||
| status_layout.addLayout(row) | ||
| self.config_status = QLabel(self.CONFIG_PREFIX + "⏳ Checking...") | ||
| row = QHBoxLayout() | ||
| row.addWidget(self.config_status) | ||
| row.addStretch() | ||
| status_layout.addLayout(row) | ||
| self.spacy_status = QLabel(self.SPACY_PREFIX + "⏳ Checking...") | ||
| row = QHBoxLayout() | ||
| row.addWidget(self.spacy_status) | ||
| row.addStretch() | ||
| status_layout.addLayout(row) | ||
| # register labels | ||
| self.status_map["voice"] = (self.voices_status, self.VOICE_PREFIX) | ||
| self.status_map["model"] = (self.model_status, self.MODEL_PREFIX) | ||
| self.status_map["config"] = (self.config_status, self.CONFIG_PREFIX) | ||
| self.status_map["spacy"] = (self.spacy_status, self.SPACY_PREFIX) | ||
| layout.addLayout(status_layout) | ||
| layout.addItem( | ||
| QSpacerItem(0, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) | ||
| ) | ||
| # Buttons | ||
| button_row = QHBoxLayout() | ||
| button_row.setSpacing(10) | ||
| self.download_btn = QPushButton("Download all") | ||
| self.download_btn.setMinimumWidth(100) | ||
| self.download_btn.setMinimumHeight(35) | ||
| self.download_btn.setEnabled(False) | ||
| self.download_btn.clicked.connect(self._start_download) | ||
| button_row.addWidget(self.download_btn) | ||
| self.close_btn = QPushButton("Close") | ||
| self.close_btn.setMinimumWidth(100) | ||
| self.close_btn.setMinimumHeight(35) | ||
| self.close_btn.clicked.connect(self._handle_close) | ||
| button_row.addWidget(self.close_btn) | ||
| layout.addLayout(button_row) | ||
| self.adjustSize() | ||
| # Status checking worker | ||
| class StatusCheckWorker(QThread): | ||
| voices_checked = pyqtSignal(bool, list) | ||
| model_checked = pyqtSignal(bool) | ||
| config_checked = pyqtSignal(bool) | ||
| spacy_model_checking = pyqtSignal(str) | ||
| spacy_model_result = pyqtSignal(str, bool) | ||
| spacy_checked = pyqtSignal(bool, list) | ||
| def run(self): | ||
| parent = self.parent() | ||
| if parent is None: | ||
| return | ||
| voices_ok, missing_voices = parent._check_kokoro_voices() | ||
| self.voices_checked.emit(voices_ok, missing_voices) | ||
| model_ok = parent._check_kokoro_model() | ||
| self.model_checked.emit(model_ok) | ||
| config_ok = parent._check_kokoro_config() | ||
| self.config_checked.emit(config_ok) | ||
| # Check spaCy models by package name to detect site-package installs | ||
| unique = _unique_sorted_models() | ||
| missing: List[str] = [] | ||
| for name in unique: | ||
| self.spacy_model_checking.emit(name) | ||
| ok = _is_package_installed(name) | ||
| self.spacy_model_result.emit(name, ok) | ||
| if not ok: | ||
| missing.append(name) | ||
| parent._spacy_models_missing = missing | ||
| self.spacy_checked.emit(len(missing) == 0, missing) | ||
| def _start_status_check(self) -> None: | ||
| self._status_worker = self.StatusCheckWorker(self) | ||
| self._status_worker.voices_checked.connect(self._update_voices_status) | ||
| self._status_worker.model_checked.connect(self._update_model_status) | ||
| self._status_worker.config_checked.connect(self._update_config_status) | ||
| self._status_worker.spacy_model_checking.connect(self._spacy_model_checking) | ||
| self._status_worker.spacy_model_result.connect(self._spacy_model_result) | ||
| self._status_worker.spacy_checked.connect(self._update_spacy_status) | ||
| # These are initialized in __init__ to keep consistent object state | ||
| # Set checking visual state | ||
| for lbl in ( | ||
| self.voices_status, | ||
| self.model_status, | ||
| self.config_status, | ||
| self.spacy_status, | ||
| ): | ||
| lbl.setStyleSheet(f"color: {COLORS['ORANGE']};") | ||
| self.spacy_status.setText(self.SPACY_PREFIX + "⏳ Checking...") | ||
| self._status_worker.start() | ||
| # UI update callbacks | ||
| def _spacy_model_checking(self, name: str) -> None: | ||
| self.spacy_status.setText(f"{self.SPACY_PREFIX}Checking {name}...") | ||
| def _spacy_model_result(self, name: str, ok: bool) -> None: | ||
| self._spacy_models_checked.append((name, ok)) | ||
| if not ok and name not in self._spacy_models_missing: | ||
| self._spacy_models_missing.append(name) | ||
| checked = len(self._spacy_models_checked) | ||
| missing_count = len(self._spacy_models_missing) | ||
| if missing_count: | ||
| self.spacy_status.setText( | ||
| f"{self.SPACY_PREFIX}{checked} checked, {missing_count} missing..." | ||
| ) | ||
| else: | ||
| self.spacy_status.setText(f"{self.SPACY_PREFIX}{checked} checked...") | ||
| def _update_voices_status(self, ok: bool, missing: List[str]) -> None: | ||
| if ok: | ||
| self._set_status("voice", "✓ Downloaded", COLORS["GREEN"]) | ||
| else: | ||
| self.has_missing = True | ||
| if missing: | ||
| self._set_status( | ||
| "voice", f"✗ Missing {len(missing)} voices", COLORS["RED"] | ||
| ) | ||
| else: | ||
| self._set_status("voice", "✗ Not downloaded", COLORS["RED"]) | ||
| def _update_model_status(self, ok: bool) -> None: | ||
| if ok: | ||
| self._set_status("model", "✓ Downloaded", COLORS["GREEN"]) | ||
| else: | ||
| self.has_missing = True | ||
| self._set_status("model", "✗ Not downloaded", COLORS["RED"]) | ||
| def _update_config_status(self, ok: bool) -> None: | ||
| if ok: | ||
| self._set_status("config", "✓ Downloaded", COLORS["GREEN"]) | ||
| else: | ||
| self.has_missing = True | ||
| self._set_status("config", "✗ Not downloaded", COLORS["RED"]) | ||
| def _update_spacy_status(self, ok: bool, missing: List[str]) -> None: | ||
| if ok: | ||
| self._set_status("spacy", "✓ Downloaded", COLORS["GREEN"]) | ||
| else: | ||
| self.has_missing = True | ||
| if missing: | ||
| self._set_status( | ||
| "spacy", f"✗ Missing {len(missing)} model(s)", COLORS["RED"] | ||
| ) | ||
| else: | ||
| self._set_status("spacy", "✗ Not downloaded", COLORS["RED"]) | ||
| self.download_btn.setEnabled(self.has_missing) | ||
| def _set_status(self, key: str, text: str, color: str) -> None: | ||
| lbl, prefix = self.status_map.get(key, (None, "")) | ||
| if not lbl: | ||
| return | ||
| lbl.setText(prefix + text) | ||
| lbl.setStyleSheet(f"color: {color};") | ||
| # Helper checks | ||
| def _check_kokoro_voices(self) -> Tuple[bool, List[str]]: | ||
| """Return (ok, missing_list) for Kokoro voices check.""" | ||
| missing = [] | ||
| try: | ||
| from huggingface_hub import try_to_load_from_cache | ||
| for voice in VOICES_INTERNAL: | ||
| if not try_to_load_from_cache( | ||
| repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt" | ||
| ): | ||
| missing.append(voice) | ||
| except Exception: | ||
| # If HF missing, report all as missing | ||
| return False, list(VOICES_INTERNAL) | ||
| return (len(missing) == 0), missing | ||
| def _check_kokoro_model(self) -> bool: | ||
| try: | ||
| from huggingface_hub import try_to_load_from_cache | ||
| return ( | ||
| try_to_load_from_cache( | ||
| repo_id="hexgrad/Kokoro-82M", filename="kokoro-v1_0.pth" | ||
| ) | ||
| is not None | ||
| ) | ||
| except Exception: | ||
| return False | ||
| def _check_kokoro_config(self) -> bool: | ||
| try: | ||
| from huggingface_hub import try_to_load_from_cache | ||
| return ( | ||
| try_to_load_from_cache( | ||
| repo_id="hexgrad/Kokoro-82M", filename="config.json" | ||
| ) | ||
| is not None | ||
| ) | ||
| except Exception: | ||
| return False | ||
| def _check_spacy_models(self) -> bool: | ||
| unique = _unique_sorted_models() | ||
| missing = [m for m in unique if not _is_package_installed(m)] | ||
| self._spacy_models_missing = missing | ||
| return len(missing) == 0 | ||
| # Download control | ||
| def _start_download(self) -> None: | ||
| self.download_btn.setEnabled(False) | ||
| self.download_btn.setText("Downloading...") | ||
| # mark the start of downloads; this triggers the labels | ||
| self._on_progress("system", "starting", "Processing, please wait...") | ||
| self.worker = PreDownloadWorker(self) | ||
| self.worker.progress.connect(self._on_progress) | ||
| self.worker.category_done.connect(self._on_category_done) | ||
| self.worker.finished.connect(self._on_download_finished) | ||
| self.worker.error.connect(self._on_download_error) | ||
| self.worker.start() | ||
| def _on_progress(self, category: str, status: str, message: str) -> None: | ||
| """Map worker (category, status, message) to UI label updates. | ||
| Status is one of: 'downloading', 'installed', 'downloaded', 'warning', 'starting'. | ||
| Category is one of: 'voice', 'model', 'spacy', 'config', or 'system'. | ||
| """ | ||
| try: | ||
| # If the category targets a specific label, update directly | ||
| if category in self.status_map: | ||
| lbl, prefix = self.status_map[category] | ||
| if not lbl: | ||
| return | ||
| # Compose message and set color based on status token | ||
| full_text = prefix + message | ||
| if len(full_text) > 60: | ||
| display_text = full_text[:57] + "..." | ||
| lbl.setText(display_text) | ||
| lbl.setToolTip(full_text) | ||
| else: | ||
| lbl.setText(full_text) | ||
| lbl.setToolTip("") # Clear tooltip if not needed | ||
| if status == "downloading": | ||
| lbl.setStyleSheet(f"color: {COLORS['ORANGE']};") | ||
| elif status in ("installed", "downloaded"): | ||
| lbl.setStyleSheet(f"color: {COLORS['GREEN']};") | ||
| elif status == "warning": | ||
| lbl.setStyleSheet(f"color: {COLORS['RED']};") | ||
| elif status == "error": | ||
| lbl.setStyleSheet(f"color: {COLORS['RED']};") | ||
| return | ||
| # System-level messages | ||
| if category == "system": | ||
| if status == "starting": | ||
| for k in self.status_map: | ||
| lbl, prefix = self.status_map[k] | ||
| if lbl: | ||
| lbl.setText(prefix + "Processing, please wait...") | ||
| lbl.setStyleSheet(f"color: {COLORS['ORANGE']};") | ||
| # other system statuses don't require action | ||
| return | ||
| except Exception: | ||
| # Do not let UI thread crash on unexpected worker message | ||
| pass | ||
| def _on_category_done(self, category: str) -> None: | ||
| for key in self.category_map.get(category, []): | ||
| self._set_status(key, "✓ Downloaded", COLORS["GREEN"]) | ||
| def _on_download_finished(self) -> None: | ||
| self.has_missing = False | ||
| self.download_btn.setText("Download all") | ||
| self.download_btn.setEnabled(False) | ||
| def _on_download_error(self, error_msg: str) -> None: | ||
| self.download_btn.setText("Download all") | ||
| self.download_btn.setEnabled(True) | ||
| for key in self.status_map: | ||
| self._set_status(key, f"✗ Error - {error_msg}", COLORS["RED"]) | ||
| def _handle_close(self) -> None: | ||
| if self.worker and self.worker.isRunning(): | ||
| self.worker.cancel() | ||
| self.worker.wait(2000) | ||
| self.accept() | ||
| def closeEvent(self, event) -> None: | ||
| if self.worker and self.worker.isRunning(): | ||
| self.worker.cancel() | ||
| self.worker.wait(2000) | ||
| super().closeEvent(event) |
| # a simple window with a list of items in the queue, no checkboxes | ||
| # button to remove an item from the queue | ||
| # button to clear the queue | ||
| from PyQt6.QtWidgets import ( | ||
| QDialog, | ||
| QVBoxLayout, | ||
| QHBoxLayout, | ||
| QDialogButtonBox, | ||
| QPushButton, | ||
| QListWidget, | ||
| QListWidgetItem, | ||
| QFileIconProvider, | ||
| QLabel, | ||
| QWidget, | ||
| QSizePolicy, | ||
| QAbstractItemView, | ||
| QCheckBox, | ||
| ) | ||
| from PyQt6.QtCore import QFileInfo, Qt | ||
| from abogen.constants import COLORS | ||
| from copy import deepcopy | ||
| from PyQt6.QtGui import QFontMetrics | ||
| from abogen.utils import load_config, save_config | ||
| # Define attributes that are safe to override with global settings | ||
| OVERRIDE_FIELDS = [ | ||
| "lang_code", | ||
| "speed", | ||
| "voice", | ||
| "save_option", | ||
| "output_folder", | ||
| "subtitle_mode", | ||
| "output_format", | ||
| "replace_single_newlines", | ||
| "use_silent_gaps", | ||
| "subtitle_speed_method", | ||
| ] | ||
| class ElidedLabel(QLabel): | ||
| def __init__(self, text): | ||
| super().__init__(text) | ||
| self._full_text = text | ||
| self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) | ||
| self.setTextFormat(Qt.TextFormat.PlainText) | ||
| def setText(self, text): | ||
| self._full_text = text | ||
| super().setText(text) | ||
| self.update() | ||
| def resizeEvent(self, event): | ||
| metrics = QFontMetrics(self.font()) | ||
| elided = metrics.elidedText( | ||
| self._full_text, Qt.TextElideMode.ElideRight, self.width() | ||
| ) | ||
| super().setText(elided) | ||
| super().resizeEvent(event) | ||
| def fullText(self): | ||
| return self._full_text | ||
| class QueueListItemWidget(QWidget): | ||
| def __init__(self, file_name, char_count): | ||
| super().__init__() | ||
| layout = QHBoxLayout() | ||
| layout.setContentsMargins(12, 0, 6, 0) | ||
| layout.setSpacing(0) | ||
| import os | ||
| name_label = ElidedLabel(os.path.basename(file_name)) | ||
| char_label = QLabel(f"Chars: {char_count}") | ||
| char_label.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']};") | ||
| char_label.setAlignment( | ||
| Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter | ||
| ) | ||
| char_label.setSizePolicy( | ||
| QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred | ||
| ) | ||
| layout.addWidget(name_label, 1) | ||
| layout.addWidget(char_label, 0) | ||
| self.setLayout(layout) | ||
| class DroppableQueueListWidget(QListWidget): | ||
| def __init__(self, parent_dialog): | ||
| super().__init__() | ||
| self.parent_dialog = parent_dialog | ||
| self.setAcceptDrops(True) | ||
| # Overlay for drag hover | ||
| self.drag_overlay = QLabel("", self) | ||
| self.drag_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter) | ||
| self.drag_overlay.setStyleSheet( | ||
| f"border:2px dashed {COLORS['BLUE_BORDER_HOVER']}; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG_HOVER']};" | ||
| ) | ||
| self.drag_overlay.setVisible(False) | ||
| self.drag_overlay.setAttribute( | ||
| Qt.WidgetAttribute.WA_TransparentForMouseEvents, True | ||
| ) | ||
| def dragEnterEvent(self, event): | ||
| if event.mimeData().hasUrls(): | ||
| for url in event.mimeData().urls(): | ||
| file_path = url.toLocalFile().lower() | ||
| if url.isLocalFile() and ( | ||
| file_path.endswith(".txt") | ||
| or file_path.endswith((".srt", ".ass", ".vtt")) | ||
| ): | ||
| self.drag_overlay.resize(self.size()) | ||
| self.drag_overlay.setVisible(True) | ||
| event.acceptProposedAction() | ||
| return | ||
| self.drag_overlay.setVisible(False) | ||
| event.ignore() | ||
| def dragMoveEvent(self, event): | ||
| if event.mimeData().hasUrls(): | ||
| for url in event.mimeData().urls(): | ||
| file_path = url.toLocalFile().lower() | ||
| if url.isLocalFile() and ( | ||
| file_path.endswith(".txt") | ||
| or file_path.endswith((".srt", ".ass", ".vtt")) | ||
| ): | ||
| event.acceptProposedAction() | ||
| return | ||
| event.ignore() | ||
| def dragLeaveEvent(self, event): | ||
| self.drag_overlay.setVisible(False) | ||
| event.accept() | ||
| def dropEvent(self, event): | ||
| self.drag_overlay.setVisible(False) | ||
| if event.mimeData().hasUrls(): | ||
| file_paths = [ | ||
| url.toLocalFile() | ||
| for url in event.mimeData().urls() | ||
| if url.isLocalFile() | ||
| and ( | ||
| url.toLocalFile().lower().endswith(".txt") | ||
| or url.toLocalFile().lower().endswith((".srt", ".ass", ".vtt")) | ||
| ) | ||
| ] | ||
| if file_paths: | ||
| self.parent_dialog.add_files_from_paths(file_paths) | ||
| event.acceptProposedAction() | ||
| else: | ||
| event.ignore() | ||
| else: | ||
| event.ignore() | ||
| def resizeEvent(self, event): | ||
| super().resizeEvent(event) | ||
| if hasattr(self, "drag_overlay"): | ||
| self.drag_overlay.resize(self.size()) | ||
| class QueueManager(QDialog): | ||
| def __init__(self, parent, queue: list, title="Queue Manager", size=(600, 700)): | ||
| super().__init__() | ||
| self.queue = queue | ||
| self._original_queue = deepcopy( | ||
| queue | ||
| ) # Store a deep copy of the original queue | ||
| self.parent = parent | ||
| self.config = load_config() # Load config for persistence | ||
| layout = QVBoxLayout() | ||
| layout.setContentsMargins(15, 15, 15, 15) # set main layout margins | ||
| layout.setSpacing(12) # set spacing between widgets in main layout | ||
| # list of queued items | ||
| self.listwidget = DroppableQueueListWidget(self) | ||
| self.listwidget.setSelectionMode( | ||
| QAbstractItemView.SelectionMode.ExtendedSelection | ||
| ) | ||
| self.listwidget.setAlternatingRowColors(True) | ||
| self.listwidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) | ||
| self.listwidget.customContextMenuRequested.connect(self.show_context_menu) | ||
| # Add informative instructions at the top | ||
| instructions = QLabel( | ||
| "<h2>How Queue Works?</h2>" | ||
| "You can add text and subtitle files (.txt, .srt, .ass, .vtt) directly using the '<b>Add files</b>' button below. " | ||
| "To add PDF, EPUB or markdown files, use the input box in the main window and click the <b>'Add to Queue'</b> button. " | ||
| "By default, each file in the queue keeps the configuration settings active when they were added. " | ||
| "Enabling the <b>'Override item settings with current selection'</b> option below will force all items to use the configuration currently selected in the main window. " | ||
| "You can view each file's configuration by hovering over them." | ||
| ) | ||
| instructions.setAlignment(Qt.AlignmentFlag.AlignLeft) | ||
| instructions.setWordWrap(True) | ||
| layout.addWidget(instructions) | ||
| # Override Checkbox | ||
| self.override_chk = QCheckBox("Override item settings with current selection") | ||
| self.override_chk.setToolTip( | ||
| "If checked, all items in the queue will be processed using the \n" | ||
| "settings currently selected in the main window, ignoring their saved state." | ||
| ) | ||
| # Load saved state (default to False) | ||
| self.override_chk.setChecked(self.config.get("queue_override_settings", False)) | ||
| # Trigger process_queue to update tooltips immediately when toggled | ||
| self.override_chk.stateChanged.connect(self.process_queue) | ||
| self.override_chk.setStyleSheet("margin-bottom: 8px;") | ||
| layout.addWidget(self.override_chk) | ||
| # Overlay label for empty queue | ||
| self.empty_overlay = QLabel( | ||
| "Drag and drop your text or subtitle files here or use the 'Add files' button.", | ||
| self.listwidget, | ||
| ) | ||
| self.empty_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter) | ||
| self.empty_overlay.setStyleSheet( | ||
| f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;" | ||
| ) | ||
| self.empty_overlay.setWordWrap(True) | ||
| self.empty_overlay.setAttribute( | ||
| Qt.WidgetAttribute.WA_TransparentForMouseEvents, True | ||
| ) | ||
| self.empty_overlay.hide() | ||
| # add queue items to the list | ||
| self.process_queue() | ||
| button_row = QHBoxLayout() | ||
| button_row.setContentsMargins(0, 0, 0, 0) # optional: no margins for button row | ||
| button_row.setSpacing(7) # set spacing between buttons | ||
| # Add files button | ||
| add_files_button = QPushButton("Add files") | ||
| add_files_button.setFixedHeight(40) | ||
| add_files_button.clicked.connect(self.add_more_files) | ||
| button_row.addWidget(add_files_button) | ||
| # Remove button | ||
| self.remove_button = QPushButton("Remove selected") | ||
| self.remove_button.setFixedHeight(40) | ||
| self.remove_button.clicked.connect(self.remove_item) | ||
| button_row.addWidget(self.remove_button) | ||
| # Clear button | ||
| self.clear_button = QPushButton("Clear Queue") | ||
| self.clear_button.setFixedHeight(40) | ||
| self.clear_button.clicked.connect(self.clear_queue) | ||
| button_row.addWidget(self.clear_button) | ||
| layout.addLayout(button_row) | ||
| layout.addWidget(self.listwidget) | ||
| # Connect selection change to update button state | ||
| self.listwidget.currentItemChanged.connect(self.update_button_states) | ||
| self.listwidget.itemSelectionChanged.connect(self.update_button_states) | ||
| buttons = QDialogButtonBox( | ||
| QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, | ||
| self, | ||
| ) | ||
| buttons.accepted.connect(self.accept) | ||
| buttons.rejected.connect(self.reject) | ||
| layout.addWidget(buttons) | ||
| self.setLayout(layout) | ||
| self.setWindowTitle(title) | ||
| self.resize(*size) | ||
| self.update_button_states() | ||
| def process_queue(self): | ||
| """Process the queue items.""" | ||
| import os | ||
| self.listwidget.clear() | ||
| if not self.queue: | ||
| self.empty_overlay.show() | ||
| self.update_button_states() | ||
| return | ||
| else: | ||
| self.empty_overlay.hide() | ||
| # Get current global settings and checkbox state for overrides | ||
| current_global_settings = self.get_current_attributes() | ||
| is_override_active = self.override_chk.isChecked() | ||
| icon_provider = QFileIconProvider() | ||
| for item in self.queue: | ||
| # Dynamic Attribute Retrieval Helper | ||
| def get_val(attr, default=""): | ||
| # If override is ON and attr is overrideable, use global setting | ||
| if is_override_active and attr in OVERRIDE_FIELDS: | ||
| return current_global_settings.get(attr, default) | ||
| # Otherwise return the item's saved attribute | ||
| return getattr(item, attr, default) | ||
| # Determine display file path (prefer save_base_path for original file) | ||
| display_file_path = getattr(item, "save_base_path", None) or item.file_name | ||
| processing_file_path = item.file_name | ||
| # Normalize paths for consistent display (fixes Windows path separator issues) | ||
| display_file_path = ( | ||
| os.path.normpath(display_file_path) | ||
| if display_file_path | ||
| else display_file_path | ||
| ) | ||
| processing_file_path = ( | ||
| os.path.normpath(processing_file_path) | ||
| if processing_file_path | ||
| else processing_file_path | ||
| ) | ||
| # Only show the file name, not the full path | ||
| display_name = display_file_path | ||
| if os.path.sep in display_file_path: | ||
| display_name = os.path.basename(display_file_path) | ||
| # Get icon for the display file | ||
| icon = icon_provider.icon(QFileInfo(display_file_path)) | ||
| list_item = QListWidgetItem() | ||
| # Tooltip Generation | ||
| tooltip = "" | ||
| # If override is active, add the warning header on its own line | ||
| if is_override_active: | ||
| tooltip += "<b style='color: #ff9900;'>(Global Override Active)</b><br>" | ||
| output_folder = get_val("output_folder") | ||
| # For plain .txt inputs we don't need to show a separate processing file | ||
| show_processing = True | ||
| try: | ||
| if isinstance( | ||
| display_file_path, str | ||
| ) and display_file_path.lower().endswith(".txt"): | ||
| show_processing = False | ||
| except Exception: | ||
| show_processing = True | ||
| tooltip += f"<b>Input File:</b> {display_file_path}<br>" | ||
| if ( | ||
| show_processing | ||
| and processing_file_path | ||
| and processing_file_path != display_file_path | ||
| ): | ||
| tooltip += f"<b>Processing File:</b> {processing_file_path}<br>" | ||
| tooltip += ( | ||
| f"<b>Language:</b> {get_val('lang_code')}<br>" | ||
| f"<b>Speed:</b> {get_val('speed')}<br>" | ||
| f"<b>Voice:</b> {get_val('voice')}<br>" | ||
| f"<b>Save Option:</b> {get_val('save_option')}<br>" | ||
| ) | ||
| if output_folder not in (None, "", "None"): | ||
| tooltip += f"<b>Output Folder:</b> {output_folder}<br>" | ||
| tooltip += ( | ||
| f"<b>Subtitle Mode:</b> {get_val('subtitle_mode')}<br>" | ||
| f"<b>Output Format:</b> {get_val('output_format')}<br>" | ||
| f"<b>Characters:</b> {getattr(item, 'total_char_count', '')}<br>" | ||
| f"<b>Replace Single Newlines:</b> {get_val('replace_single_newlines', True)}<br>" | ||
| f"<b>Use Silent Gaps:</b> {get_val('use_silent_gaps', False)}<br>" | ||
| f"<b>Speed Method:</b> {get_val('subtitle_speed_method', 'tts')}" | ||
| ) | ||
| # Add book handler options if present (Preserve logic: specific to file structure) | ||
| save_chapters_separately = getattr(item, "save_chapters_separately", None) | ||
| merge_chapters_at_end = getattr(item, "merge_chapters_at_end", None) | ||
| if save_chapters_separately is not None: | ||
| tooltip += f"<br><b>Save chapters separately:</b> {'Yes' if save_chapters_separately else 'No'}" | ||
| # Only show merge option if saving chapters separately | ||
| if save_chapters_separately and merge_chapters_at_end is not None: | ||
| tooltip += f"<br><b>Merge chapters at the end:</b> {'Yes' if merge_chapters_at_end else 'No'}" | ||
| list_item.setToolTip(tooltip) | ||
| list_item.setIcon(icon) | ||
| # Store both paths for context menu | ||
| list_item.setData( | ||
| Qt.ItemDataRole.UserRole, | ||
| { | ||
| "display_path": display_file_path, | ||
| "processing_path": processing_file_path, | ||
| }, | ||
| ) | ||
| # Use custom widget for display | ||
| char_count = getattr(item, "total_char_count", 0) | ||
| widget = QueueListItemWidget(display_file_path, char_count) | ||
| self.listwidget.addItem(list_item) | ||
| self.listwidget.setItemWidget(list_item, widget) | ||
| self.update_button_states() | ||
| def remove_item(self): | ||
| items = self.listwidget.selectedItems() | ||
| if not items: | ||
| return | ||
| from PyQt6.QtWidgets import QMessageBox | ||
| # Remove by index to ensure correct mapping | ||
| rows = sorted([self.listwidget.row(item) for item in items], reverse=True) | ||
| # Warn user if removing multiple files | ||
| if len(rows) > 1: | ||
| reply = QMessageBox.question( | ||
| self, | ||
| "Confirm Remove", | ||
| f"Are you sure you want to remove {len(rows)} selected items from the queue?", | ||
| QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, | ||
| QMessageBox.StandardButton.No, | ||
| ) | ||
| if reply != QMessageBox.StandardButton.Yes: | ||
| return | ||
| for row in rows: | ||
| if 0 <= row < len(self.queue): | ||
| del self.queue[row] | ||
| self.process_queue() | ||
| self.update_button_states() | ||
| def clear_queue(self): | ||
| from PyQt6.QtWidgets import QMessageBox | ||
| if len(self.queue) > 1: | ||
| reply = QMessageBox.question( | ||
| self, | ||
| "Confirm Clear Queue", | ||
| f"Are you sure you want to clear {len(self.queue)} items from the queue?", | ||
| QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, | ||
| QMessageBox.StandardButton.No, | ||
| ) | ||
| if reply != QMessageBox.StandardButton.Yes: | ||
| return | ||
| self.queue.clear() | ||
| self.listwidget.clear() | ||
| self.empty_overlay.resize( | ||
| self.listwidget.size() | ||
| ) # Ensure overlay is sized correctly | ||
| self.empty_overlay.show() # Show the overlay when queue is empty | ||
| self.update_button_states() | ||
| def get_queue(self): | ||
| return self.queue | ||
| def get_current_attributes(self): | ||
| # Fetch current attribute values from the parent abogen GUI | ||
| attrs = {} | ||
| parent = self.parent | ||
| if parent is not None: | ||
| # lang_code: use parent's get_voice_formula and get_selected_lang | ||
| if hasattr(parent, "get_voice_formula") and hasattr( | ||
| parent, "get_selected_lang" | ||
| ): | ||
| voice_formula = parent.get_voice_formula() | ||
| attrs["lang_code"] = parent.get_selected_lang(voice_formula) | ||
| attrs["voice"] = voice_formula | ||
| else: | ||
| attrs["lang_code"] = getattr(parent, "selected_lang", "") | ||
| attrs["voice"] = getattr(parent, "selected_voice", "") | ||
| # speed | ||
| if hasattr(parent, "speed_slider"): | ||
| attrs["speed"] = parent.speed_slider.value() / 100.0 | ||
| else: | ||
| attrs["speed"] = getattr(parent, "speed", 1.0) | ||
| # save_option | ||
| attrs["save_option"] = getattr(parent, "save_option", "") | ||
| # output_folder | ||
| attrs["output_folder"] = getattr(parent, "selected_output_folder", "") | ||
| # subtitle_mode | ||
| if hasattr(parent, "get_actual_subtitle_mode"): | ||
| attrs["subtitle_mode"] = parent.get_actual_subtitle_mode() | ||
| else: | ||
| attrs["subtitle_mode"] = getattr(parent, "subtitle_mode", "") | ||
| # output_format | ||
| attrs["output_format"] = getattr(parent, "selected_format", "") | ||
| # total_char_count | ||
| attrs["total_char_count"] = getattr(parent, "char_count", "") | ||
| # replace_single_newlines | ||
| attrs["replace_single_newlines"] = getattr( | ||
| parent, "replace_single_newlines", True | ||
| ) | ||
| # use_silent_gaps | ||
| attrs["use_silent_gaps"] = getattr(parent, "use_silent_gaps", False) | ||
| # subtitle_speed_method | ||
| attrs["subtitle_speed_method"] = getattr( | ||
| parent, "subtitle_speed_method", "tts" | ||
| ) | ||
| # book handler options | ||
| attrs["save_chapters_separately"] = getattr( | ||
| parent, "save_chapters_separately", None | ||
| ) | ||
| attrs["merge_chapters_at_end"] = getattr( | ||
| parent, "merge_chapters_at_end", None | ||
| ) | ||
| else: | ||
| # fallback: empty values | ||
| attrs = { | ||
| k: "" | ||
| for k in [ | ||
| "lang_code", | ||
| "speed", | ||
| "voice", | ||
| "save_option", | ||
| "output_folder", | ||
| "subtitle_mode", | ||
| "output_format", | ||
| "total_char_count", | ||
| "replace_single_newlines", | ||
| ] | ||
| } | ||
| attrs["save_chapters_separately"] = None | ||
| attrs["merge_chapters_at_end"] = None | ||
| return attrs | ||
| def add_files_from_paths(self, file_paths): | ||
| from abogen.utils import calculate_text_length | ||
| from PyQt6.QtWidgets import QMessageBox | ||
| import os | ||
| current_attrs = self.get_current_attributes() | ||
| duplicates = [] | ||
| for file_path in file_paths: | ||
| class QueueItem: | ||
| pass | ||
| item = QueueItem() | ||
| item.file_name = file_path | ||
| item.save_base_path = ( | ||
| file_path # For .txt files, processing and save paths are the same | ||
| ) | ||
| for attr, value in current_attrs.items(): | ||
| setattr(item, attr, value) | ||
| # Override subtitle_mode to "Disabled" for subtitle files | ||
| if file_path.lower().endswith((".srt", ".ass", ".vtt")): | ||
| item.subtitle_mode = "Disabled" | ||
| # Read file content and calculate total_char_count using calculate_text_length | ||
| try: | ||
| with open(file_path, "r", encoding="utf-8", errors="ignore") as f: | ||
| file_content = f.read() | ||
| item.total_char_count = calculate_text_length(file_content) | ||
| except Exception: | ||
| item.total_char_count = 0 | ||
| # Prevent adding duplicate items to the queue (check all attributes) | ||
| is_duplicate = False | ||
| for queued_item in self.queue: | ||
| if ( | ||
| getattr(queued_item, "file_name", None) | ||
| == getattr(item, "file_name", None) | ||
| and getattr(queued_item, "lang_code", None) | ||
| == getattr(item, "lang_code", None) | ||
| and getattr(queued_item, "speed", None) | ||
| == getattr(item, "speed", None) | ||
| and getattr(queued_item, "voice", None) | ||
| == getattr(item, "voice", None) | ||
| and getattr(queued_item, "save_option", None) | ||
| == getattr(item, "save_option", None) | ||
| and getattr(queued_item, "output_folder", None) | ||
| == getattr(item, "output_folder", None) | ||
| and getattr(queued_item, "subtitle_mode", None) | ||
| == getattr(item, "subtitle_mode", None) | ||
| and getattr(queued_item, "output_format", None) | ||
| == getattr(item, "output_format", None) | ||
| and getattr(queued_item, "total_char_count", None) | ||
| == getattr(item, "total_char_count", None) | ||
| and getattr(queued_item, "replace_single_newlines", True) | ||
| == getattr(item, "replace_single_newlines", True) | ||
| and getattr(queued_item, "use_silent_gaps", False) | ||
| == getattr(item, "use_silent_gaps", False) | ||
| and getattr(queued_item, "subtitle_speed_method", "tts") | ||
| == getattr(item, "subtitle_speed_method", "tts") | ||
| and getattr(queued_item, "save_base_path", None) | ||
| == getattr(item, "save_base_path", None) | ||
| and getattr(queued_item, "save_chapters_separately", None) | ||
| == getattr(item, "save_chapters_separately", None) | ||
| and getattr(queued_item, "merge_chapters_at_end", None) | ||
| == getattr(item, "merge_chapters_at_end", None) | ||
| ): | ||
| is_duplicate = True | ||
| break | ||
| if is_duplicate: | ||
| duplicates.append(os.path.basename(file_path)) | ||
| continue | ||
| self.queue.append(item) | ||
| if duplicates: | ||
| QMessageBox.warning( | ||
| self, | ||
| "Duplicate Item(s)", | ||
| f"Skipping {len(duplicates)} file(s) with the same attributes, already in the queue.", | ||
| ) | ||
| self.process_queue() | ||
| self.update_button_states() | ||
| def add_more_files(self): | ||
| from PyQt6.QtWidgets import QFileDialog | ||
| # Allow .txt, .srt, .ass, and .vtt files | ||
| files, _ = QFileDialog.getOpenFileNames( | ||
| self, | ||
| "Select text or subtitle files", | ||
| "", | ||
| "Supported Files (*.txt *.srt *.ass *.vtt)", | ||
| ) | ||
| if not files: | ||
| return | ||
| self.add_files_from_paths(files) | ||
| def resizeEvent(self, event): | ||
| super().resizeEvent(event) | ||
| if hasattr(self, "empty_overlay"): | ||
| self.empty_overlay.resize(self.listwidget.size()) | ||
| def update_button_states(self): | ||
| # Enable Remove if at least one item is selected, else disable | ||
| if hasattr(self, "remove_button"): | ||
| selected_count = len(self.listwidget.selectedItems()) | ||
| self.remove_button.setEnabled(selected_count > 0) | ||
| if selected_count > 1: | ||
| self.remove_button.setText(f"Remove selected ({selected_count})") | ||
| else: | ||
| self.remove_button.setText("Remove selected") | ||
| # Disable Clear if queue is empty | ||
| if hasattr(self, "clear_button"): | ||
| self.clear_button.setEnabled(bool(self.queue)) | ||
| def show_context_menu(self, pos): | ||
| from PyQt6.QtWidgets import QMenu | ||
| from PyQt6.QtGui import QAction, QDesktopServices | ||
| from PyQt6.QtCore import QUrl | ||
| import os | ||
| global_pos = self.listwidget.viewport().mapToGlobal(pos) | ||
| selected_items = self.listwidget.selectedItems() | ||
| menu = QMenu(self) | ||
| if len(selected_items) == 1: | ||
| # Add Remove action | ||
| remove_action = QAction("Remove this item", self) | ||
| remove_action.triggered.connect(self.remove_item) | ||
| menu.addAction(remove_action) | ||
| # Get paths for determining if it's a document input | ||
| item = selected_items[0] | ||
| paths = item.data(Qt.ItemDataRole.UserRole) | ||
| if isinstance(paths, dict): | ||
| display_path = paths.get("display_path", "") | ||
| processing_path = paths.get("processing_path", "") | ||
| else: | ||
| display_path = paths | ||
| processing_path = paths | ||
| doc_exts = (".md", ".markdown", ".pdf", ".epub") | ||
| is_document_input = ( | ||
| isinstance(display_path, str) | ||
| and display_path.lower().endswith(doc_exts) | ||
| ) or ( | ||
| isinstance(processing_path, str) | ||
| and processing_path.lower().endswith(doc_exts) | ||
| ) | ||
| # Add Open file action(s) | ||
| def open_file_by_path(path_label: str): | ||
| from PyQt6.QtWidgets import QMessageBox | ||
| p = display_path if path_label == "display" else processing_path | ||
| if not p: | ||
| QMessageBox.warning( | ||
| self, "File Not Found", "Path is not available." | ||
| ) | ||
| return | ||
| # Find the queue item and resolve the target path | ||
| target_path = None | ||
| for q in self.queue: | ||
| if ( | ||
| getattr(q, "save_base_path", None) == display_path | ||
| or q.file_name == display_path | ||
| ): | ||
| if path_label == "display": | ||
| target_path = ( | ||
| getattr(q, "save_base_path", None) or q.file_name | ||
| ) | ||
| else: | ||
| target_path = q.file_name | ||
| break | ||
| if ( | ||
| getattr(q, "save_base_path", None) == processing_path | ||
| or q.file_name == processing_path | ||
| ): | ||
| if path_label == "display": | ||
| target_path = ( | ||
| getattr(q, "save_base_path", None) or q.file_name | ||
| ) | ||
| else: | ||
| target_path = q.file_name | ||
| break | ||
| # Fallback to the raw path if resolution failed | ||
| if not target_path: | ||
| target_path = p | ||
| if not os.path.exists(target_path): | ||
| QMessageBox.warning( | ||
| self, "File Not Found", f"The file does not exist." | ||
| ) | ||
| return | ||
| QDesktopServices.openUrl(QUrl.fromLocalFile(target_path)) | ||
| if is_document_input: | ||
| # For documents, show two open options | ||
| open_processed_action = QAction("Open processed file", self) | ||
| open_processed_action.triggered.connect( | ||
| lambda: open_file_by_path("processing") | ||
| ) | ||
| menu.addAction(open_processed_action) | ||
| open_input_action = QAction("Open input file", self) | ||
| open_input_action.triggered.connect( | ||
| lambda: open_file_by_path("display") | ||
| ) | ||
| menu.addAction(open_input_action) | ||
| else: | ||
| # For plain text files, show single open option | ||
| open_file_action = QAction("Open file", self) | ||
| open_file_action.triggered.connect(lambda: open_file_by_path("display")) | ||
| menu.addAction(open_file_action) | ||
| # Add Go to folder action | ||
| # If the queued item represents a converted document (markdown, pdf, epub) | ||
| # show two actions: Go to processed file (the cached .txt) and Go to input file (original source) | ||
| from PyQt6.QtWidgets import QMessageBox | ||
| def open_folder_for(path_label: str): | ||
| # path_label should be either 'display' or 'processing' | ||
| p = display_path if path_label == "display" else processing_path | ||
| if not p: | ||
| QMessageBox.warning( | ||
| self, "File Not Found", "Path is not available." | ||
| ) | ||
| return | ||
| # If the stored path is the display path (original) but the actual file may be | ||
| # stored on the queue object differently, try to resolve via the queue entry. | ||
| target_path = None | ||
| for q in self.queue: | ||
| if ( | ||
| getattr(q, "save_base_path", None) == display_path | ||
| or q.file_name == display_path | ||
| ): | ||
| if path_label == "display": | ||
| target_path = ( | ||
| getattr(q, "save_base_path", None) or q.file_name | ||
| ) | ||
| else: | ||
| target_path = q.file_name | ||
| break | ||
| if ( | ||
| getattr(q, "save_base_path", None) == processing_path | ||
| or q.file_name == processing_path | ||
| ): | ||
| if path_label == "display": | ||
| target_path = ( | ||
| getattr(q, "save_base_path", None) or q.file_name | ||
| ) | ||
| else: | ||
| target_path = q.file_name | ||
| break | ||
| # Fallback to the raw path if resolution failed | ||
| if not target_path: | ||
| target_path = p | ||
| if not os.path.exists(target_path): | ||
| QMessageBox.warning( | ||
| self, | ||
| "File Not Found", | ||
| f"The file does not exist: {target_path}", | ||
| ) | ||
| return | ||
| folder = os.path.dirname(target_path) | ||
| if os.path.exists(folder): | ||
| QDesktopServices.openUrl(QUrl.fromLocalFile(folder)) | ||
| if is_document_input: | ||
| processed_action = QAction("Go to processed file", self) | ||
| processed_action.triggered.connect( | ||
| lambda: open_folder_for("processing") | ||
| ) | ||
| menu.addAction(processed_action) | ||
| input_action = QAction("Go to input file", self) | ||
| input_action.triggered.connect(lambda: open_folder_for("display")) | ||
| menu.addAction(input_action) | ||
| else: | ||
| # Default behavior for non-document inputs: single "Go to folder" action | ||
| go_to_folder_action = QAction("Go to folder", self) | ||
| def go_to_folder(): | ||
| item = selected_items[0] | ||
| paths = item.data(Qt.ItemDataRole.UserRole) | ||
| if isinstance(paths, dict): | ||
| file_path = paths.get( | ||
| "display_path", paths.get("processing_path", "") | ||
| ) | ||
| else: | ||
| file_path = paths # Fallback for old format | ||
| # Find the queue item | ||
| for q in self.queue: | ||
| if ( | ||
| getattr(q, "save_base_path", None) == file_path | ||
| or q.file_name == file_path | ||
| ): | ||
| target_path = ( | ||
| getattr(q, "save_base_path", None) or q.file_name | ||
| ) | ||
| if not os.path.exists(target_path): | ||
| QMessageBox.warning( | ||
| self, "File Not Found", f"The file does not exist." | ||
| ) | ||
| return | ||
| folder = os.path.dirname(target_path) | ||
| if os.path.exists(folder): | ||
| QDesktopServices.openUrl(QUrl.fromLocalFile(folder)) | ||
| break | ||
| go_to_folder_action.triggered.connect(go_to_folder) | ||
| menu.addAction(go_to_folder_action) | ||
| elif len(selected_items) > 1: | ||
| remove_action = QAction(f"Remove selected ({len(selected_items)})", self) | ||
| remove_action.triggered.connect(self.remove_item) | ||
| menu.addAction(remove_action) | ||
| # Always add Clear Queue | ||
| clear_action = QAction("Clear Queue", self) | ||
| clear_action.triggered.connect(self.clear_queue) | ||
| menu.addAction(clear_action) | ||
| menu.exec(global_pos) | ||
| def accept(self): | ||
| # Save the override state to config so it persists globally | ||
| self.config["queue_override_settings"] = self.override_chk.isChecked() | ||
| save_config(self.config) | ||
| super().accept() | ||
| def reject(self): | ||
| # Cancel: restore original queue | ||
| from PyQt6.QtWidgets import QMessageBox | ||
| # Warn if user changed a lot (e.g., more than 1 items difference) | ||
| original_count = len(self._original_queue) | ||
| current_count = len(self.queue) | ||
| if abs(original_count - current_count) > 1: | ||
| reply = QMessageBox.question( | ||
| self, | ||
| "Confirm Cancel", | ||
| f"Are you sure you want to cancel and discard all changes?", | ||
| QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, | ||
| QMessageBox.StandardButton.No, | ||
| ) | ||
| if reply != QMessageBox.StandardButton.Yes: | ||
| return | ||
| self.queue.clear() | ||
| self.queue.extend(deepcopy(self._original_queue)) | ||
| super().reject() | ||
| def keyPressEvent(self, event): | ||
| from PyQt6.QtCore import Qt | ||
| if event.key() == Qt.Key.Key_Delete: | ||
| self.remove_item() | ||
| else: | ||
| super().keyPressEvent(event) |
| # represents a queued item - book, chapters, voice, etc. | ||
| from dataclasses import dataclass | ||
| @dataclass | ||
| class QueuedItem: | ||
| file_name: str | ||
| lang_code: str | ||
| speed: float | ||
| voice: str | ||
| save_option: str | ||
| output_folder: str | ||
| subtitle_mode: str | ||
| output_format: str | ||
| total_char_count: int | ||
| replace_single_newlines: bool = True | ||
| use_silent_gaps: bool = False | ||
| subtitle_speed_method: str = "tts" | ||
| save_base_path: str = None | ||
| save_chapters_separately: bool = None | ||
| merge_chapters_at_end: bool = None |
| """ | ||
| Lazy-loaded spaCy utilities for sentence segmentation. | ||
| """ | ||
| # Cached spaCy module and models (lazy loaded) | ||
| _spacy = None | ||
| _nlp_cache = {} | ||
| # Language code to spaCy model mapping | ||
| SPACY_MODELS = { | ||
| "a": "en_core_web_sm", # American English | ||
| "b": "en_core_web_sm", # British English | ||
| "e": "es_core_news_sm", # Spanish | ||
| "f": "fr_core_news_sm", # French | ||
| "i": "it_core_news_sm", # Italian | ||
| "p": "pt_core_news_sm", # Brazilian Portuguese | ||
| "z": "zh_core_web_sm", # Mandarin Chinese | ||
| "j": "ja_core_news_sm", # Japanese | ||
| "h": "xx_sent_ud_sm", # Hindi (multi-language model) | ||
| } | ||
| def _load_spacy(): | ||
| """Lazy load spaCy module.""" | ||
| global _spacy | ||
| if _spacy is None: | ||
| try: | ||
| import spacy | ||
| _spacy = spacy | ||
| except ImportError: | ||
| return None | ||
| return _spacy | ||
| def get_spacy_model(lang_code, log_callback=None): | ||
| """ | ||
| Get or load a spaCy model for the given language code. | ||
| Downloads the model automatically if not available. | ||
| Args: | ||
| lang_code: Language code (a, b, e, f, etc.) | ||
| log_callback: Optional function to log messages | ||
| Returns: | ||
| Loaded spaCy model or None if unavailable | ||
| """ | ||
| def log(msg, is_error=False): | ||
| # Prefer GUI log callback when provided to avoid spamming stdout. | ||
| if log_callback: | ||
| color = "red" if is_error else "grey" | ||
| try: | ||
| log_callback((msg, color)) | ||
| except Exception: | ||
| # Fallback to printing if callback misbehaves | ||
| print(msg) | ||
| else: | ||
| print(msg) | ||
| # Check if model is cached | ||
| if lang_code in _nlp_cache: | ||
| return _nlp_cache[lang_code] | ||
| # Check if language is supported | ||
| model_name = SPACY_MODELS.get(lang_code) | ||
| if not model_name: | ||
| log(f"\nspaCy: No model mapping for language '{lang_code}'...") | ||
| return None | ||
| # Lazy load spaCy | ||
| spacy = _load_spacy() | ||
| if spacy is None: | ||
| log("\nspaCy: Module not installed, falling back to default segmentation...") | ||
| return None | ||
| # Try to load the model | ||
| try: | ||
| log(f"\nLoading spaCy model '{model_name}'...") | ||
| # sentence segmentation involving parentheses, quotes, and complex structure. | ||
| # We only disable heavier components we don't need like NER. | ||
| nlp = spacy.load( | ||
| model_name, | ||
| disable=["ner", "tagger", "lemmatizer", "attribute_ruler"], | ||
| ) | ||
| # Ensure a sentence segmentation strategy is in place | ||
| # The parser provides sents, but if it's missing (unlikely for core models), fallback to sentencizer | ||
| if "parser" not in nlp.pipe_names and "sentencizer" not in nlp.pipe_names: | ||
| nlp.add_pipe("sentencizer") | ||
| _nlp_cache[lang_code] = nlp | ||
| return nlp | ||
| except OSError: | ||
| # Model not found, attempt download | ||
| log(f"\nspaCy: Downloading model '{model_name}'...") | ||
| try: | ||
| from spacy.cli import download | ||
| download(model_name) | ||
| # Retry loading with the same fix | ||
| nlp = spacy.load( | ||
| model_name, | ||
| disable=["ner", "tagger", "lemmatizer", "attribute_ruler"], | ||
| ) | ||
| if "parser" not in nlp.pipe_names and "sentencizer" not in nlp.pipe_names: | ||
| nlp.add_pipe("sentencizer") | ||
| _nlp_cache[lang_code] = nlp | ||
| log(f"spaCy model '{model_name}' downloaded and loaded") | ||
| return nlp | ||
| except Exception as e: | ||
| log( | ||
| f"\nspaCy: Failed to download model '{model_name}': {e}...", | ||
| is_error=True, | ||
| ) | ||
| return None | ||
| except Exception as e: | ||
| log(f"\nspaCy: Error loading model '{model_name}': {e}...", is_error=True) | ||
| return None | ||
| def segment_sentences(text, lang_code, log_callback=None): | ||
| """ | ||
| Segment text into sentences using spaCy. | ||
| Args: | ||
| text: Text to segment | ||
| lang_code: Language code | ||
| log_callback: Optional function to log messages | ||
| Returns: | ||
| List of sentence strings, or None if spaCy unavailable | ||
| """ | ||
| nlp = get_spacy_model(lang_code, log_callback) | ||
| if nlp is None: | ||
| return None | ||
| # Ensure spaCy can handle large texts by adjusting max_length if necessary | ||
| try: | ||
| text_len = len(text or "") | ||
| if text_len and hasattr(nlp, "max_length") and text_len > nlp.max_length: | ||
| # increase a bit beyond the text length to be safe | ||
| nlp.max_length = text_len + 1000 | ||
| except Exception: | ||
| pass | ||
| # Process text and extract sentences | ||
| doc = nlp(text) | ||
| return [sent.text.strip() for sent in doc.sents if sent.text.strip()] | ||
| def is_spacy_available(): | ||
| """Check if spaCy can be imported.""" | ||
| return _load_spacy() is not None | ||
| def clear_cache(): | ||
| """Clear the model cache to free memory.""" | ||
| global _nlp_cache | ||
| _nlp_cache.clear() |
-374
| import os | ||
| import sys | ||
| import json | ||
| import warnings | ||
| import platform | ||
| import shutil | ||
| import subprocess | ||
| import re | ||
| from threading import Thread | ||
| warnings.filterwarnings("ignore") | ||
| # Pre-compile frequently used regex patterns for better performance | ||
| _WHITESPACE_PATTERN = re.compile(r"[^\S\n]+") | ||
| _MULTIPLE_NEWLINES_PATTERN = re.compile(r"\n{3,}") | ||
| _SINGLE_NEWLINE_PATTERN = re.compile(r"(?<!\n)\n(?!\n)") | ||
| _CHAPTER_MARKER_PATTERN = re.compile(r"<<CHAPTER_MARKER:.*?>>") | ||
| _METADATA_PATTERN = re.compile(r"<<METADATA_[^:]+:[^>]*>>") | ||
| def detect_encoding(file_path): | ||
| import chardet | ||
| import charset_normalizer | ||
| with open(file_path, "rb") as f: | ||
| raw_data = f.read() | ||
| detected_encoding = None | ||
| for detectors in (charset_normalizer, chardet): | ||
| try: | ||
| result = detectors.detect(raw_data)["encoding"] | ||
| except Exception: | ||
| continue | ||
| if result is not None: | ||
| detected_encoding = result | ||
| break | ||
| encoding = detected_encoding if detected_encoding else "utf-8" | ||
| return encoding.lower() | ||
| def get_resource_path(package, resource): | ||
| """ | ||
| Get the path to a resource file, with fallback to local file system. | ||
| Args: | ||
| package (str): Package name containing the resource (e.g., 'abogen.assets') | ||
| resource (str): Resource filename (e.g., 'icon.ico') | ||
| Returns: | ||
| str: Path to the resource file, or None if not found | ||
| """ | ||
| from importlib import resources | ||
| # Try using importlib.resources first | ||
| try: | ||
| with resources.path(package, resource) as resource_path: | ||
| if os.path.exists(resource_path): | ||
| return str(resource_path) | ||
| except (ImportError, FileNotFoundError): | ||
| pass | ||
| # Always try to resolve as a relative path from this file | ||
| parts = package.split(".") | ||
| rel_path = os.path.join( | ||
| os.path.dirname(os.path.abspath(__file__)), *parts[1:], resource | ||
| ) | ||
| if os.path.exists(rel_path): | ||
| return rel_path | ||
| # Fallback to local file system | ||
| try: | ||
| # Extract the subdirectory from package name (e.g., 'assets' from 'abogen.assets') | ||
| subdir = package.split(".")[-1] if "." in package else package | ||
| local_path = os.path.join( | ||
| os.path.dirname(os.path.abspath(__file__)), subdir, resource | ||
| ) | ||
| if os.path.exists(local_path): | ||
| return local_path | ||
| except Exception: | ||
| pass | ||
| return None | ||
| def get_version(): | ||
| """Return the current version of the application.""" | ||
| try: | ||
| with open(get_resource_path("/", "VERSION"), "r") as f: | ||
| return f.read().strip() | ||
| except Exception: | ||
| return "Unknown" | ||
| # Define config path | ||
| def get_user_config_path(): | ||
| from platformdirs import user_config_dir | ||
| # TODO Config directory is changed for Linux and MacOS. But if old config exists, it will be used. | ||
| # On non‑Windows, prefer ~/.config/abogen if it already exists | ||
| if platform.system() != "Windows": | ||
| custom_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen") | ||
| if os.path.exists(custom_dir): | ||
| config_dir = custom_dir | ||
| else: | ||
| config_dir = user_config_dir( | ||
| "abogen", appauthor=False, roaming=True, ensure_exists=True | ||
| ) | ||
| else: | ||
| # Windows and fallback case | ||
| config_dir = user_config_dir( | ||
| "abogen", appauthor=False, roaming=True, ensure_exists=True | ||
| ) | ||
| return os.path.join(config_dir, "config.json") | ||
| # Define cache path | ||
| def get_user_cache_path(folder=None): | ||
| from platformdirs import user_cache_dir | ||
| cache_dir = user_cache_dir( | ||
| "abogen", appauthor=False, opinion=True, ensure_exists=True | ||
| ) | ||
| if folder: | ||
| cache_dir = os.path.join(cache_dir, folder) | ||
| # Ensure the directory exists | ||
| os.makedirs(cache_dir, exist_ok=True) | ||
| return cache_dir | ||
| _sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes | ||
| def clean_text(text, *args, **kwargs): | ||
| # Load replace_single_newlines from config | ||
| cfg = load_config() | ||
| replace_single_newlines = cfg.get("replace_single_newlines", True) | ||
| # Collapse all whitespace (excluding newlines) into single spaces per line and trim edges | ||
| # Use pre-compiled pattern for better performance | ||
| lines = [_WHITESPACE_PATTERN.sub(" ", line).strip() for line in text.splitlines()] | ||
| text = "\n".join(lines) | ||
| # Standardize paragraph breaks (multiple newlines become exactly two) and trim overall whitespace | ||
| # Use pre-compiled pattern for better performance | ||
| text = _MULTIPLE_NEWLINES_PATTERN.sub("\n\n", text).strip() | ||
| # Optionally replace single newlines with spaces, but preserve double newlines | ||
| if replace_single_newlines: | ||
| # Use pre-compiled pattern for better performance | ||
| text = _SINGLE_NEWLINE_PATTERN.sub(" ", text) | ||
| return text | ||
| default_encoding = sys.getfilesystemencoding() | ||
| def create_process(cmd, stdin=None, text=True, capture_output=False): | ||
| import logging | ||
| logger = logging.getLogger(__name__) | ||
| # Configure root logger to output to console if not already configured | ||
| root = logging.getLogger() | ||
| if not root.handlers: | ||
| handler = logging.StreamHandler(sys.stdout) | ||
| formatter = logging.Formatter("%(message)s") | ||
| handler.setFormatter(formatter) | ||
| root.addHandler(handler) | ||
| root.setLevel(logging.INFO) | ||
| # Determine shell usage: use shell only for string commands | ||
| use_shell = isinstance(cmd, str) | ||
| kwargs = { | ||
| "shell": use_shell, | ||
| "stdout": subprocess.PIPE, | ||
| "stderr": subprocess.STDOUT, | ||
| "bufsize": 1, # Line buffered | ||
| } | ||
| if text: | ||
| # Configure for text I/O | ||
| kwargs["text"] = True | ||
| kwargs["encoding"] = default_encoding | ||
| kwargs["errors"] = "replace" | ||
| else: | ||
| # Configure for binary I/O | ||
| kwargs["text"] = False | ||
| # For binary mode, 'encoding' and 'errors' arguments must not be passed to Popen | ||
| kwargs["bufsize"] = 0 # Use unbuffered mode for binary data | ||
| if stdin is not None: | ||
| kwargs["stdin"] = stdin | ||
| if platform.system() == "Windows": | ||
| startupinfo = subprocess.STARTUPINFO() | ||
| startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW | ||
| startupinfo.wShowWindow = subprocess.SW_HIDE | ||
| kwargs.update( | ||
| {"startupinfo": startupinfo, "creationflags": subprocess.CREATE_NO_WINDOW} | ||
| ) | ||
| # Print the command being executed | ||
| print(f"Executing: {cmd if isinstance(cmd, str) else ' '.join(cmd)}") | ||
| proc = subprocess.Popen(cmd, **kwargs) | ||
| # Stream output to console in real-time if not capturing | ||
| if proc.stdout and not capture_output: | ||
| def _stream_output(stream): | ||
| if text: | ||
| # For text mode, read character by character for real-time output | ||
| while True: | ||
| char = stream.read(1) | ||
| if not char: | ||
| break | ||
| # Direct write to stdout for immediate feedback | ||
| sys.stdout.write(char) | ||
| sys.stdout.flush() | ||
| else: | ||
| # For binary mode, read small chunks | ||
| while True: | ||
| chunk = stream.read(1) # Read byte by byte for real-time output | ||
| if not chunk: | ||
| break | ||
| try: | ||
| # Try to decode binary data for display | ||
| sys.stdout.write( | ||
| chunk.decode(default_encoding, errors="replace") | ||
| ) | ||
| sys.stdout.flush() | ||
| except Exception: | ||
| pass | ||
| stream.close() | ||
| # Start a daemon thread to handle output streaming | ||
| Thread(target=_stream_output, args=(proc.stdout,), daemon=True).start() | ||
| return proc | ||
| def load_config(): | ||
| try: | ||
| with open(get_user_config_path(), "r", encoding="utf-8") as f: | ||
| return json.load(f) | ||
| except Exception: | ||
| return {} | ||
| def save_config(config): | ||
| try: | ||
| with open(get_user_config_path(), "w", encoding="utf-8") as f: | ||
| json.dump(config, f, indent=2) | ||
| except Exception: | ||
| pass | ||
| def calculate_text_length(text): | ||
| # Use pre-compiled patterns for better performance | ||
| # Ignore chapter markers and metadata patterns in a single pass | ||
| text = _CHAPTER_MARKER_PATTERN.sub("", text) | ||
| text = _METADATA_PATTERN.sub("", text) | ||
| # Ignore newlines and leading/trailing spaces | ||
| text = text.replace("\n", "").strip() | ||
| # Calculate character count | ||
| char_count = len(text) | ||
| return char_count | ||
| def get_gpu_acceleration(enabled): | ||
| """ | ||
| Check GPU acceleration availability. | ||
| Note: On Windows, torch DLLs must be pre-loaded in main.py before PyQt6 | ||
| to avoid DLL initialization errors. | ||
| """ | ||
| try: | ||
| import torch | ||
| from torch.cuda import is_available as cuda_available | ||
| if not enabled: | ||
| return "GPU available but using CPU.", False | ||
| # Check for Apple Silicon MPS | ||
| if platform.system() == "Darwin" and platform.processor() == "arm": | ||
| if torch.backends.mps.is_available(): | ||
| return "MPS GPU available and enabled.", True | ||
| else: | ||
| return "MPS GPU not available on Apple Silicon. Using CPU.", False | ||
| # Check for CUDA | ||
| if cuda_available(): | ||
| return "CUDA GPU available and enabled.", True | ||
| # Gather CUDA diagnostic info if not available | ||
| try: | ||
| cuda_devices = torch.cuda.device_count() | ||
| cuda_error = ( | ||
| torch.cuda.get_device_name(0) | ||
| if cuda_devices > 0 | ||
| else "No devices found" | ||
| ) | ||
| except Exception as e: | ||
| cuda_error = str(e) | ||
| return f"CUDA GPU is not available. Using CPU. ({cuda_error})", False | ||
| except Exception as e: | ||
| return f"Error checking GPU: {e}", False | ||
| def prevent_sleep_start(): | ||
| from abogen.constants import PROGRAM_NAME | ||
| system = platform.system() | ||
| if system == "Windows": | ||
| import ctypes | ||
| ctypes.windll.kernel32.SetThreadExecutionState( | ||
| 0x80000000 | 0x00000001 | 0x00000040 | ||
| ) | ||
| elif system == "Darwin": | ||
| _sleep_procs["Darwin"] = create_process(["caffeinate"]) | ||
| elif system == "Linux": | ||
| # Add program name and reason for inhibition | ||
| program_name = PROGRAM_NAME | ||
| reason = "Prevent sleep during abogen process" | ||
| # Only attempt to use systemd-inhibit if it's available on the system. | ||
| if shutil.which("systemd-inhibit"): | ||
| _sleep_procs["Linux"] = create_process( | ||
| [ | ||
| "systemd-inhibit", | ||
| f"--who={program_name}", | ||
| f"--why={reason}", | ||
| "--what=sleep", | ||
| "--mode=block", | ||
| "sleep", | ||
| "infinity", | ||
| ] | ||
| ) | ||
| else: | ||
| # Non-systemd distro or systemd tools not installed: skip inhibition rather than crash | ||
| print( | ||
| "systemd-inhibit not found: skipping sleep inhibition on this Linux system." | ||
| ) | ||
| def prevent_sleep_end(): | ||
| system = platform.system() | ||
| if system == "Windows": | ||
| import ctypes | ||
| ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # ES_CONTINUOUS | ||
| elif system in ("Darwin", "Linux") and _sleep_procs[system]: | ||
| try: | ||
| _sleep_procs[system].terminate() | ||
| _sleep_procs[system] = None | ||
| except Exception: | ||
| pass | ||
| def load_numpy_kpipeline(): | ||
| import numpy as np | ||
| from kokoro import KPipeline | ||
| return np, KPipeline | ||
| class LoadPipelineThread(Thread): | ||
| def __init__(self, callback): | ||
| super().__init__() | ||
| self.callback = callback | ||
| def run(self): | ||
| try: | ||
| np_module, kpipeline_class = load_numpy_kpipeline() | ||
| self.callback(np_module, kpipeline_class, None) | ||
| except Exception as e: | ||
| self.callback(None, None, str(e)) |
| 1.2.5 |
| import json | ||
| import os | ||
| from PyQt6.QtWidgets import ( | ||
| QDialog, | ||
| QVBoxLayout, | ||
| QCheckBox, | ||
| QLabel, | ||
| QHBoxLayout, | ||
| QDoubleSpinBox, | ||
| QSlider, | ||
| QScrollArea, | ||
| QWidget, | ||
| QPushButton, | ||
| QSizePolicy, | ||
| QMessageBox, | ||
| QFrame, | ||
| QLayout, | ||
| QStyle, | ||
| QListWidget, | ||
| QListWidgetItem, | ||
| QInputDialog, | ||
| QFileDialog, | ||
| QSplitter, | ||
| QMenu, | ||
| QApplication, | ||
| QComboBox, | ||
| ) | ||
| from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize | ||
| from PyQt6.QtGui import QPixmap, QIcon, QAction | ||
| from abogen.constants import ( | ||
| VOICES_INTERNAL, | ||
| SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, | ||
| LANGUAGE_DESCRIPTIONS, | ||
| COLORS, | ||
| ) | ||
| import re | ||
| import platform | ||
| from abogen.utils import get_resource_path | ||
| from abogen.voice_profiles import ( | ||
| load_profiles, | ||
| save_profiles, | ||
| delete_profile, | ||
| duplicate_profile, | ||
| export_profiles, | ||
| ) | ||
| # Constants | ||
| VOICE_MIXER_WIDTH = 100 | ||
| SLIDER_WIDTH = 32 | ||
| MIN_WINDOW_WIDTH = 600 | ||
| MIN_WINDOW_HEIGHT = 400 | ||
| INITIAL_WINDOW_WIDTH = 1200 | ||
| INITIAL_WINDOW_HEIGHT = 500 | ||
| # Language options for the language selector loaded from constants | ||
| LANGUAGE_OPTIONS = list(LANGUAGE_DESCRIPTIONS.items()) | ||
| class SaveButtonWidget(QWidget): | ||
| def __init__(self, parent, profile_name, save_callback): | ||
| super().__init__(parent) | ||
| layout = QHBoxLayout(self) | ||
| layout.setContentsMargins(0, 0, 0, 0) | ||
| self.save_btn = QPushButton("Save", self) | ||
| self.save_btn.setFixedWidth(48) | ||
| self.save_btn.clicked.connect(lambda: save_callback(profile_name)) | ||
| layout.addStretch() | ||
| layout.addWidget(self.save_btn) | ||
| self.setLayout(layout) | ||
| class FlowLayout(QLayout): | ||
| def __init__(self, parent=None, margin=0, spacing=-1): | ||
| super().__init__(parent) | ||
| if parent: | ||
| self.setContentsMargins(margin, margin, margin, margin) | ||
| self.setSpacing(spacing) | ||
| self._item_list = [] | ||
| def __del__(self): | ||
| item = self.takeAt(0) | ||
| while item: | ||
| item = self.takeAt(0) | ||
| def addItem(self, item): | ||
| self._item_list.append(item) | ||
| def count(self): | ||
| return len(self._item_list) | ||
| def expandingDirections(self): | ||
| return Qt.Orientation(0) | ||
| def hasHeightForWidth(self): | ||
| return True | ||
| def sizeHint(self): | ||
| return self.minimumSize() | ||
| def itemAt(self, index): | ||
| if 0 <= index < len(self._item_list): | ||
| return self._item_list[index] | ||
| return None | ||
| def takeAt(self, index): | ||
| if 0 <= index < len(self._item_list): | ||
| return self._item_list.pop(index) | ||
| return None | ||
| def heightForWidth(self, width): | ||
| return self._do_layout(QRect(0, 0, width, 0), True) | ||
| def setGeometry(self, rect): | ||
| super().setGeometry(rect) | ||
| self._do_layout(rect, False) | ||
| def minimumSize(self): | ||
| size = QSize() | ||
| for item in self._item_list: | ||
| size = size.expandedTo(item.minimumSize()) | ||
| margin, _, _, _ = self.getContentsMargins() | ||
| size += QSize(2 * margin, 2 * margin) | ||
| return size | ||
| def _do_layout(self, rect, test_only): | ||
| x, y = rect.x(), rect.y() | ||
| line_height = 0 | ||
| spacing = self.spacing() | ||
| for item in self._item_list: | ||
| style = self.parentWidget().style() if self.parentWidget() else QStyle() | ||
| layout_spacing_x = style.layoutSpacing( | ||
| QSizePolicy.ControlType.PushButton, | ||
| QSizePolicy.ControlType.PushButton, | ||
| Qt.Orientation.Horizontal, | ||
| ) | ||
| layout_spacing_y = style.layoutSpacing( | ||
| QSizePolicy.ControlType.PushButton, | ||
| QSizePolicy.ControlType.PushButton, | ||
| Qt.Orientation.Vertical, | ||
| ) | ||
| space_x = spacing if spacing >= 0 else layout_spacing_x | ||
| space_y = spacing if spacing >= 0 else layout_spacing_y | ||
| next_x = x + item.sizeHint().width() + space_x | ||
| if next_x - space_x > rect.right() and line_height > 0: | ||
| x = rect.x() | ||
| y = y + line_height + space_y | ||
| next_x = x + item.sizeHint().width() + space_x | ||
| line_height = 0 | ||
| if not test_only: | ||
| item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) | ||
| x = next_x | ||
| line_height = max(line_height, item.sizeHint().height()) | ||
| return y + line_height - rect.y() | ||
| class VoiceMixer(QWidget): | ||
| def __init__( | ||
| self, voice_name, language_code, initial_status=False, initial_weight=0.0 | ||
| ): | ||
| super().__init__() | ||
| self.voice_name = voice_name | ||
| self.setFixedWidth(VOICE_MIXER_WIDTH) | ||
| self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) | ||
| # TODO Set CSS for rounded corners | ||
| # self.setObjectName("VoiceMixer") | ||
| # self.setStyleSheet(self.ROUNDED_CSS) | ||
| layout = QVBoxLayout() | ||
| # Name label at the top | ||
| name = voice_name | ||
| layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter) | ||
| # Voice name label with gender icon | ||
| is_female = self.voice_name in VOICES_INTERNAL and self.voice_name[1] == "f" | ||
| # Icons layout (flag and gender) | ||
| icons_layout = QHBoxLayout() | ||
| icons_layout.setSpacing(3) | ||
| icons_layout.setAlignment( | ||
| Qt.AlignmentFlag.AlignCenter | ||
| ) # Center the icons horizontally | ||
| # Flag icon | ||
| flag_icon_path = get_resource_path( | ||
| "abogen.assets.flags", f"{language_code}.png" | ||
| ) | ||
| gender_icon_path = get_resource_path( | ||
| "abogen.assets", "female.png" if is_female else "male.png" | ||
| ) | ||
| flag_label = QLabel() | ||
| gender_label = QLabel() | ||
| flag_pixmap = QPixmap(flag_icon_path) | ||
| flag_label.setPixmap( | ||
| flag_pixmap.scaled( | ||
| 16, | ||
| 16, | ||
| Qt.AspectRatioMode.KeepAspectRatio, | ||
| Qt.TransformationMode.SmoothTransformation, | ||
| ) | ||
| ) | ||
| gender_pixmap = QPixmap(gender_icon_path) | ||
| gender_label.setPixmap( | ||
| gender_pixmap.scaled( | ||
| 16, | ||
| 16, | ||
| Qt.AspectRatioMode.KeepAspectRatio, | ||
| Qt.TransformationMode.SmoothTransformation, | ||
| ) | ||
| ) | ||
| icons_layout.addWidget(flag_label) | ||
| icons_layout.addWidget(gender_label) | ||
| # Add icons layout | ||
| layout.addLayout(icons_layout) | ||
| # Checkbox (now below icons) | ||
| self.checkbox = QCheckBox() | ||
| self.checkbox.setChecked(initial_status) | ||
| self.checkbox.stateChanged.connect(self.toggle_inputs) | ||
| layout.addWidget(self.checkbox, alignment=Qt.AlignmentFlag.AlignCenter) | ||
| # Spinbox and slider | ||
| self.spin_box = QDoubleSpinBox() | ||
| self.spin_box.setRange(0, 1) | ||
| self.spin_box.setSingleStep(0.01) | ||
| self.spin_box.setDecimals(2) | ||
| self.spin_box.setValue(initial_weight) | ||
| self.slider = QSlider(Qt.Orientation.Vertical) | ||
| self.slider.setRange(0, 100) | ||
| self.slider.setValue(int(initial_weight * 100)) | ||
| self.slider.setSizePolicy( | ||
| QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding | ||
| ) | ||
| self.slider.setFixedWidth(SLIDER_WIDTH) | ||
| # Apply slider styling after widget is added to window (see showEvent) | ||
| self._slider_style_applied = False | ||
| # Connect controls | ||
| self.slider.valueChanged.connect(lambda val: self.spin_box.setValue(val / 100)) | ||
| self.spin_box.valueChanged.connect( | ||
| lambda val: self.slider.setValue(int(val * 100)) | ||
| ) | ||
| # Layout for slider and labels | ||
| slider_layout = QVBoxLayout() | ||
| slider_layout.addWidget(self.spin_box) | ||
| slider_layout.addWidget(QLabel("1", alignment=Qt.AlignmentFlag.AlignCenter)) | ||
| slider_center_layout = QHBoxLayout() | ||
| slider_center_layout.addWidget( | ||
| self.slider, alignment=Qt.AlignmentFlag.AlignHCenter | ||
| ) | ||
| slider_center_layout.setContentsMargins(0, 0, 0, 0) | ||
| slider_center_widget = QWidget() | ||
| slider_center_widget.setLayout(slider_center_layout) | ||
| slider_layout.addWidget(slider_center_widget, stretch=1) | ||
| slider_layout.addWidget(QLabel("0", alignment=Qt.AlignmentFlag.AlignCenter)) | ||
| slider_layout.setStretch(2, 1) | ||
| layout.addLayout(slider_layout, stretch=1) | ||
| self.setLayout(layout) | ||
| self.toggle_inputs() | ||
| def showEvent(self, event): | ||
| super().showEvent(event) | ||
| # Apply slider styling once when widget is shown and has access to parent | ||
| if not self._slider_style_applied: | ||
| self._slider_style_applied = True | ||
| # Fix slider in Windows | ||
| if platform.system() == "Windows": | ||
| appstyle = QApplication.instance().style().objectName().lower() | ||
| if appstyle != "windowsvista": | ||
| # Set custom groove color for disabled state using COLORS["GREY_BACKGROUND"] | ||
| self.slider.setStyleSheet( | ||
| f""" | ||
| QSlider::groove:vertical:disabled {{ | ||
| background: {COLORS.get("GREY_BACKGROUND")}; | ||
| width: 4px; | ||
| border-radius: 4px; | ||
| }} | ||
| """ | ||
| ) | ||
| else: | ||
| # Apply same fix for Light theme on non-Windows systems | ||
| # Get theme from parent window's config | ||
| parent_window = self.window() | ||
| theme = "system" | ||
| while parent_window: | ||
| if hasattr(parent_window, "config"): | ||
| theme = parent_window.config.get("theme", "system") | ||
| break | ||
| parent_window = parent_window.parent() | ||
| if theme == "light": | ||
| self.slider.setStyleSheet( | ||
| f""" | ||
| QSlider::groove:vertical:disabled {{ | ||
| background: {COLORS.get("GREY_BACKGROUND")}; | ||
| width: 4px; | ||
| border-radius: 4px; | ||
| }} | ||
| """ | ||
| ) | ||
| def toggle_inputs(self): | ||
| is_enabled = self.checkbox.isChecked() | ||
| self.spin_box.setEnabled(is_enabled) | ||
| self.slider.setEnabled(is_enabled) | ||
| def get_voice_weight(self): | ||
| if self.checkbox.isChecked(): | ||
| return self.voice_name, self.spin_box.value() | ||
| return None | ||
| class HoverLabel(QLabel): | ||
| def __init__(self, text, voice_name, parent=None): | ||
| super().__init__(text, parent) | ||
| self.voice_name = voice_name | ||
| self.setMouseTracking(True) | ||
| self.setStyleSheet( | ||
| "background-color: rgba(140, 140, 140, 0.15); border-radius: 4px; padding: 3px 6px 3px 6px; margin: 2px;" | ||
| ) | ||
| # Create delete button | ||
| self.delete_button = QPushButton("×", self) | ||
| self.delete_button.setFixedSize(16, 16) | ||
| self.delete_button.setStyleSheet( | ||
| f""" | ||
| QPushButton {{ | ||
| background-color: {COLORS.get("RED")}; | ||
| color: white; | ||
| border-radius: 7px; | ||
| font-weight: bold; | ||
| font-size: 12px; | ||
| border: none; | ||
| padding: 0px; | ||
| margin: 0px; | ||
| }} | ||
| QPushButton:hover {{ | ||
| background-color: red; | ||
| }} | ||
| """ | ||
| ) | ||
| # Make sure the entire button is clickable, not just the text | ||
| self.delete_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) | ||
| self.delete_button.setAttribute( | ||
| Qt.WidgetAttribute.WA_TransparentForMouseEvents, False | ||
| ) | ||
| self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor) | ||
| self.delete_button.hide() | ||
| def resizeEvent(self, event): | ||
| super().resizeEvent(event) | ||
| # Position the button in the top-right corner with a small margin | ||
| self.delete_button.move(self.width() - 16, +0) | ||
| def enterEvent(self, event): | ||
| self.delete_button.show() | ||
| def leaveEvent(self, event): | ||
| self.delete_button.hide() | ||
| class VoiceFormulaDialog(QDialog): | ||
| def __init__(self, parent=None, initial_state=None, selected_profile=None): | ||
| super().__init__(parent) | ||
| # Store original profile/mix state for restoration on cancel | ||
| self._original_profile_name = None | ||
| self._original_mixed_voice_state = None | ||
| if parent is not None: | ||
| self._original_profile_name = getattr(parent, "selected_profile_name", None) | ||
| self._original_mixed_voice_state = getattr( | ||
| parent, "mixed_voice_state", None | ||
| ) | ||
| profiles = load_profiles() | ||
| self._virtual_new_profile = False | ||
| if not profiles: | ||
| # No profiles: show 'New profile' in the list, unsaved, not in JSON | ||
| self.current_profile = "New profile" | ||
| self._profile_dirty = {"New profile": True} | ||
| self._virtual_new_profile = True | ||
| profiles = {} # Do not add to JSON yet | ||
| else: | ||
| self.current_profile = ( | ||
| selected_profile | ||
| if selected_profile in profiles | ||
| else list(profiles.keys())[0] | ||
| ) | ||
| self._profile_dirty = {name: False for name in profiles} | ||
| # Track unsaved states per profile | ||
| self._profile_states = {} | ||
| # Add subtitle_combo reference if parent has it | ||
| self.subtitle_combo = None | ||
| if parent is not None and hasattr(parent, "subtitle_combo"): | ||
| self.subtitle_combo = parent.subtitle_combo | ||
| # Create main container layout with profile section and mixer section | ||
| splitter = QSplitter(Qt.Orientation.Horizontal) | ||
| # Profile section | ||
| profile_widget = QWidget() | ||
| profile_layout = QVBoxLayout(profile_widget) | ||
| profile_layout.setContentsMargins(0, 0, 0, 0) | ||
| # Profile header and save/new buttons | ||
| header_layout = QHBoxLayout() | ||
| header_layout.addWidget(QLabel("Profiles:")) | ||
| header_layout.addStretch() | ||
| self.btn_new_profile = QPushButton("New profile") | ||
| header_layout.addWidget(self.btn_new_profile) | ||
| profile_layout.addLayout(header_layout) | ||
| # Profile list | ||
| self.profile_list = QListWidget() | ||
| self.profile_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection) | ||
| self.profile_list.setSelectionBehavior(QListWidget.SelectionBehavior.SelectRows) | ||
| self.profile_list.setStyleSheet( | ||
| "QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }" | ||
| ) | ||
| icon = QIcon(get_resource_path("abogen.assets", "profile.png")) | ||
| if self._virtual_new_profile: | ||
| item = QListWidgetItem(icon, "New profile") | ||
| self.profile_list.addItem(item) | ||
| self.profile_list.setCurrentRow(0) | ||
| else: | ||
| for name in profiles: | ||
| item = QListWidgetItem(icon, name) | ||
| self.profile_list.addItem(item) | ||
| idx = list(profiles.keys()).index(self.current_profile) | ||
| self.profile_list.setCurrentRow(idx) | ||
| profile_layout.addWidget(self.profile_list) | ||
| self.profile_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) | ||
| self.profile_list.customContextMenuRequested.connect( | ||
| self.show_profile_context_menu | ||
| ) | ||
| self.profile_list.setItemWidget = ( | ||
| self.profile_list.setItemWidget | ||
| ) # for type hints | ||
| # Save and management buttons | ||
| mgmt_layout = QVBoxLayout() | ||
| self.btn_import_profiles = QPushButton("Import profile(s)") | ||
| mgmt_layout.addWidget(self.btn_import_profiles) | ||
| self.btn_export_profiles = QPushButton("Export profiles") | ||
| mgmt_layout.addWidget(self.btn_export_profiles) | ||
| profile_layout.addLayout(mgmt_layout) | ||
| # prepare mixer widget | ||
| mixer_widget = QWidget() | ||
| mixer_layout = QVBoxLayout(mixer_widget) | ||
| mixer_layout.setContentsMargins(5, 0, 0, 0) | ||
| self.setWindowTitle("Voice Mixer") | ||
| self.setWindowFlags( | ||
| Qt.WindowType.Window | ||
| | Qt.WindowType.WindowCloseButtonHint | ||
| | Qt.WindowType.WindowMaximizeButtonHint | ||
| ) | ||
| self.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) | ||
| self.resize(INITIAL_WINDOW_WIDTH, INITIAL_WINDOW_HEIGHT) | ||
| self.voice_mixers = [] | ||
| self.last_enabled_voice = None | ||
| # Header label and language selector | ||
| self.header_label = QLabel( | ||
| "Adjust voice weights to create your preferred voice mix." | ||
| ) | ||
| self.header_label.setStyleSheet("font-size: 13px;") | ||
| self.header_label.setWordWrap(True) | ||
| header_row = QHBoxLayout() | ||
| header_row.addWidget(self.header_label, 1) | ||
| header_row.addStretch() | ||
| header_row.addWidget(QLabel("Language:")) | ||
| self.language_combo = QComboBox() | ||
| for code, desc in LANGUAGE_OPTIONS: | ||
| flag = get_resource_path("abogen.assets.flags", f"{code}.png") | ||
| if flag and os.path.exists(flag): | ||
| self.language_combo.addItem(QIcon(flag), desc, code) | ||
| else: | ||
| self.language_combo.addItem(desc, code) | ||
| # set current language for profile | ||
| prof = profiles.get(self.current_profile, {}) | ||
| lang = prof.get("language") if isinstance(prof, dict) else None | ||
| if not lang: | ||
| lang = list(LANGUAGE_DESCRIPTIONS.keys())[0] | ||
| idx = self.language_combo.findData(lang) | ||
| if idx >= 0: | ||
| self.language_combo.setCurrentIndex(idx) | ||
| self.language_combo.currentIndexChanged.connect(self.mark_profile_modified) | ||
| header_row.addWidget(self.language_combo) | ||
| # Preview current voice mix using main window's preview | ||
| self.btn_preview_mix = QPushButton("Preview", self) | ||
| self.btn_preview_mix.setToolTip("Preview current voice mix") | ||
| self.btn_preview_mix.clicked.connect(self.preview_current_mix) | ||
| header_row.addWidget(self.btn_preview_mix) | ||
| mixer_layout.addLayout(header_row) | ||
| # Error message | ||
| self.error_label = QLabel( | ||
| "Please select at least one voice and set its weight above 0." | ||
| ) | ||
| self.error_label.setStyleSheet("color: red; font-weight: bold;") | ||
| self.error_label.setWordWrap(True) | ||
| self.error_label.hide() | ||
| mixer_layout.addWidget(self.error_label) | ||
| # Voice weights display | ||
| self.weighted_sums_container = QWidget() | ||
| self.weighted_sums_layout = FlowLayout(self.weighted_sums_container) | ||
| self.weighted_sums_layout.setSpacing(5) | ||
| self.weighted_sums_layout.setContentsMargins(5, 5, 5, 5) | ||
| mixer_layout.addWidget(self.weighted_sums_container) | ||
| # Separator | ||
| separator = QFrame() | ||
| separator.setFrameShadow(QFrame.Shadow.Sunken) | ||
| mixer_layout.addWidget(separator) | ||
| # Voice list scroll area | ||
| self.scroll_area = QScrollArea() | ||
| self.scroll_area.setWidgetResizable(True) | ||
| self.scroll_area.setHorizontalScrollBarPolicy( | ||
| Qt.ScrollBarPolicy.ScrollBarAsNeeded | ||
| ) | ||
| self.scroll_area.setVerticalScrollBarPolicy( | ||
| Qt.ScrollBarPolicy.ScrollBarAsNeeded | ||
| ) | ||
| self.scroll_area.viewport().installEventFilter(self) | ||
| self.voice_list_widget = QWidget() | ||
| self.voice_list_layout = QHBoxLayout() | ||
| self.voice_list_widget.setLayout(self.voice_list_layout) | ||
| self.voice_list_widget.setSizePolicy( | ||
| QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding | ||
| ) | ||
| self.scroll_area.setWidget(self.voice_list_widget) | ||
| mixer_layout.addWidget(self.scroll_area, stretch=1) | ||
| # Buttons | ||
| button_layout = QHBoxLayout() | ||
| clear_all_button = QPushButton("Clear all") | ||
| ok_button = QPushButton("OK") | ||
| cancel_button = QPushButton("Cancel") | ||
| # Set OK button as default | ||
| ok_button.setDefault(True) | ||
| ok_button.setFocus() | ||
| # Connect buttons | ||
| clear_all_button.clicked.connect(self.clear_all_voices) | ||
| ok_button.clicked.connect(self.accept) | ||
| # Connect buttons | ||
| clear_all_button.clicked.connect(self.clear_all_voices) | ||
| ok_button.clicked.connect(self.accept) | ||
| cancel_button.clicked.connect(self.reject) | ||
| button_layout.addStretch() | ||
| button_layout.addWidget(clear_all_button) | ||
| button_layout.addWidget(ok_button) | ||
| button_layout.addWidget(cancel_button) | ||
| mixer_layout.addLayout(button_layout) | ||
| self.add_voices(initial_state or []) | ||
| self.update_weighted_sums() | ||
| # assemble splitter | ||
| splitter.addWidget(profile_widget) | ||
| splitter.addWidget(mixer_widget) | ||
| splitter.setStretchFactor(1, 1) | ||
| # set as main layout | ||
| self.setLayout(QHBoxLayout()) | ||
| self.layout().addWidget(splitter) | ||
| # Connect profile actions | ||
| self.profile_list.currentRowChanged.connect(self.on_profile_selection_changed) | ||
| # Track initial profile for proper dirty-state saving | ||
| self.last_profile_row = self.profile_list.currentRow() | ||
| self.btn_new_profile.clicked.connect(self.new_profile) | ||
| self.btn_export_profiles.clicked.connect(self.export_all_profiles) | ||
| self.btn_import_profiles.clicked.connect(self.import_profiles_dialog) | ||
| # Detect modifications in voice mixers | ||
| for vm in self.voice_mixers: | ||
| vm.spin_box.valueChanged.connect(self.mark_profile_modified) | ||
| vm.checkbox.stateChanged.connect(lambda *_: self.mark_profile_modified()) | ||
| def keyPressEvent(self, event): | ||
| # Bind Delete key to delete_profile when a profile is selected | ||
| if event.key() == Qt.Key.Key_Delete and self.profile_list.hasFocus(): | ||
| item = self.profile_list.currentItem() | ||
| if item: | ||
| self.delete_profile(item) | ||
| return | ||
| super().keyPressEvent(event) | ||
| def _has_unsaved_changes(self): | ||
| # Only return True if there are actually modified (yellow background) profiles | ||
| for i in range(self.profile_list.count()): | ||
| item = self.profile_list.item(i) | ||
| # Only consider as unsaved if profile is marked dirty (yellow background) | ||
| if item.text().startswith("*"): | ||
| return True | ||
| return False | ||
| def _prompt_save_changes(self): | ||
| dirty_indices = [ | ||
| i | ||
| for i in range(self.profile_list.count()) | ||
| if self.profile_list.item(i).text().startswith("*") | ||
| ] | ||
| parent = self.parent() | ||
| if len(dirty_indices) > 1: | ||
| msg = f"You have unsaved changes in {len(dirty_indices)} profiles. Do you want to save all?" | ||
| ret = QMessageBox.question( | ||
| self, | ||
| "Unsaved Changes", | ||
| msg, | ||
| QMessageBox.StandardButton.Save | ||
| | QMessageBox.StandardButton.Discard | ||
| | QMessageBox.StandardButton.Cancel, | ||
| QMessageBox.StandardButton.Save, | ||
| ) | ||
| if ret == QMessageBox.StandardButton.Save: | ||
| # Save all using stored states | ||
| profiles = load_profiles() | ||
| for i in dirty_indices: | ||
| name = self.profile_list.item(i).text().lstrip("*") | ||
| state = self._profile_states.get(name) | ||
| if state is not None: | ||
| profiles[name] = state | ||
| self._profile_dirty[name] = False | ||
| save_profiles(profiles) | ||
| # clear states | ||
| for name in list(self._profile_states.keys()): | ||
| if name not in profiles: | ||
| continue | ||
| del self._profile_states[name] | ||
| if hasattr(parent, "populate_profiles_in_voice_combo"): | ||
| parent.populate_profiles_in_voice_combo() | ||
| # clear markers | ||
| for i in dirty_indices: | ||
| item = self.profile_list.item(i) | ||
| n = item.text().lstrip("*") | ||
| item.setText(n) | ||
| self.update_profile_save_buttons() | ||
| self.update_profile_list_colors() | ||
| return True | ||
| elif ret == QMessageBox.StandardButton.Discard: | ||
| # Discard all modifications | ||
| self._profile_states.clear() | ||
| for i in dirty_indices: | ||
| item = self.profile_list.item(i) | ||
| n = item.text().lstrip("*") | ||
| item.setText(n) | ||
| self._profile_dirty[n] = False | ||
| self.update_profile_save_buttons() | ||
| self.update_profile_list_colors() | ||
| # reload current profile | ||
| profiles = load_profiles() | ||
| if self.current_profile in profiles: | ||
| self.load_profile_state(self.current_profile) | ||
| if hasattr(parent, "populate_profiles_in_voice_combo"): | ||
| parent.populate_profiles_in_voice_combo() | ||
| return True | ||
| else: | ||
| return False | ||
| else: | ||
| # Fallback to original logic for 0 or 1 dirty profile | ||
| box = QMessageBox(self) | ||
| box.setIcon(QMessageBox.Icon.Warning) | ||
| box.setWindowTitle("Unsaved Changes") | ||
| box.setText( | ||
| "You have unsaved changes in your profile. Do you want to save the changes?" | ||
| ) | ||
| box.setStandardButtons( | ||
| QMessageBox.StandardButton.Save | ||
| | QMessageBox.StandardButton.Discard | ||
| | QMessageBox.StandardButton.Cancel | ||
| ) | ||
| box.setDefaultButton(QMessageBox.StandardButton.Save) | ||
| ret = box.exec() | ||
| if ret == QMessageBox.StandardButton.Save: | ||
| for i in range(self.profile_list.count()): | ||
| item = self.profile_list.item(i) | ||
| name = item.text().lstrip("*") | ||
| if ( | ||
| self._profile_dirty.get(name, False) | ||
| or item.text().startswith("*") | ||
| or (name == self.current_profile) | ||
| ): | ||
| self.profile_list.setCurrentRow(i) | ||
| self.save_profile_by_name(name) | ||
| if hasattr(parent, "populate_profiles_in_voice_combo"): | ||
| parent.populate_profiles_in_voice_combo() | ||
| return True | ||
| elif ret == QMessageBox.StandardButton.Discard: | ||
| profiles = load_profiles() | ||
| for i in range(self.profile_list.count()): | ||
| item = self.profile_list.item(i) | ||
| name = item.text().lstrip("*") | ||
| self._profile_dirty[name] = False | ||
| if item.text().startswith("*"): | ||
| item.setText(name) | ||
| self.update_profile_save_buttons() | ||
| self.update_profile_list_colors() | ||
| if self.current_profile in profiles: | ||
| self.load_profile_state(self.current_profile) | ||
| if hasattr(parent, "populate_profiles_in_voice_combo"): | ||
| parent.populate_profiles_in_voice_combo() | ||
| return True | ||
| else: | ||
| return False | ||
| def on_profile_selection_changed(self, row): | ||
| # Save dirty state for previous profile | ||
| if hasattr(self, "last_profile_row") and self.last_profile_row is not None: | ||
| prev_item = self.profile_list.item(self.last_profile_row) | ||
| if prev_item: | ||
| prev_name = prev_item.text().lstrip("*") | ||
| self._profile_dirty[prev_name] = prev_item.text().startswith("*") | ||
| # Do NOT auto-save if modifications pending | ||
| # load new profile | ||
| item = self.profile_list.item(row) | ||
| if item: | ||
| name = item.text().lstrip("*") | ||
| self.load_profile_state(name) | ||
| # Restore dirty state for this profile | ||
| dirty = self._profile_dirty.get(name, False) | ||
| if dirty and not item.text().startswith("*"): | ||
| item.setText("*" + item.text()) | ||
| elif not dirty and item.text().startswith("*"): | ||
| item.setText(item.text().lstrip("*")) | ||
| self.last_profile_row = row | ||
| self.update_profile_save_buttons() | ||
| self.update_profile_list_colors() | ||
| def add_voices(self, initial_state): | ||
| first_enabled_voice = None | ||
| for voice in VOICES_INTERNAL: | ||
| language_code = voice[0] # First character is the language code | ||
| matching_voice = next( | ||
| (item for item in initial_state if item[0] == voice), None | ||
| ) | ||
| initial_status = matching_voice is not None | ||
| initial_weight = matching_voice[1] if matching_voice else 1.0 | ||
| voice_mixer = self.add_voice( | ||
| voice, language_code, initial_status, initial_weight | ||
| ) | ||
| if initial_status and first_enabled_voice is None: | ||
| first_enabled_voice = voice_mixer | ||
| if first_enabled_voice: | ||
| QTimer.singleShot( | ||
| 0, lambda: self.scroll_area.ensureWidgetVisible(first_enabled_voice) | ||
| ) | ||
| def add_voice( | ||
| self, voice_name, language_code, initial_status=False, initial_weight=1.0 | ||
| ): | ||
| voice_mixer = VoiceMixer( | ||
| voice_name, language_code, initial_status, initial_weight | ||
| ) | ||
| self.voice_mixers.append(voice_mixer) | ||
| self.voice_list_layout.addWidget(voice_mixer) | ||
| voice_mixer.checkbox.stateChanged.connect( | ||
| lambda state, vm=voice_mixer: self.handle_voice_checkbox(vm, state) | ||
| ) | ||
| voice_mixer.spin_box.valueChanged.connect(self.update_weighted_sums) | ||
| voice_mixer.checkbox.stateChanged.connect(self.update_weighted_sums) | ||
| voice_mixer.spin_box.valueChanged.connect(self.mark_profile_modified) | ||
| voice_mixer.checkbox.stateChanged.connect( | ||
| lambda *_: self.mark_profile_modified() | ||
| ) | ||
| return voice_mixer | ||
| def handle_voice_checkbox(self, voice_mixer, state): | ||
| if state == Qt.CheckState.Checked.value: | ||
| self.last_enabled_voice = voice_mixer.voice_name | ||
| self.update_weighted_sums() | ||
| def get_selected_voices(self): | ||
| return [ | ||
| v | ||
| for v in (m.get_voice_weight() for m in self.voice_mixers) | ||
| if v and v[1] > 0 | ||
| ] | ||
| def update_weighted_sums(self): | ||
| # Clear previous labels | ||
| while self.weighted_sums_layout.count(): | ||
| item = self.weighted_sums_layout.takeAt(0) | ||
| if item and item.widget(): | ||
| item.widget().deleteLater() | ||
| # Get selected voices | ||
| selected = [ | ||
| (m.voice_name, m.spin_box.value()) | ||
| for m in self.voice_mixers | ||
| if m.checkbox.isChecked() and m.spin_box.value() > 0 | ||
| ] | ||
| total = sum(w for _, w in selected) | ||
| # disable Preview if no voices selected, but don't enable while loading | ||
| if not getattr(self, "_loading", False): | ||
| self.btn_preview_mix.setEnabled(total > 0) | ||
| if total > 0: | ||
| self.error_label.hide() | ||
| self.weighted_sums_container.show() | ||
| # Reorder so last enabled voice is at the end | ||
| if self.last_enabled_voice and any( | ||
| name == self.last_enabled_voice for name, _ in selected | ||
| ): | ||
| others = [(n, w) for n, w in selected if n != self.last_enabled_voice] | ||
| last = [(n, w) for n, w in selected if n == self.last_enabled_voice] | ||
| selected = others + last | ||
| # Add voice labels | ||
| for name, weight in selected: | ||
| percentage = weight / total * 100 | ||
| # Make the voice name bold and include percentage | ||
| voice_label = HoverLabel( | ||
| f'<b><span style="color:{COLORS.get("BLUE")}">{name}: {percentage:.1f}%</span></b>', | ||
| name, | ||
| ) | ||
| voice_label.setSizePolicy( | ||
| QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred | ||
| ) | ||
| voice_label.delete_button.clicked.connect( | ||
| lambda _, vn=name: self.disable_voice_by_name(vn) | ||
| ) | ||
| self.weighted_sums_layout.addWidget(voice_label) | ||
| else: | ||
| self.error_label.show() | ||
| self.weighted_sums_container.hide() | ||
| def disable_voice_by_name(self, voice_name): | ||
| for mixer in self.voice_mixers: | ||
| if mixer.voice_name == voice_name: | ||
| mixer.checkbox.setChecked(False) | ||
| break | ||
| def clear_all_voices(self): | ||
| for mixer in self.voice_mixers: | ||
| mixer.checkbox.setChecked(False) | ||
| def eventFilter(self, source, event): | ||
| if source is self.scroll_area.viewport() and event.type() == event.Type.Wheel: | ||
| # Skip if over an enabled slider | ||
| if any( | ||
| mixer.slider.underMouse() and mixer.slider.isEnabled() | ||
| for mixer in self.voice_mixers | ||
| ): | ||
| return False | ||
| # Horizontal scrolling | ||
| horiz_bar = self.scroll_area.horizontalScrollBar() | ||
| delta = -120 if event.angleDelta().y() > 0 else 120 | ||
| horiz_bar.setValue(horiz_bar.value() + delta) | ||
| return True | ||
| return super().eventFilter(source, event) | ||
| def load_profile_state(self, profile_name): | ||
| name = profile_name.lstrip("*") | ||
| profiles = load_profiles() | ||
| # load voices and language from state or JSON | ||
| if name in self._profile_states: | ||
| state = self._profile_states[name] | ||
| else: | ||
| state = profiles.get(name, {}) | ||
| voices = state.get("voices") if isinstance(state, dict) else state | ||
| lang = state.get("language") if isinstance(state, dict) else None | ||
| # apply language selection | ||
| if lang: | ||
| i = self.language_combo.findData(lang) | ||
| if i >= 0: | ||
| self.language_combo.blockSignals(True) | ||
| self.language_combo.setCurrentIndex(i) | ||
| self.language_combo.blockSignals(False) | ||
| self.current_profile = name | ||
| weights = {n: w for n, w in voices} | ||
| for vm in self.voice_mixers: | ||
| weight = weights.get(vm.voice_name, 0.0) | ||
| # block signals to avoid triggering updates | ||
| vm.checkbox.blockSignals(True) | ||
| vm.spin_box.blockSignals(True) | ||
| vm.slider.blockSignals(True) | ||
| vm.checkbox.setChecked(weight > 0) | ||
| val = weight if weight > 0 else 1.0 | ||
| vm.spin_box.setValue(val) | ||
| vm.slider.setValue(int(val * 100)) | ||
| # restore signals | ||
| vm.checkbox.blockSignals(False) | ||
| vm.spin_box.blockSignals(False) | ||
| vm.slider.blockSignals(False) | ||
| # sync enabled state | ||
| vm.toggle_inputs() | ||
| self.update_weighted_sums() | ||
| def save_profile_by_name(self, name): | ||
| profiles = load_profiles() | ||
| state = self._profile_states.get(name, None) | ||
| if state is not None: | ||
| # ensure dict format | ||
| if isinstance(state, dict): | ||
| entry = state | ||
| else: | ||
| entry = {"voices": state, "language": self.language_combo.currentData()} | ||
| profiles[name] = entry | ||
| save_profiles(profiles) | ||
| self._profile_dirty[name] = False | ||
| del self._profile_states[name] | ||
| self._virtual_new_profile = False | ||
| # Remove * marker | ||
| for i in range(self.profile_list.count()): | ||
| item = self.profile_list.item(i) | ||
| if item.text().lstrip("*") == name: | ||
| item.setText(name) | ||
| break | ||
| self.update_profile_list_colors() | ||
| self.update_profile_save_buttons() | ||
| self.update_weighted_sums() | ||
| def _handle_zero_weight_profiles(self): | ||
| profiles = load_profiles() | ||
| if len(profiles) < 1: | ||
| return False | ||
| zero = [] | ||
| for i in range(self.profile_list.count()): | ||
| item = self.profile_list.item(i) | ||
| name = item.text().lstrip("*") | ||
| weights = profiles.get(name, {}).get("voices", []) | ||
| total = 0 | ||
| if isinstance(weights, list): | ||
| for entry in weights: | ||
| if ( | ||
| isinstance(entry, (list, tuple)) | ||
| and len(entry) == 2 | ||
| and isinstance(entry[1], (int, float)) | ||
| ): | ||
| total += entry[1] | ||
| if total == 0: | ||
| zero.append((i, name)) | ||
| if not zero: | ||
| return False | ||
| msg = f"{len(zero)} invalid profile(s) with no voices selected or their total weights are 0. They will be ignored and deleted. Do you want to delete?" | ||
| reply = QMessageBox.question( | ||
| self, | ||
| "Invalid Profiles", | ||
| msg, | ||
| QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, | ||
| QMessageBox.StandardButton.Yes, | ||
| ) | ||
| if reply == QMessageBox.StandardButton.Yes: | ||
| for i, name in reversed(zero): | ||
| self.profile_list.takeItem(i) | ||
| delete_profile(name) | ||
| parent = self.parent() | ||
| if hasattr(parent, "populate_profiles_in_voice_combo"): | ||
| parent.populate_profiles_in_voice_combo() | ||
| self.update_profile_list_colors() | ||
| self.update_profile_save_buttons() | ||
| return False | ||
| else: | ||
| idx, _ = zero[0] | ||
| self.profile_list.setCurrentRow(idx) | ||
| return True | ||
| def accept(self): | ||
| # If no profiles, treat as cancel | ||
| if self.profile_list.count() == 0: | ||
| # Update subtitle_mode to match combo before closing | ||
| if self.subtitle_combo: | ||
| parent = self.parent() | ||
| if parent is not None: | ||
| parent.subtitle_mode = self.subtitle_combo.currentText() | ||
| self.reject() | ||
| return | ||
| # Prompt to save if unsaved changes, then check for zero-weight error after save | ||
| if self._has_unsaved_changes(): | ||
| if not self._prompt_save_changes(): | ||
| return | ||
| if self._handle_zero_weight_profiles(): | ||
| return | ||
| selected_voices = self.get_selected_voices() | ||
| total_weight = sum(weight for _, weight in selected_voices) | ||
| if total_weight == 0: | ||
| QMessageBox.warning( | ||
| self, | ||
| "Invalid Weights", | ||
| "The total weight of selected voices cannot be zero. Please select at least one voice or adjust the weights.", | ||
| ) | ||
| self.update_weighted_sums() | ||
| return | ||
| # Save weights to current profile | ||
| profiles = load_profiles() | ||
| profiles[self.current_profile] = { | ||
| "voices": selected_voices, | ||
| "language": self.language_combo.currentData(), | ||
| } | ||
| save_profiles(profiles) | ||
| # Mark this profile as not dirty | ||
| self._profile_dirty[self.current_profile] = False | ||
| super().accept() | ||
| def reject(self): | ||
| # Restore parent's profile/mix state on cancel | ||
| parent = self.parent() | ||
| if parent is not None: | ||
| if hasattr(self, "_original_profile_name"): | ||
| parent.selected_profile_name = self._original_profile_name | ||
| if hasattr(self, "_original_mixed_voice_state"): | ||
| parent.mixed_voice_state = self._original_mixed_voice_state | ||
| # Prompt to save if unsaved changes, then check for zero-weight error after save | ||
| if self._has_unsaved_changes(): | ||
| if not self._prompt_save_changes(): | ||
| return | ||
| if self._handle_zero_weight_profiles(): | ||
| return | ||
| super().reject() | ||
| def closeEvent(self, event): | ||
| # Restore parent's profile/mix state on close | ||
| parent = self.parent() | ||
| if parent is not None: | ||
| if hasattr(self, "_original_profile_name"): | ||
| parent.selected_profile_name = self._original_profile_name | ||
| if hasattr(self, "_original_mixed_voice_state"): | ||
| parent.mixed_voice_state = self._original_mixed_voice_state | ||
| # Prompt to save if unsaved changes, then check for zero-weight error after save | ||
| if self._has_unsaved_changes(): | ||
| if not self._prompt_save_changes(): | ||
| event.ignore() | ||
| return | ||
| if self._handle_zero_weight_profiles(): | ||
| event.ignore() | ||
| return | ||
| super().closeEvent(event) | ||
| def _parse_rgba_to_qcolor(self, rgba_str): | ||
| from PyQt6.QtCore import Qt | ||
| from PyQt6.QtGui import QColor | ||
| """Helper to convert 'rgba(R,G,B,A_float)' string to QColor.""" | ||
| match = re.match(r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)", rgba_str) | ||
| if match: | ||
| r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3)) | ||
| a_float = float(match.group(4)) | ||
| a_int = int(a_float * 255) | ||
| return QColor(r, g, b, a_int) | ||
| return Qt.GlobalColor.transparent | ||
| def mark_profile_modified(self): | ||
| item = self.profile_list.currentItem() | ||
| if item and not item.text().startswith("*"): | ||
| item.setText("*" + item.text()) | ||
| # Flag profile as dirty and store unsaved state | ||
| name = self.current_profile | ||
| self._profile_dirty[name] = True | ||
| self._profile_states[name] = { | ||
| "voices": self.get_selected_voices(), | ||
| "language": self.language_combo.currentData(), | ||
| } | ||
| self.update_profile_save_buttons() | ||
| self.update_profile_list_colors() | ||
| def new_profile(self): | ||
| import re | ||
| while True: | ||
| name, ok = QInputDialog.getText(self, "New Profile", "Enter profile name:") | ||
| if not ok or not name: | ||
| break | ||
| name = name.strip() # Remove leading/trailing spaces | ||
| if not name: | ||
| continue | ||
| if not re.match(r"^[\w\- ]+$", name): | ||
| QMessageBox.warning( | ||
| self, | ||
| "Invalid Name", | ||
| "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.", | ||
| ) | ||
| continue | ||
| profiles = load_profiles() | ||
| # Remove 'New profile' placeholder if not persisted in JSON | ||
| if ( | ||
| self.profile_list.count() == 1 | ||
| and self.profile_list.item(0).text() == "New profile" | ||
| and "New profile" not in profiles | ||
| ): | ||
| self.profile_list.takeItem(0) | ||
| self._virtual_new_profile = False | ||
| self._profile_dirty.pop("New profile", None) | ||
| if name in profiles: | ||
| QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") | ||
| continue | ||
| profiles[name] = { | ||
| "voices": [], | ||
| "language": self.language_combo.currentData(), | ||
| } | ||
| save_profiles(profiles) | ||
| self.profile_list.addItem( | ||
| QListWidgetItem( | ||
| QIcon(get_resource_path("abogen.assets", "profile.png")), name | ||
| ) | ||
| ) | ||
| self.profile_list.setCurrentRow(self.profile_list.count() - 1) | ||
| # reset UI mixers | ||
| for vm in self.voice_mixers: | ||
| vm.checkbox.setChecked(False) | ||
| vm.spin_box.setValue(1.0) | ||
| parent = self.parent() | ||
| if hasattr(parent, "populate_profiles_in_voice_combo"): | ||
| parent.populate_profiles_in_voice_combo() | ||
| break | ||
| self.update_profile_save_buttons() | ||
| self.update_profile_list_colors() | ||
| self.update_weighted_sums() | ||
| def export_all_profiles(self): | ||
| # Prevent export if any profile has total weight 0 | ||
| profiles = load_profiles() | ||
| for name, weights in profiles.items(): | ||
| total = 0 | ||
| voices = weights.get("voices", []) | ||
| if isinstance(voices, list): | ||
| for entry in voices: | ||
| if ( | ||
| isinstance(entry, (list, tuple)) | ||
| and len(entry) == 2 | ||
| and isinstance(entry[1], (int, float)) | ||
| ): | ||
| total += entry[1] | ||
| if total == 0: | ||
| QMessageBox.warning( | ||
| self, | ||
| "Export Blocked", | ||
| f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.", | ||
| ) | ||
| return | ||
| path, _ = QFileDialog.getSaveFileName( | ||
| self, "Export Profiles", "voice_profiles", "JSON Files (*.json)" | ||
| ) | ||
| if path: | ||
| export_profiles(path) | ||
| def import_profiles_dialog(self): | ||
| path, _ = QFileDialog.getOpenFileName( | ||
| self, "Import Profiles", "", "JSON Files (*.json)" | ||
| ) | ||
| if path: | ||
| from abogen.voice_profiles import load_profiles, save_profiles | ||
| # Try to read the file and count profiles | ||
| try: | ||
| import json | ||
| with open(path, "r", encoding="utf-8") as f: | ||
| data = json.load(f) | ||
| # always expect abogen_voice_profiles wrapper | ||
| if not (isinstance(data, dict) and "abogen_voice_profiles" in data): | ||
| QMessageBox.warning( | ||
| self, | ||
| "Invalid File", | ||
| "This file is not a valid abogen voice profiles file.", | ||
| ) | ||
| return | ||
| imported_profiles = data["abogen_voice_profiles"] | ||
| if not isinstance(imported_profiles, dict): | ||
| QMessageBox.warning( | ||
| self, | ||
| "Invalid File", | ||
| "This file is not a valid abogen voice profiles file.", | ||
| ) | ||
| return | ||
| count = len(imported_profiles) | ||
| except Exception: | ||
| QMessageBox.warning( | ||
| self, "Import Error", "Could not read the selected file." | ||
| ) | ||
| return | ||
| if count == 0: | ||
| QMessageBox.information( | ||
| self, "No Profiles", "No profiles found in the selected file." | ||
| ) | ||
| return | ||
| profiles = load_profiles() | ||
| collisions = [name for name in imported_profiles if name in profiles] | ||
| # Combine prompts: show both import count and overwrite count if any | ||
| if count == 1: | ||
| orig_name = next(iter(imported_profiles.keys())) | ||
| msg = f"Profile '{orig_name}' will be imported." | ||
| if collisions: | ||
| msg += f"\nThis will overwrite an existing profile." | ||
| msg += "\nContinue?" | ||
| reply = QMessageBox.question( | ||
| self, | ||
| "Import Profile", | ||
| msg, | ||
| QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, | ||
| ) | ||
| if reply != QMessageBox.StandardButton.Yes: | ||
| return | ||
| profiles.update(imported_profiles) | ||
| save_profiles(profiles) | ||
| QMessageBox.information( | ||
| self, | ||
| "Profile Imported", | ||
| f"Profile '{orig_name}' imported successfully.", | ||
| ) | ||
| else: | ||
| msg = f"{count} profiles will be imported." | ||
| if collisions: | ||
| msg += f"\n{len(collisions)} profile(s) will be overwritten." | ||
| msg += "\nContinue?" | ||
| reply = QMessageBox.question( | ||
| self, | ||
| "Import Profiles", | ||
| msg, | ||
| QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, | ||
| ) | ||
| if reply != QMessageBox.StandardButton.Yes: | ||
| return | ||
| profiles.update(imported_profiles) | ||
| save_profiles(profiles) | ||
| QMessageBox.information( | ||
| self, | ||
| "Profiles Imported", | ||
| f"{count} profiles imported successfully.", | ||
| ) | ||
| # Refresh list | ||
| self.profile_list.clear() | ||
| profiles = load_profiles() | ||
| for nm in profiles: | ||
| self.profile_list.addItem( | ||
| QListWidgetItem( | ||
| QIcon(get_resource_path("abogen.assets", "profile.png")), nm | ||
| ) | ||
| ) | ||
| if self.profile_list.count() > 0: | ||
| self.profile_list.setCurrentRow(0) | ||
| parent = self.parent() | ||
| if hasattr(parent, "populate_profiles_in_voice_combo"): | ||
| parent.populate_profiles_in_voice_combo() | ||
| self._virtual_new_profile = False | ||
| self.update_profile_save_buttons() | ||
| self.update_profile_list_colors() | ||
| def show_profile_context_menu(self, pos): | ||
| item = self.profile_list.itemAt(pos) | ||
| if not item: | ||
| return | ||
| name = item.text().lstrip("*") | ||
| menu = QMenu(self) | ||
| rename_act = QAction("Rename", self) | ||
| delete_act = QAction("Delete", self) | ||
| dup_act = QAction("Duplicate", self) | ||
| export_act = QAction("Export this profile", self) | ||
| menu.addAction(rename_act) | ||
| menu.addAction(dup_act) | ||
| menu.addAction(export_act) | ||
| menu.addAction(delete_act) | ||
| act = menu.exec(self.profile_list.viewport().mapToGlobal(pos)) | ||
| if act == rename_act: | ||
| self.rename_profile(item) | ||
| elif act == delete_act: | ||
| self.delete_profile(item) | ||
| elif act == dup_act: | ||
| self.duplicate_profile(item) | ||
| elif act == export_act: | ||
| self.export_selected_profile_item(item) | ||
| def export_selected_profile_item(self, item): | ||
| if not item: | ||
| return | ||
| name = item.text().lstrip("*") | ||
| profiles = load_profiles() | ||
| weights = profiles.get(name, {}).get("voices", []) | ||
| total = 0 | ||
| if isinstance(weights, list): | ||
| for entry in weights: | ||
| if ( | ||
| isinstance(entry, (list, tuple)) | ||
| and len(entry) == 2 | ||
| and isinstance(entry[1], (int, float)) | ||
| ): | ||
| total += entry[1] | ||
| if total == 0: | ||
| QMessageBox.warning( | ||
| self, | ||
| "Export Blocked", | ||
| f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.", | ||
| ) | ||
| return | ||
| path, _ = QFileDialog.getSaveFileName( | ||
| self, "Export Profile", f"{name}.json", "JSON Files (*.json)" | ||
| ) | ||
| if path: | ||
| # Use abogen_voice_profiles wrapper for single profile export | ||
| with open(path, "w", encoding="utf-8") as f: | ||
| json.dump( | ||
| {"abogen_voice_profiles": {name: profiles.get(name, {})}}, | ||
| f, | ||
| indent=2, | ||
| ) | ||
| def rename_profile(self, item): | ||
| name = item.text().lstrip("*") | ||
| # block if profile has unsaved changes and it's not a virtual New profile | ||
| if self._profile_dirty.get(name, False) and not ( | ||
| self._virtual_new_profile and name == "New profile" | ||
| ): | ||
| QMessageBox.warning( | ||
| self, "Unsaved Changes", "Please save the profile before renaming." | ||
| ) | ||
| return | ||
| old = item.text().lstrip("*") | ||
| import re | ||
| while True: | ||
| new, ok = QInputDialog.getText( | ||
| self, "Rename Profile", f"Profile name:", text=old | ||
| ) | ||
| if not ok or not new or new == old: | ||
| break | ||
| new = new.strip() # Remove leading/trailing spaces | ||
| if not new: | ||
| continue | ||
| if not re.match(r"^[\w\- ]+$", new): | ||
| QMessageBox.warning( | ||
| self, | ||
| "Invalid Name", | ||
| "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.", | ||
| ) | ||
| continue | ||
| profiles = load_profiles() | ||
| if new in profiles: | ||
| QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") | ||
| continue | ||
| # Special case for renaming the virtual "New profile" | ||
| if self._virtual_new_profile and name == "New profile": | ||
| # Create the profile with the new name | ||
| profiles[new] = { | ||
| "voices": self.get_selected_voices(), | ||
| "language": self.language_combo.currentData(), | ||
| } | ||
| save_profiles(profiles) | ||
| # Update tracking properties | ||
| self._virtual_new_profile = False | ||
| self._profile_dirty.pop("New profile", None) | ||
| self._profile_dirty[new] = False | ||
| # Update the current profile name | ||
| self.current_profile = new | ||
| item.setText(new) | ||
| else: | ||
| # Standard renaming for regular profiles | ||
| profiles[new] = profiles.pop(old) | ||
| save_profiles(profiles) | ||
| item.setText(new) | ||
| # Update the current profile name if it was renamed | ||
| if self.current_profile == old: | ||
| self.current_profile = new | ||
| parent = self.parent() | ||
| if hasattr(parent, "populate_profiles_in_voice_combo"): | ||
| parent.populate_profiles_in_voice_combo() | ||
| break | ||
| self.update_profile_save_buttons() | ||
| self.update_profile_list_colors() | ||
| def delete_profile(self, item): | ||
| name = item.text().lstrip("*") | ||
| if self._virtual_new_profile and name == "New profile": | ||
| row = self.profile_list.row(item) | ||
| self.profile_list.takeItem(row) | ||
| self._virtual_new_profile = False | ||
| self._profile_dirty.pop("New profile", None) | ||
| self.update_profile_save_buttons() | ||
| self.update_profile_list_colors() | ||
| return | ||
| reply = QMessageBox.question( | ||
| self, | ||
| "Delete Profile", | ||
| f"Delete profile '{name}'?", | ||
| QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, | ||
| ) | ||
| if reply == QMessageBox.StandardButton.Yes: | ||
| delete_profile(name) | ||
| row = self.profile_list.row(item) | ||
| self.profile_list.takeItem(row) | ||
| parent = self.parent() | ||
| if hasattr(parent, "populate_profiles_in_voice_combo"): | ||
| parent.populate_profiles_in_voice_combo() | ||
| self.update_profile_save_buttons() | ||
| self.update_profile_list_colors() | ||
| def duplicate_profile(self, item): | ||
| name = item.text().lstrip("*") | ||
| # block duplicating if profile has unsaved changes | ||
| if self._profile_dirty.get(name, False): | ||
| QMessageBox.warning( | ||
| self, "Unsaved Changes", "Please save the profile before duplicating." | ||
| ) | ||
| return | ||
| src = item.text().lstrip("*") | ||
| profiles = load_profiles() | ||
| base = f"{src}_duplicate" | ||
| new = base | ||
| i = 1 | ||
| while new in profiles: | ||
| new = f"{base}{i}" | ||
| i += 1 | ||
| duplicate_profile(src, new) | ||
| self.profile_list.addItem( | ||
| QListWidgetItem( | ||
| QIcon(get_resource_path("abogen.assets", "profile.png")), new | ||
| ) | ||
| ) | ||
| parent = self.parent() | ||
| if hasattr(parent, "populate_profiles_in_voice_combo"): | ||
| parent.populate_profiles_in_voice_combo() | ||
| self.update_profile_save_buttons() | ||
| self.update_profile_list_colors() | ||
| def update_profile_save_buttons(self): | ||
| # Remove all save buttons first | ||
| for i in range(self.profile_list.count()): | ||
| self.profile_list.setItemWidget(self.profile_list.item(i), None) | ||
| # Add save button to dirty profiles | ||
| for i in range(self.profile_list.count()): | ||
| item = self.profile_list.item(i) | ||
| name = item.text().lstrip("*") | ||
| if item.text().startswith("*"): | ||
| widget = SaveButtonWidget( | ||
| self.profile_list, name, self.save_profile_by_name | ||
| ) | ||
| self.profile_list.setItemWidget(item, widget) | ||
| def update_profile_list_colors(self): | ||
| from PyQt6.QtCore import Qt | ||
| profiles = load_profiles() | ||
| for i in range(self.profile_list.count()): | ||
| item = self.profile_list.item(i) | ||
| name = item.text().lstrip("*") | ||
| if self._virtual_new_profile and name == "New profile": | ||
| color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND")) | ||
| item.setData(Qt.ItemDataRole.BackgroundRole, color) | ||
| elif item.text().startswith("*"): | ||
| color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND")) | ||
| item.setData(Qt.ItemDataRole.BackgroundRole, color) | ||
| else: | ||
| item.setData( | ||
| Qt.ItemDataRole.BackgroundRole, | ||
| self.profile_list.palette().base().color(), | ||
| ) | ||
| weights = profiles.get(name, {}).get("voices", []) | ||
| total = 0 | ||
| if isinstance(weights, list): | ||
| for entry in weights: | ||
| if ( | ||
| isinstance(entry, (list, tuple)) | ||
| and len(entry) == 2 | ||
| and isinstance(entry[1], (int, float)) | ||
| ): | ||
| total += entry[1] | ||
| if total == 0: | ||
| color = self._parse_rgba_to_qcolor(COLORS.get("RED_BACKGROUND")) | ||
| item.setData(Qt.ItemDataRole.BackgroundRole, color) | ||
| self.update_profile_save_buttons() | ||
| def preview_current_mix(self): | ||
| # Disable preview until playback completes | ||
| self.btn_preview_mix.setEnabled(False) | ||
| self.btn_preview_mix.setText("Loading...") | ||
| self._loading = True | ||
| parent = self.parent() | ||
| if parent and hasattr(parent, "preview_voice"): | ||
| # Apply mixed voices and selected language | ||
| parent.mixed_voice_state = self.get_selected_voices() | ||
| parent.selected_profile_name = None | ||
| lang = self.language_combo.currentData() | ||
| parent.selected_lang = lang | ||
| parent.subtitle_combo.setEnabled( | ||
| lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION | ||
| ) | ||
| # Reset start flag and trigger preview | ||
| self._started = False | ||
| parent.preview_voice() | ||
| # Poll preview_playing: wait for start then end | ||
| self._preview_poll_timer = QTimer(self) | ||
| self._preview_poll_timer.timeout.connect(self._check_preview_done) | ||
| self._preview_poll_timer.start(200) | ||
| def _check_preview_done(self): | ||
| parent = self.parent() | ||
| if parent and hasattr(parent, "preview_playing"): | ||
| # Mark when playback starts | ||
| if parent.preview_playing: | ||
| self._started = True | ||
| # Update button text to "Playing..." when playback starts | ||
| self.btn_preview_mix.setText("Playing...") | ||
| # Once started and then stopped, re-enable | ||
| elif getattr(self, "_started", False): | ||
| self.btn_preview_mix.setEnabled(True) | ||
| self.btn_preview_mix.setText("Preview") | ||
| self._loading = False | ||
| self._preview_poll_timer.stop() |
| import re | ||
| from abogen.constants import VOICES_INTERNAL | ||
| # Calls parsing and loads the voice to gpu or cpu | ||
| def get_new_voice(pipeline, formula, use_gpu): | ||
| try: | ||
| weighted_voice = parse_voice_formula(pipeline, formula) | ||
| # device = "cuda" if use_gpu else "cpu" | ||
| # Setting the device "cuda" gives "Error occurred: split_with_sizes(): argument 'split_sizes' (position 2)" | ||
| # error when the device is gpu. So disabling this for now. | ||
| device = "cpu" | ||
| return weighted_voice.to(device) | ||
| except Exception as e: | ||
| raise ValueError(f"Failed to create voice: {str(e)}") | ||
| # Parse the formula and get the combined voice tensor | ||
| def parse_voice_formula(pipeline, formula): | ||
| if not formula.strip(): | ||
| raise ValueError("Empty voice formula") | ||
| # Initialize the weighted sum | ||
| weighted_sum = None | ||
| total_weight = calculate_sum_from_formula(formula) | ||
| # Split the formula into terms | ||
| voices = formula.split("+") | ||
| for term in voices: | ||
| # Parse each term (format: "voice_name*0.333") | ||
| voice_name, weight = term.strip().split("*") | ||
| weight = float(weight.strip()) | ||
| # normalize the weight | ||
| weight /= total_weight if total_weight > 0 else 1.0 | ||
| voice_name = voice_name.strip() | ||
| # Get the voice tensor | ||
| if voice_name not in VOICES_INTERNAL: | ||
| raise ValueError(f"Unknown voice: {voice_name}") | ||
| voice_tensor = pipeline.load_single_voice(voice_name) | ||
| # Add to weighted sum | ||
| if weighted_sum is None: | ||
| weighted_sum = weight * voice_tensor | ||
| else: | ||
| weighted_sum += weight * voice_tensor | ||
| return weighted_sum | ||
| def calculate_sum_from_formula(formula): | ||
| weights = re.findall(r"\* *([\d.]+)", formula) | ||
| total_sum = sum(float(weight) for weight in weights) | ||
| return total_sum |
| import os | ||
| import json | ||
| from abogen.utils import get_user_config_path | ||
| def _get_profiles_path(): | ||
| config_path = get_user_config_path() | ||
| config_dir = os.path.dirname(config_path) | ||
| return os.path.join(config_dir, "voice_profiles.json") | ||
| def load_profiles(): | ||
| """Load all voice profiles from JSON file.""" | ||
| path = _get_profiles_path() | ||
| if os.path.exists(path): | ||
| try: | ||
| with open(path, "r", encoding="utf-8") as f: | ||
| data = json.load(f) | ||
| # always expect abogen_voice_profiles wrapper | ||
| if isinstance(data, dict) and "abogen_voice_profiles" in data: | ||
| return data["abogen_voice_profiles"] | ||
| # fallback: treat as profiles dict | ||
| if isinstance(data, dict): | ||
| return data | ||
| except Exception: | ||
| return {} | ||
| return {} | ||
| def save_profiles(profiles): | ||
| """Save all voice profiles to JSON file.""" | ||
| path = _get_profiles_path() | ||
| os.makedirs(os.path.dirname(path), exist_ok=True) | ||
| with open(path, "w", encoding="utf-8") as f: | ||
| # always save with abogen_voice_profiles wrapper | ||
| json.dump({"abogen_voice_profiles": profiles}, f, indent=2) | ||
| def delete_profile(name): | ||
| """Remove a profile by name.""" | ||
| profiles = load_profiles() | ||
| if name in profiles: | ||
| del profiles[name] | ||
| save_profiles(profiles) | ||
| def duplicate_profile(src, dest): | ||
| """Duplicate an existing profile.""" | ||
| profiles = load_profiles() | ||
| if src in profiles and dest: | ||
| profiles[dest] = profiles[src] | ||
| save_profiles(profiles) | ||
| def export_profiles(export_path): | ||
| """Export all profiles to specified JSON file.""" | ||
| profiles = load_profiles() | ||
| with open(export_path, "w", encoding="utf-8") as f: | ||
| json.dump({"abogen_voice_profiles": profiles}, f, indent=2) |
| #!/usr/bin/env python3 | ||
| """Build PyPI package (wheel and sdist) to `dist` folder for abogen.""" | ||
| import subprocess | ||
| import os | ||
| import shutil | ||
| import tempfile | ||
| def main(): | ||
| script_dir = os.path.dirname(os.path.abspath(__file__)) | ||
| output_dir = os.path.join(script_dir, "dist") | ||
| print("🔧 abogen PyPI Package Builder") | ||
| print("=" * 40) | ||
| print(f"📁 Script directory: {script_dir}") | ||
| print(f"📦 Output directory: {output_dir}") | ||
| # Try to print package version if present | ||
| version = None | ||
| version_file = os.path.join(script_dir, "abogen", "VERSION") | ||
| if os.path.isfile(version_file): | ||
| try: | ||
| with open(version_file, "r", encoding="utf-8") as vf: | ||
| version = vf.read().strip() | ||
| except Exception: | ||
| version = None | ||
| if version: | ||
| print(f"🔖 Package version: {version}") | ||
| # Check if build module is installed, install if not | ||
| # Temporarily remove script_dir from sys.path to avoid importing local build.py | ||
| import sys | ||
| original_path = sys.path[:] | ||
| try: | ||
| sys.path = [p for p in sys.path if os.path.abspath(p) != script_dir] | ||
| import build | ||
| except ImportError: | ||
| print("📦 Installing build module...") | ||
| subprocess.run([sys.executable, "-m", "pip", "install", "build"], check=True) | ||
| finally: | ||
| sys.path = original_path | ||
| # Create output directory | ||
| print(f"📂 Preparing output directory: {output_dir}") | ||
| if os.path.exists(output_dir): | ||
| shutil.rmtree(output_dir) | ||
| os.makedirs(output_dir, exist_ok=True) | ||
| print("🏗️ Building PyPI package...") | ||
| print(" Using temporary directory to avoid module conflicts...") | ||
| # Run from temp directory to avoid local build.py shadowing the build module | ||
| with tempfile.TemporaryDirectory() as tmpdir: | ||
| print(f" Temp directory: {tmpdir}") | ||
| print(" Running: python -m build -o <output_dir> <source_dir>") | ||
| result = subprocess.run( | ||
| [sys.executable, "-m", "build", "-o", output_dir, script_dir], | ||
| check=False, | ||
| cwd=tmpdir, | ||
| ) | ||
| print("\n" + "=" * 40) | ||
| if result.returncode == 0: | ||
| print("✅ Build successful!") | ||
| print(f"📦 Files created in {output_dir}:") | ||
| files = os.listdir(output_dir) | ||
| if files: | ||
| for f in files: | ||
| file_path = os.path.join(output_dir, f) | ||
| size = os.path.getsize(file_path) | ||
| print(f" 📄 {f} ({size:,} bytes)") | ||
| else: | ||
| print(" (No files found)") | ||
| print("\n🚀 Ready for upload with:\n") | ||
| print(" - To test on Test PyPI:") | ||
| print(f" python -m twine upload --repository testpypi {output_dir}/*") | ||
| print("\n - To upload to PyPI (when ready):") | ||
| print(f" python -m twine upload {output_dir}/*") | ||
| else: | ||
| print("❌ Build failed!") | ||
| print(f" Exit code: {result.returncode}") | ||
| sys.exit(result.returncode) | ||
| if __name__ == "__main__": | ||
| main() |
-207
| # 1.2.5 | ||
| - Added new option: `Override item settings with current selection` in the queue manager. When enabled, all items in the queue will be processed using the current global settings selected in the main GUI, overriding their individual settings. When disabled, each item will retain its own specific settings. | ||
| - Fixed `Error "Could not load the Qt platform plugin "xcb"` error that occurred in some Linux distributions due to missing `libxcb-cursor0` library by conditionally loading the bundled library when the system version is unavailable, issue mentioned by @bmcgonag in #101. | ||
| - Fixed the `No module named pip` error that occurred for users who installed Abogen via the [**uv**](https://github.com/astral-sh/uv) installer. | ||
| - Fixed defaults for `replace_single_newlines` not being applied correctly in some cases. | ||
| - Fixed `Save chapters separately for queued epubs is ignored`, issue mentioned by @dymas-cz in #109. | ||
| - Fixed incorrect sentence segmentation when using spaCy, where text would erroneously split after opening parentheses. | ||
| - Improvements in code and documentation. | ||
| # 1.2.4 | ||
| - **Subtitle generation is now available for all languages!** Abogen now supports subtitle generation for non-English languages using audio duration-based timing. Available modes include `Line`, `Sentence`, and `Sentence + Comma`. (Note: Word-level subtitle modes remain English-only due to Kokoro's timestamp token limitations.) | ||
| - New option: **"Use spaCy for sentence segmentation"** You can now use [spaCy](https://spacy.io/) to automatically detect sentence boundaries and produce cleaner, more readable subtitles. Quick summary: | ||
| - **What it does:** Splits text into natural sentences so subtitle entries read better and align more naturally with speech. | ||
| - **Why this helps:** The previous punctuation-based splitting could break sentences incorrectly at common abbreviations (e.g. "Mr.", "Dr.", "Prof.") or initials, producing wrong subtitle breaks. spaCy avoids those false splits by using linguistic rules to detect real sentence boundaries. | ||
| - **For Non-English:** spaCy runs **before** audio generation to create better sentence chunks for TTS. | ||
| - **For English:** spaCy runs **during** subtitle generation to find accurate sentence breaks after TTS. | ||
| - **Note:** spaCy segmentation is only applied when subtitle mode is `Sentence` or `Sentence + Comma`. When turned off, it falls back to simple punctuation-based splitting. | ||
| - New option: **Pre-download models and voices for offline use** You can now pre-download all required Kokoro models, voices, and spaCy language models using this option in the settings menu. Allowing you to use Abogen completely offline without any internet connection. | ||
| - Added support for `.` separator in timestamps (e.g. `HH:MM:SS.ms`) for timestamp-based text files. | ||
| - Optimized regex compilation and eliminated busy-wait loops. | ||
| - Possibly fixed `Silent truncation of long paragraphs` issue mentioned in [#91](https://github.com/denizsafak/abogen/issues/91) by [@xklzlxr](https://github.com/xklzlxr) | ||
| - Fixed unused regex patterns and variable naming conventions. | ||
| - Improvements in code and documentation. | ||
| # 1.2.3 | ||
| - Same as 1.2.2, re-released to fix an issue with subtitle timing when using timestamp-based text files. | ||
| # 1.2.2 | ||
| - **You can now voice your subtitle files!** Simply add `.srt`, `.ass` or `.vtt` files to generate timed audio. Alternatively, add a text file with timestamps in `HH:MM:SS` or `HH:MM:SS,ms` format to generate audio that matches the timestamps. See [here](https://github.com/denizsafak/abogen?tab=readme-ov-file#about-timestamp-based-text-files) for detailed instructions. | ||
| - New option: **"Use silent gaps between subtitles"**: Prevents unnecessary audio speed-up by letting speech continue into the silent gaps between subtitles. | ||
| - New option: **"Subtitle speed adjustment method"**: Choose how to speed up audio when needed: | ||
| - **TTS Regeneration (better quality):** Re-generates the audio at a faster speed for more natural sound. | ||
| - **FFmpeg Time-stretch (better speed):** Quickly speeds up the generated audio. | ||
| - Added support for embedding cover images in M4B files. Abogen now automatically extracts cover images from EPUB and PDF files. You can also manually specify a cover image using the `<<METADATA_COVER_PATH:path>>` tag in your text file. (To prevent MPV from showing the cover image, you can add `audio-display=no` to your MPV config file.) | ||
| - Fixed `[WinError 1114] A dynamic link library (DLL) initialization routine failed` error on Windows, pre-loading PyTorch DLLs before initializing PyQt6 to avoid DLL initialization errors, mentioned in #98 by @ephr0n. | ||
| - Potential fix for `CUDA GPU is not available` issue, by ensuring PyTorch is installed correctly with CUDA support on Windows using the installer script. | ||
| - Improvements in code and documentation. | ||
| # 1.2.1 | ||
| - Upgraded Abogen's interface from PyQt5 to PyQt6 for better compatibility and long-term support. | ||
| - Added tooltip indicators in queue manager to display book handler options (`Save chapters separately` and `Merge chapters at the end`) for queued items. | ||
| - Added `Open processed file` and `Open input file` options for items in the queue manager, instead of just `Open file` option. | ||
| - Added loading gif animation to book handler window. | ||
| - Fixed light theme slider colors in voice mixer for better visibility (for non-Windows users). | ||
| - Fixed subtitle word-count splitting logic for more accurate segmentation. | ||
| - Improvements in code and documentation. | ||
| # 1.2.0 | ||
| - Added `Line` option to subtitle generation modes, allowing subtitles to be generated based on line breaks in the text, by @mleg in #94. | ||
| - Added a loading indicator to the book handler window for better user experience during book preprocessing. | ||
| - Fixed `cannot access local variable 'is_narrow'` error when subtitle format `SRT` was selected, mentioned by @Kinasa0096 in #88. | ||
| - Fixed folder and filename sanitization to properly handle OS-specific illegal characters (Windows, Linux, macOS), ensuring compatibility across all platforms when creating chapter folders and files. | ||
| - Fixed `/` and `\` path display by normalizing paths. | ||
| - Fixed book reprocessing issue where books were being processed every time the chapters window was opened, improving performance when reopening the same book. | ||
| - Fixed taskbar icon not appearing correctly in Windows. | ||
| - Fixed “Go to folder” button not opening the chapter output directory when only separate chapters were generated. | ||
| - Improvements in code and documentation. | ||
| # 1.1.9 | ||
| - Fixed the issue where spaces were deleted before punctuation marks while generating subtitles. | ||
| - Fixed markdown TOC generation breaks when "Replace single newlines" is enabled. | ||
| - Improvements in code and documentation. | ||
| # 1.1.8 | ||
| - Added `.md` (Markdown) file extension support by @brianxiadong in PR #75 | ||
| - Added new option `Configure silence between chapters` that lets you configure the silence between chapters, mentioned by @lfperez1982 in #79 | ||
| - Better indicators and options while displaying and managing the input and processing files. | ||
| - Improved the markdown logic to better handle various markdown structures and cases. | ||
| - Fixed subtitle splitting before commas by combining punctuation with preceding words. | ||
| - Fixed save options not working correctly in queue mode, mentioned by @jborza in #78 | ||
| - Fixed `No Qt platform plugin could be initialized` error, mentioned by @sunrainxyz in #59 | ||
| - Fixed ordered list numbers not being included in EPUB content conversion. The numbers are now properly included in the converted content, mentioned by @jefro108 in #47 | ||
| - Potentially fixed subtitle generation stucks at 9:59:59, mentioned by @bolaykim in #73 | ||
| - Improvements in code and documentation. | ||
| # 1.1.7 | ||
| - Added MPS GPU acceleration support for Silicon Mac, mentioned in https://github.com/denizsafak/abogen/issues/32#issuecomment-3155902040 by @jefro108. **Please read the [Mac](https://github.com/denizsafak/abogen?tab=readme-ov-file#mac) section in the documentation again, as it requires additional configuration.** | ||
| - Added word-by-word karaoke highlighting feature by @robmckinnon in PR #65 | ||
| - Fixed sleep inhibition error occurring on some Linux systems that do not use systemd, mentioned in #67 by @hendrack | ||
| - Improvements in code and documentation. | ||
| # 1.1.6 | ||
| - Improved EPUB chapter detection: Now reliably detects chapters from NAV HTML (TOC) files, even in non-standard EPUBs, fixes the issue mentioned by @jefro108 in #33 | ||
| - Fixed SRT subtitle numbering issue, mentioned by @page-muncher in #41 | ||
| - Fixed missing chapter contents issue in some EPUB files. | ||
| - Windows installer script now prompts the user to install the CUDA version of PyTorch even if no NVIDIA GPU is detected. | ||
| - Abogen now includes Mandarin Chinese (misaki[zh]) by default; manual installation is no longer required. | ||
| # 1.1.5 | ||
| - Changed the temporary directory path to user's cache directory, which is more appropriate for storing cache files and avoids issues with unintended cleanup. | ||
| - Fixed the isssue where extra metadata information was not being saved to M4B files when they have no chapters, ensuring that all metadata is correctly written to the output file. | ||
| - Fixed sleep prevention process not ending if program exited using Ctrl+C or kill. | ||
| - Improved automatic filename suffixing to better prevent overwriting files with the same name, even if they have different extensions. | ||
| - Improvements in code and documentation. | ||
| # 1.1.4 | ||
| - Fixed extra metadata information not being saved to M4B files, ensuring that all metadata is correctly written to the output file. | ||
| - Reformatted the code using Black for better readability and consistency. | ||
| # 1.1.3 | ||
| - `M4B (with chapters)` generation is faster now, as it directly generates `m4b` files instead of converting from `wav`, which significantly reduces processing time, fixes the issue mentioned by @Milor123 in #39 | ||
| - Better sleep state handling for Linux. | ||
| - The app window now tries to fit the screen if its height would exceed the available display area. | ||
| - Fixed issue where the app would not restart properly on Windows. | ||
| - Fixed last sentence/subtitle entry timing in generated subtitles, the end time of the final subtitle entry now correctly matches the end of the audio chunk, preventing zero or invalid timings at the end. | ||
| # v1.1.2 | ||
| - Now you can play the audio files while they are processing. | ||
| - Audio and subtitle files are now written directly to disk during generation, which significantly reduces memory usage. | ||
| - Added a better logic for detecting chapters from the epub, mentioned by @jefro108 in #33 | ||
| - Added a new option: `Reset to default settings`, allowing users to reset all settings to their default values. | ||
| - Added a new option: `Disable Kokoro's internet access`. This lets you prevent Kokoro from downloading models or voices from HuggingFace Hub, which can help avoid long waiting times if your computer is offline. | ||
| - HuggingFace Hub telemetry is now disabled by default for improved privacy. (HuggingFace Hub is used by Kokoro to download its models) | ||
| - cPotential fix for #37 and #38, where the program was becoming slow while processing large files. | ||
| - Fixed `Open folder` and `Open file` buttons in the queue manager GUI. | ||
| - Improvements in code structure. | ||
| # v1.1.1 | ||
| - Fixed adding wrong file in queue for EPUB and PDF files, ensuring the correct file is added to the queue. | ||
| - Reformatted the code using Black. | ||
| # v1.1.0 | ||
| - Added queue system for processing multiple items, allowing users to add multiple files and process them in a queue, mentioned by @jborza in #30 (Special thanks to @jborza for implementing this feature in PR #35) | ||
| - Added a feature that allows selecting multiple items in book handler (in right click menu) by @jborza in #31, that fixes #28 | ||
| - Added dark theme support, allowing users to switch between light and dark themes in the settings. | ||
| - Added auto-accept system to the chapter options dialog in conversion process, allowing the dialog to auto-accept after a certain time if no action is taken. | ||
| - Added new option: `Configure max lines in log window` that allows configuring the maximum number of lines to display in the log window. | ||
| - Improvements in documentation and code. | ||
| # v1.0.9 | ||
| - Added chunking/segmenting system that fixes memory outage issues when processing large audio files. | ||
| - Added new option: `Subtitle format`, allowing users to choose between `srt` , `ass (wide)`, `ass (narrow)`, and `ass (centered wide)` and `ass (centered narrow)` | ||
| - Improved chapter filename generation with smart word-boundary truncation at 80 characters, preventing mid-word cuts in filenames. | ||
| - `Composer` and `Genre` metadata fields for M4B files are now editable from the text editor. | ||
| - Improvements in documentation and code. | ||
| # v1.0.8 | ||
| - Added support for AMD GPUs in Linux (Special thanks to @hg000125 for his contribution in #23) | ||
| - Added voice preview caching system that stores generated previews in the cache folder, mentioned by @jborza in #22 | ||
| - Added extra metadata support for chaptered M4B files, ensuring better compatibility with audiobook players. | ||
| - Added new option: `Separate chapters audio format`, allowing to choose between `wav`, `mp4`, `flac` and `opus` formats for chaptered audio files. | ||
| - Added a download tracker that displays informative messages while downloading Kokoro models or voices from HuggingFace. | ||
| - Skipping PyTorch CUDA installation if GPU is not NVIDIA in WINDOWS_INSTALL.bat script, preventing unnecessary installation of PyTorch. | ||
| - Removed `abogen_` prefix that was adding to converted books in temp directory. | ||
| - Fixed voice preview player keeps playing silently at the background after preview ends. | ||
| - Fixed not writing separate chapters audio when output is OPUS. | ||
| - Improved input box background color handling, fixed display issues in Linux. | ||
| - Updated profile and voice mixer icons, better visibility and aesthetics in voice mixer. | ||
| - Better sleep state handling for Linux. | ||
| - Improvements in documentation and code. | ||
| # v1.0.7 | ||
| - Improve chaptered audio generation by outputting directly as `m4b` instead of converting from `wav`. | ||
| - Ignore chapter markers and single newlines when calculating text length, improving the accuracy of the text length calculation. | ||
| - Prevent cancellation if process is at 99%, ensuring the process is not interrupted at the last moment. | ||
| - Improved process handling for subpprocess calls, ensuring better management of subprocesses. | ||
| - Improved PDF handling, ignoring empty pages/chapters and better chapter handling. | ||
| - Added `Save in a project folder with metadata` option in the book handler, allowing users to save the converted items in a project folder with available metadata files. Useful if you want to work with the converted files in the future, issue mentioned by @Darthagnon in #15 | ||
| - Added `Go to folder` button in input box, allowing users to open the folder containing the converted file. | ||
| - Added `.opus` as output format for generated audio files, which is a more efficient format for audio files. | ||
| - Added `Create desktop shortcut and install` option to Linux version, allowing users to create a shortcut and install | ||
| - Added "Playing..." indicator for "Preview" button in the voice mixer. | ||
| # v1.0.6 | ||
| - Added `Insert chapter marker` button in text editor to insert chapter markers at the current cursor position. | ||
| - Added `Preview` button in voice mixer to preview the voice mix with the selected settings. | ||
| - Fixed `f-string: unmatched '['` error in Voice preview, mentioned in #14 | ||
| - Fixed the issue with the content before first chapter not being included in the output. | ||
| - Fixed m4b chapter generation opens CMD window in Windows. | ||
| # v1.0.5 | ||
| - Added new output format: `m4b`, enabling chapter metadata in audiobooks. Special thanks to @jborza for implementing this feature in PR #10. | ||
| - Better approach for determining the correct configuration folder for Linux and MacOS, using platformdirs. (Fixes Docker issue #12) | ||
| - Improvements in documentation and code. | ||
| # v1.0.4 | ||
| - Merge pull request [#7](https://github.com/denizsafak/abogen/pull/7) by @jborza that improves voice preview and documentation. | ||
| - Fixed the issue when a voice is selected, the voice mixer tries to pre-select that voice and ignores existing profiles. | ||
| - Fixed the error while renaming the default "New profile" in the voice mixer. | ||
| - Fixed subtitle_combo enabling/disabling when a voice in the voice mixer is selected. | ||
| - Prevented using special characters in the profile name to avoid conflicts. | ||
| - Improved invalid profile handling in the voice mixer. | ||
| # v1.0.3 | ||
| - Added voice mixing, allowing multiple voices to be combined into a single “Mixed Voice”, a feature mentioned by @PulsarFTW in #1. Special thanks to @jborza for making this possible through his contributions in #5. | ||
| - Added profile system to voice mixer, allowing users to create and manage multiple voice profiles. | ||
| - Improvements in the voice mixer, mostly for organizing controls and enhancing user experience. | ||
| - Added icons for flags and genders in the GUI, making it easier to identify different options. | ||
| - Improved the content and chapter extraction process for EPUB files, ensuring better handling of various structures. | ||
| - Switched to platformdirs for determining the correct desktop path, instead of using old methods. | ||
| - Fixed preview voices was not using GPU acceleration, which was causing performance issues. | ||
| - Improvements in code and documentation. | ||
| # v1.0.2 | ||
| - Enhanced EPUB handling by treating all items in chapter list (including anchors) as chapters, improving navigation and organization for poorly structured books, mentioned by @Darthagnon in #4 | ||
| - Fixed the issue with some chapters in EPUB files had missing content. | ||
| - Fixed the issue with some EPUB files only having one chapter caused the program to ignore the entire book. | ||
| - Fixed "utf-8' codec can't decode byte" error, mentioned by @nigelp in #3 | ||
| - Added "Replace single newlines with spaces" option in the menu. This can be useful for texts that have imaginary line breaks. | ||
| - Improvements in code and documentation. | ||
| # v1.0.1 | ||
| - Added abogen-cli command for better troubleshooting and error handling. | ||
| - Switched from setuptools to hatchling for packaging. | ||
| - Added classifiers to the package metadata. | ||
| - Fixed "No module named 'docopt'" and "setuptools.build_meta" import errors while using .bat installer in Windows, mentioned by @nigelp in #2 | ||
| - Improvements in code and documentation. |
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
701851
-22.31%43
-2.27%10830
-10.33%