mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
- Implemented number conversion to words for grouped numbers in text normalization. - Added configuration options for number conversion in ApostropheConfig. - Updated the normalize_apostrophes function to include number normalization. - Enhanced the dashboard UI with new sections for manuscript and narrator defaults. - Improved voice preview functionality with better handling of audio playback and status updates. - Refactored HTML templates for cleaner structure and added new fields for speaker settings. - Updated CSS styles for improved layout and responsiveness. - Added tests to ensure correct spelling of grouped numbers in the normalization process. - Included num2words as a new dependency in pyproject.toml.
476 lines
15 KiB
JavaScript
476 lines
15 KiB
JavaScript
const initDashboard = () => {
|
|
const uploadModal = document.querySelector('[data-role="upload-modal"]');
|
|
const openModalButtons = document.querySelectorAll('[data-role="open-upload-modal"]');
|
|
const readerModal = document.querySelector('[data-role="reader-modal"]');
|
|
const readerFrame = readerModal?.querySelector('[data-role="reader-frame"]') || null;
|
|
const readerHint = readerModal?.querySelector('[data-role="reader-modal-hint"]') || null;
|
|
const readerTitle = readerModal?.querySelector('#reader-modal-title') || null;
|
|
const defaultReaderHint = readerHint?.textContent || "";
|
|
const scope = uploadModal || document;
|
|
|
|
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') || {};
|
|
|
|
let lastTrigger = null;
|
|
let readerTrigger = null;
|
|
let previewAbortController = null;
|
|
let previewObjectUrl = null;
|
|
let suppressPauseStatus = false;
|
|
|
|
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 });
|
|
}
|
|
};
|
|
|
|
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 });
|
|
}
|
|
};
|
|
|
|
const openReaderModal = (trigger) => {
|
|
if (!readerModal || !readerFrame) return;
|
|
const url = trigger?.dataset.readerUrl || "";
|
|
if (!url) return;
|
|
readerTrigger = trigger || null;
|
|
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;
|
|
}
|
|
closeUploadModal();
|
|
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 when the browser blocks iframe focus
|
|
}
|
|
};
|
|
|
|
const closeReaderModal = () => {
|
|
if (!readerModal) return;
|
|
if (readerModal.hidden) return;
|
|
readerModal.hidden = true;
|
|
delete readerModal.dataset.open;
|
|
document.body.classList.remove("modal-open");
|
|
if (readerFrame) {
|
|
readerFrame.src = "about:blank";
|
|
}
|
|
if (readerHint) {
|
|
readerHint.textContent = defaultReaderHint;
|
|
}
|
|
if (readerTitle) {
|
|
readerTitle.textContent = "Read & listen";
|
|
}
|
|
if (readerTrigger && readerTrigger instanceof HTMLElement) {
|
|
readerTrigger.focus({ preventScroll: true });
|
|
}
|
|
readerTrigger = null;
|
|
};
|
|
|
|
openModalButtons.forEach((button) => {
|
|
button.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
openUploadModal(button);
|
|
});
|
|
});
|
|
|
|
if (uploadModal) {
|
|
uploadModal.addEventListener("click", (event) => {
|
|
const target = event.target;
|
|
if (target instanceof Element && target.closest('[data-role="upload-modal-close"]')) {
|
|
event.preventDefault();
|
|
closeUploadModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
if (uploadModal && !uploadModal.hidden) {
|
|
closeUploadModal();
|
|
return;
|
|
}
|
|
if (readerModal && !readerModal.hidden) {
|
|
closeReaderModal();
|
|
}
|
|
}
|
|
});
|
|
|
|
document.addEventListener("click", (event) => {
|
|
const readerClose = event.target.closest('[data-role="reader-modal-close"]');
|
|
if (readerClose) {
|
|
event.preventDefault();
|
|
closeReaderModal();
|
|
return;
|
|
}
|
|
|
|
const readerTriggerBtn = event.target.closest('[data-role="open-reader"]');
|
|
if (readerTriggerBtn) {
|
|
event.preventDefault();
|
|
openReaderModal(readerTriggerBtn);
|
|
}
|
|
});
|
|
|
|
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.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
handleVoicePreview();
|
|
});
|
|
}
|
|
|
|
[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 selectFirstProfileIfAvailable = () => {
|
|
if (!profileSelect) return false;
|
|
const saved = Array.from(profileSelect.options).filter(
|
|
(option) => option.value && option.value !== "__standard" && option.value !== "__formula",
|
|
);
|
|
if (!saved.length) {
|
|
profileSelect.value = "__standard";
|
|
return false;
|
|
}
|
|
saved[0].selected = true;
|
|
return true;
|
|
};
|
|
|
|
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) {
|
|
const hasSaved = selectFirstProfileIfAvailable();
|
|
profileSelect.addEventListener("change", updateVoiceControls);
|
|
updateVoiceControls();
|
|
if (!hasSaved) {
|
|
hydrateDefaultVoice();
|
|
}
|
|
} else {
|
|
hydrateDefaultVoice();
|
|
}
|
|
|
|
window.addEventListener("beforeunload", () => {
|
|
cancelPreviewRequest();
|
|
stopPreviewAudio();
|
|
});
|
|
};
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", initDashboard, { once: true });
|
|
} else {
|
|
initDashboard();
|
|
}
|