feat: Refactor speaker configuration and UI components, enhance custom mix functionality, and improve styling for better usability

This commit is contained in:
JB
2025-10-09 10:12:11 -07:00
parent 63f9474741
commit da0c453205
6 changed files with 263 additions and 274 deletions
+2 -68
View File
@@ -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())
+18 -51
View File
@@ -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
+130 -91
View File
@@ -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 previewButtons = container.querySelectorAll('[data-role="speaker-preview"]');
if (!previewButtons.length) 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();
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) {
previewButton.dataset.voice = formulaValue;
if (mixContainer) {
mixContainer.hidden = false;
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;
}
if (mixLabel) {
mixLabel.textContent = formulaValue;
}
previewButtons.forEach((button) => {
const kind = button.dataset.previewKind || "";
if (kind === "generated") {
button.hidden = !formulaValue;
button.dataset.voice = assignedVoice;
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 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 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 && !target.dataset.suppressFormulaClear) {
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 (!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 || "";
}
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;
updateCustomMixOption(select, formula);
select.value = "__custom_mix";
} else {
const defaultVoice = select?.dataset.defaultVoice || previewButton.dataset.voice || "";
previewButton.dataset.voice = select?.value || defaultVoice;
}
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 || "";
});
+68 -9
View File
@@ -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 {
+31 -36
View File
@@ -43,16 +43,6 @@
<div class="alert alert--info">{{ notice }}</div>
{% endif %}
<div class="prepare-step-nav" data-role="prepare-step-nav">
<button type="button" class="button button--ghost{% if is_chapters %} is-active{% endif %}" data-step-target="chapters">Step 2 · Chapters</button>
<button type="button"
class="button button--ghost{% if is_speakers %} is-active{% endif %}"
data-step-target="speakers"
{% if not is_speakers %}disabled aria-disabled="true" data-state="locked"{% else %}data-state="unlocked"{% endif %}>
Step 3 · Speakers
</button>
</div>
<div class="prepare-steps" data-role="prepare-step-panels">
<section class="prepare-step" data-step-panel="chapters">
<header class="prepare-step__header">
@@ -198,16 +188,6 @@
{% if not speaker_configs %}disabled{% endif %}>
Apply preset
</button>
<button type="submit"
class="button button--ghost"
name="randomize_speaker_config"
value="1"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post"
formnovalidate
{% if not speaker_configs %}disabled{% endif %}>
Randomize compatible voices
</button>
<label class="toggle-pill">
<input type="checkbox" name="save_speaker_config" value="1">
<span>Save roster updates back to preset</span>
@@ -274,9 +254,6 @@
{% if speaker.get('analysis_count') %}
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
{% endif %}
{% if settings.speaker_random_languages %}
<span class="badge badge--muted">Randomizer: {{ settings.speaker_random_languages | map('upper') | join(', ') }}</span>
{% endif %}
</div>
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
<span>Pronunciation</span>
@@ -293,9 +270,11 @@
<select id="speaker-{{ speaker_id }}-voice"
name="speaker-{{ speaker_id }}-voice"
data-role="speaker-voice"
data-default-voice="{{ pending.voice }}"
{% if speaker.randomize %}disabled{% endif %}>
data-default-voice="{{ pending.voice }}">
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
<option value="__custom_mix" data-role="custom-mix-option" {% if speaker.voice_formula %}selected{% else %}hidden disabled{% endif %}>
Custom mix
</option>
{% if speaker.recommended_voices %}
<optgroup label="Recommended">
{% for voice_id in speaker.recommended_voices[:6] %}
@@ -333,22 +312,39 @@
data-speaker-id="{{ speaker_id }}">
Generate voice
</button>
</div>
<div class="speaker-list__inline">
<label class="toggle-pill">
<input type="checkbox"
name="speaker-{{ speaker_id }}-randomize"
value="1"
data-role="randomize-toggle"
{% if speaker.randomize %}checked{% endif %}>
<span>Randomize compatible voice on analyze</span>
</label>
<button type="button"
class="button button--ghost button--small"
data-role="speaker-preview"
data-preview-kind="generated"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ preview_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
{% if not speaker.voice_formula %}hidden{% endif %}>
Preview generated
</button>
</div>
<div class="speaker-list__mix" data-role="speaker-mix" {% if not speaker.voice_formula %}hidden{% endif %}>
<span class="tag">Custom mix</span>
<span data-role="speaker-mix-label">{{ speaker.voice_formula or '' }}</span>
<div class="speaker-list__mix-actions">
<button type="button"
class="button button--ghost button--small"
data-role="speaker-preview"
data-preview-context="mix"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ preview_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
Preview mix
</button>
<button type="button" class="button button--ghost button--small" data-role="clear-mix">Clear</button>
</div>
</div>
<input type="hidden" name="speaker-{{ speaker_id }}-formula" value="{{ speaker.voice_formula or '' }}" data-role="speaker-formula">
</div>
<details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}>
@@ -447,7 +443,6 @@
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="f">Female</button>
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="m">Male</button>
</div>
<button type="button" class="button button--ghost" data-role="voice-modal-random">Random blend</button>
<button type="button" class="button button--ghost" data-role="voice-modal-clear">Clear selection</button>
</aside>
<section class="voice-browser__catalog" aria-label="Voice catalog">
+3 -8
View File
@@ -2,7 +2,6 @@
{% macro speaker_row(row_key, speaker, options) %}
{% set gender = (speaker.gender or 'unknown') %}
{% set randomize = speaker.randomize %}
<div class="speaker-config-row" data-role="speaker-row" data-row-id="{{ row_key }}">
<input type="hidden" name="speaker_rows" value="{{ row_key }}">
<input type="hidden" name="speaker-{{ row_key }}-id" value="{{ speaker.id or row_key }}">
@@ -30,10 +29,6 @@
</label>
</div>
<div class="speaker-config-row__footer">
<label class="toggle-pill">
<input type="checkbox" name="speaker-{{ row_key }}-randomize" value="1" {% if randomize %}checked{% endif %}>
<span>Randomize compatible voice when applied</span>
</label>
<button type="button" class="button button--ghost button--small" data-action="remove-speaker">
Remove
</button>
@@ -60,7 +55,7 @@
</span>
</div>
<div class="card__title">Speaker presets</div>
<p class="card__subtitle">Store recurring casts, control randomization defaults, and keep voices consistent between books.</p>
<p class="card__subtitle">Store recurring casts and keep voices consistent between books.</p>
{% if message %}
<div class="alert alert--success">{{ message }}</div>
@@ -125,7 +120,7 @@
<h3>Speakers</h3>
<button type="button" class="button button--ghost button--small" data-action="add-speaker">Add speaker</button>
</div>
<p class="hint">Add each recurring character, set their gender, and decide whether to randomize a compatible voice automatically.</p>
<p class="hint">Add each recurring character, set their gender, and choose a preferred voice.</p>
<div class="speaker-config-rows__list" data-role="speaker-rows">
{% set speakers_map = editing.speakers or {} %}
{% if speakers_map %}
@@ -147,7 +142,7 @@
</div>
<template id="speaker-row-template">
{{ speaker_row('__ROW__', {'id': '', 'label': '', 'gender': 'unknown', 'voice': '', 'randomize': True}, options) | safe }}
{{ speaker_row('__ROW__', {'id': '', 'label': '', 'gender': 'unknown', 'voice': ''}, options) | safe }}
</template>
</section>
{% endblock %}