From 881717c4cb49d8f9a6ba26db46fdce599abb7138 Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 8 Oct 2025 12:50:50 -0700 Subject: [PATCH] Refactor templates for audiobook preparation and settings - Updated navigation in base.html to include a link to the queue section. - Modified index.html to change step labels and added a jobs panel for real-time updates. - Enhanced prepare_job.html with a wizard-style step navigation and improved speaker configuration options. - Added new fields for speaker analysis settings and chapter options in prepare_job.html. - Introduced a voice selection modal for better user experience in speaker selection. - Updated settings.html to allow selection of randomizer languages for speakers. - Cleaned up speakers.html by removing unnecessary language selection fields and ensuring consistency in speaker configuration. --- abogen/speaker_analysis.py | 25 +- abogen/web/routes.py | 96 +++- abogen/web/static/prepare.js | 451 +++++++++++++++++ abogen/web/templates/base.html | 8 +- abogen/web/templates/index.html | 16 +- abogen/web/templates/prepare_job.html | 664 +++++++++++++++----------- abogen/web/templates/settings.html | 10 + abogen/web/templates/speakers.html | 22 +- 8 files changed, 953 insertions(+), 339 deletions(-) diff --git a/abogen/speaker_analysis.py b/abogen/speaker_analysis.py index 9f2352c..b66ccd8 100644 --- a/abogen/speaker_analysis.py +++ b/abogen/speaker_analysis.py @@ -359,9 +359,28 @@ def _reassign(assignments: Dict[str, str], old: str, new: str) -> None: def _count_gender_votes(text: str) -> Tuple[int, int]: if not text: return 0, 0 - male = len(_MALE_PRONOUN_PATTERN.findall(text)) - female = len(_FEMALE_PRONOUN_PATTERN.findall(text)) - return male, female + + male_votes = 0.0 + for token in _MALE_PRONOUN_PATTERN.findall(text): + lowered = token.lower() + if lowered in {"he", "himself"}: + male_votes += 1.0 + elif lowered == "his": + male_votes += 0.75 + else: # him + male_votes += 0.6 + + female_votes = 0.0 + for token in _FEMALE_PRONOUN_PATTERN.findall(text): + lowered = token.lower() + if lowered in {"she", "herself"}: + female_votes += 1.0 + elif lowered == "hers": + female_votes += 0.75 + else: # her + female_votes += 0.4 + + return int(round(male_votes)), int(round(female_votes)) def _derive_gender(male_votes: int, female_votes: int, current: str) -> str: diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 06f313b..60d482c 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -189,26 +189,44 @@ def _apply_speaker_config_to_roster( *, allow_randomize: bool = False, persist_changes: bool = False, + fallback_languages: Optional[Iterable[str]] = None, ) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]: if not isinstance(roster, Mapping): - return {}, [], None + effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code] + return {}, effective_languages, None updated_roster: Dict[str, Any] = {key: dict(value) for key, value in roster.items() if isinstance(value, Mapping)} if not config: - return updated_roster, [], None + effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code] + return updated_roster, effective_languages, None speakers_map = config.get("speakers") if not isinstance(speakers_map, Mapping): - return updated_roster, [], None + effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code] + return updated_roster, effective_languages, None config_languages = config.get("languages") if isinstance(config_languages, list): allowed_languages = [code for code in config_languages if isinstance(code, str) and code] else: allowed_languages = [] + if not allowed_languages and fallback_languages: + allowed_languages = [code for code in fallback_languages if isinstance(code, str) and code] default_voice = config.get("default_voice") if isinstance(config.get("default_voice"), str) else "" used_voices = {entry.get("resolved_voice") or entry.get("voice") for entry in updated_roster.values()} - {None} + narrator_voice = "" + narrator_entry = updated_roster.get("narrator") if isinstance(updated_roster, Mapping) else None + if isinstance(narrator_entry, Mapping): + narrator_voice = str( + narrator_entry.get("resolved_voice") + or narrator_entry.get("voice") + or narrator_entry.get("default_voice") + or "" + ).strip() + if narrator_voice: + used_voices.add(narrator_voice) + config_changed = False new_config_payload: Dict[str, Any] = { "language": config.get("language", "a"), @@ -236,11 +254,13 @@ def _apply_speaker_config_to_roster( randomize_flag = bool(config_entry.get("randomize")) chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice") - if allow_randomize and randomize_flag: - exclusion = used_voices | {voice_id, resolved_voice} + usable_languages = languages or allowed_languages + should_randomize = randomize_flag and (allow_randomize or not chosen_voice) + if should_randomize: + exclusion = used_voices | {voice_id, resolved_voice, narrator_voice} randomized = random_voice( gender=config_entry.get("gender", "unknown"), - allowed_languages=languages or allowed_languages, + allowed_languages=usable_languages, fallback_voice=default_voice or roster_entry.get("voice"), exclude=exclusion, ) @@ -249,6 +269,7 @@ def _apply_speaker_config_to_roster( voice_id = randomized resolved_voice = randomized config_changed = True + used_voices.add(randomized) if chosen_voice: roster_entry["resolved_voice"] = chosen_voice @@ -260,7 +281,10 @@ def _apply_speaker_config_to_roster( roster_entry["resolved_voice"] = voice_formula if not voice_formula and not voice_profile and resolved_voice: roster_entry["resolved_voice"] = resolved_voice - roster_entry["config_languages"] = languages or allowed_languages + roster_entry["config_languages"] = usable_languages or [] + + if chosen_voice: + used_voices.add(chosen_voice) # persist updates back to config payload if required if persist_changes: @@ -273,7 +297,7 @@ def _apply_speaker_config_to_roster( "voice_profile": voice_profile, "voice_formula": voice_formula, "resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id), - "languages": languages, + "languages": usable_languages, "randomize": randomize_flag, } @@ -373,7 +397,7 @@ def _extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str name = (form.get("config_name") or "").strip() language = str(form.get("config_language") or "a").strip() or "a" - allowed_languages = [code.lower() for code in _get_list("config_languages")] + allowed_languages = [] default_voice = (form.get("config_default_voice") or "").strip() notes = (form.get("config_notes") or "").strip() version = _coerce_int(form.get("config_version"), 1, minimum=1, maximum=9999) @@ -391,7 +415,6 @@ def _extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str voice_profile = (form.get(prefix + "profile") or "").strip() voice_formula = (form.get(prefix + "formula") or "").strip() randomize_flag = form.get(prefix + "randomize") in {"on", "1", "true"} - languages = [code.lower() for code in _get_list(prefix + "languages")] speaker_id = (form.get(prefix + "id") or "").strip() or slugify_label(label) speakers[speaker_id] = { "id": speaker_id, @@ -401,7 +424,7 @@ def _extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str "voice_profile": voice_profile, "voice_formula": voice_formula, "resolved_voice": voice_formula or voice, - "languages": languages, + "languages": [], "randomize": randomize_flag, } @@ -443,6 +466,12 @@ def _prepare_speaker_metadata( analysis_source = [dict(chunk) for chunk in (analysis_chunks or chunks)] threshold_value = max(1, int(threshold)) analysis_enabled = speaker_mode == "multi" and run_analysis + settings_state = _load_settings() + global_random_languages = [ + code + for code in settings_state.get("speaker_random_languages", []) + if isinstance(code, str) and code + ] if not analysis_enabled: for chunk in chunk_list: @@ -534,6 +563,7 @@ def _prepare_speaker_metadata( speaker_config, allow_randomize=allow_randomize, persist_changes=persist_config, + fallback_languages=global_random_languages, ) speakers_payload = analysis_payload.get("speakers") if isinstance(speakers_payload, dict): @@ -544,8 +574,18 @@ def _prepare_speaker_metadata( value = roster_payload.get(key) if value: speaker_meta[key] = value + effective_languages: List[str] = [] if applied_languages: - analysis_payload["config_languages"] = applied_languages + effective_languages = applied_languages + elif isinstance(analysis_payload.get("config_languages"), list): + effective_languages = [ + code for code in analysis_payload.get("config_languages", []) if isinstance(code, str) and code + ] + elif global_random_languages: + effective_languages = list(global_random_languages) + + if effective_languages: + analysis_payload["config_languages"] = effective_languages speakers_payload = analysis_payload.get("speakers") if isinstance(speakers_payload, dict): for roster_id, roster_payload in roster.items(): @@ -554,11 +594,7 @@ def _prepare_speaker_metadata( if pronunciation_value: speakers_payload[roster_id]["pronunciation"] = pronunciation_value - fallback_languages = applied_languages or [] - if not fallback_languages: - config_langs = analysis_payload.get("config_languages") - if isinstance(config_langs, list): - fallback_languages = [code for code in config_langs if isinstance(code, str)] + fallback_languages = effective_languages or [] _inject_recommended_voices(roster, fallback_languages=fallback_languages) for chunk in chunk_list: @@ -962,6 +998,7 @@ def _settings_defaults() -> Dict[str, Any]: "generate_epub3": False, "speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD, "speaker_pronunciation_sentence": "This is {{name}} speaking.", + "speaker_random_languages": [], } @@ -1031,6 +1068,13 @@ def _normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> if isinstance(value, str) and value in _SPEAKER_MODE_VALUES: return value return defaults[key] + if key == "speaker_random_languages": + if isinstance(value, (list, tuple, set)): + return [code for code in value if isinstance(code, str) and code in LANGUAGE_DESCRIPTIONS] + if isinstance(value, str): + parts = [item.strip().lower() for item in value.split(",") if item.strip()] + return [code for code in parts if code in LANGUAGE_DESCRIPTIONS] + return defaults.get(key, []) return value if value is not None else defaults.get(key) @@ -1195,12 +1239,13 @@ def index() -> str: "index.html", options=_template_options(), settings=_load_settings(), + jobs_panel=_render_jobs_panel(), ) @web_bp.get("/queue") -def queue_page() -> str: - return render_template("queue.html", jobs_panel=_render_jobs_panel()) +def queue_page() -> ResponseReturnValue: + return redirect(url_for("web.index", _anchor="queue")) @web_bp.route("/settings", methods=["GET", "POST"]) @@ -1256,6 +1301,13 @@ def settings_page() -> ResponseReturnValue: sentence_value = defaults["speaker_pronunciation_sentence"] updated["speaker_pronunciation_sentence"] = sentence_value + random_languages = [ + code.lower() + for code in form.getlist("speaker_random_languages") + if isinstance(code, str) and code.lower() in LANGUAGE_DESCRIPTIONS + ] + updated["speaker_random_languages"] = random_languages + cfg = load_config() or {} cfg.update(updated) save_config(cfg) @@ -2126,7 +2178,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: job.applied_speaker_config = config_name job.speaker_voice_languages = job.speaker_voice_languages or list(pending.speaker_voice_languages) - return redirect(url_for("web.queue_page")) + return redirect(url_for("web.index", _anchor="queue")) @web_bp.post("/jobs/prepare//cancel") @@ -2142,7 +2194,7 @@ def cancel_pending_job(pending_id: str) -> ResponseReturnValue: pending.cover_image_path.unlink() except OSError: pass - return redirect(url_for("web.index")) + return redirect(url_for("web.index", _anchor="queue")) def _render_jobs_panel() -> str: @@ -2225,7 +2277,7 @@ def clear_finished_jobs() -> ResponseReturnValue: _service().clear_finished() if request.headers.get("HX-Request"): return _render_jobs_panel() - return redirect(url_for("web.queue_page")) + return redirect(url_for("web.index", _anchor="queue")) @web_bp.get("/jobs//download") diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index 091e090..eab0cff 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -2,6 +2,22 @@ document.addEventListener("DOMContentLoaded", () => { const form = document.querySelector(".prepare-form"); if (!form) return; + const parseJSONScript = (id) => { + const el = document.getElementById(id); + if (!el) return null; + try { + const content = el.textContent || ""; + return content ? JSON.parse(content) : null; + } catch (error) { + console.warn(`Failed to parse JSON script for ${id}`, error); + return null; + } + }; + + const voiceCatalog = parseJSONScript("voice-catalog-data") || []; + const languageMap = parseJSONScript("voice-language-map") || {}; + const voiceCatalogMap = new Map(voiceCatalog.map((voice) => [voice.id, voice])); + const chapterRows = Array.from(form.querySelectorAll("[data-role=chapter-row]")); const updateRowState = (row) => { @@ -123,6 +139,9 @@ document.addEventListener("DOMContentLoaded", () => { handleRandomizeToggle(randomToggle); } } + if (!target.dataset.suppressRandomize) { + target.dataset.prevManual = target.value || ""; + } updatePreviewVoice(target); }); updatePreviewVoice(select); @@ -134,7 +153,435 @@ document.addEventListener("DOMContentLoaded", () => { checkbox.addEventListener("change", () => handleRandomizeToggle(checkbox)); }); + const wizard = document.querySelector('[data-role="prepare-wizard"]'); + if (wizard) { + const stepOrder = ["chapters", "speakers"]; + const indicatorOrder = ["setup", ...stepOrder]; + const indicator = wizard.querySelector('[data-role="wizard-indicator"]'); + const indicatorSteps = indicator ? Array.from(indicator.querySelectorAll('[data-role="wizard-step"]')) : []; + const navButtons = Array.from(wizard.querySelectorAll('[data-role="prepare-step-nav"] [data-step-target]')); + const nextButtons = Array.from(wizard.querySelectorAll('[data-role="step-next"]')); + const prevButtons = Array.from(wizard.querySelectorAll('[data-role="step-prev"]')); + const panels = new Map(); + stepOrder.forEach((step) => { + const panel = wizard.querySelector(`[data-step-panel="${step}"]`); + if (panel) { + panels.set(step, panel); + panel.hidden = step !== "chapters"; + panel.setAttribute("aria-hidden", step === "chapters" ? "false" : "true"); + } + }); + + const unlockedSteps = new Set(["chapters"]); + let currentStep = "chapters"; + + const updateIndicator = (activeStep) => { + const activeIndex = indicatorOrder.indexOf(activeStep); + indicatorSteps.forEach((item) => { + const key = item.dataset.stepKey; + if (!key) return; + const index = indicatorOrder.indexOf(key); + item.classList.remove("is-active", "is-complete"); + if (index < activeIndex) { + item.classList.add("is-complete"); + } else if (index === activeIndex) { + item.classList.add("is-active"); + } + }); + }; + + const unlockStep = (step) => { + if (unlockedSteps.has(step)) { + return; + } + unlockedSteps.add(step); + navButtons.forEach((button) => { + if (button.dataset.stepTarget === step) { + button.disabled = false; + button.removeAttribute("aria-disabled"); + button.dataset.state = "unlocked"; + } + }); + }; + + const setStep = (step) => { + if (!panels.has(step)) { + return; + } + currentStep = step; + wizard.dataset.activeStep = step; + panels.forEach((panel, key) => { + const isActive = key === step; + panel.hidden = !isActive; + panel.setAttribute("aria-hidden", isActive ? "false" : "true"); + }); + navButtons.forEach((button) => { + const target = button.dataset.stepTarget; + if (!target) return; + const isActive = target === step; + button.classList.toggle("is-active", isActive); + if (button.dataset.state === "locked" && !unlockedSteps.has(target)) { + button.disabled = true; + button.setAttribute("aria-disabled", "true"); + } else { + button.disabled = false; + button.removeAttribute("aria-disabled"); + } + }); + updateIndicator(step); + }; + + navButtons.forEach((button) => { + button.addEventListener("click", () => { + if (button.disabled) { + return; + } + const target = button.dataset.stepTarget; + if (!target) return; + unlockStep(target); + setStep(target); + }); + }); + + nextButtons.forEach((button) => { + button.addEventListener("click", () => { + const target = button.dataset.stepTarget || "speakers"; + unlockStep(target); + setStep(target); + }); + }); + + prevButtons.forEach((button) => { + button.addEventListener("click", () => { + const target = button.dataset.stepTarget || "chapters"; + setStep(target); + }); + }); + + setStep(currentStep); + } + + const voiceModal = document.querySelector('[data-role="voice-modal"]'); + let activeGenderFilter = ""; + + const modalState = { + speakerItem: null, + samples: [], + recommended: new Set(), + selectedVoice: "", + defaultVoice: "", + previewSettings: { language: "a", speed: "1", useGpu: "true" }, + }; + + const resetModalState = () => { + modalState.speakerItem = null; + modalState.samples = []; + modalState.recommended = new Set(); + modalState.selectedVoice = ""; + modalState.defaultVoice = ""; + modalState.previewSettings = { language: "a", speed: "1", useGpu: "true" }; + }; + + const renderVoiceList = (elements) => { + if (!elements) return; + const { list, searchInput, languageSelect } = elements; + if (!list) return; + list.innerHTML = ""; + const term = (searchInput?.value || "").trim().toLowerCase(); + const languageFilter = languageSelect?.value || ""; + const filtered = voiceCatalog.filter((voice) => { + if (languageFilter && voice.language !== languageFilter) return false; + if (activeGenderFilter && voice.gender_code !== activeGenderFilter) return false; + if (term) { + const haystacks = [voice.display_name, voice.id, voice.language_label, languageMap[voice.language]] + .filter(Boolean) + .map((value) => value.toLowerCase()); + if (!haystacks.some((value) => value.includes(term))) { + return false; + } + } + return true; + }).sort((a, b) => { + const aRecommended = modalState.recommended.has(a.id) ? 0 : 1; + const bRecommended = modalState.recommended.has(b.id) ? 0 : 1; + if (aRecommended !== bRecommended) { + return aRecommended - bRecommended; + } + return a.display_name.localeCompare(b.display_name); + }); + + if (!filtered.length) { + const emptyItem = document.createElement("li"); + emptyItem.className = "voice-browser__empty"; + emptyItem.textContent = "No voices matched your filters."; + list.appendChild(emptyItem); + return; + } + + filtered.forEach((voice) => { + const item = document.createElement("li"); + const button = document.createElement("button"); + button.type = "button"; + button.className = "voice-browser__entry"; + button.dataset.role = "voice-modal-item"; + button.dataset.voiceId = voice.id; + if (modalState.selectedVoice === voice.id) { + button.setAttribute("aria-current", "true"); + } + if (modalState.recommended.has(voice.id)) { + button.dataset.recommended = "true"; + } + const nameSpan = document.createElement("span"); + nameSpan.className = "voice-browser__entry-name"; + nameSpan.textContent = voice.display_name; + const metaSpan = document.createElement("span"); + metaSpan.className = "voice-browser__entry-meta"; + metaSpan.textContent = `${voice.language_label} · ${voice.gender}`; + button.appendChild(nameSpan); + button.appendChild(metaSpan); + item.appendChild(button); + list.appendChild(item); + }); + }; + + const renderSamples = (elements) => { + if (!elements) return; + const { container } = elements; + if (!container) return; + container.innerHTML = ""; + + if (!modalState.samples.length) { + const empty = document.createElement("p"); + empty.className = "hint"; + empty.textContent = "No sample paragraphs available yet."; + container.appendChild(empty); + return; + } + + modalState.samples.forEach((text, index) => { + const sample = document.createElement("article"); + sample.className = "voice-browser__sample"; + sample.dataset.sampleIndex = String(index); + if (index === 0) { + sample.dataset.active = "true"; + } + + const paragraph = document.createElement("p"); + paragraph.textContent = text; + const actions = document.createElement("div"); + actions.className = "voice-browser__sample-actions"; + + const previewButton = document.createElement("button"); + previewButton.type = "button"; + previewButton.className = "button button--ghost button--small"; + previewButton.dataset.role = "speaker-preview"; + previewButton.dataset.previewText = text; + previewButton.dataset.language = modalState.previewSettings.language; + previewButton.dataset.speed = modalState.previewSettings.speed; + previewButton.dataset.useGpu = modalState.previewSettings.useGpu; + previewButton.dataset.voice = modalState.selectedVoice || modalState.defaultVoice || ""; + previewButton.textContent = "Preview sample"; + + actions.appendChild(previewButton); + sample.appendChild(paragraph); + sample.appendChild(actions); + container.appendChild(sample); + }); + }; + + const updateModalVoiceMeta = (elements) => { + if (!elements) return; + const { nameLabel, metaLabel } = elements; + if (!nameLabel || !metaLabel) return; + if (!modalState.selectedVoice) { + nameLabel.textContent = "Select a voice to preview"; + metaLabel.textContent = ""; + return; + } + const voice = voiceCatalogMap.get(modalState.selectedVoice); + if (!voice) { + nameLabel.textContent = modalState.selectedVoice; + metaLabel.textContent = ""; + return; + } + nameLabel.textContent = voice.display_name; + metaLabel.textContent = `${voice.language_label} · ${voice.gender}`; + }; + + const refreshModal = (elements) => { + renderVoiceList(elements); + renderSamples(elements); + updateModalVoiceMeta(elements); + if (elements?.applyButton) { + elements.applyButton.disabled = !modalState.selectedVoice; + } + }; + + const openVoiceBrowser = (speakerItem, sampleIndex = 0) => { + if (!voiceModal) return; + modalState.speakerItem = speakerItem; + const select = speakerItem.querySelector('[data-role="speaker-voice"]'); + const previewTrigger = speakerItem.querySelector('[data-role="speaker-preview"]'); + modalState.defaultVoice = select?.dataset.defaultVoice || previewTrigger?.dataset.voice || ""; + modalState.selectedVoice = select && !select.disabled ? (select.value || modalState.defaultVoice) : modalState.defaultVoice; + modalState.previewSettings = { + language: previewTrigger?.dataset.language || "a", + speed: previewTrigger?.dataset.speed || "1", + useGpu: previewTrigger?.dataset.useGpu || "true", + }; + + const samplesTemplate = speakerItem.querySelector('template[data-role="speaker-samples"]'); + let samples = []; + if (samplesTemplate) { + try { + const raw = samplesTemplate.innerHTML || "[]"; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + samples = parsed.filter((value) => typeof value === "string" && value.trim().length); + } + } catch (error) { + console.warn("Unable to parse speaker samples", error); + } + } + if (previewTrigger?.dataset.previewText) { + samples.unshift(previewTrigger.dataset.previewText); + } + const uniqueSamples = Array.from(new Set(samples)); + if (sampleIndex > 0 && sampleIndex < uniqueSamples.length) { + const [picked] = uniqueSamples.splice(sampleIndex, 1); + uniqueSamples.unshift(picked); + } + modalState.samples = uniqueSamples; + modalState.recommended = new Set( + Array.from(speakerItem.querySelectorAll('[data-role="recommended-voice"]')).map((btn) => btn.dataset.voice).filter(Boolean) + ); + activeGenderFilter = ""; + + const elements = { + list: voiceModal.querySelector('[data-role="voice-modal-list"]'), + searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'), + languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'), + genderButtons: Array.from(voiceModal.querySelectorAll('[data-role="voice-modal-gender"]')), + container: voiceModal.querySelector('[data-role="voice-modal-samples"]'), + applyButton: voiceModal.querySelector('[data-role="voice-modal-apply"]'), + nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'), + metaLabel: voiceModal.querySelector('[data-role="voice-modal-selected-meta"]'), + }; + + if (elements.searchInput) elements.searchInput.value = ""; + if (elements.languageSelect) elements.languageSelect.value = ""; + elements.genderButtons.forEach((button) => { + button.setAttribute("aria-pressed", button.dataset.value === "" ? "true" : "false"); + }); + + refreshModal(elements); + + voiceModal.hidden = false; + voiceModal.dataset.open = "true"; + document.body.classList.add("modal-open"); + if (elements.searchInput) { + setTimeout(() => elements.searchInput.focus({ preventScroll: true }), 0); + } + }; + + const closeVoiceBrowser = () => { + if (!voiceModal || voiceModal.hidden) return; + voiceModal.hidden = true; + voiceModal.removeAttribute("data-open"); + document.body.classList.remove("modal-open"); + resetModalState(); + }; + + if (voiceModal) { + const elements = { + list: voiceModal.querySelector('[data-role="voice-modal-list"]'), + searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'), + languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'), + genderButtons: Array.from(voiceModal.querySelectorAll('[data-role="voice-modal-gender"]')), + container: voiceModal.querySelector('[data-role="voice-modal-samples"]'), + applyButton: voiceModal.querySelector('[data-role="voice-modal-apply"]'), + nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'), + metaLabel: voiceModal.querySelector('[data-role="voice-modal-selected-meta"]'), + }; + + if (elements.searchInput) { + elements.searchInput.addEventListener("input", () => renderVoiceList(elements)); + } + if (elements.languageSelect) { + elements.languageSelect.addEventListener("change", () => renderVoiceList(elements)); + } + elements.genderButtons.forEach((button) => { + button.addEventListener("click", () => { + activeGenderFilter = button.dataset.value || ""; + elements.genderButtons.forEach((btn) => btn.setAttribute("aria-pressed", btn === button ? "true" : "false")); + renderVoiceList(elements); + }); + }); + if (elements.list) { + elements.list.addEventListener("click", (event) => { + const target = event.target.closest('[data-role="voice-modal-item"]'); + if (!target) return; + event.preventDefault(); + modalState.selectedVoice = target.dataset.voiceId || ""; + refreshModal(elements); + }); + } + if (elements.applyButton) { + elements.applyButton.addEventListener("click", (event) => { + event.preventDefault(); + if (!modalState.speakerItem || !modalState.selectedVoice) { + return; + } + const select = modalState.speakerItem.querySelector('[data-role="speaker-voice"]'); + if (!select) return; + const randomToggle = modalState.speakerItem.querySelector('[data-role="randomize-toggle"]'); + if (randomToggle && randomToggle.checked) { + randomToggle.checked = false; + handleRandomizeToggle(randomToggle); + } + select.disabled = false; + select.dataset.suppressRandomize = "1"; + select.value = modalState.selectedVoice; + select.dispatchEvent(new Event("change", { bubbles: true })); + delete select.dataset.suppressRandomize; + select.dataset.prevManual = modalState.selectedVoice; + closeVoiceBrowser(); + }); + } + voiceModal.addEventListener("click", (event) => { + if (event.target.closest('[data-role="voice-modal-close"]')) { + event.preventDefault(); + closeVoiceBrowser(); + } + }); + if (elements.container) { + elements.container.addEventListener("click", (event) => { + const sample = event.target.closest(".voice-browser__sample"); + if (!sample) return; + elements.container.querySelectorAll(".voice-browser__sample").forEach((node) => node.removeAttribute("data-active")); + sample.dataset.active = "true"; + }); + } + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !voiceModal.hidden) { + closeVoiceBrowser(); + } + }); + + renderVoiceList(elements); + } + form.addEventListener("click", (event) => { + const modalTrigger = event.target.closest('[data-role="open-voice-browser"]'); + if (modalTrigger) { + event.preventDefault(); + const container = modalTrigger.closest(".speaker-list__item"); + if (!container) return; + const sampleIndex = Number.parseInt(modalTrigger.dataset.sampleIndex || "0", 10); + openVoiceBrowser(container, Number.isNaN(sampleIndex) ? 0 : sampleIndex); + return; + } + const chip = event.target.closest('[data-role="recommended-voice"]'); if (!chip) return; event.preventDefault(); @@ -147,7 +594,11 @@ document.addEventListener("DOMContentLoaded", () => { randomToggle.checked = false; handleRandomizeToggle(randomToggle); } + select.disabled = false; + select.dataset.suppressRandomize = "1"; select.value = chip.dataset.voice || ""; select.dispatchEvent(new Event("change", { bubbles: true })); + delete select.dataset.suppressRandomize; + select.dataset.prevManual = select.value || ""; }); }); diff --git a/abogen/web/templates/base.html b/abogen/web/templates/base.html index f7de887..8d85eda 100644 --- a/abogen/web/templates/base.html +++ b/abogen/web/templates/base.html @@ -17,11 +17,11 @@
diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index 31e17c4..899e262 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -7,15 +7,15 @@
1 - Upload + Upload & settings 2 - Speakers + Chapters 3 - Queue + Speakers

Create a New Audiobook

@@ -132,6 +132,16 @@ + +
+
+ {{ jobs_panel|safe }} +
+
{% endblock %} {% block scripts %} diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index b3da873..67fdcce 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -3,19 +3,19 @@ {% block title %}Prepare · {{ pending.original_filename }}{% endblock %} {% block content %} -
-
- +
+
+ 1 - Upload + Upload & settings - + 2 - Speakers + Chapters - + 3 - Queue + Speakers
Prepare audiobook
@@ -113,16 +113,6 @@

Press Analyze speakers after selecting chapters to discover recurring voices.

{% endif %} - {% if has_metadata %} - - {% endif %} {% endif %} @@ -134,294 +124,396 @@
{{ notice }}
{% endif %} -
-
-

Speaker configuration

-

Reuse saved presets to keep character voices consistent between projects.

-
-
- -
- - - -
-
+
+ +
- {% if roster %} -
-

Speaker settings

-

Set pronunciations, lock specific voices, or limit the randomizer by language and gender.

-
    - {% for speaker_id, speaker in roster.items() %} - {% set spoken_name = speaker.pronunciation or speaker.label %} - {% set preview_text = recommended_template | replace("{{name}}", spoken_name) %} - {% set selected_voice = speaker.resolved_voice or speaker.voice %} - {% set allowed_langs = speaker.config_languages or speaker.languages or [] %} - {% set seen = namespace(values=[]) %} -
  • -
    - {{ speaker.label }} - +
    +
    +
    +

    Step 2 · Select chapters

    +

    Choose which chapters to convert and tweak conversion options.

    +
    + +
    + {% 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 %} +
    +
    +
    + +
    +
    + {{ '{:,}'.format(chapter.characters) }} characters +
    +
    +
    +
    + + +
    +
    +
    + Preview text +
    {{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}
    +
    +
    +
    + + + +
    +
    +
    + {% endfor %} +
    + +
    +
    + + +

    Paragraphs work well for long-form narration; sentences give finer subtitle sync.

    -
    - {% if speaker.gender and speaker.gender != 'unknown' %} - {{ speaker.gender|title }} - {% else %} - Gender unknown - {% endif %} - {% if speaker.get('analysis_count') %} - {{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence - {% endif %} +
    + +
    - -
    -
    + +
  • - {% endfor %} -
-
- {% endif %} - -
- {% 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 %} -
-
-
- -
-
- {{ '{:,}'.format(chapter.characters) }} characters -
-
-
-
- - -
-
-
- Preview text -
{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}
-
-
-
- - -
-
- {% endfor %} -
-
-
- - -

Paragraphs work well for long-form narration; sentences give finer subtitle sync.

-
-
- - -
-
- - -

Only speakers that appear at least this many times will keep unique voices in multi-speaker mode.

-
-
- - -

Set to 0 to disable the pause after speaking each chapter title.

-
-
- -
-
+ {% if roster %} +
+

Speaker settings

+

Set pronunciations, lock specific voices, and audition sample paragraphs to hear casting choices.

+
    + {% for speaker_id, speaker in roster.items() %} + {% set spoken_name = speaker.pronunciation or speaker.label %} + {% set preview_text = recommended_template | replace("{{name}}", spoken_name) %} + {% set selected_voice = speaker.resolved_voice or speaker.voice %} + {% set seen = namespace(values=[]) %} + {% set sample_quotes = speaker.sample_quotes or [] %} +
  • +
    + {{ speaker.label }} + +
    + +
    + {% if speaker.gender and speaker.gender != 'unknown' %} + {{ speaker.gender|title }} + {% else %} + Gender unknown + {% endif %} + {% if speaker.get('analysis_count') %} + {{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence + {% endif %} + {% if settings.speaker_random_languages %} + Randomizer: {{ settings.speaker_random_languages | map('upper') | join(', ') }} + {% endif %} +
    + +
    +
    + + +
    +
    + +
    +
    +
    + Sample paragraphs + {% if sample_quotes %} + {% for quote in sample_quotes %} +
    +

    {{ quote }}

    +
    + + +
    +
    + {% endfor %} + {% else %} +

    No paragraphs captured yet. Analyze speakers to collect dialogue samples.

    + {% endif %} +
    + {% if speaker.recommended_voices %} +
    + Suggested: + {% for voice_id in speaker.recommended_voices[:6] %} + {% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %} + + {% endfor %} +
    + {% endif %} +
  • + {% endfor %} +
+
+ {% else %} +

No additional speakers detected yet. The narrator voice will be used for all dialogue.

+ {% endif %} -
- - - +
+ + + + +
+
+ {% endblock %} {% block scripts %} {{ super() }} + + {% endblock %} diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index c775efc..f82a53b 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -102,6 +102,16 @@ {% endfor %} +
+ + {% set selected_languages = settings.speaker_random_languages or [] %} + +

Limits random voice selection for speakers marked as random. Leave unused to allow any language.

+
diff --git a/abogen/web/templates/speakers.html b/abogen/web/templates/speakers.html index 8fa8a45..c308924 100644 --- a/abogen/web/templates/speakers.html +++ b/abogen/web/templates/speakers.html @@ -1,7 +1,6 @@ {% extends "base.html" %} {% macro speaker_row(row_key, speaker, options) %} - {% set languages = speaker.languages or speaker.config_languages or [] %} {% set gender = (speaker.gender or 'unknown') %} {% set randomize = speaker.randomize %}
@@ -29,15 +28,6 @@ {% endfor %} -