From da0c453205b852315eaeaf394f1b7d86ea23a4de Mon Sep 17 00:00:00 2001 From: JB Date: Thu, 9 Oct 2025 10:12:11 -0700 Subject: [PATCH] feat: Refactor speaker configuration and UI components, enhance custom mix functionality, and improve styling for better usability --- abogen/speaker_configs.py | 70 +------- abogen/web/routes.py | 69 ++------ abogen/web/static/prepare.js | 241 +++++++++++++++----------- abogen/web/static/styles.css | 77 +++++++- abogen/web/templates/prepare_job.html | 69 ++++---- abogen/web/templates/speakers.html | 11 +- 6 files changed, 263 insertions(+), 274 deletions(-) diff --git a/abogen/speaker_configs.py b/abogen/speaker_configs.py index 54641b4..283389e 100644 --- a/abogen/speaker_configs.py +++ b/abogen/speaker_configs.py @@ -2,11 +2,9 @@ from __future__ import annotations import json import os -import random -from dataclasses import dataclass, field -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Dict, List, Optional -from abogen.constants import LANGUAGE_DESCRIPTIONS, VOICES_INTERNAL +from abogen.constants import LANGUAGE_DESCRIPTIONS from abogen.utils import get_user_config_path _CONFIG_WRAPPER_KEY = "abogen_speaker_configs" @@ -135,7 +133,6 @@ def _sanitize_speaker(entry: Dict[str, Any]) -> Dict[str, Any]: for code in voice_languages: if isinstance(code, str) and code: normalized_langs.append(code.lower()) - randomize = bool(entry.get("randomize")) resolved_voice = entry.get("resolved_voice") or voice_formula or voice resolved_label = label or entry.get("id") or "" slug = entry.get("id") if isinstance(entry.get("id"), str) else slugify_label(resolved_label) @@ -148,7 +145,6 @@ def _sanitize_speaker(entry: Dict[str, Any]) -> Dict[str, Any]: "voice_formula": voice_formula if isinstance(voice_formula, str) else "", "resolved_voice": resolved_voice if isinstance(resolved_voice, str) else "", "languages": normalized_langs, - "randomize": randomize, } @@ -161,68 +157,6 @@ def list_configs() -> List[Dict[str, Any]]: return ordered -@dataclass -class RandomVoiceOptions: - gender: str - allowed_languages: List[str] = field(default_factory=list) - fallback_voice: Optional[str] = None - - -def random_voice( - *, - gender: str, - allowed_languages: Optional[Iterable[str]] = None, - fallback_voice: Optional[str] = None, - exclude: Optional[Iterable[str]] = None, -) -> Optional[str]: - gender = (gender or "unknown").lower() - allowed = [code.lower() for code in (allowed_languages or []) if isinstance(code, str) and code] - excluded = {voice for voice in (exclude or []) if isinstance(voice, str)} - - def _voice_gender(code: str) -> str: - if len(code) >= 2: - marker = code[1] - if marker == "m": - return "male" - if marker == "f": - return "female" - return "unknown" - - def _voice_language(code: str) -> str: - return code[0] if code else "a" - - candidates: List[str] = [] - for voice in VOICES_INTERNAL: - if voice in excluded: - continue - voice_gender = _voice_gender(voice) - voice_language = _voice_language(voice) - if allowed and voice_language not in allowed: - continue - if gender in {"male", "female"} and voice_gender != gender: - continue - candidates.append(voice) - - if not candidates and allowed: - # retry without language restriction, preserving gender - for voice in VOICES_INTERNAL: - if voice in excluded: - continue - voice_gender = _voice_gender(voice) - if gender in {"male", "female"} and voice_gender != gender: - continue - candidates.append(voice) - - if not candidates: - candidates = [voice for voice in VOICES_INTERNAL if voice not in excluded] - - if not candidates: - return fallback_voice - - choice = random.choice(candidates) - return choice or fallback_voice - - def describe_language(code: str) -> str: code = (code or "a").lower() return LANGUAGE_DESCRIPTIONS.get(code, code.upper()) diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 637f582..da1be77 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -64,7 +64,6 @@ from abogen.speaker_configs import ( get_config, list_configs, load_configs, - random_voice, save_configs, upsert_config, slugify_label, @@ -142,22 +141,34 @@ def _build_speaker_roster( payload = speakers.get(speaker_id, {}) if speaker_id == "narrator": continue - if payload.get("suppressed"): continue previous = existing_map.get(speaker_id) roster[speaker_id] = { "id": speaker_id, "label": payload.get("label") or speaker_id.replace("_", " ").title(), - "voice": base_voice, "analysis_confidence": payload.get("confidence"), "analysis_count": payload.get("count"), "gender": payload.get("gender", "unknown"), } + detected_gender = payload.get("detected_gender") + if detected_gender: + roster[speaker_id]["detected_gender"] = detected_gender + samples = payload.get("sample_quotes") + if isinstance(samples, list): + roster[speaker_id]["sample_quotes"] = samples if isinstance(previous, Mapping): for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"): value = previous.get(key) if value is not None and value != "": roster[speaker_id][key] = value + if "sample_quotes" not in roster[speaker_id]: + prev_samples = previous.get("sample_quotes") + if isinstance(prev_samples, list): + roster[speaker_id]["sample_quotes"] = prev_samples + if "detected_gender" not in roster[speaker_id]: + prev_detected = previous.get("detected_gender") + if isinstance(prev_detected, str) and prev_detected: + roster[speaker_id]["detected_gender"] = prev_detected return roster @@ -187,7 +198,6 @@ def _apply_speaker_config_to_roster( roster: Mapping[str, Any], config: Optional[Mapping[str, Any]], *, - allow_randomize: bool = False, persist_changes: bool = False, fallback_languages: Optional[Iterable[str]] = None, ) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]: @@ -214,13 +224,11 @@ def _apply_speaker_config_to_roster( 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() @@ -251,25 +259,8 @@ def _apply_speaker_config_to_roster( voice_formula = str(config_entry.get("voice_formula") or "").strip() resolved_voice = str(config_entry.get("resolved_voice") or "").strip() languages = config_entry.get("languages") if isinstance(config_entry.get("languages"), list) else [] - randomize_flag = bool(config_entry.get("randomize")) - chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("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=usable_languages, - fallback_voice=default_voice or roster_entry.get("voice"), - exclude=exclusion, - ) - if randomized: - chosen_voice = randomized - voice_id = randomized - resolved_voice = randomized - config_changed = True - used_voices.add(randomized) if chosen_voice: roster_entry["resolved_voice"] = chosen_voice @@ -298,7 +289,6 @@ def _apply_speaker_config_to_roster( "voice_formula": voice_formula, "resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id), "languages": usable_languages, - "randomize": randomize_flag, } new_config = new_config_payload if (persist_changes and config_changed) else None @@ -414,7 +404,6 @@ def _extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str voice = (form.get(prefix + "voice") or "").strip() 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"} speaker_id = (form.get(prefix + "id") or "").strip() or slugify_label(label) speakers[speaker_id] = { "id": speaker_id, @@ -425,7 +414,6 @@ def _extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str "voice_formula": voice_formula, "resolved_voice": voice_formula or voice, "languages": [], - "randomize": randomize_flag, } payload = { @@ -459,7 +447,6 @@ def _prepare_speaker_metadata( run_analysis: bool = True, speaker_config: Optional[Mapping[str, Any]] = None, apply_config: bool = False, - allow_randomize: bool = False, persist_config: bool = False, ) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any], List[str], Optional[Dict[str, Any]]]: chunk_list = [dict(chunk) for chunk in chunks] @@ -561,7 +548,6 @@ def _prepare_speaker_metadata( roster, applied_languages, updated_config = _apply_speaker_config_to_roster( roster, speaker_config, - allow_randomize=allow_randomize, persist_changes=persist_config, fallback_languages=global_random_languages, ) @@ -618,7 +604,6 @@ def _apply_prepare_form( str, bool, bool, - bool, ]: raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower() if raw_chunk_level not in _CHUNK_LEVEL_VALUES: @@ -665,11 +650,7 @@ def _apply_prepare_form( pending.speakers["narrator"] = existing_narrator selected_config = (form.get("applied_speaker_config") or "").strip() - randomize_requested = str(form.get("randomize_speaker_config", "")).strip() in {"1", "true", "on"} - apply_config_requested = ( - str(form.get("apply_speaker_config", "")).strip() in {"1", "true", "on"} - or randomize_requested - ) + apply_config_requested = str(form.get("apply_speaker_config", "")).strip() in {"1", "true", "on"} persist_config_requested = str(form.get("save_speaker_config", "")).strip() in {"1", "true", "on"} pending.applied_speaker_config = selected_config or None @@ -694,9 +675,6 @@ def _apply_prepare_form( payload.pop("voice", None) payload.pop("resolved_voice", None) - randomize_flag = form.get(f"speaker-{speaker_id}-randomize") in {"on", "1", "true"} - payload["randomize"] = randomize_flag - lang_key = f"speaker-{speaker_id}-languages" languages: List[str] = [] getter = getattr(form, "getlist", None) @@ -782,7 +760,6 @@ def _apply_prepare_form( errors, selected_total, selected_config, - randomize_requested, apply_config_requested, persist_config_requested, ) @@ -833,6 +810,9 @@ _SUPPLEMENT_TEXT_KEYWORDS: List[tuple[str, float]] = [ ("also by", 0.9), ("glossary", 0.8), ("index", 0.8), + ("newsletter", 3.2), + ("mailing list", 2.6), + ("sign-up", 2.2), ] @@ -1826,14 +1806,6 @@ def enqueue_job() -> ResponseReturnValue: custom_formula_raw = request.form.get("voice_formula", "").strip() selected_speaker_config = (request.form.get("speaker_config") or "").strip() speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None - config_randomize_default = False - if isinstance(speaker_config_payload, Mapping): - speakers_map = speaker_config_payload.get("speakers") - if isinstance(speakers_map, Mapping): - for config_entry in speakers_map.values(): - if isinstance(config_entry, Mapping) and config_entry.get("randomize"): - config_randomize_default = True - break if profile_selection in {"__standard", ""}: profile_name = "" @@ -1907,7 +1879,6 @@ def enqueue_job() -> ResponseReturnValue: run_analysis=initial_analysis, speaker_config=speaker_config_payload, apply_config=bool(speaker_config_payload), - allow_randomize=config_randomize_default, ) pending = PendingJob( @@ -1983,7 +1954,6 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue: errors, selected_total, selected_config, - randomize_requested, apply_config_requested, persist_config_requested, ) = _apply_prepare_form(pending, request.form) @@ -2036,7 +2006,6 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue: run_analysis=True, speaker_config=speaker_config_payload, apply_config=apply_config_requested or bool(speaker_config_payload), - allow_randomize=randomize_requested, persist_config=persist_config_requested, ) @@ -2077,7 +2046,6 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: errors, selected_total, selected_config, - randomize_requested, apply_config_requested, persist_config_requested, ) = _apply_prepare_form(pending, request.form) @@ -2126,7 +2094,6 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: run_analysis=analysis_active, speaker_config=speaker_config_payload, apply_config=apply_config_requested or bool(speaker_config_payload), - allow_randomize=randomize_requested, persist_config=persist_config_requested, ) pending.chunks = processed_chunks diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index e97f1b9..0296505 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -18,6 +18,63 @@ document.addEventListener("DOMContentLoaded", () => { const languageMap = parseJSONScript("voice-language-map") || {}; const voiceCatalogMap = new Map(voiceCatalog.map((voice) => [voice.id, voice])); + const formatCustomMixLabel = (formula) => { + if (!formula) return "Custom mix"; + const segments = formula + .split("+") + .map((segment) => segment.trim()) + .filter((segment) => segment.length); + if (!segments.length) { + return "Custom mix"; + } + const parts = segments.map((segment) => { + const [voiceIdRaw, weightRaw] = segment.split("*").map((token) => token.trim()); + const voiceId = voiceIdRaw || ""; + const voiceMeta = voiceCatalogMap.get(voiceId); + const displayName = voiceMeta?.display_name || voiceId || "Voice"; + const weight = Number.parseFloat(weightRaw || ""); + if (!Number.isNaN(weight)) { + return `${displayName} ${(weight * 100).toFixed(0)}%`; + } + return displayName; + }); + return parts.join(" + "); + }; + + const ensureCustomMixOption = (select) => { + if (!select) return null; + let option = select.querySelector('option[data-role="custom-mix-option"]'); + if (!option) { + option = document.createElement("option"); + option.value = "__custom_mix"; + option.dataset.role = "custom-mix-option"; + option.hidden = true; + option.disabled = true; + option.textContent = "Custom mix"; + const firstOptGroup = select.querySelector("optgroup"); + if (firstOptGroup) { + select.insertBefore(option, firstOptGroup); + } else { + select.appendChild(option); + } + } + return option; + }; + + const updateCustomMixOption = (select, formula) => { + const option = ensureCustomMixOption(select); + if (!option) return; + if (formula) { + option.hidden = false; + option.disabled = false; + option.textContent = formatCustomMixLabel(formula); + } else { + option.hidden = true; + option.disabled = true; + option.textContent = "Custom mix"; + } + }; + const chapterRows = Array.from(form.querySelectorAll("[data-role=chapter-row]")); const updateRowState = (row) => { @@ -87,99 +144,91 @@ document.addEventListener("DOMContentLoaded", () => { 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; - }; + const previewButtons = container.querySelectorAll('[data-role="speaker-preview"]'); + if (!previewButtons.length) return; - const handleRandomizeToggle = (checkbox) => { - const container = checkbox.closest(".speaker-list__item"); - if (!container) return; - const select = container.querySelector('[data-role="speaker-voice"]'); - if (!select) return; - if (checkbox.checked) { - 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 = ""; - select.dispatchEvent(new Event("change", { bubbles: true })); - delete select.dataset.suppressRandomize; - } else { - const previous = select.dataset.prevManual || ""; - select.disabled = false; - select.dataset.suppressRandomize = "1"; - select.value = previous; - select.dispatchEvent(new Event("change", { bubbles: true })); - delete select.dataset.suppressRandomize; + 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() || ""; + updateCustomMixOption(select, formulaValue); + + const defaultVoice = select.dataset.defaultVoice || ""; + let assignedVoice = select.value || defaultVoice; + if (select.value === "__custom_mix" || formulaValue) { + assignedVoice = formulaValue || defaultVoice; } + + if (formulaValue) { + if (mixContainer) mixContainer.hidden = false; + if (mixLabel) mixLabel.textContent = formulaValue; + } else { + if (mixContainer) mixContainer.hidden = true; + if (mixLabel) mixLabel.textContent = ""; + if (assignedVoice === "__custom_mix" || !assignedVoice) { + assignedVoice = defaultVoice; + } + } + + previewButtons.forEach((button) => { + const kind = button.dataset.previewKind || ""; + if (kind === "generated") { + button.hidden = !formulaValue; + button.dataset.voice = assignedVoice; + return; + } + + const context = button.dataset.previewContext || ""; + if (context === "mix") { + button.dataset.voice = formulaValue || assignedVoice; + return; + } + + button.dataset.voice = assignedVoice || defaultVoice || button.dataset.voice || ""; + }); }; const voiceSelects = Array.from(form.querySelectorAll('[data-role="speaker-voice"]')); voiceSelects.forEach((select) => { + ensureCustomMixOption(select); select.addEventListener("change", (event) => { const target = event.target; const container = target.closest(".speaker-list__item"); - if (container && !target.dataset.suppressRandomize) { - const randomToggle = container.querySelector('[data-role="randomize-toggle"]'); - if (randomToggle && randomToggle.checked && target.value) { - randomToggle.checked = false; - handleRandomizeToggle(randomToggle); + if (!container) 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"]'); + + if (target.value === "__custom_mix") { + if (!formulaInput?.value?.trim()) { + const previous = target.dataset.prevManual || ""; + target.value = previous; } + updatePreviewVoice(target); + return; } - if (container && !target.dataset.suppressFormulaClear) { - const formulaInput = container.querySelector('[data-role="speaker-formula"]'); - const mixContainer = container.querySelector('[data-role="speaker-mix"]'); + + if (!target.dataset.suppressFormulaClear) { if (formulaInput) { formulaInput.value = ""; } + if (mixLabel) { + mixLabel.textContent = ""; + } if (mixContainer) { mixContainer.hidden = true; } + updateCustomMixOption(target, ""); } - if (!target.dataset.suppressRandomize) { - target.dataset.prevManual = target.value || ""; - } + + target.dataset.prevManual = target.value || ""; updatePreviewVoice(target); delete target.dataset.suppressFormulaClear; }); updatePreviewVoice(select); }); - const randomizeToggles = Array.from(form.querySelectorAll('[data-role="randomize-toggle"]')); - randomizeToggles.forEach((checkbox) => { - handleRandomizeToggle(checkbox); - checkbox.addEventListener("change", () => handleRandomizeToggle(checkbox)); - }); - const finalizeAction = form.getAttribute("action"); const analyzeUrl = form.dataset.analyzeUrl || ""; const activeStepInput = form.querySelector('[data-role="active-step-input"]'); @@ -427,12 +476,7 @@ document.addEventListener("DOMContentLoaded", () => { 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 || ""; } @@ -442,26 +486,29 @@ document.addEventListener("DOMContentLoaded", () => { if (mixContainer) { mixContainer.hidden = !formula; } - if (select) { - select.disabled = false; - select.dataset.suppressRandomize = "1"; + + if (!select) { + return; + } + + ensureCustomMixOption(select); + + if (formula) { + if (select.value !== "__custom_mix") { + select.dataset.prevManual = select.value || select.dataset.prevManual || select.dataset.defaultVoice || ""; + } 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; - } + updateCustomMixOption(select, formula); + select.value = "__custom_mix"; + } else { + const fallback = select.dataset.prevManual || select.dataset.defaultVoice || ""; + select.dataset.suppressFormulaClear = "1"; + updateCustomMixOption(select, ""); + select.value = fallback; } + + updatePreviewVoice(select); + delete select.dataset.suppressFormulaClear; }; const hideGenderMenus = () => { @@ -999,16 +1046,8 @@ document.addEventListener("DOMContentLoaded", () => { if (!container) return; const select = container.querySelector('[data-role="speaker-voice"]'); if (!select) return; - const randomToggle = container.querySelector('[data-role="randomize-toggle"]'); - if (randomToggle && randomToggle.checked) { - 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/static/styles.css b/abogen/web/static/styles.css index 3ab3fd9..696c224 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -1231,6 +1231,62 @@ body { gap: 0.5rem; font-size: 0.8rem; margin-bottom: 0.3rem; + align-items: center; +} + +.speaker-gender { + position: relative; + display: inline-flex; + align-items: center; +} + +.speaker-gender__pill { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.32rem 0.8rem; + min-width: 8.5rem; + border-radius: 999px; + border: 1px solid rgba(56, 189, 248, 0.35); + background: rgba(56, 189, 248, 0.12); + color: var(--accent); + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} + +.speaker-gender__pill:hover, +.speaker-gender__pill.is-open { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.18); +} + +.speaker-gender__menu { + position: absolute; + top: calc(100% + 0.45rem); + left: 0; + z-index: 40; + min-width: 220px; + padding: 0.75rem; + border-radius: 16px; + border: 1px solid var(--panel-border); + background: rgba(15, 23, 42, 0.96); + box-shadow: 0 18px 40px rgba(7, 14, 32, 0.55); +} + +.speaker-gender__menu[hidden] { + display: none !important; +} + +.speaker-gender__options { + display: grid; + gap: 0.35rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.speaker-gender__options .chip { + width: 100%; + justify-content: center; } .speaker-list__controls { @@ -1289,21 +1345,21 @@ body { .prepare-form { display: grid; - gap: 1.5rem; + gap: 2rem; } .chapter-grid { display: grid; - gap: 1rem; + gap: 1.25rem; } .chapter-card { border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 20px; - padding: 1rem 1.25rem; + border-radius: 22px; + padding: 1.25rem 1.5rem; background: rgba(15, 23, 42, 0.32); display: grid; - gap: 0.85rem; + gap: 1.1rem; } .chapter-card[data-disabled="true"] { @@ -1314,7 +1370,7 @@ body { display: flex; justify-content: space-between; align-items: flex-start; - gap: 1rem; + gap: 1.25rem; } .chapter-card__title { @@ -1517,17 +1573,20 @@ body { .prepare-actions { display: flex; - justify-content: space-between; + justify-content: flex-end; align-items: center; gap: 1rem; + padding-top: 1.25rem; } .prepare-actions .button { - min-width: 190px; + min-width: 200px; + padding-left: 1.75rem; + padding-right: 1.75rem; } .prepare-actions .button--ghost { - border-color: rgba(148, 163, 184, 0.25); + border-color: rgba(148, 163, 184, 0.35); } .toggle-pill input:checked + span { diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index daec1c5..1b56178 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -43,16 +43,6 @@
{{ notice }}
{% endif %} -
- - -
-
@@ -198,16 +188,6 @@ {% if not speaker_configs %}disabled{% endif %}> Apply preset -
+
Custom mix {{ speaker.voice_formula or '' }} - +
+ + +
@@ -447,7 +443,6 @@ -
diff --git a/abogen/web/templates/speakers.html b/abogen/web/templates/speakers.html index c308924..f8dc2bc 100644 --- a/abogen/web/templates/speakers.html +++ b/abogen/web/templates/speakers.html @@ -2,7 +2,6 @@ {% macro speaker_row(row_key, speaker, options) %} {% set gender = (speaker.gender or 'unknown') %} - {% set randomize = speaker.randomize %}
@@ -30,10 +29,6 @@
Speaker presets
-

Store recurring casts, control randomization defaults, and keep voices consistent between books.

+

Store recurring casts and keep voices consistent between books.

{% if message %}
{{ message }}
@@ -125,7 +120,7 @@

Speakers

-

Add each recurring character, set their gender, and decide whether to randomize a compatible voice automatically.

+

Add each recurring character, set their gender, and choose a preferred voice.

{% set speakers_map = editing.speakers or {} %} {% if speakers_map %} @@ -147,7 +142,7 @@
{% endblock %}