From b0875c7486ed223f342b7fd93e11cea2c80f01e1 Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 8 Oct 2025 15:15:10 -0700 Subject: [PATCH] Enhance audiobook workflow UI with modal and styling updates - Updated styles.css to introduce new modal and card styles for improved layout and responsiveness. - Modified index.html to implement a modal for uploading files and settings, enhancing user experience. - Refactored prepare_job.html to support a wizard-like interface for preparing jobs, including step indicators and dynamic content updates. - Added functionality for gender selection and voice mixing in the speaker preparation section. - Improved accessibility and usability with better hints and instructions throughout the forms. --- abogen/speaker_analysis.py | 69 +++- abogen/web/routes.py | 33 +- abogen/web/static/dashboard.js | 75 +++- abogen/web/static/prepare.js | 574 ++++++++++++++++++++++---- abogen/web/static/styles.css | 285 +++++++++++++ abogen/web/templates/index.html | 243 ++++++----- abogen/web/templates/prepare_job.html | 194 ++++----- 7 files changed, 1140 insertions(+), 333 deletions(-) diff --git a/abogen/speaker_analysis.py b/abogen/speaker_analysis.py index b66ccd8..b44e3b2 100644 --- a/abogen/speaker_analysis.py +++ b/abogen/speaker_analysis.py @@ -78,29 +78,41 @@ class SpeakerGuess: label: str count: int = 0 confidence: str = "low" - sample_quotes: List[str] = field(default_factory=list) + sample_quotes: List[Dict[str, str]] = field(default_factory=list) suppressed: bool = False gender: str = "unknown" + detected_gender: str = "unknown" male_votes: int = 0 female_votes: int = 0 - def register_occurrence(self, confidence: str, quote: Optional[str]) -> None: + def register_occurrence( + self, + confidence: str, + text: str, + quote: Optional[str], + male_votes: int, + female_votes: int, + ) -> None: self.count += 1 if _CONFIDENCE_RANK.get(confidence, 0) > _CONFIDENCE_RANK.get(self.confidence, 0): self.confidence = confidence - if quote: - normalized = quote.strip() - if normalized and normalized not in self.sample_quotes: - self.sample_quotes.append(normalized[:240]) + + excerpt = _build_excerpt(text, quote) + gender_hint = _format_gender_hint(male_votes, female_votes) + if excerpt: + payload = {"excerpt": excerpt, "gender_hint": gender_hint} + if payload not in self.sample_quotes: + self.sample_quotes.append(payload) if len(self.sample_quotes) > 3: self.sample_quotes = self.sample_quotes[:3] - def register_gender(self, male_votes: int, female_votes: int) -> None: if male_votes: self.male_votes += male_votes if female_votes: self.female_votes += female_votes - self.gender = _derive_gender(self.male_votes, self.female_votes, self.gender) + self.detected_gender = _derive_gender(self.male_votes, self.female_votes, self.detected_gender) + if self.gender in {"unknown", "male", "female"}: + self.gender = _derive_gender(self.male_votes, self.female_votes, self.gender) def as_dict(self) -> Dict[str, Any]: return { @@ -108,9 +120,10 @@ class SpeakerGuess: "label": self.label, "count": self.count, "confidence": self.confidence, - "sample_quotes": list(self.sample_quotes), + "sample_quotes": [dict(sample) for sample in self.sample_quotes], "suppressed": self.suppressed, "gender": self.gender, + "detected_gender": self.detected_gender, } @@ -188,9 +201,7 @@ def analyze_speakers( label_index[label] = record_id speaker_guesses[record_id] = SpeakerGuess(speaker_id=record_id, label=label) guess = speaker_guesses[record_id] - guess.register_occurrence(confidence, quote) - if record_id != narrator_id and (male_votes or female_votes): - guess.register_gender(male_votes, female_votes) + guess.register_occurrence(confidence, text, quote, male_votes, female_votes) if record_id != speaker_id: # Maintain mapping to canonical ID in assignments. assignments[chunk_id] = record_id @@ -400,6 +411,40 @@ def _derive_gender(male_votes: int, female_votes: int, current: str) -> str: return "unknown" +def _build_excerpt(text: str, quote: Optional[str]) -> str: + normalized = (text or "").strip() + if not normalized: + return "" + if quote: + location = normalized.find(quote) + if location != -1: + start = max(0, location - 120) + end = min(len(normalized), location + len(quote) + 120) + snippet = normalized[start:end].strip() + if start > 0: + snippet = "…" + snippet + if end < len(normalized): + snippet = snippet + "…" + return snippet + if len(normalized) > 240: + return normalized[:240].rstrip() + "…" + return normalized + + +def _format_gender_hint(male_votes: int, female_votes: int) -> str: + if male_votes and female_votes: + return "Context mentions both male and female pronouns." + if male_votes: + if male_votes >= 3: + return "Multiple male pronouns detected nearby." + return "Some male pronouns detected in the surrounding text." + if female_votes: + if female_votes >= 3: + return "Multiple female pronouns detected nearby." + return "Some female pronouns detected in the surrounding text." + return "No clear pronoun signal detected." + + def _normalize_candidate_name(raw: str) -> Optional[str]: if not raw: return None diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 60d482c..637f582 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -1965,7 +1965,7 @@ def prepare_job(pending_id: str) -> str: if not pending: abort(404) pending = cast(PendingJob, pending) - return _render_prepare_page(pending) + return _render_prepare_page(pending, active_step="chapters") @web_bp.post("/jobs/prepare//analyze") @@ -1989,7 +1989,7 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue: ) = _apply_prepare_form(pending, request.form) if errors: - return _render_prepare_page(pending, error=" ".join(errors)) + return _render_prepare_page(pending, error=" ".join(errors), active_step="chapters") if pending.speaker_mode != "multi": setattr(pending, "analysis_requested", False) @@ -1998,13 +1998,18 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue: return _render_prepare_page( pending, error="Switch to multi-speaker mode to analyze speakers.", + active_step="chapters", ) if not enabled_overrides: setattr(pending, "analysis_requested", False) pending.chunks = [] pending.speaker_analysis = {} - return _render_prepare_page(pending, error="Select at least one chapter to analyze.") + return _render_prepare_page( + pending, + error="Select at least one chapter to analyze.", + active_step="chapters", + ) raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal) analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence") @@ -2054,7 +2059,7 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue: notice_message = "Speaker analysis updated." if persist_config_requested and config_name: notice_message = "Speaker analysis updated and configuration saved." - return _render_prepare_page(pending, notice=notice_message) + return _render_prepare_page(pending, notice=notice_message, active_step="speakers") @web_bp.post("/jobs/prepare/") @@ -2078,14 +2083,22 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: ) = _apply_prepare_form(pending, request.form) if errors: - return _render_prepare_page(pending, error=" ".join(errors)) + return _render_prepare_page( + pending, + error=" ".join(errors), + active_step=request.form.get("active_step") or "speakers", + ) if pending.speaker_mode != "multi": setattr(pending, "analysis_requested", False) if not enabled_overrides: pending.chunks = [] - return _render_prepare_page(pending, error="Select at least one chapter to convert.") + return _render_prepare_page( + pending, + error="Select at least one chapter to convert.", + active_step="chapters", + ) raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal) analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence") @@ -2217,7 +2230,14 @@ def _render_prepare_page( *, error: Optional[str] = None, notice: Optional[str] = None, + active_step: Optional[str] = None, ) -> str: + if not active_step: + active_step = ( + request.form.get("active_step") + if request.method == "POST" + else request.args.get("step") + ) or "chapters" return render_template( "prepare_job.html", pending=pending, @@ -2225,6 +2245,7 @@ def _render_prepare_page( settings=_load_settings(), error=error, notice=notice, + active_step=active_step, ) diff --git a/abogen/web/static/dashboard.js b/abogen/web/static/dashboard.js index 04907c9..3a59b60 100644 --- a/abogen/web/static/dashboard.js +++ b/abogen/web/static/dashboard.js @@ -1,16 +1,69 @@ const initDashboard = () => { - const profileSelect = document.querySelector('[data-role="voice-profile"]'); - const voiceField = document.querySelector('[data-role="voice-field"]'); - const voiceSelect = document.querySelector('[data-role="voice-select"]'); - const formulaField = document.querySelector('[data-role="formula-field"]'); - const formulaInput = document.querySelector('[data-role="voice-formula"]'); - const languageSelect = document.getElementById("language"); + const uploadModal = document.querySelector('[data-role="upload-modal"]'); + const openModalButtons = document.querySelectorAll('[data-role="open-upload-modal"]'); + const scope = uploadModal || document; - const sourceText = document.querySelector('[data-role="source-text"]'); - const previewEl = document.querySelector('[data-role="text-preview"]'); - const previewBody = document.querySelector('[data-role="preview-body"]'); - const charCountEl = document.querySelector('[data-role="char-count"]'); - const wordCountEl = document.querySelector('[data-role="word-count"]'); + 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 sourceText = scope.querySelector('[data-role="source-text"]'); + const previewEl = scope.querySelector('[data-role="text-preview"]'); + const previewBody = scope.querySelector('[data-role="preview-body"]'); + const charCountEl = scope.querySelector('[data-role="char-count"]'); + const wordCountEl = scope.querySelector('[data-role="word-count"]'); + + let lastTrigger = null; + + 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 }); + } + }; + + 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" && uploadModal && !uploadModal.hidden) { + closeUploadModal(); + } + }); const hydrateDefaultVoice = () => { if (!voiceSelect) return; diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index eab0cff..e97f1b9 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -23,6 +23,7 @@ document.addEventListener("DOMContentLoaded", () => { const updateRowState = (row) => { const enabled = row.querySelector('[data-role=chapter-enabled]'); const inputs = Array.from(row.querySelectorAll("input[type=text], select, textarea")); + const warning = row.querySelector('[data-role=chapter-warning]'); const isChecked = enabled ? enabled.checked : true; row.dataset.disabled = isChecked ? "false" : "true"; @@ -46,6 +47,11 @@ document.addEventListener("DOMContentLoaded", () => { const select = row.querySelector("select[data-role=voice-select]"); toggleFormula(select); + + if (warning) { + warning.hidden = isChecked; + warning.setAttribute("aria-hidden", isChecked ? "true" : "false"); + } }; const toggleFormula = (select) => { @@ -78,26 +84,28 @@ document.addEventListener("DOMContentLoaded", () => { } }); - const analyzeButton = form.querySelector('[data-role="analyze-button"]'); - const speakerModeSelect = form.querySelector("#speaker_mode"); - const updateAnalyzeVisibility = () => { - if (!analyzeButton || !speakerModeSelect) return; - const isMulti = speakerModeSelect.value === "multi"; - analyzeButton.hidden = !isMulti; - analyzeButton.setAttribute("aria-hidden", isMulti ? "false" : "true"); - analyzeButton.disabled = !isMulti; - }; - - if (analyzeButton && speakerModeSelect) { - speakerModeSelect.addEventListener("change", updateAnalyzeVisibility); - updateAnalyzeVisibility(); - } - const updatePreviewVoice = (select) => { const container = select.closest(".speaker-list__item"); if (!container) return; const previewButton = container.querySelector('[data-role="speaker-preview"]'); if (!previewButton) return; + const formulaInput = container.querySelector('[data-role="speaker-formula"]'); + const mixContainer = container.querySelector('[data-role="speaker-mix"]'); + const mixLabel = container.querySelector('[data-role="speaker-mix-label"]'); + const formulaValue = formulaInput?.value?.trim(); + if (formulaValue) { + previewButton.dataset.voice = formulaValue; + if (mixContainer) { + mixContainer.hidden = false; + } + if (mixLabel) { + mixLabel.textContent = formulaValue; + } + return; + } + if (mixContainer) { + mixContainer.hidden = true; + } const defaultVoice = select.dataset.defaultVoice || previewButton.dataset.voice || ""; const currentVoice = select.disabled ? defaultVoice : (select.value || defaultVoice); previewButton.dataset.voice = currentVoice || defaultVoice; @@ -112,6 +120,14 @@ document.addEventListener("DOMContentLoaded", () => { if (!select.dataset.prevManual) { select.dataset.prevManual = select.value; } + const formulaInput = container.querySelector('[data-role="speaker-formula"]'); + const mixContainer = container.querySelector('[data-role="speaker-mix"]'); + if (formulaInput) { + formulaInput.value = ""; + } + if (mixContainer) { + mixContainer.hidden = true; + } select.dataset.suppressRandomize = "1"; select.disabled = true; select.value = ""; @@ -139,10 +155,21 @@ document.addEventListener("DOMContentLoaded", () => { handleRandomizeToggle(randomToggle); } } + if (container && !target.dataset.suppressFormulaClear) { + const formulaInput = container.querySelector('[data-role="speaker-formula"]'); + const mixContainer = container.querySelector('[data-role="speaker-mix"]'); + if (formulaInput) { + formulaInput.value = ""; + } + if (mixContainer) { + mixContainer.hidden = true; + } + } if (!target.dataset.suppressRandomize) { target.dataset.prevManual = target.value || ""; } updatePreviewVoice(target); + delete target.dataset.suppressFormulaClear; }); updatePreviewVoice(select); }); @@ -153,6 +180,9 @@ document.addEventListener("DOMContentLoaded", () => { checkbox.addEventListener("change", () => handleRandomizeToggle(checkbox)); }); + const finalizeAction = form.getAttribute("action"); + const analyzeUrl = form.dataset.analyzeUrl || ""; + const activeStepInput = form.querySelector('[data-role="active-step-input"]'); const wizard = document.querySelector('[data-role="prepare-wizard"]'); if (wizard) { const stepOrder = ["chapters", "speakers"]; @@ -163,17 +193,22 @@ document.addEventListener("DOMContentLoaded", () => { const nextButtons = Array.from(wizard.querySelectorAll('[data-role="step-next"]')); const prevButtons = Array.from(wizard.querySelectorAll('[data-role="step-prev"]')); const panels = new Map(); + const initialStep = wizard.dataset.initialStep || "chapters"; 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 isInitial = step === initialStep; + panel.hidden = !isInitial; + panel.setAttribute("aria-hidden", isInitial ? "false" : "true"); } }); const unlockedSteps = new Set(["chapters"]); - let currentStep = "chapters"; + if (initialStep === "speakers") { + unlockedSteps.add("speakers"); + } + let currentStep = initialStep; const updateIndicator = (activeStep) => { const activeIndex = indicatorOrder.indexOf(activeStep); @@ -210,6 +245,9 @@ document.addEventListener("DOMContentLoaded", () => { } currentStep = step; wizard.dataset.activeStep = step; + if (activeStepInput) { + activeStepInput.value = step; + } panels.forEach((panel, key) => { const isActive = key === step; panel.hidden = !isActive; @@ -231,6 +269,30 @@ document.addEventListener("DOMContentLoaded", () => { updateIndicator(step); }; + const submitForAnalysis = () => { + if (!analyzeUrl) { + unlockStep("speakers"); + setStep("speakers"); + return; + } + if (!form.reportValidity()) { + return; + } + if (activeStepInput) { + activeStepInput.value = "speakers"; + } + if (finalizeAction) { + form.setAttribute("data-finalize-action", finalizeAction); + } + form.action = analyzeUrl; + form.submit(); + if (finalizeAction) { + window.setTimeout(() => { + form.action = finalizeAction; + }, 0); + } + }; + navButtons.forEach((button) => { button.addEventListener("click", () => { if (button.disabled) { @@ -246,6 +308,10 @@ document.addEventListener("DOMContentLoaded", () => { nextButtons.forEach((button) => { button.addEventListener("click", () => { const target = button.dataset.stepTarget || "speakers"; + if (target === "speakers") { + submitForAnalysis(); + return; + } unlockStep(target); setStep(target); }); @@ -264,11 +330,183 @@ document.addEventListener("DOMContentLoaded", () => { const voiceModal = document.querySelector('[data-role="voice-modal"]'); let activeGenderFilter = ""; + const clamp = (value, min, max) => Math.min(Math.max(value, min), max); + + const parseFormula = (formula) => { + const mix = new Map(); + if (!formula) return mix; + const parts = formula.split("+"); + parts.forEach((part) => { + const segment = part.trim(); + if (!segment) return; + const pieces = segment.split("*"); + const voiceId = pieces[0].trim(); + if (!voiceId) return; + let weight = 1; + if (pieces[1]) { + const parsed = Number.parseFloat(pieces[1].trim()); + if (!Number.isNaN(parsed) && parsed > 0) { + weight = parsed; + } + } + mix.set(voiceId, clamp(weight, 0.05, 1)); + }); + return mix; + }; + + const normaliseMix = (mix) => { + const entries = Array.from(mix.entries()); + const total = entries.reduce((sum, [, weight]) => sum + weight, 0); + if (!total) return mix; + entries.forEach(([voiceId, weight]) => { + mix.set(voiceId, weight / total); + }); + return mix; + }; + + const formatMix = (mix) => { + const entries = Array.from(mix.entries()); + if (!entries.length) return ""; + let total = entries.reduce((sum, [, weight]) => sum + weight, 0); + if (total < 0.5) { + const scale = 0.5 / total; + entries.forEach(([voiceId, weight]) => { + mix.set(voiceId, clamp(weight * scale, 0.05, 1)); + }); + total = entries.reduce((sum, [, weight]) => sum + weight, 0); + } + return entries + .map(([voiceId, weight]) => `${voiceId}*${(weight / total).toFixed(2)}`) + .join("+"); + }; + + const genderLabel = (value) => { + switch ((value || "unknown").toLowerCase()) { + case "male": + return "Male"; + case "female": + return "Female"; + case "either": + return "Either"; + default: + return "Unknown"; + } + }; + + const buildRandomMix = (gender, countOverride) => { + const genderCode = (gender || "unknown").toLowerCase(); + const pool = voiceCatalog.filter((voice) => { + const code = (voice.gender_code || "").toLowerCase(); + if (genderCode === "female") return code === "f"; + if (genderCode === "male") return code === "m"; + if (genderCode === "either") return code === "f" || code === "m"; + return true; + }); + if (!pool.length) { + return null; + } + const voices = [...pool]; + for (let i = voices.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [voices[i], voices[j]] = [voices[j], voices[i]]; + } + const count = clamp(countOverride || Math.floor(Math.random() * 4) + 1, 1, 4); + const selected = voices.slice(0, count); + const mix = new Map(); + const rawWeights = selected.map(() => Math.random() + 0.2); + const total = rawWeights.reduce((sum, weight) => sum + weight, 0); + selected.forEach((voice, index) => { + mix.set(voice.id, rawWeights[index] / total); + }); + return mix; + }; + + const applyFormulaToSpeaker = (speakerItem, formula) => { + if (!speakerItem) return; + const select = speakerItem.querySelector('[data-role="speaker-voice"]'); + const formulaInput = speakerItem.querySelector('[data-role="speaker-formula"]'); + const mixLabel = speakerItem.querySelector('[data-role="speaker-mix-label"]'); + const mixContainer = speakerItem.querySelector('[data-role="speaker-mix"]'); + const previewButton = speakerItem.querySelector('[data-role="speaker-preview"]'); + const randomToggle = speakerItem.querySelector('[data-role="randomize-toggle"]'); + if (randomToggle && randomToggle.checked) { + randomToggle.checked = false; + handleRandomizeToggle(randomToggle); + } + if (formulaInput) { + formulaInput.value = formula || ""; + } + if (mixLabel) { + mixLabel.textContent = formula || ""; + } + if (mixContainer) { + mixContainer.hidden = !formula; + } + if (select) { + select.disabled = false; + select.dataset.suppressRandomize = "1"; + select.dataset.suppressFormulaClear = "1"; + if (formula) { + select.value = ""; + } else if (!select.value && select.dataset.prevManual) { + select.value = select.dataset.prevManual; + } + select.dispatchEvent(new Event("change", { bubbles: true })); + delete select.dataset.suppressRandomize; + } + if (previewButton) { + if (formula) { + previewButton.dataset.voice = formula; + } else { + const defaultVoice = select?.dataset.defaultVoice || previewButton.dataset.voice || ""; + previewButton.dataset.voice = select?.value || defaultVoice; + } + } + }; + + const hideGenderMenus = () => { + form.querySelectorAll('[data-role="gender-menu"]').forEach((menu) => { + menu.hidden = true; + menu.setAttribute("aria-hidden", "true"); + }); + form.querySelectorAll('[data-role="gender-pill"]').forEach((pill) => { + pill.classList.remove("is-open"); + }); + }; + + const setGenderForSpeaker = (genderContainer, value) => { + if (!genderContainer) return; + const normalized = value || "unknown"; + const input = genderContainer.querySelector('[data-role="gender-input"]'); + if (input) { + input.value = normalized; + } + const pill = genderContainer.querySelector('[data-role="gender-pill"]'); + if (pill) { + pill.dataset.current = normalized; + pill.textContent = `${genderLabel(normalized)} voice`; + } + const options = genderContainer.querySelectorAll('[data-role="gender-option"]'); + options.forEach((option) => { + if ((option.dataset.value || "unknown") === normalized) { + option.dataset.state = "active"; + } else { + option.removeAttribute("data-state"); + } + }); + }; + + Array.from(form.querySelectorAll('[data-role="speaker-gender"]')).forEach((container) => { + const input = container.querySelector('[data-role="gender-input"]'); + setGenderForSpeaker(container, input?.value || "unknown"); + }); + const modalState = { speakerItem: null, samples: [], recommended: new Set(), - selectedVoice: "", + mix: new Map(), + highlighted: "", defaultVoice: "", previewSettings: { language: "a", speed: "1", useGpu: "true" }, }; @@ -277,11 +515,14 @@ document.addEventListener("DOMContentLoaded", () => { modalState.speakerItem = null; modalState.samples = []; modalState.recommended = new Set(); - modalState.selectedVoice = ""; + modalState.mix = new Map(); + modalState.highlighted = ""; modalState.defaultVoice = ""; modalState.previewSettings = { language: "a", speed: "1", useGpu: "true" }; }; + const getMixFormula = () => formatMix(normaliseMix(new Map(modalState.mix))); + const renderVoiceList = (elements) => { if (!elements) return; const { list, searchInput, languageSelect } = elements; @@ -289,26 +530,28 @@ document.addEventListener("DOMContentLoaded", () => { 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; + 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); - }); + 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"); @@ -325,7 +568,10 @@ document.addEventListener("DOMContentLoaded", () => { button.className = "voice-browser__entry"; button.dataset.role = "voice-modal-item"; button.dataset.voiceId = voice.id; - if (modalState.selectedVoice === voice.id) { + if (modalState.mix.has(voice.id)) { + button.dataset.inMix = "true"; + } + if (modalState.highlighted === voice.id) { button.setAttribute("aria-current", "true"); } if (modalState.recommended.has(voice.id)) { @@ -344,20 +590,91 @@ document.addEventListener("DOMContentLoaded", () => { }); }; + const renderMix = (elements) => { + const { mixList, mixTotal } = elements; + if (!mixList) return; + mixList.innerHTML = ""; + const entries = Array.from(normaliseMix(new Map(modalState.mix)).entries()); + const total = entries.reduce((sum, [, weight]) => sum + weight, 0); + if (mixTotal) { + mixTotal.textContent = `Total weight: ${total.toFixed(2)}`; + } + if (!entries.length) { + const empty = document.createElement("p"); + empty.className = "voice-browser__empty"; + empty.textContent = "Add voices from the list to build a blend."; + mixList.appendChild(empty); + return; + } + entries.forEach(([voiceId, weight]) => { + const wrapper = document.createElement("div"); + wrapper.className = "voice-browser__mix-item"; + wrapper.dataset.voiceId = voiceId; + + const header = document.createElement("div"); + header.className = "voice-browser__mix-header"; + const voiceMeta = voiceCatalogMap.get(voiceId) || {}; + const title = document.createElement("span"); + title.className = "voice-browser__mix-name"; + title.textContent = voiceMeta.display_name || voiceId; + const weightLabel = document.createElement("span"); + weightLabel.className = "voice-browser__mix-weight"; + weightLabel.textContent = weight.toFixed(2); + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "voice-browser__mix-remove"; + removeBtn.setAttribute("aria-label", `Remove ${title.textContent} from blend`); + removeBtn.textContent = "✕"; + removeBtn.addEventListener("click", () => { + modalState.mix.delete(voiceId); + if (modalState.highlighted === voiceId) { + modalState.highlighted = ""; + } + renderMix(elements); + renderVoiceList(elements); + updateModalMeta(elements); + updateApplyState(elements); + }); + header.appendChild(title); + header.appendChild(weightLabel); + header.appendChild(removeBtn); + + const slider = document.createElement("input"); + slider.type = "range"; + slider.min = "5"; + slider.max = "100"; + slider.step = "1"; + slider.value = String(Math.round(weight * 100)); + slider.addEventListener("input", () => { + const value = clamp(Number.parseInt(slider.value, 10) / 100, 0.05, 1); + modalState.mix.set(voiceId, value); + modalState.highlighted = voiceId; + renderMix(elements); + updateModalMeta(elements); + updateApplyState(elements); + }); + + wrapper.appendChild(header); + wrapper.appendChild(slider); + mixList.appendChild(wrapper); + }); + }; + const renderSamples = (elements) => { if (!elements) return; - const { container } = elements; - if (!container) return; - container.innerHTML = ""; + const { samplesContainer } = elements; + if (!samplesContainer) return; + samplesContainer.innerHTML = ""; if (!modalState.samples.length) { const empty = document.createElement("p"); empty.className = "hint"; empty.textContent = "No sample paragraphs available yet."; - container.appendChild(empty); + samplesContainer.appendChild(empty); return; } + const formula = getMixFormula(); modalState.samples.forEach((text, index) => { const sample = document.createElement("article"); sample.className = "voice-browser__sample"; @@ -379,28 +696,32 @@ document.addEventListener("DOMContentLoaded", () => { 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.dataset.voice = formula || modalState.defaultVoice || ""; previewButton.textContent = "Preview sample"; actions.appendChild(previewButton); sample.appendChild(paragraph); sample.appendChild(actions); - container.appendChild(sample); + samplesContainer.appendChild(sample); }); }; - const updateModalVoiceMeta = (elements) => { + const updateModalMeta = (elements) => { if (!elements) return; const { nameLabel, metaLabel } = elements; if (!nameLabel || !metaLabel) return; - if (!modalState.selectedVoice) { - nameLabel.textContent = "Select a voice to preview"; + if (!modalState.mix.size) { + nameLabel.textContent = "Select voices to build a blend"; metaLabel.textContent = ""; return; } - const voice = voiceCatalogMap.get(modalState.selectedVoice); + const highlight = modalState.highlighted && modalState.mix.has(modalState.highlighted) + ? modalState.highlighted + : Array.from(modalState.mix.keys())[0]; + modalState.highlighted = highlight; + const voice = voiceCatalogMap.get(highlight); if (!voice) { - nameLabel.textContent = modalState.selectedVoice; + nameLabel.textContent = highlight; metaLabel.textContent = ""; return; } @@ -408,13 +729,19 @@ document.addEventListener("DOMContentLoaded", () => { metaLabel.textContent = `${voice.language_label} · ${voice.gender}`; }; + const updateApplyState = (elements) => { + const { applyButton } = elements || {}; + if (!applyButton) return; + const formula = getMixFormula(); + applyButton.disabled = !formula; + }; + const refreshModal = (elements) => { renderVoiceList(elements); + renderMix(elements); renderSamples(elements); - updateModalVoiceMeta(elements); - if (elements?.applyButton) { - elements.applyButton.disabled = !modalState.selectedVoice; - } + updateModalMeta(elements); + updateApplyState(elements); }; const openVoiceBrowser = (speakerItem, sampleIndex = 0) => { @@ -422,8 +749,13 @@ document.addEventListener("DOMContentLoaded", () => { modalState.speakerItem = speakerItem; const select = speakerItem.querySelector('[data-role="speaker-voice"]'); const previewTrigger = speakerItem.querySelector('[data-role="speaker-preview"]'); + const formulaInput = speakerItem.querySelector('[data-role="speaker-formula"]'); modalState.defaultVoice = select?.dataset.defaultVoice || previewTrigger?.dataset.voice || ""; - modalState.selectedVoice = select && !select.disabled ? (select.value || modalState.defaultVoice) : modalState.defaultVoice; + modalState.mix = formulaInput?.value ? parseFormula(formulaInput.value) : new Map(); + if (!modalState.mix.size && select && select.value) { + modalState.mix.set(select.value, 1); + } + modalState.mix = normaliseMix(modalState.mix); modalState.previewSettings = { language: previewTrigger?.dataset.language || "a", speed: previewTrigger?.dataset.speed || "1", @@ -437,7 +769,9 @@ document.addEventListener("DOMContentLoaded", () => { const raw = samplesTemplate.innerHTML || "[]"; const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { - samples = parsed.filter((value) => typeof value === "string" && value.trim().length); + samples = parsed + .map((entry) => (typeof entry === "string" ? entry : entry?.excerpt)) + .filter((value) => typeof value === "string" && value.trim().length); } } catch (error) { console.warn("Unable to parse speaker samples", error); @@ -462,7 +796,9 @@ document.addEventListener("DOMContentLoaded", () => { 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"]'), + mixList: voiceModal.querySelector('[data-role="voice-modal-mix-list"]'), + mixTotal: voiceModal.querySelector('[data-role="voice-modal-mix-total"]'), + samplesContainer: 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"]'), @@ -498,10 +834,14 @@ document.addEventListener("DOMContentLoaded", () => { 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"]'), + mixList: voiceModal.querySelector('[data-role="voice-modal-mix-list"]'), + mixTotal: voiceModal.querySelector('[data-role="voice-modal-mix-total"]'), + samplesContainer: 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"]'), + randomButton: voiceModal.querySelector('[data-role="voice-modal-random"]'), + clearButton: voiceModal.querySelector('[data-role="voice-modal-clear"]'), }; if (elements.searchInput) { @@ -522,29 +862,44 @@ document.addEventListener("DOMContentLoaded", () => { const target = event.target.closest('[data-role="voice-modal-item"]'); if (!target) return; event.preventDefault(); - modalState.selectedVoice = target.dataset.voiceId || ""; + const voiceId = target.dataset.voiceId; + if (!voiceId) return; + if (!modalState.mix.has(voiceId)) { + modalState.mix.set(voiceId, 0.5); + } + modalState.highlighted = voiceId; + renderMix(elements); + renderVoiceList(elements); + updateModalMeta(elements); + updateApplyState(elements); + }); + } + if (elements.randomButton) { + elements.randomButton.addEventListener("click", () => { + const genderInput = modalState.speakerItem?.querySelector('[data-role="gender-input"]'); + const gender = genderInput?.value || "unknown"; + const mix = buildRandomMix(gender); + if (mix) { + modalState.mix = mix; + modalState.highlighted = Array.from(mix.keys())[0]; + refreshModal(elements); + } + }); + } + if (elements.clearButton) { + elements.clearButton.addEventListener("click", () => { + modalState.mix.clear(); + modalState.highlighted = ""; 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; + if (!modalState.speakerItem) return; + const formula = getMixFormula(); + if (!formula) return; + applyFormulaToSpeaker(modalState.speakerItem, formula); closeVoiceBrowser(); }); } @@ -554,11 +909,13 @@ document.addEventListener("DOMContentLoaded", () => { closeVoiceBrowser(); } }); - if (elements.container) { - elements.container.addEventListener("click", (event) => { + if (elements.samplesContainer) { + elements.samplesContainer.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")); + elements.samplesContainer + .querySelectorAll(".voice-browser__sample") + .forEach((node) => node.removeAttribute("data-active")); sample.dataset.active = "true"; }); } @@ -572,6 +929,59 @@ document.addEventListener("DOMContentLoaded", () => { } form.addEventListener("click", (event) => { + const genderMenu = event.target.closest('[data-role="gender-menu"]'); + const genderPill = event.target.closest('[data-role="gender-pill"]'); + if (!genderMenu && !genderPill) { + hideGenderMenus(); + } + + if (genderPill) { + event.preventDefault(); + const menu = genderPill.parentElement?.querySelector('[data-role="gender-menu"]'); + const isOpen = menu && !menu.hidden; + hideGenderMenus(); + if (menu && !isOpen) { + menu.hidden = false; + menu.setAttribute("aria-hidden", "false"); + genderPill.classList.add("is-open"); + } + return; + } + + const genderOption = event.target.closest('[data-role="gender-option"]'); + if (genderOption) { + event.preventDefault(); + const container = genderOption.closest('[data-role="speaker-gender"]'); + setGenderForSpeaker(container, genderOption.dataset.value); + hideGenderMenus(); + return; + } + + const clearMixButton = event.target.closest('[data-role="clear-mix"]'); + if (clearMixButton) { + event.preventDefault(); + const container = clearMixButton.closest(".speaker-list__item"); + applyFormulaToSpeaker(container, ""); + return; + } + + const generateButton = event.target.closest('[data-role="generate-voice"]'); + if (generateButton) { + event.preventDefault(); + const container = generateButton.closest(".speaker-list__item"); + if (!container) return; + const genderInput = container.querySelector('[data-role="gender-input"]'); + const genderValue = genderInput?.value || "unknown"; + const mix = buildRandomMix(genderValue); + if (!mix) { + console.warn("No voices available to generate a mix for", genderValue); + return; + } + const formula = formatMix(normaliseMix(mix)); + applyFormulaToSpeaker(container, formula); + return; + } + const modalTrigger = event.target.closest('[data-role="open-voice-browser"]'); if (modalTrigger) { event.preventDefault(); @@ -601,4 +1011,10 @@ document.addEventListener("DOMContentLoaded", () => { delete select.dataset.suppressRandomize; select.dataset.prevManual = select.value || ""; }); + + document.addEventListener("click", (event) => { + if (!form.contains(event.target)) { + hideGenderMenus(); + } + }); }); diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index bfe751d..fccb1a0 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -125,6 +125,291 @@ body { z-index: -1; } +.card--workflow { + display: grid; + gap: 1.25rem; +} + +.card__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.card--modal { + padding: 0; + border-radius: 28px; + overflow: hidden; +} + +.modal { + position: fixed; + inset: 0; + padding: 2rem; + display: none; + align-items: center; + justify-content: center; + z-index: 300; +} + +.modal[data-open="true"] { + display: flex; +} + +.modal[hidden] { + display: none !important; +} + +.modal__overlay { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.74); + backdrop-filter: blur(8px); +} + +.modal__content { + position: relative; + width: min(95vw, 1120px); + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 30px 70px rgba(7, 14, 32, 0.55); + border-radius: 24px; + overflow: hidden; +} + +.modal__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1.5rem; + padding: 1.75rem 2rem 0; +} + +.modal__heading { + display: grid; + gap: 0.5rem; +} + +.modal__eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.75rem; + color: var(--muted); +} + +.modal__title { + margin: 0; + font-size: 1.6rem; + font-weight: 600; +} + +.modal__body { + padding: 1.5rem 2rem 2rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.modal__footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 0 2rem 2rem; + border-top: 1px solid rgba(148, 163, 184, 0.12); + background: linear-gradient(180deg, transparent, rgba(15, 23, 42, 0.3)); +} + +.modal-open { + overflow: hidden; +} + +.modal__content.voice-browser { + padding: 0; + border-radius: 28px; +} + +.voice-browser__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.75rem 1.75rem 1.25rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.16); +} + +.voice-browser__body { + display: grid; + grid-template-columns: 260px 1fr 320px; + gap: 1.5rem; + padding: 1.5rem 1.75rem 1.75rem; + overflow-y: auto; + max-height: calc(90vh - 150px); +} + +.voice-browser__filters, +.voice-browser__catalog, +.voice-browser__mix { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.voice-browser__filters .field { + gap: 0.35rem; +} + +.voice-browser__filters .button { + width: 100%; +} + +.voice-browser__gender { + display: grid; + gap: 0.5rem; +} + +.voice-browser__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.75rem; +} + +.voice-browser__entry { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 0.85rem 1rem; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(15, 23, 42, 0.35); + color: inherit; + text-align: left; +} + +.voice-browser__entry:hover, +.voice-browser__entry[aria-current="true"], +.voice-browser__entry[data-in-mix="true"] { + border-color: rgba(56, 189, 248, 0.55); + box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.18); +} + +.voice-browser__entry-name { + font-weight: 600; +} + +.voice-browser__entry-meta { + font-size: 0.85rem; + color: var(--muted); +} + +.voice-browser__mix { + border: 1px dashed rgba(148, 163, 184, 0.25); + border-radius: 18px; + padding: 1.25rem; + background: rgba(15, 23, 42, 0.25); + gap: 1.25rem; +} + +.voice-browser__mix-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.voice-browser__mix-list { + display: grid; + gap: 1rem; +} + +.voice-browser__mix-item { + display: grid; + gap: 0.75rem; + padding: 1rem; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.16); + background: rgba(15, 23, 42, 0.3); +} + +.voice-browser__mix-header h3, +.voice-browser__mix-name { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.voice-browser__mix-remove { + appearance: none; + border: none; + background: none; + color: var(--muted); + font-size: 1.15rem; + cursor: pointer; +} + +.voice-browser__mix-remove:hover { + color: var(--danger); +} + +.voice-browser__mix-weight { + font-size: 0.85rem; + color: var(--muted); +} + +.voice-browser__preview { + display: grid; + gap: 1rem; +} + +.voice-browser__samples { + display: grid; + gap: 0.75rem; +} + +.voice-browser__sample { + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + padding: 0.85rem 1rem; + background: rgba(15, 23, 42, 0.28); +} + +.voice-browser__actions { + display: flex; + justify-content: flex-end; +} + +.voice-browser__empty { + margin: 0; + text-align: center; + color: var(--muted); +} + +@media (max-width: 1100px) { + .voice-browser__body { + grid-template-columns: 1fr; + } + + .voice-browser__mix { + order: 3; + } +} + +@media (max-width: 768px) { + .modal { + padding: 1rem; + } + + .modal__header, + .modal__body, + .modal__footer { + padding-left: 1.25rem; + padding-right: 1.25rem; + } +} + .step-indicator { display: flex; flex-wrap: wrap; diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index 899e262..a0b6c5f 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -3,7 +3,7 @@ {% block title %}abogen · Dashboard{% endblock %} {% block content %} -
+
1 @@ -19,119 +19,142 @@

Create a New Audiobook

-
-
-
- - +

Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and speakers in the next steps.

+
+ +
+
+ +
+
+ + +
+ + +
+{% set wizard_step = (active_step or 'chapters') %} +{% set is_chapters = wizard_step == 'chapters' %} +{% set is_speakers = wizard_step == 'speakers' %} +
1 Upload & settings - + 2 Chapters - + 3 Speakers @@ -30,92 +33,8 @@ {% set applied_languages = pending.speaker_voice_languages or analysis.get('config_languages', []) or [] %} {% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} -
-
-
-
-
-
Source
-
{{ pending.original_filename }}
-
-
-
Language
-
{{ options.languages.get(pending.language, pending.language|upper) }}
-
-
-
Default voice
-
{% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}
-
-
-
Total chapters
-
{{ pending.chapters|length }}
-
-
-
Characters
-
{{ '{:,}'.format(pending.total_characters|default(0)) }}
-
-
-
Chapter intro delay
-
{{ '%.1f'|format(pending.chapter_intro_delay) }} seconds
-
-
-
Chunk granularity
-
{{ pending.chunk_level|replace('_', ' ')|title }}
-
-
-
Speaker mode
-
{{ pending.speaker_mode|replace('_', ' ')|title }}
-
-
-
Speaker analysis threshold
-
{{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}
-
-
-
EPUB 3 package
-
{% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}
-
- {% if applied_languages %} -
-
Config languages
-
{{ applied_languages | map('upper') | join(', ') }}
-
- {% endif %} -
-
- {% if analysis_speakers or show_analysis_prompt or has_metadata %} -
- {% if analysis_speakers %} - {% set ordered_ids = analysis.get('ordered_speakers', []) %} -
-

Detected speakers

- {% if ordered_ids %} -
    - {% for sid in ordered_ids %} - {% set speaker = analysis_speakers.get(sid) %} - {% if speaker %} -
  • - {{ speaker.label }} - {% if speaker.gender and speaker.gender != 'unknown' %} - · {{ speaker.gender|title }} - {% endif %} - · {{ speaker.count }} lines · confidence {{ speaker.confidence|title }} -
  • - {% endif %} - {% endfor %} -
- {% else %} -

No additional speakers met the threshold yet. All dialogue will use the narrator voice.

- {% endif %} -
- {% elif show_analysis_prompt %} -
-

Detected speakers

-

Press Analyze speakers after selecting chapters to discover recurring voices.

-
- {% endif %} -
- {% endif %} -
+ + {% if error %}
{{ error }}
@@ -125,15 +44,20 @@ {% endif %}
- - + +

Step 2 · Select chapters

-

Choose which chapters to convert and tweak conversion options.

+

Choose which chapters to convert and tweak conversion options. We'll analyse speakers automatically when you continue.

@@ -190,6 +114,9 @@
+
{% endfor %} @@ -300,6 +227,10 @@ {% 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') %}
  • {{ speaker.label }} @@ -319,11 +250,27 @@
    - {% 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 %} @@ -380,6 +327,12 @@ data-speaker-id="{{ speaker_id }}"> Browse voices +
    +
    + Custom mix + {{ speaker.voice_formula or '' }} + +
    +
  • Sample paragraphs {% if sample_quotes %} {% for quote in sample_quotes %} + {% set excerpt = quote.excerpt if quote is mapping else quote %} + {% set gender_hint = quote.gender_hint if quote is mapping else '' %}
    -

    {{ quote }}

    +

    {{ excerpt }}

    + {% if gender_hint %} +

    {{ gender_hint }}

    + {% endif %}
    {% if speaker.recommended_voices %} @@ -449,15 +413,6 @@
    -
    @@ -492,17 +447,26 @@
    + +
      -
      -

      Select a voice to preview

      -

      -
      -
      - -
      +
      +
      +

      Selected voices

      +

      Total weight: 0.00

      +
      +
      +
      +

      Select a voice to preview

      +

      +
      +
      + +
      +